singe/docs/de/lessons/18-qte.adoc

607 lines
22 KiB
Text

[#_lesson_18_quick_time_events]
== Lektion 18: Quick-Time-Events
image::learn/18-qte.png[Die fertige Lektion, 480]
Eine Aufforderung erscheint. Du hast eine halbe Sekunde. Drück das Richtige, und die
Geschichte geht weiter; drück das Falsche oder gar nichts, und sie geht woandershin.
Das ist schon die ganze Idee, und erstaunlich viele Videospiele bestehen aus fast nichts
anderem. Jedes Teil hast du schon: Lektion vierzehn hat dir Frames gegeben, Lektion sechs
eine Liste von Datensätzen, Lektion drei Eingaben und Lektion sechzehn Verzweigungen. Diese
Lektion setzt sie zusammen und fügt eine Sache hinzu, die wirklich neu ist -- ein
Zeitfenster und das, was passiert, wenn es sich schließt.
[#_the_clock_is_the_film]
=== Die Uhr ist der Film
Alles hier wird in *Frames* gemessen, nicht in Sekunden, und dabei lohnt es sich
anzuhalten, denn das ist die eine Entscheidung, die das Ganze funktionieren lässt.
Lektion vierzehn hat gesagt, dass ein Frame die Adresse eines Augenblicks im Film ist. Die
Aufforderung muss erscheinen, wenn der Schauspieler zum Schlag ausholt, und das
Zeitfenster muss sich schließen, wenn die Faust ankommt. Das sind keine Zeiten, das sind
Stellen im Material, und du hast sie gefunden, indem du Schritt für Schritt hindurchgegangen
bist. Wenn du das Zeitfenster in Sekunden schreibst, musst du eine Stoppuhr starten, wenn
die Aufforderung erscheint, und dann können die beiden Uhren -- deine und die des Films --
auseinanderlaufen. Wenn das Video auf einem langsamen Rechner ruckelt, läuft deine
Stoppuhr weiter, und der Spieler wird für seine Hardware bestraft.
Frames können nicht auseinanderlaufen, denn es gibt nur eine Uhr. `discGetFrame()` ist
die Zeit, immer. Ein Zeitfenster sind zwei Frame-Nummern, und „wie viel ist noch übrig“
ist eine Subtraktion.
Starte mit demselben Video wie vorher:
----
Singe -R -v Singe/menuBackground.mkv moves
----
Immer noch das lila Gitter und der Sonnenuntergang. Stell dir einen Kampf vor.
[#_a_move_is_a_record]
=== Eine Aktion ist ein Datensatz
Hier ist die Tabelle, an der die ganze Lektion hängt:
[source,lua]
----
local moves = {
{ first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" },
{ first = 140, last = 185, switch = SWITCH_UP, name = "UP" },
{ first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" },
{ first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" },
}
----
Vier Aktionen. Jede sagt, wann das Zeitfenster aufgeht, wann es sich schließt, welcher
Schalter die Antwort ist und was auf den Bildschirm kommt. Fünfundvierzig Frames sind bei
der Bildrate dieses Videos etwa anderthalb Sekunden, und das ist großzügig; echte Spiele
geben dir oft weniger als die Hälfte davon.
Alles, was eine Aktion zu einer Aktion macht, steht in dieser Tabelle. Nirgends weiter
unten gibt es Code, der weiß, dass es vier Aktionen gibt oder dass die zweite nach oben
geht oder dass das erste Zeitfenster bei Frame sechzig aufgeht. Eine fünfte Aktion
hinzuzufügen heißt, eine Zeile hinzuzufügen. Alle Aktionen neu zu timen heißt, Zahlen zu
ändern. Niemand muss eine Funktion lesen, um den Kampf zu ändern.
Das ist kein Trick nur für diese Lektion. Das ist der Unterschied zwischen einem Spiel, das
du feinabstimmen kannst, und einem, bei dem du es nicht kannst, und es kostet nichts, es
von Anfang an so zu machen. Wenn du merkst, dass du gerade zum vierten Mal
`if frame == 60 then` schreiben willst, hör auf und mach stattdessen eine Tabelle.
[#_what_happens_when]
=== Was wann passiert
Mit einer Aktion können drei Dinge passieren, und das sind drei verschiedene Ausgänge,
nicht zwei:
* Der Spieler drückt innerhalb des Zeitfensters den richtigen Schalter. Gib Punkte und
spiel weiter.
* Der Spieler drückt innerhalb des Zeitfensters einen *falschen* Schalter. Er hat es
versucht und ist gescheitert.
* Das Zeitfenster schließt sich ganz ohne Antwort. Er ist erstarrt.
Fauler Code behandelt die letzten beiden gleich. Spiele tun das fast nie, denn sie fühlen
sich beim Spielen völlig verschieden an: Falsch zu raten ist ein Fehler, und zu erstarren
ist ein anderer Fehler, und der Film geht meistens für jeden woandershin. Hier schickt die
falsche Antwort die Disc zu Frame `362` und keine Antwort zu Frame `388`, und bei diesem
Material heißt das zwei etwas verschiedene Mengen Sonnenuntergang. Bei echtem Material
wären es zwei verschiedene Einstellungen davon, wie du verlierst.
Also bekommt jeder Datensatz während des Durchgangs ein fünftes Feld -- `answer` -- mit
dem Wert `nil`, bis mit der Aktion etwas passiert, und dann einem von drei Strings:
[source,lua]
----
move.answer = "right"
move.answer = "wrong"
move.answer = "late"
----
`nil` ist der Wert, den Lua dir für ein Feld gibt, das niemand gesetzt hat. Du hast ihn
in Lektion drei kennengelernt. Hier verdient er sich seinen Platz: „noch keine Antwort“
ist wirklich ein anderer Zustand als jede der drei Antworten, und `nil` sagt das, ohne dass
du dir ein Wort dafür ausdenken musst.
[#_finding_the_move_that_is_being_asked]
=== Die Aktion finden, nach der gerade gefragt wird
Alles andere muss eine Sache wissen: Welche Aktion ist gerade offen, wenn überhaupt eine?
[source,lua]
----
local function moveAt(frame)
for _, move in ipairs(moves) do
if move.answer == nil and frame >= move.first and frame <= move.last then
return move
end
end
return nil
end
----
Geh die Liste durch und antworte mit der ersten Aktion, deren Zeitfenster diesen Frame
abdeckt und die noch nicht beantwortet ist. Passt nichts, antworte mit `nil`.
Dass hier absichtlich `nil` zurückgegeben wird, ist bemerkenswert. Eine Funktion, die
manchmal keine Antwort hat, sollte das sagen, und „keine Aktion ist offen“ ist eine
völlig gute Antwort. Der Aufrufer schreibt dann `if move ~= nil then`, und der Code, der
die Aufforderung zeichnet, läuft nie, wenn es nichts zum Auffordern gibt.
Der Test `move.answer == nil` verhindert, dass nach einer Aktion zweimal gefragt wird. In
dem Moment, in dem der Spieler sie richtig hat, verschwindet die Aufforderung, auch wenn
das Zeitfenster noch offen ist -- und genau das soll sie tun, denn er hat ja geantwortet.
[#_reading_the_answer]
=== Die Antwort lesen
[source,lua]
----
function onInputPressed(what)
if not isDirection(what) then
return
end
local move = moveAt(discGetFrame())
if move == nil then
return
end
if what == move.switch then
move.answer = "right"
score = score + RIGHT_SCORE
else
move.answer = "wrong"
endRun("WRONG WAY", WRONG_FRAME)
end
end
----
Lies es von oben nach unten. Wenn der Schalter keine der vier Richtungen ist, geht er uns
nichts an -- raus. Wenn keine Aktion offen ist, bedeutet ein Druck auf eine Richtung
nichts -- raus. Sonst gibt es genau eine Aktion, mit der verglichen wird, und einen
Vergleich, den es zu machen gilt.
Diese beiden frühen `return`-Zeilen sind Wächter: Sie werfen die Fälle hinaus, die dich
nicht interessieren, damit der Code darunter immer nur in der einen Situation läuft, für
die er geschrieben wurde. Andersherum geschrieben, mit der eigentlichen Arbeit drei Ebenen
tief in drei `if`-Blöcken verschachtelt, sagt es dasselbe und ist viel schwerer zu lesen.
`isDirection` ist eine winzige Funktion, die nichts anderes tut, als die erste Zeile kurz
zu halten:
[source,lua]
----
local function isDirection(what)
return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT
end
----
Achte darauf, dass sie den Vergleich selbst zurückgibt, statt
`if ... then return true else return false end` zu sagen. Ein Vergleich ist schon `true`
oder `false`. Zu prüfen, ob etwas wahr ist, nur um dann zu sagen, dass es wahr ist, ist
eine Gewohnheit, die man früh ablegen sollte.
[#_the_window_closing]
=== Das Zeitfenster schließt sich
Niemand drückt etwas, und die Frames ziehen weiter vorbei. Irgendetwas muss das bemerken.
Dieses Etwas ist `onOverlayUpdate`, denn es läuft in jedem Frame, ob der Spieler etwas tut
oder nicht:
[source,lua]
----
if not runOver then
for _, move in ipairs(moves) do
if move.answer == nil and frame > move.last then
move.answer = "late"
endRun("TOO SLOW", LATE_FRAME)
break
end
end
end
----
Eine Aktion, die nicht beantwortet ist und deren letzter Frame schon vorbei ist, wurde
verpasst. `break` verlässt die Schleife sofort, denn sobald der Durchgang vorbei ist, hat
es keinen Sinn, sich den Rest anzusehen.
`endRun` ist die Verzweigung, und es ist dieselbe Verzweigung, die du in Lektion sechzehn
geschrieben hast:
[source,lua]
----
local function endRun(reason, frame)
verdict = reason
runOver = true
discSkipToFrame(frame)
end
----
Drei Zeilen. Merk dir, was gesagt werden soll, merk dir, dass der Durchgang vorbei ist,
und schick den Film woandershin. Ein verpasstes Quick-Time-Event ist kein besonderer
Mechanismus. Es ist eine Entscheidung, die der Spieler getroffen hat, indem er keine
getroffen hat, und der Film verzweigt darauf genau so, wie er bei einer Tür verzweigt, die
der Spieler gewählt hat.
[#_a_gauge_that_drains]
=== Eine Anzeige, die sich leert
Der Spieler kann keine Frame-Nummern sehen. Er muss sehen, wie die Zeit abläuft, und er
muss es sehen, ohne von der Action wegzuschauen, und das heißt: eine Form, die sich
ändert, statt einer Zahl, die zählt.
Zwei Zahlen geben dir alles:
[source,lua]
----
local left = move.last - frame
local span = move.last - move.first
----
`span` ist, wie lang das Zeitfenster ist. `left` ist, wie viel davon noch übrig ist. Der
Balken ist `left` von `span` der vollen Breite:
[source,lua]
----
fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H)
----
`//` ist eine Division, die den Nachkommateil wegwirft, und das willst du hier, denn einen
Balken, der hundertdreiunddreißig und ein Drittel Pixel breit ist, gibt es nicht. Multiplizier, bevor
du teilst: `GAUGE_W * left // span` und nicht `GAUGE_W * (left // span)`. Sonst wird
der Nachkommateil weggeworfen, solange er noch die einzige Information ist, die du hast,
und der Balken springt zwischen voll und leer hin und her, ohne irgendetwas dazwischen.
Ein gefülltes Rechteck gibt es im Overlay nicht. `overlayBox` zeichnet einen Umriss, und
den willst du für den Rahmen um die Anzeige, und die Füllung machst du selbst:
[source,lua]
----
local function fillBar(x, y, width, height)
if width < 1 then
return
end
for row = 0, height - 1 do
overlayLine(x, y + row, x + width - 1, y + row)
end
end
----
Achtzehn waagerechte Linien, übereinandergestapelt. Der Wächter oben ist wichtig: Im
allerletzten Frame des Zeitfensters ist `left` null, die Breite ist null, und ohne ihn ist
`x + width - 1` ein Pixel *links* von `x`, und du zeichnest eine kurze Linie rückwärts
statt gar nichts.
Der letzte Schliff ist ein Farbwechsel, wenn es eng wird:
[source,lua]
----
if left * 3 < span then
colorForeground(255, 60, 60, 255)
else
colorForeground(255, 200, 0, 255)
end
----
Im letzten Drittel wird der Balken rot. So geschrieben -- die übrige Zeit multiplizieren,
statt die Spanne zu teilen -- bleibt alles bei ganzen Zahlen und funktioniert für ein
Zeitfenster jeder Länge.
[#_the_whole_thing_10]
=== Das Ganze
[source,lua]
----
-- Programmieren lernen mit Singe -- Lektion 18: Quick-Time-Events
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
local FIRST_FRAME = 20
local WRONG_FRAME = 362
local LATE_FRAME = 388
local LAST_FRAME = 415
local PROMPT_COL = 54
local PROMPT_ROW = 22
local GAUGE_X = 210
local GAUGE_Y = 330
local GAUGE_W = 300
local GAUGE_H = 18
local RIGHT_SCORE = 100
local moves = {
{ first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" },
{ first = 140, last = 185, switch = SWITCH_UP, name = "UP" },
{ first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" },
{ first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" },
}
local score = 0
local runOver = false
local verdict = ""
local function fillBar(x, y, width, height)
if width < 1 then
return
end
for row = 0, height - 1 do
overlayLine(x, y + row, x + width - 1, y + row)
end
end
local function isDirection(what)
return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT
end
local function moveAt(frame)
for _, move in ipairs(moves) do
if move.answer == nil and frame >= move.first and frame <= move.last then
return move
end
end
return nil
end
local function startRun()
score = 0
runOver = false
verdict = ""
for _, move in ipairs(moves) do
move.answer = nil
end
discSkipToFrame(FIRST_FRAME)
end
local function endRun(reason, frame)
verdict = reason
runOver = true
discSkipToFrame(frame)
end
function onInputPressed(what)
if runOver then
if what == SWITCH_START1 then
startRun()
end
return
end
if not isDirection(what) then
return
end
local move = moveAt(discGetFrame())
if move == nil then
return
end
if what == move.switch then
move.answer = "right"
score = score + RIGHT_SCORE
else
move.answer = "wrong"
endRun("WRONG WAY", WRONG_FRAME)
end
end
function onOverlayUpdate()
local frame = discGetFrame()
if not runOver then
for _, move in ipairs(moves) do
if move.answer == nil and frame > move.last then
move.answer = "late"
endRun("TOO SLOW", LATE_FRAME)
break
end
end
end
if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then
if not runOver then
verdict = "CLEAR"
runOver = true
end
discSearch(LAST_FRAME)
end
overlayClear()
local move = nil
if not runOver then
move = moveAt(frame)
end
if move ~= nil then
local left = move.last - frame
local span = move.last - move.first
overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name)
colorForeground(255, 255, 255, 255)
overlayBox(GAUGE_X - 2, GAUGE_Y - 2, GAUGE_X + GAUGE_W + 1, GAUGE_Y + GAUGE_H + 1)
if left * 3 < span then
colorForeground(255, 60, 60, 255)
else
colorForeground(255, 200, 0, 255)
end
fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H)
end
overlayPrint(2, 1, "SCORE " .. score .. " FRAME " .. frame)
if runOver then
overlayPrint(2, 3, verdict)
overlayPrint(2, 4, "PRESS 1 TO RUN IT AGAIN")
end
return OVERLAY_UPDATED
end
startRun()
----
Die Pfeiltasten beantworten die Aufforderungen. Schaff alle vier, und der Film läuft bis
zum Ende und bleibt dort bei `CLEAR` stehen. Mach eine falsch oder setz dich auf deine
Hände, und er springt zu einem anderen Frame, mit einem anderen Wort auf dem Bildschirm.
Mit `1` geht es noch einmal los.
[#_what_just_happened_14]
=== Was gerade passiert ist
Das meiste hast du schon gelesen. Vier Stücke sind einen zweiten Blick wert.
[source,lua]
----
if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then
----
Das Ende des Films muss abgefangen werden, sonst läuft er über das Ende hinaus, und es gibt
nichts mehr zu sehen. `discSearch` geht zu einem Frame und *pausiert* dort, und genau das
will eine letzte Einstellung. Die Hälfte mit `discGetState()` verhindert, dass das in jedem
der folgenden Frames wieder passiert: Sobald die Disc pausiert ist, ist sie nicht mehr
`DISC_PLAYING`, also ist die Bedingung falsch, und der Sprung wird nicht sechzigmal pro
Sekunde wiederholt. `discGetState` und die Konstanten `DISC_*` stehen in der Referenz unter
`discGetState`.
[source,lua]
----
local move = nil
if not runOver then
move = moveAt(frame)
end
----
`onOverlayUpdate` fragt selbst noch einmal nach der offenen Aktion, statt es sich von
`onInputPressed` sagen zu lassen. Das ist Absicht. Es gibt genau eine Funktion, die weiß,
welche Aktion offen ist, und alle fragen sie. Käme die Aufforderung aus einer Variablen,
die der Eingabecode auf dem neuesten Stand hält, dann würden sich zwei Stücke Code
dieselbe Tatsache merken, und früher oder später läge eines davon falsch. Wenn du die Wahl
zwischen Fragen und Merken hast, frag.
[source,lua]
----
overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name)
----
`overlayPrint` zählt in Zeichenzellen, wie schon seit Lektion eins, also ist die
Aufforderung nach Augenmaß platziert: Spalte vierundfünfzig liegt für Wörter dieser Länge
nah genug an der Mitte. Nah genug, nicht richtig, und „PRESS RIGHT“ endet drei Zeichen
weiter rechts als „PRESS UP“. Lektion neunzehn lädt eine echte Schriftart und mit ihr die
Möglichkeit, einen String in Pixeln auszumessen und richtig zu zentrieren.
[source,lua]
----
function onInputPressed(what)
if runOver then
if what == SWITCH_START1 then
startRun()
end
return
end
----
Ein Callback, zwei völlig verschiedene Aufgaben, ausgewählt durch `runOver`. Während der
Durchgang läuft, bedeuten die Richtungen Aktionen; sobald er vorbei ist, bedeutet nur noch
die Starttaste etwas. Eine Variable, die entscheidet, welche Regeln gelten, ist ein
*Zustand*, und so hat Lektion zwölf das auch genannt, und ein Spiel besteht zum größten
Teil aus einer kleinen Zahl davon.
[#_try_it_18]
=== Probier es aus
. *Mach es schwerer.* Ändere das `last` der ersten Aktion von `105` in `75`. Fünfzehn
Frames sind eine halbe Sekunde. Spiel es und finde heraus, ob du das schaffst.
. *Füg eine Aktion hinzu.* Schreib eine fünfte Zeile in die Tabelle, mit einem
Zeitfenster zwischen den Frames `370` und `400`, und setz `LAST_FRAME` auf `418` hoch.
Keine andere Zeile solltest du anfassen müssen. Wenn doch, weiß irgendetwas im Code zu
viel über die Tabelle.
. *Bewerte die Geschwindigkeit.* Gib mehr Punkte für frühes Antworten. Rechne in
`onInputPressed`, dort, wo die richtige Antwort Punkte bekommt, `left` und `span` aus
`move` und `discGetFrame()` so aus wie die Anzeige, und addiere
`RIGHT_SCORE * left // span` statt `RIGHT_SCORE`. Entscheide, ob das Spiel dadurch
besser wird oder nur hektischer.
. *Gib einer falschen Antwort ihre eigene Folge.* Statt den Durchgang zu beenden, lass
einen falschen Druck fünfzig Punkte kosten und das Zeitfenster für einen neuen Versuch
offen. Du musst aufhören, `move.answer` zu setzen, und dabei begegnest du dem Grund,
warum es überhaupt gesetzt wurde.
. *Zwei Aufforderungen auf einmal.* Lass die Zeitfenster zweier Aktionen sich überlappen
und sieh, was passiert. `moveAt` gibt den ersten Treffer zurück, also wird immer nur nach
einer gefragt. Entscheide, was du willst und was es dafür bräuchte.
[#_break_it_on_purpose_17]
=== Mach es absichtlich kaputt
Lösch das `break` aus der Schleife, die verpasste Aktionen findet, und verpass die erste
mit Absicht. Der Durchgang endet, die Disc springt, und alles sieht richtig aus.
Setz jetzt `debugPrint(move.name .. " missed")` neben `move.answer = "late"` und mach es
noch einmal. Die Konsole zeigt eine Zeile, wie zu erwarten.
Dann nimm auch noch das `if not runOver then` um die Schleife heraus und verpass wieder
die erste Aktion:
----
LEFT missed
UP missed
RIGHT missed
DOWN missed
----
Die erste Zeile kommt, wenn sich das Zeitfenster schließt, wie vorher. Die anderen drei
kommen zusammen im allernächsten Frame, in dem Augenblick, in dem die Disc bei 388 landet:
Jedes übrige Zeitfenster liegt jetzt in der Vergangenheit, also wird jede unbeantwortete
Aktion auf einmal verpasst, und jede ruft `endRun` auf und springt noch einmal. Das Spiel
funktioniert immer noch mehr oder weniger, und genau das macht es gefährlich. Drei
zusätzliche Sprünge in einem einzigen Frame, dreimal wird das Urteil überschrieben, und
nichts auf dem Bildschirm sagt es dir.
Auch hier gibt es keine Fehlermeldung. Die Lehre ist, dass dieses `break` und dieser
Wächter keine Ordnungsliebe sind. Sie sind die zwei Zeilen, die sagen „das ist schon
entschieden“, und Code, der etwas immer wieder entscheidet, das schon entschieden ist, ist
eine der zuverlässigsten Quellen von Bugs, die du je schreiben wirst.
[#_what_you_learned_18]
=== Was du gelernt hast
* Ein Quick-Time-Event ist eine Aufforderung, ein Zeitfenster und eine Folge.
* Miss das Zeitfenster in Frames, denn der Film ist die Uhr, und es darf immer nur eine
Uhr geben.
* Halte die Aktionen in einer Tabelle von Datensätzen, damit eine Aktion hinzuzufügen
heißt, eine Zeile hinzuzufügen.
* Richtig, falsch und keine Antwort sind drei Ausgänge, und sie führen meistens an drei
verschiedene Orte.
* `nil` in einem Feld eines Datensatzes ist ein echter Zustand: Noch hat niemand
geantwortet.
* Eine Funktion beantwortet „Welche Aktion ist offen?“, und alle fragen sie, statt eine
Kopie aufzuheben.
* Wächter -- ein frühes `return` für die Fälle, die dich nicht interessieren -- halten
die eigentliche Arbeit unverschachtelt.
* `//` teilt und wirft den Nachkommateil weg; multiplizier, bevor du teilst.
* `overlayBox` zeichnet einen Umriss, also ist ein gefüllter Balken ein Stapel von
`overlayLine`-Aufrufen.
* Eine verpasste Aktion ist eine Verzweigung, und `discSkipToFrame` nimmt sie, genau wie
in Lektion sechzehn.
[#_next_time_18]
=== Beim nächsten Mal
Teil drei ist fertig. Du kannst Film abspielen, auf ihn warten, an ihm verzweigen, auf ihn
zielen und den Spieler gegen ihn antreten lassen. Was du über all das geschrieben hast, ist
die Konsolenschrift der Engine, die sechs Pixel breit ist und für Diagnosen gedacht. In
Lektion neunzehn lädst du eine echte Schrift, misst sie aus und sorgst dafür, dass die
Wörter auf dem Bildschirm so aussehen, als gehörten sie zum Spiel.