Retrospiele für PICO-8 entwickeln

TL;DR

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.

PICO-8 Willkommensbildschirm

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

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:

Der Spriteeditor. Unten sind die vier verschiedenfarbigen Blöcke zu sehen

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 ersten Blöcke auf dem Bildschirm

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:

Drehung einer 2x2 und einer 3x3 Matrix indem jeweils zuerst transponiert und dann gespiegelt wird

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:

  1. Matrix transponieren
  2. 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

Beispielhaftes Spielfeld wobei die unteren Blöcke rot umrandet sind
Die Kollisionsobjekte sind rot hervorgehoben

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

Das Spielfeld im Map-Editor

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.

Zum Spiel

Kommentare

Um einen Kommentar hinzuzufügen, schreibe eine E-Mail mittels dieses Links.