[#_lesson_16_branching] == Lektion 16: Verzweigungen image::learn/16-branching.png[Die fertige Lektion, 480] Ein verzweigtes Videospiel ist ein Film mit Gabelungen darin. Eine Szene läuft, der Spieler entscheidet, und als Nächstes läuft eine andere Szene. Mach das zwanzig- oder dreißigmal, und du hast ein Spiel, das die Leute jahrelang immer wieder spielen, weil sie das Ende suchen, das sie noch nicht gesehen haben. In dieser Lektion baust du eines. Es hat vier Szenen, zwei Entscheidungen, einen Timer bei jeder Entscheidung, einen Tod und drei Leben. Der Code, der es abspielt, ist etwa dreißig Zeilen lang, und er wäre immer noch etwa dreißig Zeilen lang, wenn das Spiel zweihundert Szenen hätte -- denn das Spiel selbst wird überhaupt nicht in Code geschrieben. [#_imagine_it_is_a_dragon] === Stell dir vor, es ist ein Drache Das Video, das jeder hat, ist `Singe/menuBackground.mkv`, dieselben 420 Frames, die du seit Lektion vierzehn steuerst. Es ist ein Feuerball, ein Drache aus Papier, das Wort `SINGE` und dann ein magentafarbenes Gitter, das unter einer orangefarbenen Sonne endlos davonrollt. Ein Drama ist es nicht. Niemand darin könnte in Gefahr geraten, und dem Sonnenuntergang ist egal, wie du dich entscheidest. Spiel trotzdem mit. Für zwei Minuten ist der Drache echt genug, und alles, was du gleich schreibst, funktioniert genau gleich, wenn das Material ein Stuntman ist, der von einer Klippe stürzt. Hier ist die Karte, und die Frame-Nummern sind die, die du in Lektion vierzehn selbst herausgefunden hast: [cols="1,1,3",options="header"] |=== | Szene | Frames | Was passiert | `arrive` | 0 bis 119 | Feuer, und der Drache landet. *Left or Right?* | `sunset` | 120 bis 259 | Er speit Feuer. *Duck Left or run Right?* | `pit` | 260 bis 339 | Du wirst gefressen. Du verlierst ein Leben. | `escape` | 340 bis 410 | Das Gitter rollt davon, und du bist entkommen. |=== Links ist sozusagen immer richtig: Links von `arrive` führt zu `sunset`, links von `sunset` bringt dich zu `escape`. Rechts führt beide Male in die `pit`, und zu langes Zögern ebenso. [#_the_map_is_data_not_code] === Die Karte ist Daten, kein Code Hier ist das ganze Spiel, und es ist eine Tabelle. [source,lua] ---- local scenes = { arrive = { first = 0, last = 119, ask = 30, prompt = "The dragon lands. Left or Right?", left = "sunset", right = "pit", goesTo = "pit" }, sunset = { first = 120, last = 259, ask = 170, prompt = "It breathes fire. Duck Left or run Right?", left = "escape", right = "pit", goesTo = "pit" }, pit = { first = 260, last = 339, death = true, goesTo = "arrive" }, escape = { first = 340, last = 410 } } ---- Eine Tabelle mit Namen statt Zahlen auf der linken Seite, genau wie in Lektion sechs, und jeder Wert darin ist wieder eine Tabelle. Jede dieser inneren Tabellen ist ein *Datensatz*: eine Szene, Feld für Feld beschrieben. `scenes.arrive` ist die erste Szene. `scenes.arrive.first` ist `0`. `scenes.arrive.left` ist der String `"sunset"`, und das ist der Name einer anderen Szene in derselben Tabelle -- so werden die Gabelungen aufgeschrieben. Eine Szene enthält nicht die Szene nach ihr. Sie enthält den *Namen* der Szene nach ihr, und der Name wird nachgeschlagen, wenn es so weit ist. Die Felder bedeuten Folgendes: `first` und `last`:: Die Frames, zwischen denen die Szene läuft. `ask`:: Der Frame, in dem die Frage erscheint. Der Spieler hat von `ask` bis `last` Zeit zu antworten, und das ist der Timer. `prompt`:: Was die Frage sagt. `left` und `right`:: Die Szene, zu der die jeweilige Antwort führt. `goesTo`:: Wohin die Szene geht, wenn niemand gewählt hat -- weil dem Spieler die Zeit ausgegangen ist oder weil es gar keine Frage gab. Bei `pit`, einem Tod, ist es der Ort, an den der neue Versuch zurückkehrt. `death`:: Nur vorhanden, und dann `true`, bei einer Szene, die ein Leben kostet. An dieser Tabelle sind drei Dinge bemerkenswert. Das erste: Sie ist das Designdokument. Du kannst sie jemandem geben, der noch nie eine Zeile Lua geschrieben hat, und er kann das Spiel aus ihr herauslesen. Wenn sich der Drache dreifach gabeln soll, fügst du ein Feld hinzu. Wenn du eine weitere Szene willst, fügst du einen weiteren Datensatz hinzu. Du suchst nicht nach dem Code, der Szenen abspielt, denn den änderst du nicht. Das zweite: Die Datensätze haben nicht alle dieselbe Form. `pit` hat kein `ask`, kein `prompt` und kein `left` -- darin gibt es nichts zu wählen. `escape` hat kein `goesTo`, denn es ist das Ende und führt nirgendwohin. Ein Feld, das nicht in einer Tabelle steht, liest sich als `nil`, das Wort, das du in Lektion drei für „da ist nichts“ kennengelernt hast, und nach `scenes.escape.goesTo` zu fragen ist kein Fehler. Es ist `nil`, und `nil` ist eine Antwort. Code, der diese Datensätze liest, muss darauf vorbereitet sein, und gleich siehst du die eine Zeile, die es ist. Das dritte ist, was die Tabelle *nicht* enthält: Nichts darin zeichnet, spielt ab oder wartet. Sie ist eine Beschreibung. Diese Trennung ist der ganze Trick dieser Lektion. [#_one_function_that_plays_any_scene] === Eine Funktion, die jede Szene abspielt Das ist die Code-Hälfte, und es gibt nur eine Funktion von nennenswerter Größe. Geschrieben im Thread-Modell aus Lektion fünfzehn, denn nichts ist so sehr ein Ablauf wie ein verzweigtes Spiel. [source,lua] ---- local function playScene(name) local scene = scenes[name] discSkipToFrame(scene.first) while discGetFrame() < scene.last do local frame = discGetFrame() local asking = scene.ask ~= nil and frame >= scene.ask if not asking then answer = nil elseif answer == "left" or answer == "right" then return scene[answer] end drawScene(scene, frame, asking) singeYield() end discPause() if scene.death then lives = lives - 1 if lives == 0 then ending = "The dragon wins. Game over." return nil end waitForGo("Press the space bar to try again.") end return scene.goesTo end ---- Sie nimmt den Namen einer Szene, spielt sie ab und gibt den Namen der Szene zurück, die als Nächstes kommt. Das ist ihre ganze Abmachung, und das Spiel besteht dann aus drei Zeilen: [source,lua] ---- function singeMain() local scene = "arrive" while scene ~= nil do scene = playScene(scene) end waitForGo(ending .. " Space to quit.") end ---- Fang bei `arrive` an. Spiel die Szene ab, die du gerade in der Hand hast, und nimm in die Hand, was sie dir zurückgibt. Wenn sie `nil` zurückgibt, gibt es keine nächste Szene, die Schleife endet, und das Spiel ist vorbei. [#_the_whole_thing_8] === Das Ganze Leg `dragon.singe` in deinem Ordner `movie` an und tipp das hier ein. Es ist das bisher längste Skript im Buch, und jedes Stück davon ist erklärt, bis auf die zwei kleinen Hilfsfunktionen, die danach kommen. [source,lua] ---- local FPS = 30 local scenes = { arrive = { first = 0, last = 119, ask = 30, prompt = "The dragon lands. Left or Right?", left = "sunset", right = "pit", goesTo = "pit" }, sunset = { first = 120, last = 259, ask = 170, prompt = "It breathes fire. Duck Left or run Right?", left = "escape", right = "pit", goesTo = "pit" }, pit = { first = 260, last = 339, death = true, goesTo = "arrive" }, escape = { first = 340, last = 410 } } local answer = nil local ending = "You got out alive." local lives = 3 local function drawScene(scene, frame, asking) overlayClear() overlayPrint(2, 2, "Lives: " .. lives) if scene.death then overlayPrint(2, 4, "That did not go well.") elseif asking then overlayPrint(2, 4, scene.prompt) overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.") end end local function waitForGo(text) answer = nil while answer ~= "go" do overlayClear() overlayPrint(2, 2, "Lives: " .. lives) overlayPrint(2, 4, text) singeYield() end end local function playScene(name) local scene = scenes[name] discSkipToFrame(scene.first) while discGetFrame() < scene.last do local frame = discGetFrame() local asking = scene.ask ~= nil and frame >= scene.ask if not asking then answer = nil elseif answer == "left" or answer == "right" then return scene[answer] end drawScene(scene, frame, asking) singeYield() end discPause() if scene.death then lives = lives - 1 if lives == 0 then ending = "The dragon wins. Game over." return nil end waitForGo("Press the space bar to try again.") end return scene.goesTo end function onInputPressed(what) if what == SWITCH_LEFT then answer = "left" elseif what == SWITCH_RIGHT then answer = "right" elseif what == SWITCH_BUTTON1 then answer = "go" end end function singeMain() local scene = "arrive" while scene ~= nil do scene = playScene(scene) end waitForGo(ending .. " Space to quit.") end dofile("Singe/Framework.singe") ---- Starte es: ---- Singe -R -v Singe/menuBackground.mkv movie/dragon.singe ---- Spiel es vier- oder fünfmal. Geh zweimal nach links und entkomme. Geh nach rechts und werde gefressen. Setz dich auf deine Hände und werde trotzdem gefressen. Verlier alle drei Leben. [#_what_just_happened_12] === Was gerade passiert ist [source,lua] ---- discSkipToFrame(scene.first) while discGetFrame() < scene.last do ---- Eine Szene ist ein Sprung, gefolgt von Warten, und das ist das Muster aus Lektion fünfzehn, nur dass die Zahlen aus dem Datensatz kommen, statt eingetippt zu sein. `discSkipToFrame` geht zu einem Frame und spielt ab dort ab, und genau das ist ein Szenenwechsel. Die Frame-Nummer stimmt in dem Augenblick, in dem der Aufruf zurückkehrt, also glaubt das `while` nie, es sei noch in der Szene, die du gerade verlassen hast. [source,lua] ---- local asking = scene.ask ~= nil and frame >= scene.ask ---- Die eine Zeile, die auf ein fehlendes Feld vorbereitet ist, und der Grund, warum die Todesszene `ask` weglassen kann. `and` heißt, dass beide Hälften wahr sein müssen, wie schon in Lektion drei, und Lua prüft zuerst die linke Hälfte. Wenn `scene.ask` `nil` ist, ist die linke Hälfte falsch, Lua hört dort auf, und der Vergleich auf der rechten Seite findet nie statt. Und das ist auch gut so, denn `nil` mit einer Zahl zu vergleichen ist ein Fehler, und dem begegnest du am Ende dieser Lektion. `asking` ist die Antwort auf „Steht die Frage gerade auf dem Bildschirm?“, einmal pro Frame ausgerechnet und dreimal benutzt. [source,lua] ---- if not asking then answer = nil elseif answer == "left" or answer == "right" then return scene[answer] end ---- Solange die Frage nicht zu sehen ist, wird alles weggeworfen, was der Spieler drückt. Das muss so sein, sonst beantwortet ein Spieler, der während des Films auf der linken Pfeiltaste lehnt, eine Frage, die er noch gar nicht gelesen hat. Sobald die Frage zu sehen ist, beendet eine Antwort die Szene sofort. `return scene[answer]` ist einen zweiten Blick wert: `answer` enthält den String `"left"` oder `"right"`, und `scene["left"]` ist dasselbe wie `scene.left`. Wenn du den Namen in eckige Klammern setzt statt hinter einen Punkt, kannst du ein Feld nachschlagen, dessen Namen du in einer Variablen hast. Zwei Zeilen Code für beide Antworten, und bei fünf Antworten wären es immer noch zwei Zeilen. Das `elseif` erklärt auch, warum nur `"left"` und `"right"` durchgelassen werden. Die Leertaste setzt `answer` auf `"go"`, und `scene["go"]` ist `nil`, also würde ein verirrter Druck auf die Leertaste während einer Frage das Spiel stillschweigend beenden. Sag, was du annimmst. [source,lua] ---- discPause() ---- Direkt nach der Schleife, und leicht zu vergessen. Die Szene hat ihren letzten Frame erreicht, aber die Disc hat keine Ahnung, dass die Szene vorbei ist -- sie spielt einfach weiter in das Material, das zufällig als Nächstes in der Datei liegt, und in einem echten Spiel ist das die Mitte einer Szene von ganz woanders. Halte sie in dem Moment an, in dem du sie nicht mehr willst. [source,lua] ---- if scene.death then lives = lives - 1 ---- `scene.death` ist bei jeder Szene außer `pit` `nil`, und `nil` zählt in einem `if` als falsch, also liest sich das als „wenn diese Szene ein Tod war“. Du musst `death = false` nicht in die anderen drei Datensätze schreiben, und du solltest es auch nicht: Dass das Feld fehlt, sagt es genauso gut, und dann gibt es nur eine Stelle, die entscheidet, welche Szenen tödlich sind. Der neue Versuch steckt in den zwei Zeilen darunter. Nimm ein Leben weg, und wenn noch welche übrig sind, bleib auf dem Frame stehen und warte auf die Leertaste. Dann schickt `return scene.goesTo` den Spieler zurück zu `arrive`, und das weiß der Datensatz. Ändere `goesTo` in `"sunset"`, und aus dem Neustart wird ein Checkpoint -- ein Wort, und die Schwierigkeit des Spiels ändert sich. [source,lua] ---- overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.") ---- Der Timer. `scene.last - frame` ist die Zahl der Frames, die noch übrig sind, und wenn du durch die Frames in einer Sekunde teilst, werden daraus Sekunden. `math.ceil` rundet eine Zahl auf die nächste ganze Zahl auf, also zeigt der Countdown `3`, `2`, `1` und nie `0.4` oder `2.966666`. `FPS` ist `30`, weil dieses Video mit etwas über dreißig Frames pro Sekunde läuft. Es ist die einzige Zahl im Skript, die geschätzt ist, und sie ist eine benannte Schätzung oben in der Datei statt einer `30`, die mitten in einer Zeile vergraben ist, damit es genau eine Sache zu ändern gibt, wenn du das Video wechselst. Achte darauf, woran der Timer gemessen wird: am Film. Der Spieler läuft gegen das Video um die Wette, nicht gegen eine Uhr, also kann die Frage nie länger bleiben als die Einstellung, zu der sie gehört. Das ist der Grund, warum verzweigte Spiele in Frames zählen. [#_why_not_write_it_out_by_hand] === Warum nicht alles von Hand ausschreiben Du könntest dieses Spiel als eine lange Funktion schreiben. Spiel die Frames 0 bis 119 ab; wenn links, spiel 120 bis 259 ab; wenn wieder links, spiel 340 bis 410 ab; sonst spiel 260 bis 339 ab, nimm ein Leben weg, geh zurück an den Anfang. Bei vier Szenen wäre das kürzer als das, was du jetzt hast. Bei vierzig Szenen wäre es eine Katastrophe, und es lohnt sich, genau zu sagen, warum. Die Frame-Nummern wären über den ganzen Code verstreut, jede einmal hingeschrieben und nie benannt, also hieße es, jede Stelle zu suchen, an der sie vorkommt, wenn du änderst, wo eine Szene anfängt. Die Form der Geschichte wäre in der Form der `if`-Anweisungen vergraben, also könnte niemand das Spiel sehen, ohne alles zu lesen. Und eine Szene hinzuzufügen hieße, den Code zu ändern, der abspielt, also den Code, der schon funktioniert -- jede neue Szene eine weitere Gelegenheit, eine alte kaputt zu machen. Die Version, die du geschrieben hast, hat ein Stück Code, das Szenen abspielt, und ein Stück Daten, das sagt, welche Szenen es gibt. Eine Szene hinzuzufügen berührt nur die Daten. Das ist die zusätzliche halbe Stunde wert, und dieselbe Idee steckt auch hinter der Datei `games.dat` aus Lektion dreizehn: Beschreib die Sache und schreib dann ein Stück Code, das die Beschreibung liest. [#_try_it_16] === Probier es aus . *Füg ein Ende hinzu.* Gib `escape` eine Frage und eine zweite Endszene, die Frames von irgendwo im Film wiederverwendet. Nur die Tabelle ändert sich. . *Mach es fair.* Setz `ask` in beiden Szenen herunter, sodass der Spieler fünf Sekunden statt drei hat, und dann herauf, sodass er eine hat. Finde die Zahl, die du wirklich ausliefern würdest. . *Verschieb den Checkpoint.* Ändere das `goesTo` von `pit` in `"sunset"` und spiel es. Dann überleg, was passiert, wenn ein Spieler in `arrive` stirbt -- und ob die Todesszene wissen sollte, woher sie kam, statt es gesagt zu bekommen. . *Zähl, was der Spieler gesehen hat.* Leg neben `scenes` eine Tabelle an, die festhält, welche Szenennamen schon gespielt wurden, und zeig die Anzahl in der Ecke. Jetzt hast du ein Spiel, das sagen kann: „Du hast drei von fünf Enden gefunden.“ . *Mach die Karte kaputt.* Ändere das `left` von `arrive` in `"cave"`, eine Szene, die es nicht gibt. Starte es, geh nach links und lies, was Lua sagt. Dieser Fehler ist der häufigste, den ein datengesteuertes Spiel hat, und wenn du seine Form kennst, sparst du dir eine Stunde. [#_break_it_on_purpose_15] === Mach es absichtlich kaputt Nimm die Absicherung aus der Zeile mit `asking` heraus, sodass sie so lautet: [source,lua] ---- local asking = frame >= scene.ask ---- Starte es und geh nach rechts. Die Szene `arrive` läuft problemlos, denn `arrive` hat ein `ask`. Dann beginnt die Szene `pit`, die keins hat, und das Spiel bleibt mit dieser Meldung stehen: ---- Error executing function 'onOverlayUpdate': movie/dragon.singe:68: attempt to compare nil with number stack traceback: [C]: in function 'error' Singe/Framework.singe:603: in function 'onOverlayUpdate' ---- Lies es so, wie Lektion eins es dir beigebracht hat. Die Datei und die Zeile kommen zuerst, und sie stimmen genau: Zeile 68 ist die Zeile, die du gerade geändert hast. Die Beschwerde ist der Rest: Du hast Lua gefragt, ob eine Zahl größer als `nil` ist, und Lua hat keine Ahnung. `scene.ask` ist `nil`, weil der Datensatz `pit` kein Feld `ask` hat, und ein Feld, das nicht da ist, ist `nil`. Ein Teil dieser Meldung verdient eine Erklärung, denn er ist eine Lüge durch Weglassen. Die genannte Funktion ist `onOverlayUpdate`, und die hast du nicht geschrieben. Das ist die eigene des Frameworks, die, von der Lektion fünfzehn gesagt hat, dass sie installiert wird, um dein `singeMain` anzutreiben. Der Fehler kam durch sie heraus, also ist das der Name, den die Engine gerade zur Hand hatte, und der Traceback darunter -- die Liste der Funktionen, die liefen, die innerste zuerst -- kann nur zeigen, wie das Framework deinen Fehler weiterreicht. Ignorier beides. Die Datei und die Zeile ganz oben sind deine, und sie sind die, die stimmen. [#_what_you_learned_16] === Was du gelernt hast * Ein verzweigtes Spiel ist eine Menge von Szenen, und jede davon nennt die Szenen, die nach ihr kommen. * Halte die Szenen als Daten -- eine Tabelle von Datensätzen -- und der Code, der sie abspielt, bleibt eine einzige Funktion, egal wie groß das Spiel wird. * Ein Datensatz enthält den Namen der nächsten Szene, nicht die Szene selbst, und der Name wird nachgeschlagen, wenn er gebraucht wird. * Ein Feld, das nicht in einem Datensatz steht, ist `nil`, und das ist kein Fehler, aber Code, der Datensätze liest, muss damit rechnen. * `scene[answer]` schlägt das Feld nach, dessen Name in einer Variablen steht; `scene.left` und `scene["left"]` sind dasselbe Feld. * Häng den Timer an das Video: Eine Entscheidung dauert von einem Frame bis zu einem anderen, also kann sie nie länger dauern als ihre eigene Einstellung. * Wirf alles weg, was der Spieler gedrückt hat, bevor die Frage erschien. * Halte die Disc an, sobald eine Szene endet, sonst spielt sie weiter in das Material der nächsten Szene. * Ein fehlendes Feld, das für falsch steht -- `death` bei drei von vier Datensätzen -- hält die Beschreibung kurz und ehrlich. * `nil` mit einer Zahl zu vergleichen ist ein Fehler, und mit `and` vermeidest du ihn. [#_next_time_16] === Beim nächsten Mal Dein Spiel fragt nach links oder rechts. Die Arcade-Automaten, in denen diese Art von Spiel groß geworden ist, fragten etwas Schwierigeres: Ziel auf das Ding auf dem Bildschirm und schieß darauf. In Lektion siebzehn geht es um Lightguns -- wohin der Spieler zielt, was das Spiel damit macht und die Kalibrierung, ohne die ein echter Automat nicht auskommt.