{}const=>[]async()letfn</>var
開発Lua

Luaのイベントシステム:柔軟なイベント、サブスクリプション、シグナルのアーキテクチャを作成する

Luaで強力なイベントシステムをゼロから作成する方法を学びましょう!イベントの処理方法、サブスクリプションの設定方法、優先順位とシグナルの操作方法を学びます。ゲーム開発の実例:最も単純な実装からデバッグを伴う本格的なシステムまで。

К

Kodik

著者

4分で読める

イベントシステムは、プログラムのさまざまな部分が厳密な接続を作成することなく相互に通信できるようにする基本的なプログラミングパターンの1つです。キャラクターがダメージを受ける可能性のあるゲームがあるとします。健康、インターフェース、サウンドの更新機能を直接呼び出す代わりに、「イベントが発生しました—キャラクターがダメージを受けました」と報告します。そして、すべての関係するシステムはそれに反応します。

イベントが必要な理由

従来のアプローチでは、ダメージを受けたときにインターフェースを更新し、サウンドを再生し、アニメーションを表示するには、これらすべてのシステムの呼び出しをダメージ関数に記述する必要があります。これにより、コードが結合され、維持および拡張が難しくなるという問題が発生します。イベントシステムはこの問題をエレガントに解決します。プログラムのコンポーネントが互いに独立して動作することを可能にします。

プレイヤーがコインを集めるゲームを開発しているとします。簡単なバージョンでは、コインを集めるときにカウンターを更新し、サウンドを再生し、アニメーションを表示し、達成度を確認するなど、コードは次のようになります。これらはすべて1つの関数に含まれています。しかし、1か月後に、収集されたコインについても知る必要があるクエストシステムを追加したい場合はどうなりますか?コインコレクションコードに再び入り、そこに新しい呼び出しを追加する必要があります。イベントでは、クエストシステムを「コイン収集」イベントにサインするだけで、自動的に通知を受け取ることができます。

🔥 10万人以上の学生が参加中

理論を読むのに疲れた?
コーディングの時間だ!

Kodik — 実践でプログラミングを学ぶアプリ。AIメンター、インタラクティブなレッスン、実際のプロジェクト。

🤖 AI 24時間
🎓 修了証
💰 無料
🚀 始める
今日参加

イベントシステムの最も単純な実装

基本的な実装から始めましょう。イベントシステムは基本的に、キーがイベント名、値がイベント発生時に実行されるリスナ関数のリストであるテーブルです。

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("Player received " .. damage .. " damage from " .. 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, "Fireball")

ご覧のとおり、1つのイベントに複数の関数を割り当てることができ、それらはすべて順番に実行されます。各関数はイベントパラメータを受け取り、独自の方法でそれらを使用できます。1つの関数はコンソールにメッセージを出力し、もう1つはインターフェースを更新し、3つ目はサウンドを再生します。同時に、イベントが発生する場所(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("Unit check")
end, 10)

EventSystem:subscribe("player_attack", function()
    print("Damage")
end, 5)

EventSystem:subscribe("player_attack", function()
    print("Impact animation")
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

このアプローチにより、リスナーはイベントの結果に影響を与えることができます。イベントを完全にキャンセルするか(キャンセル済み= true )、パラメータを変更するか(ダメージを減らす)ことができます。

1回限りのサブスクリプション

ハンドラーが1回だけ動作する必要がある場合があります。たとえば、「ゲームがロードされた」イベントは1回のみ処理する必要があります。メソッドを追加します once:

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

ここでは、コールバックを呼び出し、イベントのサブスクリプションを解除するラッパー関数を作成します。使い方は簡単です。

EventSystem:once("game_loaded", function()
    print("Game loaded! This message will only appear once")
    initializeGame()
end)

実例:達成システム

イベントシステムを使用して、シンプルなアチーブメントシステムを作成しましょう。

Achievements = {
    list = {
        first_kill = { unlocked = false, name = "First Blood" },
        coin_collector = { unlocked = false, name = "Collector", 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("🏆 Achievement received: " .. 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 received: " .. damage)
end)

EventSystem:emit(Events.PLAYER_DAMAGED, 25)

ヒント: このアプローチは、入力ミスから保護します。Events.PLAYER_DAMAGD (入力ミス)と入力すると、Lua はイベントを見つけられないだけでなく、エラーを出します。

イベントシステムのデバッグ

プロジェクトにイベントが多い場合は、それらの呼び出しを追跡する機能を追加すると便利です。

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

ハンドラのエラー

重要な点は、ハンドラーの1つでエラーが発生した場合、残りのハンドラーが実行されないことです。保護を追加できます。

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

これで、1つのハンドラーがエラーで終了しても、残りのハンドラーは動作し続けます。

完全実装

これが、すべての機能を備えたイベントシステムの最終バージョンです。

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

イベントシステムは、コードをより柔軟で拡張性の高いものにする強力なツールです。コンポーネント間の厳密な接続の代わりに、各システムが独立して動作できる、ゆるやかに接続されたアーキテクチャーを得ることができます。これは、グラフィックス、サウンド、物理、AI、インターフェースなど、数十の異なるシステムが相互作用するゲームでは特に重要です。

Luaやその他のプログラミング言語をゼロから習得することができます コディケ — 初心者向けの実践的なコースを提供する当社の教育プラットフォーム。

そして、私たちは素晴らしい Telegramチャンネル 質問をしたり、プロジェクトを共有したり、志を同じくする人々と交流したりできるフレンドリーなコミュニティ! 🚀

🎯先延ばしをやめよう

記事は気に入った?
実践の時間だ!

Kodikでは読むだけでなく、すぐにコードを書く。理論 + 実践 = 本当のスキル。

即座に実践
🧠AIがコードを説明
🏆修了証

登録不要 • カード不要