Сповіщення
Очистити все

Пишемо скрипти. Менеджер сигналів


Ранг:
Майстер
Роль:
Гість
Записи:
752
Приєднався:
7 місяців тому
 

Система сигналів у стилі 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 ) 
_ _ _ _

щоб система подій знала, що треба викликати ці функції.

Трохи про термінологію. У різних системах і мовами різних частин цього процесу використовуються різні назви. Подія (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 ( s 

Функціональний клас на таблиці будується трохи складніше:

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 . За наявності движкових колбеків на натискання кнопок виходить дуже комфортна система. Наприклад, є напрацювання по модулям налагодження, які дозволяють знімати інформацію про різні об'єкти поблизу актора і виводити її в лог або на екран по натисканню необхідних поєднань. З іншого боку, я не включаю ці модулі в реліз для тестерів, і це не вимагає зміни жодного рядка коду.


   
Цитата