{}const=>[]async()letfn</>var
DéveloppementLua

Système d'événements en Lua : créer une architecture flexible d'événements, d'abonnements et de signaux

Apprenez à créer un puissant système d'événements en Lua à partir de zéro ! Apprenez à gérer les événements, à configurer les abonnements, à travailler avec les priorités et les signaux. Exemples pratiques pour le développement de jeux : de la mise en œuvre la plus simple à un système complet avec débogage.

К

Kodik

Auteur

9 min de lecture

Le système d'événements est l'un des modèles de programmation fondamentaux qui permet à différentes parties d'un programme de communiquer entre elles sans créer de lien rigide. Imaginez que vous avez un jeu où le personnage peut subir des dégâts. Au lieu d'appeler directement les fonctions de mise à jour de la santé, de l'interface et des sons, nous signalons simplement : « Un événement s'est produit : le personnage a subi des dégâts ». Et tous les systèmes intéressés y réagiront eux-mêmes.

Pourquoi avons-nous besoin d'événements ?

Dans l'approche classique, si nous voulons que l'interface se mette à jour, que le son joue et que l'animation apparaisse lorsque des dégâts sont subis, nous devons écrire les appels de tous ces systèmes dans la fonction de dégâts. Cela crée un problème : notre code devient lié, il est difficile à maintenir et à développer. Le système d'événements résout ce problème avec élégance : il permet aux composants du programme de fonctionner indépendamment les uns des autres.

Supposons que vous développiez un jeu où le joueur collecte des pièces. Dans une version simple, le code peut ressembler à ceci : lors de la collecte d'une pièce, nous mettons à jour le compteur, jouons un son, affichons une animation et vérifions les réalisations. Tout cela est dans une seule fonction. Mais que se passe-t-il si, dans un mois, vous souhaitez ajouter un système de quête qui doit également connaître les pièces collectées ? Vous devrez à nouveau entrer dans le code de collecte de pièces et y ajouter un nouvel appel. Avec les événements, il suffit de s'abonner au système de quête pour l'événement « pièce collectée », et il recevra automatiquement des notifications.

🔥 100 000+ étudiants déjà avec nous

Marre de lire la théorie ?
Il est temps de coder !

Kodik — une appli où tu apprends à coder par la pratique. Mentor IA, leçons interactives, projets réels.

🤖 IA 24/7
🎓 Certificats
💰 Gratuit
🚀 Commencer
Ont rejoint aujourd'hui

La mise en œuvre la plus simple du système d'événements

Commençons par la mise en œuvre de base. Le système d'événements est essentiellement un tableau où les clés sont les noms des événements et les valeurs sont des listes de fonctions d'écoute qui doivent être exécutées lorsqu'un événement se produit.

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

Cette implémentation simple est déjà opérationnelle. La méthode subscribe enregistre une fonction d'écoute pour un événement spécifique. S'il n'y a pas encore de liste d'auditeurs pour cet événement, nous créons un tableau vide. Ensuite, nous ajoutons la fonction à cette liste. La méthode emit appelle tous les auditeurs enregistrés pour un événement spécifique, en leur transmettant tous les paramètres qui ont été transmis lors de l'appel d'émission.

Voyons comment l'utiliser en pratique :

-- Подписываемся на событие "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")

Comme vous pouvez le voir, nous pouvons attribuer plusieurs fonctions à un événement, et elles seront toutes exécutées à tour de rôle. Chaque fonction reçoit les paramètres de l'événement et peut les utiliser à sa manière. Une fonction affiche un message dans la console, une autre met à jour l'interface, une troisième joue un son. Dans ce cas, l'endroit où l'événement se produit (emit) ne sait rien de ces fonctions et n'en dépend pas.

Se désabonner des événements

Parfois, vous devez arrêter d'écouter l'événement. Par exemple, l'effet temporaire est terminé ou l'objet a été supprimé du jeu. Ajoutons la possibilité de se désabonner :

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

Un point important : pour se désabonner, nous avons besoin d'un lien vers la même fonction que nous avons signée. Les fonctions anonymes ne peuvent pas être désabonnées si nous n'avons pas enregistré de lien vers elles :

-- Неправильно: нельзя отписаться
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) -- Работает!

Priorités des événements

Dans certains cas, l'ordre d'exécution des gestionnaires est important. Par exemple, nous voulons que la vérification du blocage des attaques se produise avant que les dégâts ne soient infligés. Ajoutons un système de priorités :

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

Nous pouvons maintenant spécifier la priorité lors de l'abonnement :

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)

Transfert et modification des données

Il est souvent nécessaire non seulement de notifier un événement, mais aussi de transmettre des données que les auditeurs peuvent modifier. Par exemple, le système d'armure peut réduire les dégâts subis. Pour ce faire, vous pouvez transférer un tableau avec les données :

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

Cette approche permet aux auditeurs d'influencer le résultat de l'événement. Nous pouvons annuler complètement l'événement (cancelled = true) ou modifier ses paramètres (réduire les dégâts).

Abonnements uniques

Parfois, il est nécessaire que le gestionnaire ne fonctionne qu'une seule fois. Par exemple, l'événement « jeu chargé » doit être traité une seule fois. Ajoutons la méthode once :

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

Ici, nous créons une fonction d'enveloppe qui appelle notre rappel, puis se désabonne de l'événement. L'utilisation est simple :

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

Exemple pratique : système de réalisations

Créons un système de réalisations simple en utilisant notre système d'événements :

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

Maintenant, n'importe où dans le code du jeu, nous pouvons simplement appeler des événements, et le système de réalisations suivra automatiquement les progrès :

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

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

Signaux nommés et typage

Pour les grands projets, il est utile de créer une liste centralisée de tous les événements du jeu. Cela permet d'éviter les fautes de frappe et rend le code plus compréhensible :

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)

Conseil : Cette approche protège contre les fautes de frappe - si nous écrivons Events.PLAYER_DAMAGD (avec une faute de frappe), Lua renverra une erreur, et ne trouvera pas simplement l'événement.

Débogage du système d'événements

Lorsque le projet comporte de nombreux événements, il est utile d'ajouter la possibilité de suivre leurs appels :

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

Erreurs dans les gestionnaires

Détail important — si une erreur se produit dans l'un des gestionnaires, les autres gestionnaires ne s'exécuteront pas. Vous pouvez ajouter une protection :

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

Désormais, même si un gestionnaire tombe en erreur, les autres continueront à fonctionner.

Mise en œuvre complète

Voici la version finale de notre système d'événements avec toutes les fonctionnalités :

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

Le système d'événements est un outil puissant qui rend votre code plus flexible et extensible. Au lieu d'une connexion rigide entre les composants, vous obtenez une architecture faiblement liée où chaque système peut fonctionner indépendamment. Ceci est particulièrement important dans les jeux où des dizaines de systèmes différents interagissent : graphismes, son, physique, intelligence artificielle, interface et bien plus encore.

Vous pouvez apprendre Lua et d'autres langages de programmation à partir de zéro dans Codique — notre plateforme éducative avec des cours pratiques pour les développeurs débutants.

Et nous avons aussi un super chaîne Telegram avec une communauté amicale où vous pouvez poser des questions, partager vos projets et discuter avec des personnes partageant les mêmes idées ! 🚀

🎯Arrête de reporter

Tu as aimé l'article ?
Place à la pratique !

Avec Kodik, tu ne lis pas seulement — tu codes immédiatement. Théorie + pratique = vraies compétences.

Pratique instantanée
🧠L'IA explique le code
🏆Certificat

Sans inscription • Sans carte