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