singe/docs/de/lessons/16-branching.adoc

535 lines
20 KiB
Text

[#_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.