Retrospiele für PICO-8 entwickeln
Computerspiele gehören wohl zu den besten Einstiegsdrogen die die IT zu bieten hat: der Traum vom eigenen Spiel hat wohl schon so manchen dazu inspiriert, Programmieren zu lernen. Aber sobald man sich ernsthafter mit der Materie befasst, entdeckt man zahlreiche Hindernisse: moderne Computerspiele werden von Studios mit mehreren Hundert Entwicklern erstellt, und insbesondere die aufwendige Grafik lässt sich als Einsteiger nicht „einfach mal so“ nachbauen.
Die Lösung: Ansprüche zurückschrauben und eine kleine Zeitreise in vergangene Jahrzehnte machen. Denn damals war alles noch viel einfacher™. Das Programm PICO-8 ist eine wunderbare Zeitmaschine für solche Bestrebungen – es emuliert gewisserweise eine Fantasie-Spielekonsole die es nie gegeben hat. So gibt es einige Beschränkungen: der Bildschirm ist nur 128x128 Pixel groß, es gibt nur 16 festgelegte Farben und der Code darf aus maximal 8192 Tokens bestehen. Denn (Platz)not macht erfinderisch!
Ein weiterer Vorteil von PICO-8: es bringt alle Werkzeuge zur (Retro-)Spieleerstellung mit: mit dem Sprite-Editor zeichnet man seine Charaktere, fügt diese im Map-Editor zur Karte zusammen, schreibt den Quellcode im Code-Editor und komponiert Soundeffekte und Musik im SFX-Editor. Einen einzigen Nachteil hat PICO-8: es ist nicht kostenlos und auch nicht Open Source, aber irgendwie muss der Indie-Entwickler auch sein Geld verdienen, und eine Einmalzahlung von $15 ist beinahe eine willkommene Abwechslung bei all den Abomodellen da draußen.
Genug der Werbung, befassen wir uns ein wenig mit der technischen Seite. PICO-8 kann als ausführbares Programm für Windows, Linux und Mac heruntergeladen werden. Es gibt auch einen ARM-Build für den Raspberry Pi. Startet man das Programm, öffnet sich das 128x128-Pixel-große Fenster, in dem sich alles abspielt. Ich will hier keine große Einleitung verfassen, denn es wird ein umfassendes README mitgeliefert, dass viel ausführlicher genau das tut. Spiele werden auf cartridges gespeichert. Diese lassen sich auch mit einem Texteditor öffnen, zum Beispiel falls man mit dem eingebauten Code-Editor Probleme hat. Die Grafiken und Klänge werden als HEX-String codiert am Ende der Datei abgelegt. Der Programmcode selbst ist in Lua. Wenn man bereits in anderen Programmiersprachen Erfahrungen gesammelt hat, gibt es einige Dinge die Lua anders macht.
Verbreitete Fehler bei Lua
- Der erste Eintrag eines Feldes (Arrays) hat Index 1, nicht 0, also aufpassen beim Schreiben von Schleifen.
- Wenn Variablen kein
local
bei der Definition vorangestellt wird, sind sie standardmäßig globale Variablen. - Globale Variablen müssen nicht deklariert werden, bevor man sie benutzen kann. Initialisiert man
foobar = 42
am Anfang des Codes und referenziert später das falschgeschriebenefoobaz
, so hat dies einfach den Wertnil
, ohne das ein Syntax-Fehler oder dergleichen ausgelöst wird. Das hat mich bereits mehrfach verwirrt und einiges an Debug-Zeit gekostet :) - Die Operation
a / b
teilt mit Gleitkommaarithmetik, währenda \ b
in PICO-8 die Nachkommastellen abschneidet. - Die Ungleichheit von zwei Variablen kann mit
a ~= b
geprüft werden, nicht mit!=
.
Ein TETRIS-Klon
Ein Spielfeld für die Spielsteine
Ein sehr bekanntes und recht einfaches Spiel, zu dessem Nachbau ich mich entschloss, ist TETRIS. Der erste Schritt war die Definition eines Spielfelds:
field = {}
field_width = 10 -- in blocks
field_height = 20 -- in blocks
function initialize_field()
for y=1,field_height do
add(field, {}) -- (1)
for x=1,field_width do
add(field[y], 0) -- (2)
end
end
end
Hier ist field
ein zweidimensionales Array, also ein Array von Arrays von Zahlen. Die einzelnen Zahlen repräsentieren dabei, ob sich dort ein Block befindet oder nicht - 0 bedeutet leer und Zahlen größer als 0 stehen für die verschiedenen Farben von Blöcken. Um das Spielfeld zu initialisieren wird zuerst für jede Reihe (y-Koordinate) ein neues Array angelegt (1). Dieses wird dann mit so vielen Nullen gefüllt, wie es Spalten gibt (2).
Nach der Initialisierung würde uns der Ausdruck field[1]
die erste Reihe zurückgeben, und field[1][1]
den ersten Block in der ersten Reihe bzw. für zwei beliebige x,y Koordinaten field[y][x]
. Weil zuerst die Zeile und dann die Spalte indiziert wird, nennt man das im Englischen auch row major order.
Für jeden Block im Spielfeld kann man dann einen Sprite zeichen, in unterschiedlichen Farben:
Die Spielsteine (⠏,⡇,⠛,⠗,⠳) in Tetris bestehen aus mehreren Blöcken, folglich liegt es nahe eine Liste zu definieren mit allen möglich Formen:
pieces={
{ {1,0},
{1,1},
{1,0} },
{ {1,1},
{1,1} },
{ {1,1,1,1} },
{ {1,1},
{1,0},
{1,0} },
{ {1,0},
{1,1},
{0,1} },
}
Für den aktuell bewegte Spielstein muss man zusätzlich genau wissen, welche Form, Position und Farbe er hat. Erst wenn er unten auf einen anderen Stein trifft und unbeweglich wird, fügen wir ihn in das field
-Array an. Andernfalls müsste man sich merken welche Blöcke in field
noch beweglich sind und zu welchem Spielstein sie gehören.
current_piece = nil
current_color = nil
current_x = nil -- can range from 1 to field_width (inclusive)
current_y = nil -- can range from 1 to field_height (inclusive)
Das Feld auf den Bildschirm bringen
Aktuell ist der Zustand des Spielfeldes gut im Hauptspeicher unserer Konsole gespeichert, aber viel sehen können wir davon nicht. Für das Zeichen kann man eine Methode _draw()
definieren, die dann von PICO-8 30 mal in der Sekunde aufgerufen wird. Es folgt eine gekürzte Version der draw-Methode:
block_size = 5 -- pixels
function _draw()
-- draw the field on the screen
cls()
start_x = (128 - field_width * block_size) \ 2
start_y = (128 - field_height * block_size) \ 2
if field ~= nil then
draw_block_array(start_x, start_y, field)
end
-- draw the current piece
if current_piece ~= nil then
draw_block_array(start_x + (current_x - 1)* block_size,
start_y + (current_y - 1) * block_size,
current_piece)
end
end
function draw_block_array(start_x,start_y,array)
for y=1,#array do
for x=1,#array[1] do
field_val = array[y][x]
if field_val > 0 then -- (2)
screen_x = start_x + (x - 1) * block_size
screen_y = start_y + (y - 1) * block_size
spr(field_val-1, screen_x, screen_y) -- (1)
end
end
end
end
Das eigentliche Zeichnen eines Blockarrays wurde in eine eigene Methode ausgelagert, da sie an anderer Stelle nochmal nützlich ist. Die eigentliche Magie passiert an Stelle (1): der Aufruf von spr
zeichnet ein Sprite an die passenden Bildschirmkoordinaten. Vorsicht: die Nummerierung von Sprites in PICO-8 beginnt bei 0, anders als die Arrayindizierung in Lua!
Die geschachtelten Schleifen davor iterieren über jeden Block im Array, und die if Abfrage (2) stellt sicher, dass dort überhaupt ein Block gezeichnet werden soll, denn 0 steht ja wie oben erwähnt für leer.
Die Spielelogik
Der Spielezustand wird nun korrekt gespeichert und angezeigt, wenn auch nur sehr rudimentär. Was fehlt ist die eigentliche Logik: das Drehen und Bewegen des aktuellen Spielblocks usw.
Hierfür wird die _update
-Methode genutzt, die ebenfalls von PICO-8 aufgerufen wird. Schauen wir sie im Detail an:
Blöcke drehen
function _update()
if btnp(⬆) then
rotate_piece = rotate_array_2d(current_piece)
if not test_collision(
current_x, current_y, rotate_piece) then
current_piece = rotate_piece
end
Anfangs wird überprüft, ob der Hoch-Knopf gedrückt wird. Wenn ja, wird der aktuelle Spielstein gedreht mittels der rotate_array_2d
-Methode, und falls es keine Kollision gibt zwischen dem gedrehten Spielstein und dem Spielfeld wird der aktuelle durch den gedrehten ersetzt. Die Methode zur Kollisionsprüfung wird später noch erläutert. Die Methode zur Rotation sieht wie folgt aus:
function rotate_array_2d(src)
target = {}
-- transpose it first
for x=1,#src[1] do
add(target, {})
for y=1,#src do
add(target[x],
src[y][x])
end
end
-- flip it vertical for
-- 90 deg rotation
for y=1,#target do
for x=1,#target[1]\2 do
mirror_x =
#target[1] + 1 - x
target[y][x], target[y][mirror_x] =
target[y][mirror_x], target[y][x]
end
end
return target
end
Wie aus den Kommentaren ersichtlich wird die Eingabe zuerst transponiert und dann vertikal gespiegelt. Beim Transponieren werden die Zeilen zu Spalten. Hier das ganze an einem kleinen Beispiel demonstriert:
Für die 2x2-große Matrix hätte man die Rotationsmatrix auch einfach so aufschreiben können, aber unser Code soll mit beliebig großen Matrizen funktionieren (und auch mit nicht quadratischen). Folglich lautet der „Algorithmus“ zum Rotieren von Matrizen:
- Matrix transponieren
- Vertikal spiegeln für Linksdrehung, horizontal für Rechtsdrehung
Blöcke schnell absetzen
Der nächste Abschnitt der Updatemethode enthält die Funktionalität der Runter-Taste. Sie soll den Block sofort unten absetzen, ohne warten zu müssen bis er nach unten fällt. Hierzu erhöhen wir die y-Koordinate solange, bis eine Kollision im nächsten Schritt auftreten würde und spielen dann einen Soundeffekt:
elseif btnp(⬇)
and not last_block_skipped
then
while not test_collision(
current_x, current_y + 1, current_piece) do
current_y += 1
end
sfx(2)
Das last_block_skipped
speichert ob wir schon einmal den Block schnell abgesetzt haben, denn wenn dann der neue Block oben erscheint und der Spieler immernoch die Runter-Taste gedrückt hält, soll der neue Block nicht auch schnell abgesetzt werden.
Blöcke nach links/rechts bewegen
Fehlt nur noch eine Bewegungsrichtung: die Translation nach links oder rechts. Hierzu prüfen wir ebenfalls, ob die Verschiebung eine Kollision verursachen würde, und falls nicht erhöhen/erniedrigen wir die x-Koordinate des aktuellen Spielsteins.
elseif btnp(⬅) and
not test_collision(current_x - 1, current_y, current_piece) then
current_x -= 1
elseif btnp(➡) and
not test_collision(current_x + 1, current_y, current_piece) then
current_x += 1
end
Kollisionserkennung
Die Methode test_collision(target_x, target_y, array)
prüft ob ein Spielstein der Form array
an der Stelle target_x, target_y
mit anderen Blöcken überlappen würde. Zuerst werden triviale Bedingungen geprüft: befinden sich die Zielkoordinaten außerhalb des Spielfeldes? Falls ja, kollidiert der Spielstein selbstverständlich.
if target_x < 1 or target_y < 1 then
return true
end
Danach wird für jede Spalte von Blöcken des Spielsteins der unterste ausfindig gemacht, und falls er existiert seine Koordinaten auf dem Spielfeld berechnet:
for x=1,#array[1] do
lowest_block = 0
for y=#array,1,-1 do
if lowest_block == 0 and array[y][x] > 0 then
lowest_block = y
end
end
if lowest_block ~= 0 then
field_x = target_x + x - 1
field_y = target_y + lowest_block - 1
Falls nun dieser Block außerhalb des Spielfeldes liegt oder auf dem Spielfeld an derselben Stelle bereits einer war, liegt eine Kollision vor und wir liefern true
zurück:
if field_x > field_width then
return true
end
if field_y > field_height then
return true
end
if field[field_y][field_x] > 0 then
return true
end
end
end
return false
Erst ganz am Ende, wenn keine der Prüfungen fehlgeschlagen ist, geben wir false
zurück, um zu signalisieren, dass keine Kollision vorliegt.
Dadurch dass die Zielkoordinaten als Parameter beim Methodenaufruf übergeben werden, können wir auch hypothetische Kollisionsabfragen tätigen, wie beispielsweise bei der Links/Rechtsbewegung: würde der Spielstein kollidieren, wenn er sich einen Block weiter rechts befände?
Volle Reihen eliminieren, neue Steine spawnen
Wenn eine Reihe ganz ausgefüllt ist bei Tetris, wird sie eliminiert und alle darüberliegenden rücken nach unten, um den freigewordenen Platz aufzufüllen. Außerdem muss nach dem Absetzen eines Spielsteins der nächste generiert werden. Dies geschieht in der Methode piece_collision
. Sie wird aufgerufen, wann immer ein Spielstein abgesetzt wird (bspw. nachdem er mit darunterliegenden Blöcken kollidiert ist).
function piece_collision()
-- Piece has collided, spawn new one and merge
-- the old one into the field
for y=1,#current_piece do
for x=1,#current_piece[1] do
mx = x + current_x - 1
my = y + current_y - 1
if field[my][mx] == 0 then
field[my][mx] = current_piece[y][x]
end
end
end
while eliminate_full_row() do
sfx(1)
end
spawn_piece()
end
Zuerst wird der aktuelle Block zum field
hinzugefügt, da seine Position sich nicht mehr durch den Spieler ändern lässt.
Danach werden solange volle Reihen eliminiert, bis keine mehr übrig sind, und zu guter Letzt ein neuer Spielstein erstellt.
Sehen wir uns den Quelltext von eliminate_full_row()
stückweise an:
function eliminate_full_row()
-- checks whether a full row has appeared that can be eliminated
-- returns true if a row has been eliminated
row_number = 0
for y=1,#field do
full_row = 1
for x=1,field_width do
if field[y][x] == 0 then
full_row = 0
end
end
if full_row == 1 then
row_number = y
break
end
end
Im ersten Teil wird der Index der Zeile ausfindiggemacht, die voll ist. Dafür setzen wir jede Zeile ein Flag full_row
auf 1 und setzen es auf 0, wenn eine der Zellen leer ist. Falls das Flag danach immernoch 1 ist, ist unsere Zeile voll, wir haben unseren Index gefunden und können die Schleife verlassen.
-- eliminate row by pulling all rows above it one down
for y=row_number,1,-1 do
for x=1,field_width do
if y == 1 then
field[y][x] = 0
else
field[y][x] = field[y - 1][x]
end
end
end
return row_number ~= 0
end
Im zweiten Teil wird dann die volle Reihe eliminiert, in dem alle Reihen die darüber liegen eins nach unten aufgerückt werden. Die erste Schleife läuft über alle y von dem Index der vollen Zeile bis hoch zur ersten Zeile. Da die Zeilen von oben nach unten adressiert werden, ist der Index der Zeile darüber immer um 1 geringer. Falls wir bei y=1
angekommen sind gibt es keine Zeile die darüber liegt, weshalb wir die einfach mit Nullen auffüllen.
Falls keine volle Zeile gefunden wurde, ist row_number=0
, und die zweite for-Schleife wird nicht ausgeführt. Jeder Aufruf von eliminate_full_row
eliminiert also maximal eine volle Zeile. Vermutlich nicht die effizienteste Lösung, aber dafür recht simpel.
Ebenfalls simpel ist die spawn_piece
-Methode:
function spawn_piece()
current_piece = copy_array_2d(pieces[next_piece])
next_piece = rnd(#pieces)\1+1
current_color = next_color
next_color = rnd(num_colors) + 1
for y=1,#current_piece do
for x=1,#current_piece[1] do
current_piece[y][x] =
current_piece[y][x] * current_color
end
end
current_x = max(1,(field_width - #current_piece[1]) \ 2)
current_y = 1
if test_collision(
current_x,
current_y,
current_piece) then
debug_msg = "game over"
state = 9
sfx(2)
end
end
Der nächste Spielstein wird immer zufällig ausgewählt, in current_piece
kopiert und in der passenden Farbe eingefärbt.
Falls der neue Spielstein nicht mehr auf das Feld passt und es zu einer Kollision kommen würde ist das Spiel vorbei.
So viel zur Spielelogik. Einige kleinere Details wurden noch nicht besprochen, sie sind aber aus dem Quelltext leicht ersichtlich und einfach zu verstehen.
Spielfeld
Für das Spielfeld habe ich eine sehr grobe Gestaltung gewählt, die vielleicht später nochmal überarbeitet werden sollte. Wesentlich ist, dass der Spieler die Begrenzungen erkennt und das genügend Raum bleibt für die Anzeige des nächsten Blocks und des Scores.
Enough talk, let’s play
PICO-8 bietet den Export als HTML-Datei, also kann das Spiel direkt hier im Browser gestartet werden.
Steuerung: Pfeiltasten Links/Rechts bewegt horizontal, Hoch dreht den Block, Runter setzt den Block ab, x startet ein neues Spiel.
Kommentare
Um einen Kommentar hinzuzufügen, schreibe eine E-Mail mittels dieses Links.