Dans cet article, je parlerai de la façon dont vous pouvez implémenter des séquences d'actions et des cinématiques dans les jeux vidéo. Cet article est une traduction de cet article et sur le même sujet, j'ai fait une présentation à Lua à Moscou, donc si vous préférez regarder la vidéo, vous pouvez la regarder ici .
Le code de l'article est écrit en Lua, mais peut facilement être écrit dans d'autres langues (à l'exception de la méthode qui utilise des coroutines, car elles ne sont pas dans toutes les langues).
L'article montre comment créer un mécanisme qui vous permet d'écrire des cinématiques de la forme suivante:
local function cutscene(player, npc) player:goTo(npc) if player:hasCompleted(quest) then npc:say("You did it!") delay(0.5) npc:say("Thank you") else npc:say("Please help me") end end
Entrée
Les séquences d'action se retrouvent souvent dans les jeux vidéo. Par exemple, dans les cinématiques: le personnage rencontre l'ennemi, lui dit quelque chose, l'ennemi répond, etc. La séquence d'actions peut être trouvée dans le gameplay. Jetez un oeil à ce gif:

1. La porte s'ouvre
2. Le personnage entre dans la maison
3. La porte se ferme
4. L'écran s'assombrit progressivement
5. Le niveau change
6. L'écran s'estompe fortement
7. Le personnage entre dans le café
Les séquences d'actions peuvent également être utilisées pour scénariser le comportement des PNJ ou pour implémenter des batailles de boss dans lesquelles le boss effectue certaines actions les unes après les autres.
Le problème
La structure d'une boucle de jeu standard rend difficile la mise en œuvre de séquences d'actions. Disons que nous avons la boucle de jeu suivante:

while game:isRunning() do processInput() dt = clock.delta() update(dt) render() end
Nous voulons implémenter la cinématique suivante: le joueur s'approche du PNJ, le PNJ dit: "Vous l'avez fait!", Et puis après une courte pause dit: "Merci!". Dans un monde idéal, nous l'écririons comme ceci:
player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you")
Et ici, nous sommes confrontés à un problème. Il faut un certain temps pour terminer les étapes. Certaines actions peuvent même attendre la saisie du lecteur (par exemple, pour fermer la boîte de dialogue). Au lieu de la fonction de
delay
, vous ne pouvez pas appeler le même
sleep
- il semblerait que le jeu soit gelé.
Jetons un coup d'œil à quelques approches pour résoudre le problème.
bool, enum, machines d'état
La façon la plus évidente d'implémenter une séquence d'actions consiste à stocker des informations sur l'état actuel dans des booléens, des lignes ou des énumérations. Le code ressemblera à ceci:
function update(dt) if cutsceneState == 'playerGoingToNpc' then player:continueGoingTo(npc) if player:closeTo(npc) then cutsceneState = 'npcSayingYouDidIt' dialogueWindow:show("You did it!") end elseif cutsceneState == 'npcSayingYouDidIt' then if dialogueWindow:wasClosed() then cutsceneState = 'delay' end elseif ... ...
Cette approche conduit facilement à du code spaghetti et à de longues chaînes d'expressions if-else, donc je recommande d'éviter cette méthode de résolution du problème.
Liste d'actions
Les listes d'actions sont très similaires aux machines à états. La liste d'actions est une liste d'actions qui sont exécutées l'une après l'autre. Dans la boucle du jeu, la fonction de
update
est appelée pour l'action en cours, ce qui nous permet de traiter l'entrée et de rendre le jeu, même si l'action prend beaucoup de temps. Une fois l'action terminée, nous passons à la suivante.
Dans la cinématique que nous voulons implémenter, nous devons implémenter les actions suivantes: GoToAction, DialogueAction et DelayAction.
Pour d'autres exemples, j'utiliserai la bibliothèque
middleclass pour OOP dans Lua.
Voici comment une
DelayAction
implémentée:
La fonction
ActionList:update
ressemble à ceci:
function ActionList:update(dt) if not self.isFinished then self.currentAction:update(dt) if self.currentAction.isFinished then self:goToNextAction() if not self.currentAction then self.isFinished = true end end end end
Et enfin, la mise en œuvre de la cinématique elle-même:
function makeCutsceneActionList(player, npc) return ActionList:new { GoToAction:new { entity = player, target = npc }, SayAction:new { entity = npc, text = "You did it!" }, DelayAction:new { delay = 0.5 }, SayAction:new { entity = npc, text = "Thank you" } } end
Remarque : dans Lua, un appel à
someFunction({ ... })
peut être fait comme ceci:
someFunction{...}
. Cela vous permet d'écrire
DelayAction:new{ delay = 0.5 }
au lieu de
DelayAction:new({delay = 0.5})
.
Ça a l'air beaucoup mieux. Le code montre clairement la séquence d'actions. Si nous voulons ajouter une nouvelle action, nous pouvons facilement le faire. Il est assez simple de créer des classes comme
DelayAction
pour rendre l'écriture des cinématiques plus pratique.
Je vous conseille de voir la présentation de Sean Middleditch sur les listes d'actions, qui fournit des exemples plus complexes.
Les listes d'actions sont généralement très utiles. Je les ai utilisés pour mes jeux pendant un certain temps et dans l'ensemble j'étais content. Mais cette approche présente également des inconvénients. Disons que nous voulons implémenter une cinématique légèrement plus complexe:
local function cutscene(player, npc) player:goTo(npc) if player:hasCompleted(quest) then npc:say("You did it!") delay(0.5) npc:say("Thank you") else npc:say("Please help me") end end
Pour faire une simulation if / else, vous devez implémenter des listes non linéaires. Cela peut être fait à l'aide de balises. Certaines actions peuvent être marquées, puis, par certaines conditions, au lieu de passer à l'action suivante, vous pouvez passer à une action qui a la balise souhaitée. Cela fonctionne, mais il n'est pas aussi facile à lire et à écrire que la fonction ci-dessus.
Les coroutines Lua font de ce code une réalité.
Coroutines
Les bases de Corua à Lua
La corutine est une fonction qui peut être interrompue puis reprise plus tard. Les coroutines sont exécutées dans le même thread que le programme principal. Aucun nouveau thread n'est jamais créé pour coroutine.
Pour suspendre
coroutine.yield
, vous devez appeler
coroutine.yield
, pour reprendre -
coroutine.resume
. Un exemple simple:
local function f() print("hello") coroutine.yield() print("world!") end local c = coroutine.create(f) coroutine.resume(c) print("uhh...") coroutine.resume(c)
Sortie du programme:
bonjour
euh ...
monde
Voici comment cela fonctionne. Tout d'abord, nous créons
coroutine.create
utilisant
coroutine.create
. Après cet appel, la corutine ne démarre pas. Pour cela, nous devons l'exécuter à l'aide de
coroutine.resume
. Ensuite, la fonction
f
est appelée, qui écrit «bonjour» et s'arrête avec
coroutine.yield
. Cela revient à
return
, mais nous pouvons reprendre
f
avec
coroutine.resume
.
Si vous passez des arguments lors de l'appel de
coroutine.yield
, ils deviendront les valeurs de retour de l'appel correspondant à
coroutine.resume
dans le "flux principal".
Par exemple:
local function f() ... coroutine.yield(42, "some text") ... end ok, num, text = coroutine.resume(c) print(num, text)
ok
est une variable qui nous permet de connaître l'état d'une coroutine. Si
ok
est
true
, alors avec coroutine tout va bien, aucune erreur ne s'est produite à l'intérieur. Les valeurs de retour qui le suivent (
num
,
text
) sont les mêmes arguments que nous avons passés pour
yield
.
Si
ok
est
false
, alors quelque chose s'est mal passé avec la coroutine, par exemple, la fonction d'
error
été appelée à l'intérieur. Dans ce cas, la deuxième valeur de retour sera un message d'erreur. Un exemple de coroutine dans laquelle une erreur se produit:
local function f() print(1 + notDefined) end c = coroutine.create(f) ok, msg = coroutine.resume(c) if not ok then print("Coroutine failed!", msg) end
Conclusion:
Coroutine a échoué! entrée: 4: tentative d'exécution d'arithmétique sur une valeur nulle (global «non défini»)
Le statut Coroutine peut être obtenu en appelant
coroutine.status
. La corutine peut être dans les conditions suivantes:
- «Running» - Coroutine est en cours d'exécution.
coroutine.status
été appelé à partir de la corutine elle-même - «Suspendu» - Corutin a été suspendu ou n'a jamais été démarré
- "Normal" - la corutine est active, mais pas exécutée. Autrement dit, la corutine a lancé une autre corutine en elle-même
- «Dead» - exécution terminée de la coroutine (c'est-à-dire, la fonction dans la coroutine terminée)
Maintenant, à l'aide de ces connaissances, nous pouvons mettre en œuvre un système de séquences d'actions et de cinématiques basées sur des coroutines.
Création de cinématiques à l'aide de corutine
Voici à quoi ressemblera la classe
Action
base sur le nouveau système:
function Action:launch() self:init() while not self.finished do local dt = coroutine.yield() self:update(dt) end self:exit() end
L'approche est similaire aux listes d'actions: la fonction de
update
de l'action est appelée jusqu'à la fin de l'action. Mais ici, nous utilisons des coroutines et
yield
à chaque itération de la boucle de jeu (
Action:launch
est appelé depuis une coroutine). Quelque part dans la
update
boucle de jeu, nous reprenons l'exécution de la cinématique actuelle comme ceci:
coroutine.resume(c, dt)
Et enfin, créer une cinématique:
function cutscene(player, npc) player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you") end
Voici comment la fonction de
delay
est implémentée:
function delay(time) action = DelayAction:new { delay = time } action:launch() end
La création de tels wrappers améliore considérablement la lisibilité du code de la cinématique.
DelayAction
implémenté comme ceci:
Cette implémentation est identique à celle que nous avons utilisée dans les listes d'actions! Jetons un coup d'œil à la fonction
Action:launch
:
function Action:launch() self:init() while not self.finished do local dt = coroutine.yield()
L'essentiel ici est la
while
, qui s'exécute jusqu'à la fin de l'action. Cela ressemble à ceci:

Voyons maintenant la fonction
goTo
:
function Entity:goTo(target) local action = GoToAction:new { entity = self, target = target } action:launch() end function GoToAction:initialize(params) ... end function GoToAction:update(dt) if not self.entity:closeTo(self.target) then ...
Les coroutines vont bien avec les événements. Implémentez la classe
WaitForEventAction
:
function WaitForEventAction:initialize(params) self.finished = false eventManager:subscribe { listener = self, eventType = params.eventType, callback = WaitForEventAction.onEvent } end function WaitForEventAction:onEvent(event) self.finished = true end
Cette fonction n'a pas besoin de la méthode de
update
. Il sera exécuté (bien qu'il ne fasse rien ...) jusqu'à ce qu'il reçoive un événement du type requis. Voici l'application pratique de cette classe - l'implémentation de la fonction
say
:
function Entity:say(text) DialogueWindow:show(text) local action = WaitForEventAction:new { eventType = 'DialogueWindowClosed' } action:launch() end
Simple et lisible. Lorsque la boîte de dialogue se ferme, elle distribue un événement de type «DialogueWindowClosed». L'action say se termine et la suivante commence à s'exécuter.
En utilisant coroutine, vous pouvez facilement créer des cinématiques non linéaires et des arbres de dialogue:
local answer = girl:say('do_you_love_lua', { 'YES', 'NO' }) if answer == 'YES' then girl:setMood('happy') girl:say('happy_response') else girl:setMood('angry') girl:say('angry_response') end

Dans cet exemple, la fonction
say
est légèrement plus complexe que celle que j'ai montrée précédemment. Il renvoie le choix du joueur dans le dialogue, mais il n'est pas difficile à mettre en œuvre. Par exemple,
WaitForEventAction
peut être utilisé en
WaitForEventAction
, qui interceptera l'événement PlayerChoiceEvent, puis renverra le choix du lecteur dont les informations seront contenues dans l'objet événement.
Exemples un peu plus complexes
Avec coroutine, vous pouvez facilement créer des tutoriels et des petites quêtes. Par exemple:
girl:say("Kill that monster!") waitForEvent('EnemyKilled') girl:setMood('happy') girl:say("You did it! Thank you!")

Les coroutines peuvent également être utilisées pour l'IA. Par exemple, vous pouvez créer une fonction avec laquelle le monstre se déplacera le long d'une trajectoire:
function followPath(monster, path) local numberOfPoints = path:getNumberOfPoints() local i = 0

Lorsque le monstre voit le joueur, nous pouvons simplement arrêter de remplir la coroutine et l'enlever. Par conséquent, une boucle infinie (bien
while true
) à l'intérieur de
followPath
n'est pas vraiment infinie.
Avec la corutine, vous pouvez effectuer des actions "parallèles". La cinématique ne passera pas à l'action suivante tant que les deux actions ne seront pas terminées. Par exemple, nous allons créer une cinématique où une fille et un chat se rendent au point d'un ami à différentes vitesses. Après qu'ils soient venus vers elle, le chat dit «miaou».
function cutscene(cat, girl, meetingPoint) local c1 = coroutine.create( function() cat:goTo(meetingPoint) end) local c2 = coroutine.create( function() girl:goTo(meetingPoint) end) c1.resume() c2.resume()
La partie la plus importante ici est la fonction
waitForFinish
, qui est un wrapper autour de la classe
WaitForFinishAction
, qui peut être implémentée comme suit:
function WaitForFinishAction:update(dt) if coroutine.status(self.c1) == 'dead' and coroutine.status(self.c2) == 'dead' then self.finished = true else if coroutine.status(self.c1) ~= 'dead' then coroutine.resume(self.c1, dt) end if coroutine.status(self.c2) ~= 'dead' then coroutine.resume(self.c2, dt) end end
Vous pouvez rendre cette classe plus puissante si vous autorisez la synchronisation du Nième nombre d'actions.
Vous pouvez également créer une classe qui attendra la fin de l'
une des coroutines, au lieu d'attendre la fin de l'exécution de
toutes les coroutines. Par exemple, il peut être utilisé dans les mini-jeux de course. À l'intérieur de la coroutine, il y aura une attente pour que l'un des coureurs atteigne la ligne d'arrivée, puis exécute une séquence d'actions.
Avantages et inconvénients de la corutine
Les coroutines sont un mécanisme très utile. En les utilisant, vous pouvez écrire des cinématiques et du code de jeu faciles à lire et à modifier. Les cinématiques de ce type peuvent facilement être écrites par des moddeurs ou des personnes qui ne sont pas des programmeurs (par exemple, des concepteurs de jeux ou de niveaux).
Et tout cela est effectué dans un seul thread, donc il n'y a pas de problème de synchronisation ou de
condition de concurrence .
L'approche présente des inconvénients. Par exemple, il peut y avoir des problèmes avec la sauvegarde. Disons que votre jeu a un long tutoriel implémenté avec coroutine. Pendant ce tutoriel, le joueur ne pourra pas sauvegarder car Pour ce faire, vous devrez enregistrer l'état actuel de la coroutine (qui inclut l'intégralité de sa pile et les valeurs des variables à l'intérieur), afin que lors d'un chargement supplémentaire à partir de la sauvegarde, vous puissiez continuer le didacticiel.
(
Remarque : en utilisant la bibliothèque
PlutoLibrary, les coroutines peuvent être sérialisées, mais la bibliothèque ne fonctionne qu'avec Lua 5.1)
Ce problème ne se produit pas avec les cinématiques, car généralement dans les jeux au milieu de la cinématique n'est pas autorisé.
Le problème avec un long tutoriel peut être résolu si vous le divisez en petits morceaux. Supposons qu'un joueur passe par la première partie d'un tutoriel et doit se rendre dans une autre pièce pour continuer le tutoriel. À ce stade, vous pouvez créer un point de contrôle ou donner au joueur la possibilité de sauvegarder. Dans la sauvegarde, nous écrirons quelque chose comme «le joueur a terminé la partie 1 du tutoriel». Ensuite, le joueur passera par la deuxième partie du tutoriel, pour laquelle nous utiliserons déjà une autre coroutine. Et ainsi de suite ... Lors du chargement, nous commençons juste à exécuter la coroutine correspondant à la partie que le joueur doit traverser.
Conclusion
Comme vous pouvez le voir, il existe plusieurs approches différentes pour implémenter une séquence d'actions et de cinématiques. Il me semble que l'approche coroutine est très puissante et je suis heureuse de la partager avec les développeurs. J'espère que cette solution au problème vous facilitera la vie et vous permettra de réaliser des cinématiques épiques dans vos jeux.