Implémentation de cinématiques et séquences d'actions dans les jeux

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 ... ... --   ... end end 

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:

 --  function DelayAction:initialize(params) self.delay = params.delay self.currentTime = 0 self.isFinished = false end function DelayAction:update(dt) self.currentTime = self.currentTime + dt if self.currentTime > self.delay then self.isFinished = true end end 

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 -- ... -    actionList:update(dt) 

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) -- will print '42 "some 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 -- -  ... local c = coroutine.create(cutscene, player, npc) coroutine.resume(c, dt) 

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:

 -- Action -   DelayAction local DelayAction = class("DelayAction", Action) function DelayAction:initialize(params) self.delay = params.delay self.currentTime = 0 self.isFinished = false end function DelayAction:update(dt) self.currentTime = self.currentTime + dt if self.currentTime >= self.delayTime then self.finished = true end end 

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() -- the most important part self:update(dt) end self:exit() end 

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 ... --  , AI else self.finished = true end end 

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 --      while true do monster:goTo(path:getPoint(i)) if i < numberOfPoints - 1 then i = i + 1 --     else --   i = 0 end end end 



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() --  waitForFinish(c1, c2) --    cat:say("meow") ... end 

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.

Source: https://habr.com/ru/post/fr427135/


All Articles