singe/docs/de/lessons/07-a-game.adoc

825 lines
30 KiB
Text

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