[#_lesson_7_a_game] == Lektion 7: Ein Spiel image::learn/07-a-game.png[Die fertige Lektion, 480] Vor sechs Lektionen hast du ein Wort auf einen schwarzen Bildschirm gebracht. Seitdem hast du jede Idee gelernt, aus der ein kleines Spiel besteht: Variablen, Entscheidungen, Schleifen, eigene Funktionen und Listen von Dingen. Diese Lektion bringt dir keine neue Idee bei. Sie gibt die aus, die du schon hast. Du baust ein Spiel namens Dodge. Blöcke fallen vom Himmel, du schiebst einen Balken am unteren Rand entlang, um ihnen aus dem Weg zu gehen, und jeder Block, der dich verfehlt, ist ein Punkt. Erwischen dich drei davon, ist es vorbei. Du baust es in sechs Schritten, und jeder davon läuft: Du musst nie hundert Zeilen abtippen und hoffen. [#_make_the_folder] === Leg den Ordner an Leg einen Ordner namens `dodge` an und darin eine Datei namens `dodge.singe`. Starte es so, wie du seit Lektion eins alles startest: ---- Singe -R dodge ---- Lass das die ganze Lektion über in einem Terminal laufen. Jedes Mal, wenn du speicherst, startet das Spiel mit deiner Änderung neu. [#_stage_one_something_you_can_steer] === Schritt eins: Etwas, das du steuern kannst Tipp das ein, speichere und steuere es mit den Pfeiltasten nach links und rechts. [source,lua] ---- dofile("Singe/Framework.singe") local PLAYER_WIDTH = 40 local PLAYER_HEIGHT = 8 local PLAYER_SPEED = 4 local PLAYER_MARGIN = 4 local screenWidth = overlayGetWidth() local screenHeight = overlayGetHeight() local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN local playerX = (screenWidth - PLAYER_WIDTH) / 2 local goingLeft = false local goingRight = false function onInputPressed(what) if what == SWITCH_LEFT then goingLeft = true elseif what == SWITCH_RIGHT then goingRight = true end end function onInputReleased(what) if what == SWITCH_LEFT then goingLeft = false elseif what == SWITCH_RIGHT then goingRight = false end end function onOverlayUpdate() if goingLeft then playerX = playerX - PLAYER_SPEED end if goingRight then playerX = playerX + PLAYER_SPEED end if playerX < 0 then playerX = 0 elseif playerX > screenWidth - PLAYER_WIDTH then playerX = screenWidth - PLAYER_WIDTH end overlayClear() colorForeground(80, 255, 160) overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1) return OVERLAY_UPDATED end ---- Fünfzig Zeilen, und fast nichts davon ist neu. Zwei Dinge darin schon, also nimm dir zuerst die erste Zeile vor. [source,lua] ---- dofile("Singe/Framework.singe") ---- `dofile` gehört zu Lua, nicht zu Singe, und es bedeutet „geh und führ diese Datei aus, dann mach hier weiter“. Die Datei, die es ausführt, ist `Singe/Framework.singe`, eine der Dateien, die die Engine in deinen Arbeitsordner entpackt hat, als du in Lektion eins zum ersten Mal irgendetwas gestartet hast. Öffne sie in deinem Texteditor und sieh hinein: Es ist gewöhnliches Lua, nicht anders als das, was du seit Lektion zwei schreibst, und es steckt nichts Magisches darin. Sie auszuführen gibt deinem Skript eine Reihe von Namen, die es danach benutzen kann. Zu diesen greift dieses Buch: * `DIR`, in dem der Ordner steht, in dem dein eigenes Skript liegt. Lektion neun braucht es, sobald ein Spiel ein Bild lädt, und es ist der eigentliche Grund, warum diese Zeile hier steht. * `SCANCODE` und `MODIFIER`, Namen für jede Taste einer Tastatur, für ein Spiel, das Getipptes liest statt eines Joysticks. * `GAMEPAD_0` bis `GAMEPAD_3` und `MOUSE_0` bis `MOUSE_3`, für einen Automaten, an dem von etwas mehr als eins angeschlossen ist. * Ein paar Helfer, deren Namen mit `util` anfangen, und die sechs Aufrufe, die eine Punktzahl an eine Bestenliste schicken; das ist Lektion achtundzwanzig. * Die zweite, ganz andere Art, ein Spiel zu schreiben; das ist Lektion fünfzehn. Eine Sache gibt es dir *nicht*, auch wenn man das ganz natürlich annimmt. `SWITCH_LEFT` und jeder andere Name, der mit `SWITCH_` anfängt, kommt von der Engine selbst, bevor überhaupt ein Skript läuft. Darum konnten die Lektionen drei bis sechs die Pfeiltasten abfragen, ohne etwas zu laden. Das Framework ergänzt, was schon da ist; es liefert es nicht. Dieses Spiel hier benutzt nichts aus dem Framework. Die Zeile steht da, weil jedes Spiel von Lektion neun bis zum Ende des Buchs sie sehr wohl braucht, und weil fast jedes echte Singe-Spiel so anfängt. Sie wird einmal gelesen, beim Start, und dann vergessen. Das zweite Neue ist, wie die Pfeiltasten behandelt werden, denn das ist das Erste, was Anfänger falsch machen. `onInputPressed` bewegt die Spielfigur nicht. Es setzt `goingLeft` auf `true`, und `onInputReleased` setzt es wieder auf `false`. Das Bewegen passiert in `onOverlayUpdate`, das sechzigmal pro Sekunde fragt: „Hält der Spieler links gedrückt?“ Würdest du die Spielfigur stattdessen in `onInputPressed` bewegen, bekämst du einen Schritt pro Druck: Singe meldet ein Drücken genau einmal, wenn die Taste nach unten geht, und wiederholt es nie, und der Eintrag zu `onInputPressed` in der Referenz sagt das klar und deutlich. Die Taste zu halten würde überhaupt nichts tun. Der Callback fürs Drücken hält also fest, was über die Welt wahr ist, und das Update entscheidet, was damit geschieht. Bleib bei dieser Trennung, und dein Eingabecode bleibt für immer fünf Zeilen lang. Die vier Namen in Großbuchstaben sind gewöhnliche Variablen. `PLAYER_SPEED` in Großbuchstaben zu schreiben ist eine Nachricht an dich selbst und an jeden, der mitliest: Dieser Wert wird einmal oben gesetzt und ändert sich nie. Nichts in Lua erzwingt das. Es ist wichtig, weil es gleich zehn davon geben wird, und wenn das Spiel zu schwer ist, willst du eine offensichtliche Stelle haben, an der du eine Zahl herunterdrehst. `screenWidth` und `screenHeight` kommen von der Engine, statt eingetippt zu werden, also richtet sich das Spiel nach der Größe, die das Overlay gerade hat. `playerY` wird einmal aus der Höhe ausgerechnet: Der Balken sitzt `PLAYER_MARGIN` Pixel über dem unteren Rand und bewegt sich nie nach oben oder unten, also gibt es keinen Grund, es in jedem Frame neu auszurechnen. Das `- 1` in der Zeile mit `overlayBox` ist kein Tippfehler. `overlayBox` zeichnet durch beide Ecken, die du ihm gibst, also ist ein Kasten von `playerX` bis `playerX + 40` einundvierzig Pixel breit. Eins abzuziehen macht den Balken, den du siehst, genau vierzig breit, und das wird in Schritt vier wichtig, wenn etwas entscheiden muss, ob es dich berührt hat. [#_stage_two_something_to_dodge] === Schritt zwei: Etwas zum Ausweichen Ein Block, der fällt. Füg der Liste oben zwei Konstanten hinzu: [source,lua] ---- local BLOCK_SIZE = 12 local BLOCK_SPEED = 1.5 ---- Zwei weitere Variablen unter `goingRight`: [source,lua] ---- local blockX = math.random(0, screenWidth - BLOCK_SIZE) local blockY = -BLOCK_SIZE ---- Lass ihn in `onOverlayUpdate` fallen, direkt über der Zeile `overlayClear()`: [source,lua] ---- blockY = blockY + BLOCK_SPEED if blockY > screenHeight then blockX = math.random(0, screenWidth - BLOCK_SIZE) blockY = -BLOCK_SIZE end ---- Und zeichne ihn unter der Zeile, die die Spielfigur zeichnet: [source,lua] ---- colorForeground(255, 90, 90) overlayBox(blockX, blockY, blockX + BLOCK_SIZE - 1, blockY + BLOCK_SIZE - 1) ---- Speichere. Ein rotes Quadrat fällt, kommt unten an und taucht oben an einer neuen Stelle wieder auf. Er fängt bei `-BLOCK_SIZE` an, und das ist über dem oberen Rand des Bildschirms. An einer negativen Position ist nichts falsch: Die Engine zeichnet den Teil des Kastens, der auf dem Overlay liegt, und wirft den Rest stillschweigend weg. Der Block gleitet ins Bild, statt plötzlich aufzutauchen. `BLOCK_SPEED` ist `1.5`, keine ganze Zahl, also ist `blockY` die meiste Zeit so etwas wie `83.5`. Auch das ist in Ordnung. Beim Zeichnen wird der Bruchteil abgeschnitten, und weil er in der Variablen bleibt, kann sich der Block langsamer als ein Pixel pro Frame bewegen. [#_stage_three_a_sky_full_of_them] === Schritt drei: Ein Himmel voll davon Ein Block ist kein Spiel. In Lektion sechs hast du gelernt, viele von etwas in einer Liste aufzubewahren, und genau dafür war das gut. Lösch `blockX` und `blockY` und setz an ihre Stelle das hier: [source,lua] ---- local blocks = {} local spawnTimer = 0 ---- Füg oben noch eine Konstante hinzu: [source,lua] ---- local SPAWN_FRAMES = 40 ---- Jetzt eine Funktion, die einen Block macht und ihn in die Liste legt. Setz sie über `onInputPressed`: [source,lua] ---- function spawnBlock() local block = {} block.x = math.random(0, screenWidth - BLOCK_SIZE) block.y = -BLOCK_SIZE table.insert(blocks, block) end ---- Ersetze den Fall-Code aus Schritt zwei durch das hier: [source,lua] ---- spawnTimer = spawnTimer - 1 if spawnTimer <= 0 then spawnBlock() spawnTimer = SPAWN_FRAMES end for i = #blocks, 1, -1 do local block = blocks[i] block.y = block.y + BLOCK_SPEED if block.y > screenHeight then table.remove(blocks, i) end end ---- Und ersetze die eine Zeile, die den Block gezeichnet hat, durch eine Schleife über alle: [source,lua] ---- for i, block in ipairs(blocks) do overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1) end ---- Speichere. Es regnet Blöcke. Ein Block ist ein Datensatz: eine kleine Tabelle mit einem `x` und einem `y` darin, und so verhinderst du, dass zwei Zahlen, die zusammengehören, auseinanderdriften. `blocks` ist eine Liste solcher Datensätze, und diese Form hat die Welt fast jedes Spiels. `spawnTimer` zählt pro Frame um eins herunter, und wenn er null erreicht, wird ein Block geboren und der Timer wieder aufgezogen. Vierzig Frames sind etwa zwei Drittel einer Sekunde. In diesem Spiel gibt es keine Uhr, und es braucht auch keine: `onOverlayUpdate` ist der Herzschlag, und Herzschläge zu zählen ist eine völlig brauchbare Art, Zeit zu messen. Die Fall-Schleife läuft *rückwärts* -- von `#blocks` hinunter bis `1`, Schritt für Schritt -- weil sie unterwegs Dinge entfernt. Das ist die Regel aus Lektion sechs, und jetzt hast du zum ersten Mal einen echten Grund, sie zu befolgen. Entfernst du beim Vorwärtslaufen Eintrag drei, rückt alles um eins nach, also ist der Block, der Eintrag vier war, jetzt Eintrag drei, und die Schleife sieht ihn nie an. Beim Rückwärtslaufen ist alles, was sich verschiebt, schon erledigt. Die Zeichenschleife benutzt dagegen `ipairs`, weil sie nichts entfernt und nur einen Block nach dem anderen haben will. [#_stage_four_getting_hit] === Schritt vier: Getroffen werden Die Blöcke fallen glatt durch dich hindurch. Zeit, das zu bemerken. Die Engine hat eine Funktion für genau diese Frage. Ändere die Prüfung in der Fall-Schleife von diesem: [source,lua] ---- if block.y > screenHeight then table.remove(blocks, i) end ---- zu diesem: [source,lua] ---- if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) or block.y > screenHeight then table.remove(blocks, i) end ---- Speichere und fahr mit dem Balken in einen fallenden Block. Er verschwindet. `collideRects` bekommt zwei Rechtecke und antwortet `true`, wenn sie sich überlappen. Jedes Rechteck sind vier Zahlen: eine Ecke, dann eine Breite und eine Höhe -- *nicht* zwei Ecken, was die andere vernünftige Art ist, ein Rechteck zu beschreiben, und die Art, wie `overlayBox` es macht. Der Eintrag zu `collideRects` in der Referenz nennt die Reihenfolge, und es lohnt sich, ihn jetzt anzusehen, denn die acht Argumente in der falschen Reihenfolge zu übergeben ist die Art Fehler, die überhaupt keine Fehlermeldung erzeugt, nur ein Spiel, in dem nie etwas irgendetwas trifft. Das könntest du selbst schreiben. Es sind vier Vergleiche, und es ist wirklich nicht schwer. Nimm trotzdem die der Engine, aus drei Gründen. Sie ist richtig, auch in den kniffligen Fällen: ein Rechteck ohne Breite, zwei Rechtecke, die sich nur an einer Kante berühren. Sie sagt, was sie meint, also liest sich die Zeile als „haben sich die beiden überlappt“ statt als vier mit `and` verbundene Vergleiche, die du jedes Mal entschlüsseln musst, wenn du zu ihr zurückkommst. Und wenn du bei Lektion elf ankommst, wo die Dinge ständig aneinanderstoßen, kennst du die Familie schon: `collidePointRect` für einen Mausklick, `collideCircles` für zwei runde Dinge und `collideSegments` für einen Schuss gegen eine Wand. Die Referenz führt sie zusammen auf. Das Einzige, was die Engine dir nicht abnehmen kann, ist zu entscheiden, was ein Rechteck *bedeutet*. Das Rechteck des Blocks ist hier der ganze Block. In einem echten Spiel willst du oft, dass das Trefferrechteck etwas kleiner ist als das Bild, damit sich ein knappes Verfehlen auch wie ein knappes Verfehlen anfühlt. Das ist eine Designentscheidung, und in Lektion elf triffst du sie. [#_stage_five_score_and_lives] === Schritt fünf: Punkte und Leben Getroffen zu werden sollte etwas kosten, und Überleben sollte etwas wert sein. Zwei weitere Konstanten oben: [source,lua] ---- local BLOCK_FASTER = 0.05 local START_LIVES = 3 ---- Drei weitere Variablen, neben `spawnTimer`: [source,lua] ---- local blockSpeed = BLOCK_SPEED local score = 0 local lives = START_LIVES ---- `BLOCK_SPEED` ist jetzt die Geschwindigkeit, mit der das Spiel *anfängt*, und `blockSpeed` die, mit der es gerade läuft. Ändere die Fall-Zeile so, dass sie die Variable benutzt: [source,lua] ---- block.y = block.y + blockSpeed ---- Dann trenn die beiden Fälle, denn sie tun jetzt Verschiedenes: [source,lua] ---- if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then table.remove(blocks, i) lives = lives - 1 elseif block.y > screenHeight then table.remove(blocks, i) score = score + 1 blockSpeed = blockSpeed + BLOCK_FASTER end ---- Und zeig die Zahlen an. Setz das direkt vor das `return`: [source,lua] ---- overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives) ---- Speichere und spiel eine Minute lang. Es wird schwerer, und das ist der ganze Trick: Jeder Block, dem du ausweichst, lässt die nächsten um ein Zwanzigstel Pixel pro Frame schneller fallen. Nach zwanzig Punkten sind sie spürbar flotter. Du musstest kein einziges Wort über Schwierigkeitsstufen schreiben. Du wirst auch zusehen, wie `LIVES` auf `-4` fällt, weil das Spiel noch nichts anhält. Das ist der nächste Schritt, und er ist der wichtigste. [#_stage_six_game_over_and_going_again] === Schritt sechs: Game Over und noch eine Runde Hier ist die Falle. Der naheliegende Weg zu einem Game-Over-Bildschirm ist eine Variable namens `gameOver`, die auf `true` gesetzt wird, wenn die Leben aufgebraucht sind. Dann willst du vor dem ersten Spiel einen Titelbildschirm, also fügst du `started` hinzu. Dann willst du eine Pausentaste, also fügst du `paused` hinzu. Jetzt hast du drei Wahr-oder-falsch-Variablen, acht Kombinationen davon, und fünf dieser Kombinationen sind Unsinn, den dein Code sorgfältig nie erzeugen darf. Jeder Anfänger schreibt dieses Spiel mindestens einmal, und hier gehen kleine Spiele sterben. Mach stattdessen das hier. Eine Variable hält fest, *was das Spiel gerade tut*, als Wort: [source,lua] ---- local state = "waiting" ---- Sie ist `"waiting"` vor dem ersten Spiel, `"playing"` während eines Spiels und `"over"`, wenn die Leben weg sind. Drei Zustände, eine Variable, und so etwas wie eine unmögliche Kombination gibt es nicht. An den Strings ist nichts Magisches; es sind Wörter, die du gewählt hast, und du musst sie jedes Mal gleich schreiben. Jetzt die Funktion, die ein Spiel beginnt. Jede Variable, die zu einem Spiel gehört, bekommt hier ihren Anfangswert zurück, an einer einzigen Stelle: [source,lua] ---- function startGame() playerX = (screenWidth - PLAYER_WIDTH) / 2 blocks = {} blockSpeed = BLOCK_SPEED spawnTimer = 0 score = 0 lives = START_LIVES state = "playing" end ---- Das letzte Leben zu verlieren beendet es. Im Zweig für die Kollision, unter `lives = lives - 1`: [source,lua] ---- if lives == 0 then state = "over" end ---- Die Leertaste startet ein Spiel, aber nur, wenn nicht schon eins läuft. Füg `onInputPressed` einen Zweig hinzu: [source,lua] ---- elseif what == SWITCH_BUTTON1 and state ~= "playing" then startGame() ---- `~=` heißt „ist nicht gleich“. `state ~= "playing"` ist wahr beim Warten und wahr nach dem Ende, und das sind genau die beiden Momente, in denen die Leertaste ein Spiel starten soll. `SWITCH_BUTTON1` ist standardmäßig die Leertaste und außerdem der A-Knopf an einem Controller. Zwei kleine Funktionen bringen die Wörter auf den Bildschirm. `printCentered` erledigt die Rechnung einmal, damit der Titelbildschirm und der Game-Over-Bildschirm sie nicht jeder für sich schlecht machen: [source,lua] ---- function drawOverText() printCentered(7, "GAME OVER") printCentered(9, "PRESS SPACE TO PLAY AGAIN") end function drawWaitingText() printCentered(6, "DODGE THE BLOCKS") printCentered(8, "ARROW KEYS TO MOVE") printCentered(10, "PRESS SPACE TO START") end function printCentered(row, text) overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) end ---- mit einer weiteren Konstanten oben: [source,lua] ---- local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth() ---- Zum Schluss der wichtige Teil. Schieb alles, was zeichnet, aus `onOverlayUpdate` in eine Funktion namens `drawGame`, schieb alles, was bewegt, in eine Funktion namens `updatePlaying` -- beide stehen im vollständigen Skript unten -- und lass `onOverlayUpdate` so aussehen: [source,lua] ---- function onOverlayUpdate() if state == "playing" then updatePlaying() end overlayClear() drawGame() if state == "waiting" then drawWaitingText() elseif state == "over" then drawOverText() end return OVERLAY_UPDATED end ---- Speichere. Du hast ein Spiel: einen Titelbildschirm, ein Spiel, ein Game Over und einen Weg zurück an den Anfang. Lies diese dreizehn Zeilen noch einmal, denn sie sind die Form jedes Spiels, das du je schreiben wirst. Die Welt bewegt sich nur, solange der Zustand `"playing"` ist, also friert der Game-Over-Bildschirm ein, und die letzten Blöcke hängen genau dort, wo sie waren. Die Welt wird in jedem Frame gezeichnet, egal in welchem Zustand, denn das Bild ist in allen dreien dasselbe; der Zustand entscheidet nur, welche Wörter darüber kommen. Und es gibt genau eine Stelle, die diese Entscheidung trifft. Das hast du dir mit der Variablen `state` erkauft. Wenn du eine Pause einbaust, fügst du diesem `if` einen Zweig hinzu und `onInputPressed` eine Zeile, und sonst muss nichts im Spiel irgendetwas davon wissen. [#_the_whole_thing_6] === Das Ganze Hier ist es, fertig. Wenn deins nicht übereinstimmt, lohnt es sich, den Unterschied zu finden. [source,lua] ---- dofile("Singe/Framework.singe") local PLAYER_WIDTH = 40 local PLAYER_HEIGHT = 8 local PLAYER_SPEED = 4 local PLAYER_MARGIN = 4 local BLOCK_SIZE = 12 local BLOCK_SPEED = 1.5 local BLOCK_FASTER = 0.05 local SPAWN_FRAMES = 40 local START_LIVES = 3 local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth() local screenWidth = overlayGetWidth() local screenHeight = overlayGetHeight() local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN local playerX = (screenWidth - PLAYER_WIDTH) / 2 local goingLeft = false local goingRight = false local blocks = {} local blockSpeed = BLOCK_SPEED local spawnTimer = 0 local score = 0 local lives = START_LIVES local state = "waiting" function drawGame() colorForeground(80, 255, 160) overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1) colorForeground(255, 90, 90) for i, block in ipairs(blocks) do overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1) end overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives) end function drawOverText() printCentered(7, "GAME OVER") printCentered(9, "PRESS SPACE TO PLAY AGAIN") end function drawWaitingText() printCentered(6, "DODGE THE BLOCKS") printCentered(8, "ARROW KEYS TO MOVE") printCentered(10, "PRESS SPACE TO START") end function printCentered(row, text) overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) end function spawnBlock() local block = {} block.x = math.random(0, screenWidth - BLOCK_SIZE) block.y = -BLOCK_SIZE table.insert(blocks, block) end function startGame() playerX = (screenWidth - PLAYER_WIDTH) / 2 blocks = {} blockSpeed = BLOCK_SPEED spawnTimer = 0 score = 0 lives = START_LIVES state = "playing" end function updatePlaying() if goingLeft then playerX = playerX - PLAYER_SPEED end if goingRight then playerX = playerX + PLAYER_SPEED end if playerX < 0 then playerX = 0 elseif playerX > screenWidth - PLAYER_WIDTH then playerX = screenWidth - PLAYER_WIDTH end spawnTimer = spawnTimer - 1 if spawnTimer <= 0 then spawnBlock() spawnTimer = SPAWN_FRAMES end for i = #blocks, 1, -1 do local block = blocks[i] block.y = block.y + blockSpeed if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then table.remove(blocks, i) lives = lives - 1 if lives == 0 then state = "over" end elseif block.y > screenHeight then table.remove(blocks, i) score = score + 1 blockSpeed = blockSpeed + BLOCK_FASTER end end end function onInputPressed(what) if what == SWITCH_LEFT then goingLeft = true elseif what == SWITCH_RIGHT then goingRight = true elseif what == SWITCH_BUTTON1 and state ~= "playing" then startGame() end end function onInputReleased(what) if what == SWITCH_LEFT then goingLeft = false elseif what == SWITCH_RIGHT then goingRight = false end end function onOverlayUpdate() if state == "playing" then updatePlaying() end overlayClear() drawGame() if state == "waiting" then drawWaitingText() elseif state == "over" then drawOverText() end return OVERLAY_UPDATED end ---- Ungefähr hundertzwanzig Zeilen Code, und du hast sie alle eine Handvoll nach der anderen geschrieben. [#_what_just_happened_4] === Was gerade passiert ist Ein paar Stücke verdienen einen zweiten Blick. [source,lua] ---- local blocks = {} local score = 0 function startGame() score = 0 end ---- Die Daten sind `local`, die Funktionen nicht. Das sind zwei bewusste und gegensätzliche Entscheidungen. Die Daten sind `local`, damit sie zu dieser Datei gehören und nichts von außen hineingreifen und sie ändern kann. Die Funktionen sind global, damit sie sich in beliebiger Reihenfolge gegenseitig aufrufen können: `drawOverText` ist vor `printCentered` definiert und ruft es trotzdem auf, weil ein globaler Name erst im Moment des Aufrufs nachgeschlagen wird, und bis dahin ist die ganze Datei gelesen. Mach diese Funktionen `local`, und die Datei muss so sortiert sein, dass nichts benutzt wird, bevor es auftaucht, und über diese Regel würdest du für den Rest des Buchs stolpern. Drei der Namen hier haben überhaupt keine Wahl: `onOverlayUpdate` und die beiden Eingabe-Callbacks müssen global sein, denn so findet Singe sie. Innerhalb der Datei ist die Reihenfolge alphabetisch, mit den drei Callbacks am Ende. Das ist keine Regel von Lua. Es ist dafür da, dass du `spawnBlock` in sechs Monaten findest, ohne irgendetwas zu lesen. [source,lua] ---- overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) ---- `+#text+` ist die Länge eines Strings, in Zeichen. `+#+` hast du in Lektion sechs kennengelernt, als es die Einträge einer Liste gezählt hat, und hier erledigt es dieselbe Aufgabe. `"GAME OVER"` hat neun Zeichen, also fängt es auf einem Bildschirm mit sechzig Spalten in Spalte fünfundzwanzig an, und links davon bleiben fünfundzwanzig Zellen Platz und rechts sechsundzwanzig. `math.floor` wirft den Bruchteil weg, denn eine Spalte fünfundzwanzigeinhalb gibt es nicht. `TEXT_COLUMNS` rechnet aus, wie viele Zeichenzellen nebeneinander auf den Bildschirm passen. `overlayPrint` zählt in Zellen, während alles andere in Pixeln zählt, also wird die Breite des Overlays durch die Breite einer Zelle geteilt, und die sagt dir `overlayGetFontWidth`. Zwei Schrägstriche statt einem, denn ein Bruchteil einer Spalte ist kein Ort, an dem man etwas ausgeben kann. Rechne es so aus, und die Zentrierung bleibt bei jeder Overlay-Größe richtig; darum geht es im vierten Punkt von *Probier es aus*. [source,lua] ---- colorForeground(255, 90, 90) ---- `colorForeground` setzt eine Farbe, die gesetzt bleibt. Es ist kein Argument von `overlayBox`; es ist ein Schalter, den du umlegst, und alles, was danach gezeichnet wird, kommt in dieser Farbe heraus, bis du ihn wieder umlegst. Darum setzt `drawGame` Grün, zeichnet die Spielfigur, setzt Rot und zeichnet dann alle Blöcke, ohne die Farbe noch einmal zu erwähnen. Auf `overlayPrint` wirkt es sich nicht aus. Die Konsolenschrift wird genau so, wie sie ist, in ihren eigenen Farben aufs Overlay kopiert, und der Eintrag zu `overlayPrint` in der Referenz sagt das ausdrücklich. Farbiger Text braucht eine geladene Schriftart, und das ist Lektion neunzehn. [#_try_it_7] === Probier es aus . *Mach es leichter, dann unfair.* Ändere `PLAYER_WIDTH` in `80` und spiel eine Runde. Dann ändere es in `12`, so groß wie ein Block. Beachte, dass du für keins von beiden das Zeichnen oder die Kollision anfassen musstest. . *Schneller beschleunigen.* Ändere `BLOCK_FASTER` von `0.05` in `0.5` und sieh, wie viele Punkte du schaffst. Dann probier `0` und frag dich, ob das Spiel dann noch spielenswert ist. . *Ein Extraleben.* Gib dem Spieler alle zehn Punkte ein Leben zurück. Alles, was du brauchst, steht schon in `updatePlaying`, in dem Zweig, der die Punktzahl erhöht. . *Spiel es größer.* Starte es mit `Singe -R --canvas=1280x720 dodge`. Das Spiel richtet sich richtig ein, samt zentriertem Text, weil es die Engine gefragt hat, wie groß der Bildschirm ist. `TEXT_COLUMNS` hat auch gefragt. . *Halt es an.* Füg einen vierten Zustand hinzu, `"paused"`. `SWITCH_BUTTON2` soll zwischen `"playing"` und `"paused"` wechseln, und während der Pause soll sich nichts bewegen. Richtig gemacht sind das drei Zeilen, und `updatePlaying` musst du überhaupt nicht anfassen. [#_break_it_on_purpose_7] === Mach es absichtlich kaputt Geh in `spawnBlock` und lösch die Zeile, die `block.y` setzt, sodass es so lautet: [source,lua] ---- function spawnBlock() local block = {} block.x = math.random(0, screenWidth - BLOCK_SIZE) table.insert(blocks, block) end ---- Speichere, drück die Leertaste, und Singe hält hiermit an: ---- Error executing function 'onOverlayUpdate': dodge/dodge.singe:97: attempt to perform arithmetic on a nil value (field 'y') stack traceback: dodge/dodge.singe:97: in function 'updatePlaying' dodge/dodge.singe:135: in function 'onOverlayUpdate' ---- Zeile 97 ist `block.y = block.y + blockSpeed`. Die Beschwerde lautet, dass etwas in dieser Rechnung keine Zahl ist, und der Teil in Klammern sagt dir, was: `field 'y'`, also das `y` in einer Tabelle. Es hat keinen Wert, weil du ihm nie einen gegeben hast, und etwas ohne Wert ist in Lua `nil`. Achte darauf, wo die Fehlermeldung ist und wo der Fehler. Die Meldung kommt aus `updatePlaying`, vierzig Zeilen von `spawnBlock` entfernt, und dort ist überhaupt nichts falsch. `updatePlaying` ist nur das erste Stück Code, das das Pech hat, den Schaden zu berühren. Das ist der Normalfall, nicht die Ausnahme, und dieser Spur rückwärts zu folgen ist eine Fertigkeit. Setz die Zeile wieder ein. Dann blätter um, denn in der nächsten Lektion geht es um nichts anderes. [#_what_you_learned_7] === Was du gelernt hast * Ein Spiel ist eine Welt, die sich ändert, eine Welt, die gezeichnet wird, und eine Entscheidung darüber, was davon zu tun ist -- und das sind drei getrennte Stücke Code. * Die Eingabe-Callbacks sollten festhalten, was gedrückt gehalten wird; das Update sollte entscheiden, was das bedeutet. * Eine Liste von Datensätzen ist die Form fast jeder Spielwelt. * Entferne Dinge aus einer Liste, indem du sie rückwärts durchläufst. * `collideRects` beantwortet, ob sich zwei Rechtecke überlappen, und die Version der Engine ist besser als deine, weil sie richtig ist, sich gut liest und eine Familie hat, der du wieder begegnen wirst. * Eine Variable `state`, die ein Wort hält, schlägt drei Wahr-oder-falsch-Flags und schlägt sie weiter, während das Spiel wächst. * Starte ein Spiel aus einer Funktion, die alles zurücksetzt, damit das zweite Spiel genau wie das erste ist. * Zahlen, an denen du vielleicht drehen willst, gehören in benannte Konstanten oben in der Datei. * Bau ein Spiel in Schritten, die jeder für sich laufen. Ein Programm, das nie funktioniert hat, ist viel schwerer zu reparieren als eins, das vor fünf Minuten noch funktioniert hat. [#_next_time_7] === Beim nächsten Mal Du hast jetzt genug Code geschrieben, um interessante Fehler zu machen. Die nächste Lektion ist die, um die sich kein Anfängerbuch kümmert: was die Fehlermeldungen von Singe wirklich sagen, wie du herausfindest, was wirklich in einer Variablen steckt, statt zu raten, und wie du ein Problem so lange halbierst, bis es sich nirgends mehr verstecken kann.