Event-система в Lua: создаём гибкую архитектуру событий, подписок и сигналов

Научитесь создавать мощную Event-систему в Lua с нуля! Узнайте, как правильно обрабатывать события, настраивать подписки, работать с приоритетами и сигналами. Практические примеры для игровой разработки: от простейшей реализации до полноценной системы с отладкой.

РазработкаLua

6 мин

Event-система (система событий) — это один из фундаментальных паттернов программирования, который позволяет различным частям программы общаться между собой, не создавая жёсткой связи. Представьте, что у вас есть игра, где персонаж может получить урон. Вместо того чтобы напрямую вызывать функции обновления здоровья, интерфейса и звуков, мы просто сообщаем: "Произошло событие — персонаж получил урон". А все заинтересованные системы сами на это отреагируют.

Зачем нужны события?

В классическом подходе, если мы хотим, чтобы при получении урона обновился интерфейс, заиграл звук и появилась анимация, нам нужно в функции получения урона прописать вызовы всех этих систем. Это создаёт проблему: наш код становится связанным, его сложно поддерживать и расширять. Event-система решает эту проблему элегантно — она позволяет компонентам программы работать независимо друг от друга.

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

Простейшая реализация Event-системы

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

EventSystem = {}
EventSystem.events = {}

function EventSystem:subscribe(eventName, callback)
    if not self.events[eventName] then
        self.events[eventName] = {}
    end
    table.insert(self.events[eventName], callback)
end

function EventSystem:emit(eventName, ...)
    if self.events[eventName] then
        for _, callback in ipairs(self.events[eventName]) do
            callback(...)
        end
    end
end

Эта простая реализация уже работоспособна. Метод subscribe регистрирует функцию-слушателя для конкретного события. Если для данного события ещё нет списка слушателей, мы создаём пустую таблицу. Затем добавляем функцию в этот список. Метод emit вызывает все зарегистрированные слушатели для конкретного события, передавая им все параметры, которые были переданы при вызове emit.

Давайте посмотрим, как это использовать на практике:

-- Подписываемся на событие "player_damaged"
EventSystem:subscribe("player_damaged", function(damage, source)
    print("Игрок получил " .. damage .. " урона от " .. source)
end)

EventSystem:subscribe("player_damaged", function(damage)
    -- Обновляем интерфейс
    updateHealthBar(damage)
end)

EventSystem:subscribe("player_damaged", function()
    -- Проигрываем звук
    playSound("hurt.wav")
end)

-- Где-то в коде игры происходит событие
EventSystem:emit("player_damaged", 25, "Огненный шар")

Как видите, мы можем подписать несколько функций на одно событие, и все они выполнятся по очереди. Каждая функция получает параметры события и может использовать их по-своему. Одна функция выводит сообщение в консоль, другая обновляет интерфейс, третья проигрывает звук. При этом место, где происходит событие (emit), ничего не знает об этих функциях и не зависит от них.

Отписка от событий

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

function EventSystem:unsubscribe(eventName, callback)
    if not self.events[eventName] then
        return
    end
    
    for i, listener in ipairs(self.events[eventName]) do
        if listener == callback then
            table.remove(self.events[eventName], i)
            return
        end
    end
end

Важный момент — для отписки нам нужна ссылка на ту же самую функцию, которую мы подписывали. Анонимные функции отписать не получится, если мы не сохранили на них ссылку:

-- Неправильно: нельзя отписаться
EventSystem:subscribe("event", function() print("test") end)
EventSystem:unsubscribe("event", function() print("test") end) -- Не сработает!

-- Правильно: сохраняем ссылку
local myCallback = function() print("test") end
EventSystem:subscribe("event", myCallback)
EventSystem:unsubscribe("event", myCallback) -- Работает!

Приоритеты событий

В некоторых случаях важен порядок выполнения обработчиков. Например, мы хотим, чтобы проверка на блокирование атаки происходила раньше, чем нанесение урона. Добавим систему приоритетов:

function EventSystem:subscribe(eventName, callback, priority)
    if not self.events[eventName] then
        self.events[eventName] = {}
    end
    
    priority = priority or 0
    
    local listener = {
        callback = callback,
        priority = priority
    }
    
    table.insert(self.events[eventName], listener)
    
    -- Сортируем по приоритету (больший приоритет выполняется раньше)
    table.sort(self.events[eventName], function(a, b)
        return a.priority > b.priority
    end)
end

function EventSystem:emit(eventName, ...)
    if self.events[eventName] then
        for _, listener in ipairs(self.events[eventName]) do
            listener.callback(...)
        end
    end
end

Теперь мы можем указать приоритет при подписке:

EventSystem:subscribe("player_attack", function()
    print("Проверка блока")
end, 10)

EventSystem:subscribe("player_attack", function()
    print("Нанесение урона")
end, 5)

EventSystem:subscribe("player_attack", function()
    print("Анимация удара")
end, 1)

Передача данных и их модификация

Часто нужно не просто уведомить о событии, но и передать данные, которые слушатели могут модифицировать. Например, система брони может уменьшить получаемый урон. Для этого можно передавать таблицу с данными:

local eventData = {
    damage = 50,
    damageType = "fire",
    cancelled = false
}

EventSystem:subscribe("before_damage", function(data)
    -- Проверяем иммунитет к огню
    if player.hasFireImmunity and data.damageType == "fire" then
        data.cancelled = true
    end
end, 10)

EventSystem:subscribe("before_damage", function(data)
    -- Применяем защиту от брони
    data.damage = data.damage * (1 - player.armor / 100)
end, 5)

EventSystem:emit("before_damage", eventData)

if not eventData.cancelled then
    player.health = player.health - eventData.damage
end

Такой подход позволяет слушателям влиять на результат события. Мы можем отменить событие полностью (cancelled = true) или изменить его параметры (уменьшить урон).

Одноразовые подписки

Иногда нужно, чтобы обработчик сработал только один раз. Например, событие "игра загружена" должно обработаться единожды. Добавим метод once:

function EventSystem:once(eventName, callback, priority)
    local onceWrapper
    onceWrapper = function(...)
        callback(...)
        self:unsubscribe(eventName, onceWrapper)
    end
    
    self:subscribe(eventName, onceWrapper, priority)
end

Здесь мы создаём функцию-обёртку, которая вызывает наш callback, а затем отписывается от события. Использование простое:

EventSystem:once("game_loaded", function()
    print("Игра загружена! Это сообщение появится только один раз")
    initializeGame()
end)

Практический пример: система достижений

Давайте создадим простую систему достижений, используя нашу Event-систему:

Achievements = {
    list = {
        first_kill = { unlocked = false, name = "Первая кровь" },
        coin_collector = { unlocked = false, name = "Коллекционер", coins_needed = 100 }
    },
    coins_collected = 0
}

function Achievements:init()
    EventSystem:subscribe("enemy_killed", function()
        if not self.list.first_kill.unlocked then
            self.list.first_kill.unlocked = true
            self:showAchievement(self.list.first_kill.name)
        end
    end)
    
    EventSystem:subscribe("coin_collected", function()
        self.coins_collected = self.coins_collected + 1
        if self.coins_collected >= self.list.coin_collector.coins_needed 
           and not self.list.coin_collector.unlocked then
            self.list.coin_collector.unlocked = true
            self:showAchievement(self.list.coin_collector.name)
        end
    end)
end

function Achievements:showAchievement(name)
    print("🏆 Достижение получено: " .. name)
    EventSystem:emit("achievement_unlocked", name)
end

Теперь где угодно в коде игры мы можем просто вызывать события, и система достижений будет отслеживать прогресс автоматически:

-- В коде боевой системы
function Enemy:die()
    EventSystem:emit("enemy_killed", self)
    self:remove()
end

-- В коде сбора предметов
function Coin:collect()
    EventSystem:emit("coin_collected")
    self:remove()
end

Именованные сигналы и типизация

Для больших проектов полезно создать централизованный список всех событий в игре. Это помогает избежать опечаток и делает код более понятным:

Events = {
    PLAYER_DAMAGED = "player_damaged",
    PLAYER_HEALED = "player_healed",
    ENEMY_KILLED = "enemy_killed",
    COIN_COLLECTED = "coin_collected",
    LEVEL_COMPLETED = "level_completed",
    GAME_PAUSED = "game_paused"
}

-- Использование
EventSystem:subscribe(Events.PLAYER_DAMAGED, function(damage)
    print("Урон получен: " .. damage)
end)

EventSystem:emit(Events.PLAYER_DAMAGED, 25)

Совет: Такой подход защищает от опечаток — если мы напишем Events.PLAYER_DAMAGD (с опечаткой), Lua выдаст ошибку, а не просто не найдёт событие.

Отладка Event-системы

Когда в проекте много событий, полезно добавить возможность отслеживать их вызовы:

EventSystem.debug = false

function EventSystem:emit(eventName, ...)
    if self.debug then
        print("Event fired: " .. eventName)
    end
    
    if self.events[eventName] then
        for i, listener in ipairs(self.events[eventName]) do
            if self.debug then
                print("  -> Calling listener #" .. i)
            end
            listener.callback(...)
        end
    end
end

-- Включаем отладку
EventSystem.debug = true

Ошибки в обработчиках

Важная деталь — если в одном из обработчиков произойдёт ошибка, остальные обработчики не выполнятся. Можно добавить защиту:

function EventSystem:emit(eventName, ...)
    if self.events[eventName] then
        for _, listener in ipairs(self.events[eventName]) do
            local success, error = pcall(listener.callback, ...)
            if not success then
                print("Error in event handler: " .. tostring(error))
            end
        end
    end
end

Теперь даже если один обработчик упадёт с ошибкой, остальные продолжат работать.

Полная реализация

Вот финальная версия нашей Event-системы со всеми возможностями:

EventSystem = {
    events = {},
    debug = false
}

function EventSystem:subscribe(eventName, callback, priority)
    if not self.events[eventName] then
        self.events[eventName] = {}
    end
    
    priority = priority or 0
    
    local listener = {
        callback = callback,
        priority = priority
    }
    
    table.insert(self.events[eventName], listener)
    
    table.sort(self.events[eventName], function(a, b)
        return a.priority > b.priority
    end)
    
    return callback
end

function EventSystem:unsubscribe(eventName, callback)
    if not self.events[eventName] then return end
    
    for i, listener in ipairs(self.events[eventName]) do
        if listener.callback == callback then
            table.remove(self.events[eventName], i)
            return true
        end
    end
    
    return false
end

function EventSystem:once(eventName, callback, priority)
    local onceWrapper
    onceWrapper = function(...)
        callback(...)
        self:unsubscribe(eventName, onceWrapper)
    end
    
    self:subscribe(eventName, onceWrapper, priority)
    return onceWrapper
end

function EventSystem:emit(eventName, ...)
    if self.debug then
        print("Event: " .. eventName)
    end
    
    if self.events[eventName] then
        for i, listener in ipairs(self.events[eventName]) do
            local success, error = pcall(listener.callback, ...)
            if not success then
                print("Event handler error in '" .. eventName .. "': " .. tostring(error))
            end
        end
    end
end

function EventSystem:clear(eventName)
    if eventName then
        self.events[eventName] = nil
    else
        self.events = {}
    end
end

return EventSystem

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

Освоить Lua и другие языки программирования с нуля можно в Кодике — нашей образовательной платформе с практическими курсами для начинающих разработчиков.

А ещё у нас есть крутой телеграм канал с дружеским комьюнити, где можно задавать вопросы, делиться своими проектами и общаться с единомышленниками! 🚀

Комментарии