825 lines
30 KiB
Text
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.
|