Менеджер сигналов — S.T.A.L.K.E.R. Inside Wiki

Менеджер сигналов

Материал из S.T.A.L.K.E.R. Inside Wiki

Перейти к: навигация, поиск


Система сигналов в стиле Boost.Signals или делегатов C#

Введение

Как мы все знаем, давно распространена практика "навешивания" всевозможных действий на разные вызовы биндера актора: виртуальные функции, такие как net_spawn и update, а также всевозможные колбеки. Как правило, это делается вставкой строки в один из методов биндера в модуле bind_stalker.script (в котором как раз и находится класс actor_binder). Для случая метода update это может выглядеть примерно так:

function actor_binder:update(delta)
    object_binder.update(self, delta)
    ...
    my_module.update(delta)
    ...
end

здесь my_module.update(delta) - эта наша врезка. Вызовы из разных методов и колбеков используются для разных целей. Тот-же апдейт широко (даже слишком широко) используется для всевозможных периодических проверок. Данный подход имеет несколько недостатков.

Недостатки существующих подходов

1. Добавление очередного сервиса требует внесения изменений в модуль bind_stalker.script. Это не плохо само по себе, но постепенно приводит к замусориванию и разрастанию этого модуля, что не всем нравится. Кроме того, внесение изменений в существующий код потенциально чревато ошибками.

2. Поскольку каждый новый добавляемый сервис пишется по-разному, то возникает изрядный разнобой как в именовании вызовов, так и в передаваемых аргументах. Это опять же не плохо само по себе, но затрудняет сопровождение кода.

3. Усложнение отладки. Давно известна проблема подвисания различных вызовов биндера в случае возникновения в нем исключения движка. При таких ситуациях вызов тихо перестаёт вызываться с неопределёнными последствиями. Впрочем, неопределённость проявляется только в виде неочевидности и немгновенности эффектов. С точки зрения глобальных последствий всё достаточно определённо. Это почти всегда фатально для дальнейшего продолжения игры, приводит к порче сейвов и крайне мутным и трудноотлаживаемым глюкам.

Поэтому крайне важно отловить такую ситуацию сразу и попросту остановить игру. Желательно также получить информацию о том, какой именно вызов вызвал зависание. Для этой цели обычно делают систему со счётчиком и флажками, которая позволяет понять, что вызов не завершился и по крайней мере понять, в какой именно момент он подвис. Однако, при каждом внесении изменения требуется подлаживать также и отладочные вызовы, что по крайней мере усложняет процесс и раздувает код. Кроме того, при этом сложно отлавливать ситуацию вложенных вызовов.

4. Ряд используемых в модостроении техник сложно назвать как-то иначе, кроме как уродливые. Одна из таких техник, широко используемая в соединении с сервисами биндера - это использование колбека на использование инвентарного предмета. Сложности начинаются тогда, когда на колбек использования повешено множество вызовов. Естественно подразумевается, что реально что-то делать с использованным предметом должен только один из вызовов, хотя технически что-то делает каждый, в частности проверяет предмет и выясняет, должен ли данный вызов что-то по этому поводу делать.

Другой пример - изменение свойств предметов методом их пересоздания. Суть в том, что движок ограничивает возможность изменения свойств существующих объектов, и единственным путём является просто удаление объекта и создание нового с новыми свойствами. Реальная проблема возникает тогда, когда этот предмет является аргументом одного из таких вызовов и передаётся по цепочке от одного к другому. В один прекрасный момент один из вызовов в цепочке удаляет предмет с целью создать вместо него новый. Теперь следующий вызов в цепочке имеет дело с предметов в стадии удаления. Клиентский объект ещё есть, а серверного уже нет. Похожие ситуации может возникнуть и между разными вызовами, когда к примеру один вызов перемещает объект из слота в рюкзак, чтобы освободить слот, а затем удаляет объект. При помещении предмета в рюкзак срабатывает другой вызов и в нём уже серверного объекта нет с самого начала.

В общем, при дезорганизованном добавлении сервисов с этой проблемой крайне сложно бороться, что автор этих строк вполне прочувствовал на своей шкуре. К сожалению, хотя подобные приёмы и уродливые, они работают. Если не прибегать к движковым правкам, то ничего лучше в распоряжении модостроителей нет. Таким образом, если нельзя обойтись без использования этих трюков, то нужны инструменты, которые позволят как-то бороться с последствиями.

Описанная здесь система событий как раз и позволяет во многой части преодолеть перечисленные проблемы.

Кратко об идее события и подписки на событие

Об этом много написано (см. boost.signals, делегаты C#, слоты и сигналы Qt и т.п.), но не лишним будет и повториться. Попробую изложить идею на конкретном примере.

Пусть у нас есть ситуация, когда актор использует некий предмет в инвентаре. Мы имеем движковый колбек, который срабатывает при использовании предмета, расположен в модуле bind_stalker.script

function actor_binder:use_inventory_item(obj)
    ...
end

Как было описано в предыдущей части, в модостроении этот колбек используется для многочисленных действий. К примеру, интерфейс использования спального мешка, ремкомплекта и вообще всевозможных устройств, активируемых предметов из инвентаря, дополнительные эффекты от съедаемых предметов и медикаментов и т.п. Соответственно, вызов соответствующих обработок может выглядеть примерно так:

function actor_binder:use_inventory_item(obj)
    sleep_manager.on_use(obj) -- активация спального мешка
    remkit.use_object(obj) -- активация ремкита
    healing.use_item(obj) -- дополнительные эффекты от препаратов
end

Недостатки этого подхода были подробно описаны ранее.

Что здесь событие? Событие - это факт использования актором предмета. Из примера видно, что событие одно, а действий, которые происходят по событию, может быть много. А может и не быть вовсе, хотя событие происходит вне зависимости от того, связаны ли с ним действия. Отсюда вытекает простая идея отделить событие от обработчиков события. Т.е. я хотел бы иметь в коде что-то вроде такого:

function actor_binder:use_inventory_item(obj)
    генерировать_событие_использования_предмета(obj)
end

что приводило бы к срабатыванию функций sleep_manager.on_use(obj), remkit.use_object(obj) и healing.use_item(obj). Естественно, что это потребует неких усилий: нужен некий промежуточный код, который будет хранить список функций, связанных с событием, который будет при активации события вызывать эти функции одна за другой, передавая в них один и тот-же аргумент; нужен сервисный код, который позволит регистрировать функции-обработчики и связывать их с конкретным событием, а также отвязывать. В частности, используя этот сервисный код, надо предварительно выполнить действия такого рода:

связать_функцию_с_событием_использования_предмета(sleep_manager.on_use)
связать_функцию_с_событием_использования_предмета(remkit.use_object)
связать_функцию_с_событием_использования_предмета(healing.use_item)

чтобы система событий знала, что надо вызвать эти три функции.

Немного о терминологии. В разных системах и языках для разных частей этого процесса используются разные названия. Событие (event) может также называться сигналом (signal). Функции-обработчики могут также называться слотами (slot), подписчиками (subscriber), делегатами (delegate), колбеками (callback). Соответственно процесс связывания сигнала и обработчика тоже может называться по-разному: (un)subscribe, (dis)connect, add/remove и прочие слова, которые так или иначе могут означать "связать", "добавить", "подписать", "назначить" и т.п. В предлагаемой системе я использую термины "сигнал", "слот", "подписать/отписать". На самом деле рекомендую не делать из этого священную корову, главное понимать смысл.

Немого лирики. Как можно заключить из вышесказанного, все эти заморочки с событиями и обработчиками не делают ничего нового, кроме как вызывают функции. Т.е. мы просто создаём новый способ вызывать функции, который имеет некие преимущества. Какие, будет описано далее.

Преимущества системы сигналов перед простым подходом

  1. Добавление очередного подписчика не приводит к изменению того модуля, откуда генерируется сигнал. Разумеется, один раз надо вставить код генерации сигнала, но после этого этот код уже меняться не будет, вне зависимости от количества модулей, использующих этот сигнал. Таким образом, можно добавлять новые куски в мод с минимальным изменением существующего кода, и вообще разные части мода будут более изолированы друг от друга. Это особенно важно для громоздких глобальных модов, состоящих из кучи разных компонентов.
  2. Как следствие единой системы вызовов для конкретного сигнала принимается соглашение о списках аргументов. Все подписчики на этот сигнал должны следовать этому соглашению. Это на самом деле сильно повышает надёжность системы в целом, поскольку снижает вероятность ошибок при разработке.
  3. Наличие централизованный системы вызовов даёт возможность использовать разные сервисы, которые в противном случае требовали бы индивидуальной и громоздкой реализации для каждого вызова.
    • Внедрена централизованная система отладки, которая автоматически определяет подвисший вызов. Теперь никакой подвисший колбек не останется незамеченным, если его обработка организована через менеджер сигналов. При этом никаких дополнительных усилий это не требует, не нужны обрамляющие отладочные вызовы, счётчики и т.п., поскольку это внедрено в код менеджера сигналов.
    • Частично сглаживается проблема драки за один объект между вызовами. Это особенно важно для обработчиков инвентарных предметов, а также событий нажатий клавиатуры. Общая проблема заключается в том, что событие одно, а адресовано оно как правило только одному из обработчиков. Т.е. использование предмета "спальный мешок" адресовано только обработчику менеджера сна. При этом, зачастую объект удаляется его обработчиком, что приводит к тому, что последующие обработчики имеют дело с объектом в стадии удаления (клиентский объект ещё есть, а серверного уже нет), что запросто может приводить к багам или дополнительному громоздкому коду, который должен эту ситуацию проверять. При использовании же системы сигналов у обработчиков есть возможность завершить цепочку вызовов на себе, и предотвратить вызов оставшихся в цепочке обработчиков. Для этого обработчик должен вернуть true (не вернуть ничего или вернуть false будет означать разрешение продолжить обработку). Кроме того, для некоторых вызовов сам менеджер производит проверку существования объекта, и если он уже удалён, то дальнейшие вызовы не будет сделаны вообще. Для обработчиков нажатий клавиатуры этот подход позволяет снизить нагрузку на процессор.
    • Есть возможность распределения нагрузки в виде низкоприоритетных (или очерёдных колбеков). В основном это имеет смысл только для использования вместе с сигналом update, тем не менее весьма полезно. Без такой фишки приходится делать это вручную со счётчиком, километровым кодом с if-ами и т.п.
  4. Обработчики можно не только подписывать на сигналы, но и отписывать. Это позволяет делать разнообразные динамические компоненты, которые "цепляются" к нужному сигналу и отсоединяются по мере необходимости. Это упрощает разработку, поскольку позволяет избежать громоздкого кода, который при отсутствии такой возможности проверял бы необходимость вызова, и, как следствие, повышает надёжность системы. Это активно используется, к примеру, в системе таймеров.
  5. На события можно подписывать не только обычные функции, но и методы классов. Это фишка конкретно этой реализации, но на мой взгляд полезная. Используется при реализации тех-же таймеров.
  6. В принципе возможны трюки с эмуляцией сигнала. Т.е. к примеру сигнал update биндера актора нормально вызывается из биндера актора, но никто не мешает принудительно вызывать его откуда-то ещё, что вызовет срабатывание всех подписанных на этот сигнал обработчиков. Я бы не рекомендовал использовать это без лишней нужды, но тем не менее возможность такая есть.
  7. Автоподключение модулей. Идея простая: пишется модуль с функциями-обработчиками каких-то событий, имя модуля должно следовать неким правилам для его распознавания менеджером, и также модуль должен содержать специальную переменную-метку и функцию, которая автоматически будет выполнена. В этой функции выполняется подписка на события функций из этого модуля. Используя эту технику можно написать модуль с колбеками, не изменив вообще ни строки ни в одном другом модуле. Всё, что надо сделать для его использования, просто поместить файл в папку scripts. Например, можно написать модуль, который выводит на худ информацию об объекте под прицелом по нажатию сочетания клавиш (нужны естественно колбеки на клавиши), или удаляет объект под прицелом, или меняет его свойства, или телепортирует актора на три метра вперёд, или открывает окно тестового спавна и т.п. Не нужен модуль, просто убрал его из папки со скриптами. Это всё очень удобно для отладочных модулей. Для использования в штатных компонентах не рекомендуется, поскольку по ряду причин для автоподписываемых модулей ослаблены проверки корректности.

Использование менеджера

Общие сведения

Код менеджера сигналов находится в двух модулях:

ogse_signals.script - собственно менеджер сигналов

ogse_signals_addons_list.script - список подписываемых модулей

Глобальный объект менеджера сигналов существует в единственном экземпляре. Поучить его можно функцией ogse_signals.get_mgr(). Объект менеджера используется для всех дальнейших операций: подписки/отписки функций и вызова сигналов.

Для подписки используется метод менеджера subscribe(slot_descriptor), для отписки - метод unsubscribe(slot_descriptor), для генерации сигнала - метод call("signal_name", <список аргументов>) slot_descriptor - это таблица, имеющая вид:

slot_descriptor = {signal = "signal_name", fun = function_or_class_member, self = object_reference_or_nil, queued = true}

Если необходимо отписать функцию от события, то надо сохранить этот дескриптор и позднее использовать его в функции unsubscribe. Для иллюстрации всего этого далее приводятся несколько конкретных примеров использования.

Подписка глобальной функции

Допустим, имеется модуль some_module.script, а в нём функция

function some_function(arg1, arg2)
end

Тогда я могу выполнить подписку этой функции на сигнал "some_signal" таким образом:

local slot_desc = {signal = "some_signal", fun = some_module.some_function} -- дескриптор слота
ogse_signals.get_mgr():subscribe(slot_desc) -- подписали слот

С этого момента при генерации сигнала "some_signal" будет вызываться функция some_module.some_function. Генерация сигнала (а по-простому вызов) осуществляется так:

ogse_signals.get_mgr():call("some_signal", arg1, arg2) -- вызов сигнала. В каждый обработчик будут переданы аргументы arg1, arg2

Для отписки этой функции делаем так (при условии, что мы сохранили дескриптор слота slot_desc):

ogse_signals.get_mgr():unsubscribe(slot_desc) -- отписали функцию


Замечание: модули, в котором находится функция some_module.some_function, из которого осуществляется подписка и отписка функции и откуда вызывается сигнал, могут быть совершенно разными. Хотя чаще всего получается так, что первые три - это один модуль, а откуда вызывается сигнал - другой.

Подписка глобальной функции в низкоприоритетную очередь

Здесь рассмотрим пример подписки функции на очерЁдное выполнение или оно же выполнение с низким приоритетом. Смысл очерёдности в том, что за каждый вызов сигнала выполняются не все подписанные функции, а только одна, в следующий раз следующая за ней в очереди и т.д. по кругу. В основном это имеет смысл только для события "update", которое вызывается из функции апдейта биндера актора. Используя эту возможность, можно распределить нагрузку между последовательными апдейтами за счёт снижения частоты вызовов каждого конкретного подписчика. Обращаю внимание, что при этом частота вызовов будет зависеть от количества подписчиков - чем их больше, тем реже они вызываются. Естественно, не для любых операций это годится, а только для тех, где важен факт срабатывания по принципу "пусть сработает хоть когда-нибудь". Если важна скорость реакции, то всегда остаётся возможность подписать на то же событие апдейта с высоким приоритетом. Технически эта фишка работает с использованием второй очереди.

В примере ниже подразумевается, что подписываемая функция и код подписывания/отписывания находятся в одном модуле. Таким образом, я могу избежать указания имени модуля. Более того, я могу использовать системную ссылку this, которая означает "этот модуль", чтобы избежать потенциальных конфликтов имён (вдруг среди глобальных имён встречается on_update).

function on_update() -- функция-обработчик события низкоприоритетного обновления
end
 
ogse_signals.get_mgr():subscribe({signal = "on_update", fun = this.on_update, queued = true}) -- подписали
 

В качестве дополнительного замечания. В этом примере мы не хотим отписываться от сигнала. Это вполне нормальная ситуация в особенности для глобальных функций типа обработчиков периодического апдейта. В этом случае нет необходимости сохранять дескриптор слота и синтаксис в целом упрощается.

Подписка метода класса

Имеется возможность подписать на событие метод класса. Напоминаю, что при вызове метода класса в него передаётся скрытый аргумент self, соответственно, при регистрации обработчика надо в дескрипторе указать дополнительный параметр со ссылкой на объект класса. Пример ниже показывает идею, на которой основана работы системы таймеров:

class "simple_timer"
function simple_timer:__init() -- конструктор класса
    self.slot_desc = {signal = "on_update", self = self, fun = self.on_update}
    self.sm = ogse_signals.get_mgr()
    sm:subscribe(self.slot_desc)
end
function simple_timer:on_update() -- функция периодической проверки некоего условия
    if <выполнилось некое условие> then
        self:on_finish()
    end
end
function simple_timer:on_finish() -- отписка и завершение выполнения
    sm:unsubscribe(self.slot_desc)
end

В данном примере объект класса при своём создании сам регистрирует один из своих методов на событие периодического апдейта. Далее, в этой функции проверяется некое условие. Когда это условие выполняется, то вызывается функция завершения работы, которая, кроме выполнения разной полезной нагрузки, также разрегистрирует класс в менеджере сигналов. Таким образом, для начала работы этого простого таймера достаточно просто его создать.

simple_timer()

и он автоматически начнёт работу. Более того, нет даже необходимости нигде хранить ссылку на этот объект, поскольку она хранится в менеджере сигналов (в поле self). При отписывании же, после окончании работы таймера, эта ссылка удаляется и вместе с ней сборщиком мусора удаляется и объект таймера.

Разумеется, это только упрощённый пример и лишь один из вариантов использования этой возможности.

Подписывание функционального объекта

Достаточно экзотическая возможность. Вряд ли потребуется, особенно при наличии возможности подписать метод класса.

Если с помощью метатаблицы задать классу оператор "call", то такой класс, с одной стороны, можно подписать как глобальную функцию, а с другой - он может хранить состояние, в отличие от простой функции.

class "some_luabind_class"
function some_luabind_class:__init()
	local mt = getmetatable(self)
	mt.__call = self.method_to_call
end
function some_luabind_class:method_to_call()
end
 
local slot_desc = {signal = "signal_name", fun = some_luabind_class()}
ogse_signals.get_mgr():subscribe(slot_desc) -- подписали в высокоприоритетную очередь
--...
ogse_signals.get_mgr():unsubscribe(slot_desc) -- отписали

Функциональный класс на таблице строится немного сложнее:

local t = {}
function t:method_to_call()
end
local mt = {}
mt.__call = t.method_to_call
setmetatable(t, mt)
 

и далее всё как в предыдущем фрагменте.

Подписка модуля

Часто встречается ситуация, когда имеется некий файл скрипта, который содержит в себе набор обработчиков разных событий. Многие геймплейные минимоды представляют собой такие модули. Их интеграция обычно включает явное прописывание вызовов этих обработчиков из разных колбеков игры. Специально для облегчения этого процесса в менеджере сигналов имеется возможность подписывать модуль целиком. Для подписывания модуля надо сделать следующее:

1. Вписать имя модуля без расширения в таблицу addons в файле ogse_signals_addons_list.script. Допустим, у меня есть модуль my_module.script, тогда я напишу так:
addons = {
	"my_module",
}
Примечание: запятая после последнего элемента массива допускается синтаксисом Lua.

2. В самом модуле my_module.script должна иметься глобальная функция attach(sm) с единственным аргументом. Эта функция будет вызвана автоматически при старте игры, а аргумент - это ссылка на менеджер сигналов. Функция может выглядеть примерно так:

function attach(sm)
	sm:subscribe({signal = "on_spawn", fun = this.on_spawn})
	sm:subscribe({signal = "on_use",   fun = this.on_item_use})
	sm:subscribe({signal = "on_update",fun = this.on_update, queued = true})
	sm:subscribe({signal = "on_save",  fun = this.on_save})
end

т.е. её задача - явно подписать на нужные сигналы обработчики из этого модуля.

Этот подход позволяет выполнить интеграцию скриптовой части минимода с минимальным остальных скриптов. По сути, извне модуля меняется только таблица в файле ogse_signals_addons_list.script - туда добавляется одна строка. Разумеется, соответствующие сигналы уже должны быть заведены в разных колбеках мода, но для большинства стандартных сигналов (типа колбеков биндера актора update, spawn, use_item и т.п.) это обычно уже сделано. Также, соглашения о вызовах обработчиков из подключаемого модуля должны следовать соглашениям о вызовах соответствующих сигналов.

Авторегистрация модуля

Имеется возможность подключать модуль без вписывания его в файл ogse_signals_addons_list.script. Для этого он должен отвечать двум дополнительным требованиям:

  1. Имя модуля должно начинаться с "ogse_"
  2. Модуль должен содержать глобальную переменную auto_attach, установленную в true.

Менеджер сигналов сканирует каталог скриптов, находит подходящие по имени, проверяет наличие переменной auto_attach и её значение, наличие функции attach, и если всё это присутствует, то пытается зарегистрировать модуль (т.е. собственно выполнить функцию attach).

Данный способ, несмотря на внешнее удобство, не рекомендуется использовать для "серьёзных" компонент. Дело в том, что если в модуле автоподключаемого модуля имеется синтаксическая ошибка, то при автоподключении он будет просто проигнорирован, что снижает надёжность системы. Данную возможность предпочтительно использовать для отладочных плагинов.

В качестве лирического отступления. Данная фишка на заре создания этой системы была основным функционалом, ради которого я эту систему и делал. Мне нужна была возможность создавать отладочные модули, которые можно было подключать/отключать просто перенося файл скрипта в папку scripts. При наличии движковых колбеков на нажатия клавиш получается очень удобная система. К примеру, есть наработки по отладочным модулям, которые позволяют снимать информацию о разных объектах вблизи актора и выводить её в лог или на экран по нажатию нужных сочетаний. С другой стороны я не включаю эти модули в релиз для тестеров, и это не требует изменения ни одной строки кода.

Авторы

Статья создана: malandrinus

Другие места
LANGUAGE