Kapitel 3 · Snake

Snake in Python – mit eigenem Levelformat

Friss alle Äpfel, dann öffnet sich der blaue Ausgang zum nächsten Level – aber stoß nirgends an, auch nicht an dich selbst. Das Besondere an dieser Version: Die Level sind einfache Textdateien, die du selbst entwerfen kannst.

Aus dem Buch · Kapitel 3 Fortgeschritten Spiele Python 3 · läuft im Browser
Bereit · Python startet beim ersten Klick
# Snake – Kapitel 3 »Snake«, finale Version mit Leveln
# aus »Coding for Fun mit Python«, portiert auf Python 3 und c4f.
# Die Dateien snake.py, playground.py und snakeGame_v3.py sind hier vereint.
import os
from c4f import Screen, sleep, KEYDOWN, K_LEFT, K_RIGHT, K_UP, K_DOWN

cellSize = 16


class Snake(object):
    '''
    represents a snake in our snake game
    '''
    NOCOLLISION =  0
    COLLISION   = -1
    EATAPPLE    = -2
    PASSEXIT    = -3

    def __init__(self, playground):
        self.playground = playground
        self.expansion = 0

    def move(self, direction):
        x, y = self.playground.snakePosition[0]
        dx, dy = direction
        newHeadPosition = (x + dx, y + dy)

        collisionEvent = self.playground.collisionDetection(newHeadPosition)
        if collisionEvent == Snake.EATAPPLE:
            self.expansion += 4

        self.playground.snakePosition.insert(0, newHeadPosition)
        if self.expansion == 0:
            self.playground.snakePosition.pop()
        else:
            self.expansion -= 1

        return collisionEvent


class Playground(object):
    '''
    represents the playground in our snake game
    '''

    def __init__(self):
        self.width          = 0
        self.height         = 0
        self.applePositions = []
        self.barPositions   = []
        self.exitPosition   = []
        self.snakePosition  = []

    def loadLevel(self, fileName):
        datei = open(fileName, "r")
        y = -1
        for zeile in datei:
            x = -1
            y += 1
            for zeichen in zeile:
                if zeichen != '\n':
                    x += 1
                    if zeichen == 'A':
                        self.applePositions.append((x, y))
                    elif zeichen == 'S':
                        self.snakePosition.append((x, y))
                    elif zeichen == 's':
                        self.snakePosition.insert(0, (x, y))
                    elif zeichen == 'H':
                        self.barPositions.append((x, y))
                    elif zeichen == 'E':
                        self.exitPosition.append((x, y))
        datei.close()

        self.width  = x
        self.height = y
        print(self.width, " -> ", self.height)

    def collisionDetection(self, newHeadPosition):
        if newHeadPosition in self.snakePosition:
            print("self collision")
            return Snake.COLLISION

        if newHeadPosition in self.barPositions:
            print("bar collision")
            return Snake.COLLISION

        if newHeadPosition in self.applePositions:
            print("apple collision")
            self.applePositions.remove(newHeadPosition)
            return Snake.EATAPPLE

        if newHeadPosition in self.exitPosition:
            print("exit collision")
            return Snake.PASSEXIT

        return Snake.NOCOLLISION


gameCycle    = 0
currentLevel = 1
direction    = (0, 0)
commands     = []
playground   = Playground()
snake        = Snake(playground)


def drawElement(element, color):
    for part in element:
        x, y = part
        screen.rect(x * cellSize, y * cellSize, cellSize - 1, cellSize - 1, color)


def handleEvent(event):
    global commands
    if event.type == KEYDOWN:
        if event.key == K_RIGHT:
            commands.append(( +1,  0))
        if event.key == K_LEFT:
            commands.append(( -1,  0))
        if event.key == K_UP:
            commands.append((  0, -1))
        if event.key == K_DOWN:
            commands.append((  0, +1))


async def showASCIIArt(fileName, delay):
    datei = open(fileName, "r")
    asciiArt = []
    y = -1
    for zeile in datei:
        x = -1
        y += 1
        for zeichen in zeile:
            if zeichen != '\n':
                x += 1
                if zeichen == 'X':
                    asciiArt.append((x, y))
    datei.close()

    screen.clear()
    drawElement(asciiArt, (255, 0, 0))
    await sleep(delay / 1000)


async def showGameOver():
    await showASCIIArt("GameOver.txt", 3000)
    restartGame()


async def showYouWon():
    global currentLevel
    currentLevel += 1
    await showASCIIArt("YouWon.txt", 5000)
    loadNextLevel(currentLevel)


def resetGameState():
    # reinitialize the game (restart)
    global direction, commands, playground, snake
    playground  = Playground()
    snake       = Snake(playground)
    direction   = (0, 0)
    commands    = []
    screen.events()  # alte Tastendrücke verwerfen (pygame.event.clear)


def loadNextLevel(nextLevel):
    global currentLevel
    if not os.path.exists("level%d.txt" % nextLevel):
        print("Alle Level geschafft – es geht wieder von vorn los!")
        currentLevel = nextLevel = 1
    resetGameState()
    playground.loadLevel("level%d.txt" % nextLevel)


def restartGame():
    global currentLevel
    currentLevel = 1
    loadNextLevel(currentLevel)


async def main():
    global screen, gameCycle, direction
    playground.loadLevel("level1.txt")
    screen = Screen((playground.width + 1) * cellSize, (playground.height + 1) * cellSize,
                    background="black", title="Snake")
    screen.controls(["LEFT", "UP", "DOWN", "RIGHT"])

    while True:
        for event in screen.events():
            handleEvent(event)

        gameCycle += 1
        gameCycle %= 4
        if gameCycle == 0:
            if commands:
                validCommand = False
                while not validCommand and commands:
                    new_x, new_y = commands.pop(0)
                    old_x, old_y = direction
                    if not ((abs(new_x) == abs(old_x)) and (abs(new_y) == abs(old_y))):
                        validCommand = True

                if validCommand:
                    direction = (new_x, new_y)

            if direction != (0, 0):
                collisionEvent = snake.move(direction)
                if collisionEvent == Snake.COLLISION:
                    await showGameOver()
                if collisionEvent == Snake.PASSEXIT:
                    if not playground.applePositions:
                        await showYouWon()

            screen.clear()
            drawElement(playground.barPositions,   (128, 128, 128))
            drawElement(playground.applePositions, (255, 0, 0))
            drawElement(playground.snakePosition,  (0, 255, 0))
            if not playground.applePositions:
                drawElement(playground.exitPosition, (0, 0, 255))

        await screen.frame(20)  # 20 Durchläufe pro Sekunde wie pygame.time.delay(50)


await main()
Snake-Spielfeld: graue Mauern, rote Äpfel und eine grüne Schlange auf schwarzem Grund

Pfeiltasten oder die Tasten unter dem Spielfeld. Erst ins Bild klicken, dann los!

Konsole

    

Was du hier lernst

  • Klassen und Objekte
  • Level als Textdateien lesen
  • Listen als Warteschlange
  • Kollisionserkennung mit in
  • Konstanten als Rückgabewerte
  • Spielschleife mit Takt

So funktioniert Snake

Snake besteht aus erstaunlich wenigen Teilen: einem Spielfeld mit Mauern, Äpfeln und einem Ausgang, einer Schlange, die sich Feld für Feld bewegt, und einer Schleife, die beides immer wieder zeichnet. Das Programm teilt die Aufgaben auf zwei Klassen auf: Playground kennt das Spielfeld, Snake kennt die Bewegung.

1. Ein Level ist eine Textdatei

Statt Mauern und Äpfel im Code festzulegen, liest das Programm das Level aus einer Datei. Jedes Zeichen steht für ein Feld:

HHHHHHHHHHHHHHHHHHHH
H   A        A     H
H   S     HHHHH    H
H   S           E  H
HHHHHHHHHHHHHHHHHHHH

H ist eine Mauer (Hindernis), A ein Apfel, S ein Stück Schlange und E der Ausgang. loadLevel geht die Datei Zeile für Zeile und Zeichen für Zeichen durch und sammelt die Positionen als Tupel (x, y) in Listen:

for zeile in datei:
    x = -1
    y += 1
    for zeichen in zeile:
        if zeichen != '\n':
            x += 1
            if zeichen == 'A':
                self.applePositions.append((x, y))

Der große Vorteil: Neue Level entstehen in jedem Texteditor, ohne eine Zeile Python zu ändern. Das Spielfeld übernimmt Breite und Höhe einfach aus der Datei.

2. Die Schlange ist eine Liste

Die Schlange ist nichts weiter als eine Liste von Feldern, der Kopf steht vorn. Bewegen heißt: vorn ein neues Feld einfügen, hinten eines entfernen.

self.playground.snakePosition.insert(0, newHeadPosition)
if self.expansion == 0:
    self.playground.snakePosition.pop()
else:
    self.expansion -= 1

Frisst die Schlange einen Apfel, erhöht sich expansion um 4. Solange der Zähler größer als 0 ist, fällt das Entfernen am Ende weg – die Schlange wächst also über die nächsten vier Schritte.

3. Kollisionen mit in

Bevor die Schlange weiterzieht, prüft collisionDetection, was auf dem Zielfeld liegt. Pythons in fragt, ob ein Tupel in einer Liste vorkommt:

if newHeadPosition in self.barPositions:
    print("bar collision")
    return Snake.COLLISION

Die Antwort ist eine von vier Konstanten der Klasse Snake: NOCOLLISION, COLLISION, EATAPPLE oder PASSEXIT. Solche benannten Werte machen den Code lesbarer als nackte Zahlen. Die print-Aufrufe landen in der Konsole unter dem Spielfeld – dort siehst du bei jedem Apfel »apple collision«.

Warum die Schlange sich selbst beißen kann

Die erste Prüfung schaut, ob der neue Kopf auf einem Feld der Schlange landet. Weil das Programm den Kopf erst danach einfügt und das Schwanzende entfernt, zählt auch das Feld mit, das im selben Schritt frei würde. Ein kleines Detail, das das Spiel etwas strenger macht als manche andere Snake-Version.

4. Steuerung über eine Warteschlange

Jeder Pfeiltastendruck landet als Richtung in der Liste commands. Bei jedem Schritt nimmt das Programm den ältesten Befehl heraus – commands.pop(0) – und prüft ihn:

if not ((abs(new_x) == abs(old_x)) and (abs(new_y) == abs(old_y))):
    validCommand = True

Die Bedingung verwirft Befehle auf derselben Achse. Wer nach rechts fährt, kann nicht direkt nach links umdrehen, das wäre sofort tödlich. Die Warteschlange hat noch einen Vorteil: Drückst du schnell »hoch« und »links« hintereinander, geht keiner der beiden Befehle verloren – sie werden in zwei Schritten nacheinander ausgeführt.

5. Ein Takt für das Spiel

Die Hauptschleife läuft 20-mal pro Sekunde, die Schlange soll aber gemächlicher ziehen. Dafür zählt gameCycle von 0 bis 3 und springt dann zurück:

gameCycle += 1
gameCycle %= 4
if gameCycle == 0:
    ...

Nur bei 0 bewegt sich die Schlange – also fünfmal pro Sekunde. Tastendrücke werden trotzdem bei jedem Durchlauf eingesammelt, damit sich das Spiel direkt anfühlt.

6. Game Over und das nächste Level

Stößt die Schlange an, zeigt showASCIIArt den Schriftzug aus GameOver.txt – dieselbe Idee wie beim Level: Ein X in der Textdatei wird zu einem roten Kästchen. Hat die Schlange alle Äpfel gefressen, erscheint der Ausgang in Blau. Wer hindurchfährt, sieht »You won« und landet im nächsten Level.

Schneller mit Mengen

in muss bei einer Liste im schlimmsten Fall jedes Element ansehen. Bei ein paar hundert Mauerfeldern ist das kein Problem. Für riesige Level wäre eine Menge (set) besser: Sie beantwortet in sofort, egal wie groß sie ist.

Probier mal

Jede Variante ändert den Code oben. Ein Klick auf »Ausprobieren« übernimmt die Änderung in den Editor und startet das Programm. »Zurücksetzen« holt das Original zurück.

  1. 01Doppeltes Tempo

    Die Schlange bewegt sich nur bei jedem vierten Durchlauf der Hauptschleife. Mit jedem zweiten wird sie doppelt so schnell – und das Spiel deutlich schwieriger.

    - gameCycle %= 4
    + gameCycle %= 2
  2. 02Gefräßige Schlange

    Jeder Apfel lässt die Schlange um vier Felder wachsen. Mit zehn Feldern pro Apfel wird es im zweiten Level richtig eng.

    - self.expansion += 4
    + self.expansion += 10
  3. 03Dein eigenes Level

    Ein Level ist nur Text: H ist eine Mauer, A ein Apfel, S die Schlange, E der Ausgang. Diese Variante schreibt vor dem Start ein eigenes, kleines Level in die Datei level1.txt. Zeichne es um – das Spielfeld passt seine Größe automatisch an.

    -     playground.loadLevel("level1.txt")
    -     screen = Screen(
    +     myLevel = """HHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
    + H                            H
    + H   A         HH         A   H
    + H             HH             H
    + H   S    HHHHHHHHHHHH        H
    + H   S         HH        A    H
    + H   S                        H
    + H         A        HHHH   E  H
    + H                            H
    + HHHHHHHHHHHHHHHHHHHHHHHHHHHHHH"""
    +     with open("level1.txt", "w") as datei:
    +         datei.write(myLevel)
    +     playground.loadLevel("level1.txt")
    +     screen = Screen(
  4. 04Retro-Handy

    Graugrüner Hintergrund, dunkle Pixel: So sah Snake auf den ersten Handys aus. Es ändern sich nur die Farben beim Zeichnen.

    - background="black", title="Snake"
    + background=(155, 188, 15), title="Snake"
    - drawElement(playground.barPositions,   (128, 128, 128))
    + drawElement(playground.barPositions,   (48, 98, 48))
    - drawElement(playground.applePositions, (255, 0, 0))
    + drawElement(playground.applePositions, (139, 60, 20))
    - drawElement(playground.snakePosition,  (0, 255, 0))
    + drawElement(playground.snakePosition,  (15, 56, 15))

Vom Buch in den Browser

Im Buch verteilt sich Snake auf drei Dateien: snake.py mit der Klasse Snake, playground.py mit Playground und snakeGame_v3.py mit der Hauptschleife. Hier stehen alle drei in einer Datei, damit du alles in einem Editor hast. Die Klassen sind unverändert, die Level- und ASCII-Art-Dateien liegen im virtuellen Dateisystem des Browsers und werden wie im Original mit open() gelesen.

Im Buch (pygame + OpenGL) Hier (c4f)
Viereck aus vier glVertex3f, Feldgröße 10 screen.rect(x * 16, y * 16, 15, 15, farbe)
glColor3f(0.5, 0.5, 0.5) Farbe (128, 128, 128)
pygame.event.poll() / K_RIGHT usw. screen.events() / K_RIGHT usw. aus c4f
pygame.time.delay(50) await screen.frame(20)
pygame.time.delay(3000) in showASCIIArt await sleep(3)
pygame.event.clear() screen.events() (holt alte Ereignisse ab)

Weil showASCIIArt jetzt mit await sleep(...) wartet, sind auch showGameOver und showYouWon async-Funktionen und werden mit await aufgerufen. Das ist die einzige strukturelle Änderung.

Python 3: Aus print self.width, " -> ", self.height wird print(self.width, " -> ", self.height). Die Dateien werden nach dem Lesen geschlossen.

Zwei kleine Fehler aus dem Original sind behoben – beide fallen erst nach dem letzten Level auf:

  • In restartGame() fehlte global currentLevel. Die Zuweisung currentLevel = 1 erzeugte deshalb nur eine lokale Variable, der Levelzähler lief nach einem Game Over weiter.
  • Nach Level 2 wollte das Spiel level3.txt laden, die es nicht gibt, und brach mit einem Fehler ab. Jetzt prüft loadNextLevel mit os.path.exists, ob die Datei existiert, und beginnt sonst wieder mit Level 1.

Neu sind außerdem die Touch-Tasten unter dem Spielfeld (screen.controls), damit Snake auch auf dem Handy spielbar ist.

Original aus dem Buch ansehen snakeGame_v3.py · Python 2
'''
Created on 07.11.2009

@author: Lars Heppert
'''

from OpenGL.GL import *
from OpenGL.GLU import *
import pygame, math, datetime
from pygame.locals import *

from coding4fun.kapitel04.snake import Snake
from coding4fun.kapitel04.playground import Playground

gameCycle       = 0
currentLevel    = 1
direction       = (0, 0)
commands        = []
playground      = Playground()
snake           = Snake(playground)

def resize((width, height)):
    if height == 0:
        height = 1
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    glOrtho(-10.0, playground.width * 10.0 + 20.0, playground.height * 10.0 + 20.0, -10.0, -6.0, 0.0)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():
    glClearColor(0.0, 0.0, 0.0, 0.0)
                      
def clearScreen():
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    glLoadIdentity()
    glTranslatef(0.0, 0.0, 3.0)
                        
def drawElement(element, color):
    r, g, b = color
    glColor3f(r, g, b)
    glBegin(GL_QUADS)
    for part in element:
        x, y    = part
        x       = x * 10.0
        y       = y * 10.0
        glVertex3f(x, y, 0.0)
        glVertex3f(9.0 + x, y, 0.0)
        glVertex3f(9.0 + x, 9.0 + y, 0.0)
        glVertex3f(x, 9.0 + y, 0.0)
    glEnd()

def handleEvent(event):            
    if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
        return False
    
    global commands
    if event.type == KEYDOWN:
        if event.key == K_RIGHT:
            commands.append(( +1,  0))
        if event.key == K_LEFT:
            commands.append(( -1,  0))
        if event.key == K_UP:
            commands.append((  0, -1))
        if event.key == K_DOWN:
            commands.append((  0, +1))
    
    return True
        
def showASCIIArt(fileName, delay):
    datei = open(fileName, "r")
    asciiArt = []
    y = -1
    for zeile in datei:
        x = -1
        y += 1
        for zeichen in zeile:
            if zeichen != '\n':
                x += 1
                if zeichen == 'X':
                    asciiArt.append((x, y))

    clearScreen()
    drawElement(asciiArt, (1.0, 0.0, 0.0))
    pygame.display.flip()
    pygame.time.delay(delay)
        
def showGameOver():
    showASCIIArt("GameOver.txt", 3000)
    restartGame()
        
def showYouWon():
    global currentLevel
    currentLevel += 1
    showASCIIArt("YouWon.txt", 5000)
    loadNextLevel(currentLevel)

def resetGameState():
    # reinitialize the game (restart)
    global direction, commands, playground, snake
    playground  = Playground()
    snake       = Snake(playground)    
    direction   = (0, 0)
    commands    = []
    pygame.event.clear()

def loadNextLevel(nextLevel):
    resetGameState()
    playground.loadLevel("level%d.txt" % nextLevel)

def restartGame():
    currentLevel = 1
    loadNextLevel(currentLevel)
        
def main():
    pygame.init()
    video_flags = OPENGL | HWSURFACE | DOUBLEBUF
    
    playground.loadLevel("level1.txt")    
    screenSize = (playground.width * 20, playground.height * 20)    
    pygame.display.set_mode(screenSize, video_flags)
    resize(screenSize)
    
    init()
    while True:
        if not handleEvent(pygame.event.poll()):
            break
        
        global gameCycle
        gameCycle += 1
        gameCycle %= 4
        if gameCycle == 0:
            global direction
            if commands:
                validCommand = False
                while not validCommand and commands:
                    new_x, new_y = commands.pop(0)
                    old_x, old_y = direction
                    if not ((abs(new_x) == abs(old_x)) and (abs(new_y) == abs(old_y))):
                        validCommand  = True
                
                if validCommand:
                    direction = (new_x, new_y)
            
            if direction != (0, 0):
                collisionEvent = snake.move(direction)
                if collisionEvent == Snake.COLLISION:
                    showGameOver()
                if collisionEvent == Snake.PASSEXIT:
                    if not playground.applePositions:
                        showYouWon()

            clearScreen()
            drawElement(playground.barPositions,    (0.5, 0.5, 0.5))
            drawElement(playground.applePositions,  (1.0, 0.0, 0.0))
            drawElement(playground.snakePosition,   (0.0, 1.0, 0.0))
            if not playground.applePositions:
                drawElement(playground.exitPosition,    (0.0, 0.0, 1.0))
            
                
            pygame.display.flip()
        
        pygame.time.delay(50)

if __name__ == '__main__':
    main()
Original aus dem Buch ansehen snake.py · Python 2
'''
Created on 17.11.2009

@author: Lars Heppert
'''

class Snake(object):
    '''
    represents a snake in our snake game
    '''
    NOCOLLISION =  0
    COLLISION   = -1
    EATAPPLE    = -2
    PASSEXIT    = -3

    def __init__(self, playground):
        self.playground = playground
        self.expansion = 0
        
    def move(self, direction):
        x, y = self.playground.snakePosition[0]
        dx, dy = direction
        newHeadPosition = (x + dx, y + dy)
        
        collisionEvent = self.playground.collisionDetection(newHeadPosition)
        if collisionEvent == Snake.EATAPPLE:
            self.expansion += 4
    
        self.playground.snakePosition.insert(0, newHeadPosition)    
        if self.expansion == 0:
            self.playground.snakePosition.pop()
        else:
            self.expansion -= 1
            
        return collisionEvent
Original aus dem Buch ansehen playground.py · Python 2
'''
Created on 17.11.2009

@author: sourcer
'''

from coding4fun.kapitel04.snake import Snake

class Playground(object):
    '''
    represents the playground in our snake game
    '''

    def __init__(self):
        self.width          = 0
        self.height         = 0
        self.applePositions = []
        self.barPositions   = []
        self.exitPosition   = []
        self.snakePosition  = []
    
    def loadLevel(self, fileName):
        datei = open(fileName, "r")
        y = -1
        for zeile in datei:
            x = -1
            y += 1
            for zeichen in zeile:
                if zeichen != '\n':
                    x += 1
                    if zeichen == 'A':
                        self.applePositions.append((x, y))
                    elif zeichen == 'S':
                        self.snakePosition.append((x, y))
                    elif zeichen == 's':
                        self.snakePosition.insert(0, (x, y))
                    elif zeichen == 'H':
                        self.barPositions.append((x,y))
                    elif zeichen == 'E':
                        self.exitPosition.append((x,y))
            
        self.width  = x
        self.height = y
        print self.width, " -> ", self.height

    def collisionDetection(self, newHeadPosition):
        if newHeadPosition in self.snakePosition:
            print "self collision"
            return Snake.COLLISION
        
        if newHeadPosition in self.barPositions:
            print "bar collision"
            return Snake.COLLISION
        
        if newHeadPosition in self.applePositions:
            print "apple collision"
            self.applePositions.remove(newHeadPosition)
            return Snake.EATAPPLE
            
        if newHeadPosition in self.exitPosition:
            print "exit collision"
            return Snake.PASSEXIT
        
        return Snake.NOCOLLISION