Implementierung von Zwischensequenzen und Abfolgen von Aktionen in Spielen

In diesem Beitrag werde ich darüber sprechen, wie Sie Sequenzen von Aktionen und Zwischensequenzen in Videospielen implementieren können. Dieser Artikel ist eine Übersetzung dieses Artikels und zu demselben Thema habe ich eine Präsentation bei Lua in Moskau gehalten. Wenn Sie das Video also lieber sehen möchten, können Sie es hier ansehen.

Der Artikelcode ist in Lua geschrieben, kann aber leicht in anderen Sprachen geschrieben werden (mit Ausnahme der Methode, die Coroutinen verwendet, da diese nicht in allen Sprachen verfügbar sind).

Der Artikel zeigt, wie Sie einen Mechanismus erstellen, mit dem Sie Zwischensequenzen in der folgenden Form schreiben können:

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 

Eintrag


Action-Sequenzen finden sich häufig in Videospielen. Zum Beispiel in Zwischensequenzen: Der Charakter trifft den Feind, sagt etwas zu ihm, der Feind antwortet und so weiter. Die Reihenfolge der Aktionen finden Sie im Gameplay. Schauen Sie sich dieses GIF an:



1. Die Tür öffnet sich
2. Der Charakter betritt das Haus
3. Die Tür schließt sich
4. Der Bildschirm wird allmählich dunkler
5. Der Pegel ändert sich
6. Der Bildschirm wird hell ausgeblendet
7. Der Charakter betritt das Café

Aktionssequenzen können auch verwendet werden, um das Verhalten von NPCs zu skripten oder um Bosskämpfe zu implementieren, in denen der Boss einige Aktionen nacheinander ausführt.

Das Problem


Die Struktur einer Standardspielschleife erschwert die Implementierung von Aktionssequenzen. Nehmen wir an, wir haben die folgende Spielschleife:



 while game:isRunning() do processInput() dt = clock.delta() update(dt) render() end 

Wir wollen die folgende Zwischensequenz implementieren: Der Spieler nähert sich dem NPC, der NPC sagt: "Du hast es geschafft!" Und nach einer kurzen Pause sagt er: "Danke!". In einer idealen Welt würden wir es so schreiben:

 player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you") 

Und hier stehen wir vor einem Problem. Es dauert einige Zeit, bis die Schritte abgeschlossen sind. Einige Aktionen warten möglicherweise sogar auf die Eingabe durch den Player (z. B. um das Dialogfeld zu schließen). Anstelle der delay können Sie nicht denselben sleep aufrufen - es sieht so aus, als wäre das Spiel eingefroren.

Schauen wir uns einige Ansätze zur Lösung des Problems an.

Bool, Enum, State Machines


Der naheliegendste Weg, eine Folge von Aktionen zu implementieren, besteht darin, Informationen über den aktuellen Status in Bools, Zeilen oder Aufzählungen zu speichern. Der Code sieht ungefähr so ​​aus:

 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 

Dieser Ansatz führt leicht zu Spaghetti-Code und langen Ketten von if-else-Ausdrücken. Ich empfehle daher, diese Methode zur Lösung des Problems zu vermeiden.

Aktionsliste


Aktionslisten sind Zustandsautomaten sehr ähnlich. Eine Aktionsliste ist eine Liste von Aktionen, die nacheinander ausgeführt werden. In der Spielschleife wird die update für die aktuelle Aktion aufgerufen, mit der wir die Eingabe verarbeiten und das Spiel rendern können, auch wenn die Aktion lange dauert. Nachdem die Aktion abgeschlossen ist, fahren wir mit dem nächsten fort.

In der Zwischensequenz, die wir implementieren möchten, müssen wir die folgenden Aktionen implementieren: GoToAction, DialogueAction und DelayAction.

Für weitere Beispiele werde ich die Middleclass- Bibliothek für OOP in Lua verwenden.

So wird eine DelayAction implementiert:

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

Die Funktion ActionList:update sieht folgendermaßen aus:

 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 

Und schließlich die Implementierung der Zwischensequenz selbst:

 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) 

Hinweis : In Lua kann ein Aufruf von someFunction({ ... }) wie someFunction{...} : someFunction{...} . Auf diese Weise können Sie DelayAction:new{ delay = 0.5 } anstelle von DelayAction:new({delay = 0.5}) schreiben.

Es sieht viel besser aus. Der Code zeigt deutlich die Abfolge der Aktionen. Wenn wir eine neue Aktion hinzufügen möchten, können wir dies problemlos tun. Es ist ganz einfach, Klassen wie DelayAction zu DelayAction , um das Schreiben von Zwischensequenzen bequemer zu gestalten.

Ich rate Ihnen, die Präsentation von Sean Middleditch über Aktionslisten zu sehen, die komplexere Beispiele enthält.


Aktionslisten sind im Allgemeinen sehr nützlich. Ich habe sie für einige Zeit für meine Spiele verwendet und war insgesamt glücklich. Dieser Ansatz hat jedoch auch Nachteile. Angenommen, wir möchten eine etwas komplexere Zwischensequenz implementieren:

 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 

Um eine if / else-Simulation durchzuführen, müssen Sie nichtlineare Listen implementieren. Dies kann mithilfe von Tags erfolgen. Einige Aktionen können mit Tags versehen werden. Unter bestimmten Bedingungen können Sie dann, anstatt zur nächsten Aktion überzugehen, zu einer Aktion mit dem gewünschten Tag wechseln. Es funktioniert, ist jedoch nicht so einfach zu lesen und zu schreiben wie die obige Funktion.

Lua Coroutinen verwirklichen diesen Code.

Coroutinen


Corua Grundlagen in Lua


Corutin ist eine Funktion, die angehalten und später wieder aufgenommen werden kann. Coroutinen werden im selben Thread wie das Hauptprogramm ausgeführt. Für Coroutine werden niemals neue Threads erstellt.

Um coroutine.yield , müssen Sie coroutine.yield aufrufen, um fortzufahren - coroutine.resume . Ein einfaches Beispiel:

 local function f() print("hello") coroutine.yield() print("world!") end local c = coroutine.create(f) coroutine.resume(c) print("uhh...") coroutine.resume(c) 

Programmausgabe:

 Hallo
 äh ...
 Welt


So funktioniert es Zuerst erstellen wir coroutine.create mit coroutine.create . Nach diesem Aufruf wird Corutin nicht gestartet. Dazu müssen wir es mit coroutine.resume ausführen. Dann wird die Funktion f aufgerufen, die „Hallo“ schreibt und sich mit coroutine.yield pausiert. Dies ähnelt der return , aber wir können f mit coroutine.resume .

Wenn Sie beim Aufrufen von coroutine.yield Argumente übergeben, werden diese zu den Rückgabewerten des entsprechenden Aufrufs von coroutine.resume im "Hauptstrom".

Zum Beispiel:

 local function f() ... coroutine.yield(42, "some text") ... end ok, num, text = coroutine.resume(c) print(num, text) -- will print '42 "some text"' 

ok ist eine Variable, mit der wir den Status einer Coroutine ermitteln können. Wenn ok true , dann ist mit Coroutine alles in Ordnung, es sind keine Fehler im Inneren aufgetreten. Die darauf folgenden Rückgabewerte ( num , text ) sind dieselben Argumente, die wir übergeben haben, um zu yield .

Wenn ok false , ist ein error mit der Coroutine aufgetreten, z. B. wurde die error aufgerufen. In diesem Fall ist der zweite Rückgabewert eine Fehlermeldung. Ein Beispiel für eine Coroutine, in der ein Fehler auftritt:

 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 

Fazit:

 Coroutine fehlgeschlagen!  Eingabe: 4: Versuch, eine Arithmetik für einen Nullwert durchzuführen (global 'notDefined')


Der Coroutine-Status kann durch Aufrufen von coroutine.status abgerufen werden. Corutin kann unter folgenden Bedingungen sein:

  • "Laufen" - Coroutine läuft gerade. coroutine.status wurde von corutin selbst aufgerufen
  • "Suspended" - Corutin wurde angehalten oder noch nie gestartet
  • "Normal" - Corutin ist aktiv, wird aber nicht ausgeführt. Das heißt, Corutin hat ein weiteres Corutin in sich selbst gestartet
  • "Tot" - Coroutine abgeschlossene Ausführung (dh die Funktion innerhalb der Coroutine abgeschlossen)

Mit Hilfe dieses Wissens können wir nun ein System von Abfolgen von Aktionen und Zwischensequenzen implementieren, die auf Coroutinen basieren.

Zwischensequenzen mit Corutin erstellen


So sieht die Basis- Action Klasse auf dem neuen System aus:

 function Action:launch() self:init() while not self.finished do local dt = coroutine.yield() self:update(dt) end self:exit() end 

Der Ansatz ähnelt Aktionslisten: Die update der Aktion wird aufgerufen, bis die Aktion abgeschlossen ist. Aber hier verwenden wir Coroutinen und geben in jeder Iteration der Spielschleife nach ( Action:launch wird von einer Coroutine aufgerufen). Irgendwo in der update Spieleschleife setzen wir die Ausführung der aktuellen Zwischensequenz wie folgt fort:

 coroutine.resume(c, dt) 

Und schließlich eine Zwischensequenz erstellen:

 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) 

So wird die delay implementiert:

 function delay(time) action = DelayAction:new { delay = time } action:launch() end 

Das Erstellen solcher Wrapper verbessert die Lesbarkeit des Zwischensequenzcodes erheblich. DelayAction implementiert:

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

Diese Implementierung ist identisch mit der, die wir in Aktionslisten verwendet haben! Werfen wir einen Blick auf die Funktion Action:launch erneut:

 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 

Die Hauptsache hier ist die while , die ausgeführt wird, bis die Aktion abgeschlossen ist. Es sieht ungefähr so ​​aus:



Schauen wir uns nun die goTo Funktion an:

 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 

Coroutinen passen gut zu Ereignissen. Implementieren Sie die WaitForEventAction Klasse:

 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 

Diese Funktion benötigt keine update . Es wird ausgeführt (obwohl es nichts tut ...), bis es ein Ereignis mit dem erforderlichen Typ empfängt. Hier ist die praktische Anwendung dieser Klasse - Implementierung der Funktion say :

 function Entity:say(text) DialogueWindow:show(text) local action = WaitForEventAction:new { eventType = 'DialogueWindowClosed' } action:launch() end 

Einfach und lesbar. Wenn das Dialogfeld geschlossen wird, wird ein Ereignis vom Typ 'DialogueWindowClosed' ausgelöst. Die Say-Aktion endet und die nächste beginnt mit der Ausführung.

Mit Coroutine können Sie ganz einfach nichtlineare Zwischensequenzen und Dialogbäume erstellen:

 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 



In diesem Beispiel ist die Funktion say etwas komplexer als die zuvor gezeigte. Es gibt die Wahl des Spielers im Dialog zurück, ist jedoch nicht schwer zu implementieren. Beispielsweise kann WaitForEventAction , wodurch das PlayerChoiceEvent-Ereignis WaitForEventAction und dann die Auswahl des Players zurückgegeben wird, dessen Informationen im Ereignisobjekt enthalten sind.

Etwas komplexere Beispiele


Mit Coroutine können Sie ganz einfach Tutorials und kleine Quests erstellen. Zum Beispiel:

 girl:say("Kill that monster!") waitForEvent('EnemyKilled') girl:setMood('happy') girl:say("You did it! Thank you!") 



Coroutinen können auch für KI verwendet werden. Sie können beispielsweise eine Funktion festlegen, mit der sich das Monster auf einer bestimmten Flugbahn bewegt:

 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 



Wenn das Monster den Spieler sieht, können wir einfach aufhören, die Coroutine zu erfüllen und sie entfernen. Daher ist eine Endlosschleife ( while true ) in followPath nicht wirklich unendlich.

Mit corutin können Sie "parallele" Aktionen ausführen. Die Zwischensequenz fährt erst mit der nächsten Aktion fort, wenn beide Aktionen abgeschlossen sind. Zum Beispiel machen wir eine Zwischensequenz, in der ein Mädchen und eine Katze mit unterschiedlichen Geschwindigkeiten zum Punkt eines Freundes gehen. Nachdem sie zu ihr gekommen sind, sagt die Katze "Miau".

 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 

Der wichtigste Teil hierbei ist die waitForFinish Funktion, ein Wrapper um die WaitForFinishAction Klasse, der wie folgt implementiert werden kann:

 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 

Sie können diese Klasse leistungsfähiger machen, wenn Sie die Synchronisierung der N-ten Anzahl von Aktionen zulassen.

Sie können auch eine Klasse erstellen, die wartet, bis eine der Coroutinen abgeschlossen ist, anstatt darauf zu warten, dass alle Coroutinen die Ausführung abgeschlossen haben. Zum Beispiel kann es in Renn-Minispielen verwendet werden. Innerhalb der Coroutine wird gewartet, bis einer der Fahrer die Ziellinie erreicht und dann eine Abfolge von Aktionen ausführt.

Vor- und Nachteile von Corutin


Coroutinen sind ein sehr nützlicher Mechanismus. Mit ihnen können Sie Zwischensequenzen und Gameplay-Code schreiben, der einfach zu lesen und zu ändern ist. Zwischensequenzen dieser Art können leicht von Moddern oder Personen geschrieben werden, die keine Programmierer sind (z. B. Spiel- oder Leveldesigner).

Und all dies wird in einem Thread ausgeführt, sodass es kein Problem mit der Synchronisation oder den Race-Bedingungen gibt .

Der Ansatz hat Nachteile. Beispielsweise können Probleme beim Speichern auftreten. Angenommen, in Ihrem Spiel ist ein langes Tutorial mit Coroutine implementiert. Während dieses Tutorials kann der Player da nicht speichern Dazu müssen Sie den aktuellen Status der Coroutine (einschließlich des gesamten Stapels und der Werte der darin enthaltenen Variablen) speichern, damit Sie beim weiteren Laden aus dem Speicher das Lernprogramm fortsetzen können.

( Hinweis : Mit der PlutoLibrary- Bibliothek können Coroutinen serialisiert werden, die Bibliothek funktioniert jedoch nur mit Lua 5.1.)

Dieses Problem tritt bei Zwischensequenzen nicht auf normalerweise ist in Spielen in der Mitte der Zwischensequenz nicht erlaubt.

Das Problem mit einem langen Tutorial kann gelöst werden, wenn Sie es in kleine Teile zerlegen. Angenommen, ein Spieler durchläuft den ersten Teil eines Tutorials und muss in einen anderen Raum gehen, um das Tutorial fortzusetzen. An diesem Punkt können Sie einen Kontrollpunkt erstellen oder dem Spieler die Möglichkeit zum Speichern geben. Beim Speichern schreiben wir so etwas wie "Der Spieler hat Teil 1 des Tutorials abgeschlossen". Als nächstes geht der Spieler den zweiten Teil des Tutorials durch, für den wir bereits eine andere Coroutine verwenden werden. Und so weiter ... Beim Laden beginnen wir einfach mit der Ausführung der Coroutine, die dem Teil entspricht, den der Spieler durchlaufen muss.

Fazit


Wie Sie sehen können, gibt es verschiedene Ansätze zum Implementieren einer Abfolge von Aktionen und Zwischensequenzen. Es scheint mir, dass der Coroutine-Ansatz sehr leistungsfähig ist und ich freue mich, ihn mit den Entwicklern zu teilen. Ich hoffe, dass diese Lösung des Problems Ihr Leben leichter macht und es Ihnen ermöglicht, epische Zwischensequenzen in Ihren Spielen zu erstellen.

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


All Articles