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

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()
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.