Sokoban-Tutorial

Bei der Suche nach simplen Games, die man in PewPew implementieren könnte, hilft es, auf die Zeit zurückzublicken, wo die Computer im Vergleich zu heute noch jung und leistungsschwach waren, so dass simple Games die einzigen waren, die sie laufen lassen konnten. Zu diesen Spielen gehört Sokoban. Versuchen wir’s doch mit Sokoban für PewPew!

Bei Sokoban handelt es sich um ein Knobel-Spiel, in dem du einen Lagerarbeiter spielst, der Verpackungskisten solange herumschieben muss, bis sie an den richtigen Stellen sind, welche durch die Markierungen am Boden erkennbar sind. Unglücklicherweise scheint das Lagerhaus eine Art Labyrinth zu sein, dazu noch ist es dem Arbeiter nur möglich, die Kisten zu schieben und nicht zu ziehen, also musst du vorsichtig vorausplanen: Falls du eine Kiste in eine Ecke schiebst, kriegst du sie nicht mehr von dort weg.

Erstelle wie im Gummiball-Tutorial eine Datei mit dem Namen code.py, das den Aufbau und die Endlosschleife beinhaltet. Anstelle eines leeren Hintergrundes erstellen wir das Spielfeld schon zu Beginn: Ein paar Wände, denen wir die Farbe 1 geben (trüb/grün), die die frei zugänglichen Felder mit der Farbe 0 (schwarz) begrenzen.

import pew

pew.init()
screen = pew.Pix.from_iter((
    (1, 1, 1, 1, 1, 1, 1, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 1, 1, 1, 1, 1, 1, 1),
))

while True:
    keys = pew.keys()

    pew.show(screen)
    pew.tick(1/6)

Fügen wir die Spielfigur hinzu, dargestellt mit einem hellen Pixel, das mit den Richtungstasten bewegt werden kann. Wir löschen das Pixel an der alten Position am Anfang der Endlosschleife, indem die Farbe zu 0 (schwarzer Boden) geändert wird. Dann, falls eine Taste gedrückt wurde, wird die Position verändert und schließlich das Pixel an der neuen Position gezeichnet:

...
    (1, 1, 1, 1, 1, 1, 1, 1),
))

x = 4
y = 1

while True:
    screen.pixel(x, y, 0)
    keys = pew.keys()
    if keys & pew.K_UP:
        y -= 1
    elif keys & pew.K_DOWN:
        y += 1
    elif keys & pew.K_LEFT:
        x -= 1
    elif keys & pew.K_RIGHT:
        x += 1
    screen.pixel(x, y, 3)
    pew.show(screen)
    pew.tick(1/6)

Beachte, dass wir elif benützen (Python’s Schreibweise für „else, if“), um die Steuertasten zu überprüfen, was bedeutet, dass wir uns nur in eine von vier Richtungen aufs Mal bewegen können. Würden wir if für jede Taste benützen, könnten wir z. B. gleichzeitig uns nach rechts und nach unten bewegen, was ein diagonaler Schritt wäre. (Auch könnte man sich gleichzeitig nach oben und unten bewegen, was mit Stehenbleiben gleichzusetzen wäre.) In Sokoban sollte es nicht möglich sein, sich diagonal zu bewegen, deswegen verwenden wir elif.

Wenn du dies ausführst, wirst du sehen, dass der Spieler durch die Wände gehen und außerhalb des Bildschirms sein kann, und dabei Löcher in der Wand hinterlässt. Lass uns das nun beheben.

Anstatt dass sich die Spielfigur sofort bewegt, wenn eine Taste gedrückt wird, sollten wir die Bewegung zuerst in die Variablen dx und dy speichern. (Das d steht für „Delta“ oder „Differenz“, weil die Variablen die Differenz zwischen der neuen und alten Position repräsentieren). Dann schauen wir uns zuerst an, was sich bei der neuen Position befindet, und bewegen uns nur dann, wenn das Feld ein „Boden“ (0) ist.

...
while True:
    screen.pixel(x, y, 0)
    keys = pew.keys()
    dx = 0
    dy = 0
    if keys & pew.K_UP:
        dy = -1
    elif keys & pew.K_DOWN:
        dy = 1
    elif keys & pew.K_LEFT:
        dx = -1
    elif keys & pew.K_RIGHT:
        dx = 1
    target = screen.pixel(x+dx, y+dy)
    if target == 0:
        x += dx
        y += dy
    screen.pixel(x, y, 3)
    pew.show(screen)
    pew.tick(1/6)

Jetzt, da dies so funktioniert wie es sollte, fahren wir doch fort mit dem nächsten Element, nämlich eine Kiste, repräsentiert durch ein helles Pixel (3):

...
    (1, 1, 1, 1, 1, 1, 1, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 3, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 1, 1, 1, 1, 1, 1, 1),
...

Falls sich eine Kiste vor der Spielfigur, die sich in deren Richtung bewegen will, befindet, wird die Kiste ein Feld nach vorne geschoben, und der Spieler kann sich bewegen:

...
    if target == 0:
        x += dx
        y += dy
    elif target == 3:
        screen.pixel(x+dx+dx, y+dy+dy, 3)
        x += dx
        y += dy
    screen.pixel(x, y, 3)
    pew.show(screen)
    pew.tick(1/6)

Die Kiste braucht nicht an ihrer alten Position gelöscht zu werden, da sie sowieso sofort durch den Spieler überschrieben wird.

Teste es aus und du wirst merken, dass du Kisten durch Wände schieben kannst und somit wieder Löcher kreierst. Offensichtlich müssen wir zuerst überprüfen, was sich hinter der Kiste befindet, bevor wir uns entscheiden, sie zu bewegen.

...
    target = screen.pixel(x+dx, y+dy)
    behind = screen.pixel(x+dx+dx, y+dy+dy)
    if target == 0:
        x += dx
        y += dy
    elif target == 3 and behind == 0:
        screen.pixel(x+dx+dx, y+dy+dy, 3)
        x += dx
        y += dy
...

Das funktioniert, jedoch haben wir jetzt zwei helle Pixel auf dem Bildschirm, die Spielfigur und die Kiste, und falls sich die Figur nicht bewegt, sind sie kaum unterscheidbar. Wir haben immer noch eine ungenutzte Farbe zur Verfügung, die wir für eine der beiden benutzen könnten, 2 (mittlere Helligkeit oder rot), diese Farbe werden wir aber später für die Markierung auf den Böden verwenden. Stattdessen lassen wir den Spieler blinken. Dafür brauchen wir eine weitere Variable, die sich merkt, was der letzte Zustand war, und diese wird jedesmal „umgeschaltet“, wenn die Spielfigur gezeichnet wird. Die beste Wahl für eine solche Variable mit zwei Zuständen wäre ein Boolean, dessen zwei Werte True und False sind.

...
x = 4
y = 1
blink = True

while True:
...
...
        y += dy
    screen.pixel(x, y, 3 if blink else 2)
    blink = not blink
    pew.show(screen)
    pew.tick(1/6)

Nun zum letzten noch fehlenden Element: Die Markierungen am Boden. Wir geben ihnen die Farbe 2:

...
    (1, 1, 1, 1, 1, 1, 1, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 3, 0, 2, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 1, 1, 1, 1, 1, 1, 1),
...

Und die Spielfigur und Kisten können sich darauf frei bewegen wie bei den normalen Böden:

...
    behind = screen.pixel(x+dx+dx, y+dy+dy)
    if target in {0, 2}:
        x += dx
        y += dy
    elif target == 3 and behind in {0, 2}:
        screen.pixel(x+dx+dx, y+dy+dy, 3)
        x += dx
        y += dy
...

Teste es aus und du wirst ein weiteres Problem finden: Die Markierung wird entweder durch die Spielfigur oder einer Kiste, die drüber geht, gelöscht. Das liegt daran, dass die Markierung von einem Pixel einer anderen Farbe überschrieben wird und deswegen die Information, ob eine Markierung da war oder nicht, verloren geht. Dadurch wird zu Beginn des nächsten Durchgangs der Schlaufe das Feld zu einem normalen Boden (0) , auch wenn dort eine Markierung (2) sein sollte. Wir müssen irgendwie dafür sorgen, dass diese Information erhalten bleibt.

Um dies zu lösen, gibt es folgenden Trick, den wir benutzen können. Bis jetzt haben wir immer für die Pixel die Werte 0–3 für schwarz, trüb, mittel, hell oder schwarz, grün, rot, orange verwendet. Dies sind all die Farben, die unsere Hardware anzeigen kann. Aber was passiert, wenn wir höhere Zahlen verwenden? Wenn du da was ausprobierst, wirst du bemerken, dass 4 schwarz ist, 5 wieder trüb/grün, 6 mittel/rot, 7 hell/orange, 8 schwarz usw. – das Muster wiederholt sich alle 4 Stufen. In anderen Worten, du kannst 4 einem Pixel hinzufügen, ohne dessen Farbe zu ändern.

Wir können dies zu unserem Vorteil nutzen: Wenn wir den Kisten auf normalen Böden die Zahl 3 und den Kisten auf Markierungen die 7 zuweisen, werden sie gleich aussehen, aber dennoch in unserem Code unterscheidbar sein. Dasselbe gilt für die Spielfigur: Blinkt sie in den Farben 2 und 3, ist sie auf normalem Boden, sind es die Farben 6 und 7, ist sie auf einer Markierung.

Für die Spielfigur schreiben wir diesen Code bei der Zeile, wo wir die Spielfigur entfernen und den Boden wiederherstellen (mit oder ohne Markierung), und bei der Zeile, wo wir die Figur über des vorherigen Bodens oder Kiste neu zeichnen (auch hier mit oder ohne Markierung):

...
while True:
    screen.pixel(x, y, 0 if screen.pixel(x, y) < 4 else 2)
    keys = pew.keys()
...
...
        y += dy
    screen.pixel(x, y, (3 if blink else 2) + (4 if screen.pixel(x, y) in {2, 7} else 0))
    blink = not blink
...

Für die Kiste implementieren wir den Code bei der Zeile, wo wir eine Kiste vor der Spielfigur erkennen und dort, wo die Kiste über den vorherigen Boden gezeichnet wird:

...
    if target in {0, 2}:
        x += dx
        y += dy
    elif target in {3, 7} and behind in {0, 2}:
        screen.pixel(x+dx+dx, y+dy+dy, 3 if behind == 0 else 7)
        x += dx
        y += dy
...

Teste es aus und versuche einmal, über eine Markierung zu gehen und eine Kiste darüber zu schieben, ohne die Markierung dabei permanent zu löschen. Gute Arbeit – somit sollte unsere Spielmechanik vollständig sein. Das Spiel ist jedoch noch nicht dazu in der Lage, das Level als abgeschlossen zu sehen, wenn sich alle Kisten auf Markierungen befinden. Lass uns das noch hinzufügen.

Am einfachsten lässt sich das prüfen, indem wir alle noch leeren Markierungen zählen: Sind keine mehr übrig, ist das Puzzle gelöst. Iteriere über alle Pixel (mit einer äußeren Schleife über alle Reihen und einer inneren Schleife über alle Pixel einer Reihe) und zähle jedes Mal, wenn du eine Markierung findest, eins dazu. Bleibt der Zähler bei 0, bricht die Top-Level Endlosschleife ab, was dazu führt, dass das Programm beendet, da es nach der Schleife keine Code-Zeilen mehr gibt. Es ist wichtig, dass wir die Überprüfung vor dem Zeichnen der Spielfigur machen, da sie sonst auf einer Markierung stehen könnte und sie somit versteckt wäre und nicht gezählt wird.

...
    elif target in {3, 7} and behind in {0, 2}:
        screen.pixel(x+dx+dx, y+dy+dy, 3 if behind == 0 else 7)
        x += dx
        y += dy
    count = 0
    for b in range(8):
        for a in range(8):
            if screen.pixel(a, b) == 2:
                count += 1
    if count == 0:
        break
    screen.pixel(x, y, (3 if blink else 2) + (4 if screen.pixel(x, y) in {2, 7} else 0))
    blink = not blink
    pew.show(screen)
    pew.tick(1/6)

Du kannst dies jetzt austesten, aber da eine einzige Kiste nicht genügt, fügen wir eine zweite und eine dazugehörige Markierung hinzu.

...
    (1, 1, 1, 1, 1, 1, 1, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 3, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 0, 0, 3, 0, 2, 0, 1),
    (1, 0, 2, 0, 0, 0, 0, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 1, 1, 1, 1, 1, 1, 1),
...

Nachdem du überprüft hast, ob das Programm zum richtigen Zeitpunkt das Puzzle als gelöst sieht – also ob das Game beendet wird, wenn sich beide Kisten auf Markierungen befinden und nicht vorher oder erst später – ist es jetzt dir überlassen, das Spiel interessanter zu gestalten, indem mehr Wände hinzufügst. Oder vielleicht willst du das Game erweitern, indem du mehrere Levels mit kontinuierlich wachsendem Schwierigkeitsgrad hinzufügst? Oder indem du eine belohnende Animation für ein geschafftes Level hinzufügst? Viel Spaß dabei!

Hier, den kompletten Code in seinem letzten Zustand:

import pew

pew.init()
screen = pew.Pix.from_iter((
    (1, 1, 1, 1, 1, 1, 1, 1),
    (1, 0, 0, 0, 0, 0, 0, 1),
    (1, 1, 3, 1, 0, 0, 0, 1),
    (1, 0, 0, 1, 0, 1, 1, 1),
    (1, 0, 0, 3, 0, 2, 0, 1),
    (1, 0, 2, 1, 0, 1, 0, 1),
    (1, 0, 0, 0, 0, 1, 0, 1),
    (1, 1, 1, 1, 1, 1, 1, 1),
))

x = 4
y = 1
blink = True

while True:
    screen.pixel(x, y, 0 if screen.pixel(x, y) < 4 else 2)
    keys = pew.keys()
    dx = 0
    dy = 0
    if keys & pew.K_UP:
        dy = -1
    elif keys & pew.K_DOWN:
        dy = 1
    elif keys & pew.K_LEFT:
        dx = -1
    elif keys & pew.K_RIGHT:
        dx = 1
    target = screen.pixel(x+dx, y+dy)
    behind = screen.pixel(x+dx+dx, y+dy+dy)
    if target in {0, 2}:
        x += dx
        y += dy
    elif target in {3, 7} and behind in {0, 2}:
        screen.pixel(x+dx+dx, y+dy+dy, 3 if behind == 0 else 7)
        x += dx
        y += dy
    count = 0
    for b in range(8):
        for a in range(8):
            if screen.pixel(a, b) == 2:
                count += 1
    if count == 0:
        break
    screen.pixel(x, y, (3 if blink else 2) + (4 if screen.pixel(x, y) in {2, 7} else 0))
    blink = not blink
    pew.show(screen)
    pew.tick(1/6)

Du kannst den Code auch hier finden: https://github.com/pewpew-game/game-sokoban