Lab8: Snake

Forberedelser
Innlevering og retting

I denne lab’en skal vi lage et spill hvor spilleren styrer en sulten python-slange på jakt etter epler. Vi kan kalle spillet for Snake. For å få laben bestått, må du levere et fullt ut funksjonelt spill. Laben rettes manuelt av gruppeleder etter innleveringsfristen.

Det er viktig at du starter tidlig og ber om hjelp dersom du blir sittende fast. Samarbeid gjerne med andre (men skriv opp hvem du har jobbet med). Om noe er uklart, spør gjerne i lab8-kanalen på Discord også.



Steg 0: kom i gang

Opprett en fil uke_08_snake.py, og ta utgangspunkt i koden under. Husk å legge en kopi av uib_inf100_graphics.py i samme mappe.

from uib_inf100_graphics import *

def app_started(app):
    # Modellen.
    # Denne funksjonen kalles én gang ved programmets oppstart.
    # Her skal vi __opprette__ variabler i som behøves i app.
    ...

def timer_fired(app):
    # En kontroller.
    # Denne funksjonen kalles ca 10 ganger per sekund som standard.
    # Funksjonen kan __endre på__ eksisterende variabler i app.
    ...

def key_pressed(app, event):
    # En kontroller.
    # Denne funksjonen kalles hver gang brukeren trykker på tastaturet.
    # Funksjonen kan __endre på__ eksisterende variabler i app.
    ...

def redraw_all(app, canvas):
    # Visningen.
    # Denne funksjonen tegner vinduet. Funksjonen kalles hver gang
    # modellen har endret seg, eller vinduet har forandret størrelse.
    # Funksjonen kan __lese__ variabler fra app, men har ikke lov til
    # å endre på dem.
    ...

run_app(width=500, height=400, title="Snake")
Steg 1: tegn et rutenett

I dette steget skal vi skrive en funksjon som tegner et rutenett

Opprett en funksjon draw_board(canvas, x1, y1, x2, y2, board, debug_mode) med følgende parametere:

Ta gjerne utgangspunkt i koden din for å tegne et rutenett fra lab7. Den vil trenge noen små justeringer:

Her er et løsningsforslag til oppgave 1 i lab7.

def draw_grid(canvas, x1, y1, x2, y2, color_grid):
    rows = len(color_grid)
    cols = len(color_grid[0])

    cell_width = (x2 - x1) / cols
    cell_height = (y2 - y1) / rows

    for row in range(rows):
        for col in range(cols):
            cell_x1 = x1 + col * cell_width
            cell_y1 = y1 + row * cell_height
            cell_x2 = cell_x1 + cell_width
            cell_y2 = cell_y1 + cell_height
            canvas.create_rectangle(cell_x1, cell_y1, cell_x2, cell_y2,
                                    fill=color_grid[row][col])

  • En mulighet for å oppnå dette er å opprette en hjelpefunksjon get_color(value) som regner ut fargen basert på reglene over (bruk if-setninger). For eksempel skal get_color(-1) returnere strengen “cyan”, get_color(0) skal returnere strengen “lightgray”, og get_color(1) skal returnere strengen “orange”. Du kan teste en slik hjelpefunksjon med koden under om du ønsker.
print("Tester get_color...", end="")
assert("cyan" == get_color(-1))
assert("lightgray" == get_color(0))
assert("orange" == get_color(1))
assert("orange" == get_color(42))
print("OK")
  • I draw_board -funksjonen kan vi kalle på get_color -funksjonen når vi trenger å regne ut en farge. Opprett en variabel color inne i de nøstede for-løkkene, og gi den en verdi ved å kalle på get_color -funksjonen med board[row][col] som argument. Bruk så variabelen color som argument for fill-parameteren til create_rectangle når en rute tegnes.

For eksempel, bruk f-strengen f"{row},{col}\n{board[row][col]}" (her antas det at iterandene i de nøstede for-løkkene som tegner rutenettet kalles row og col)

  • Bruk create_text -metoden (se grafikk 1 for eksempel på bruk). La x-koordinatet du gir som input til create_text være midt mellom x-koordinatene du gir som input til create_rectangle (cell_mid_x = (cell_x1 + cell_x2) / 2). Gjør det samme for y-koordinatet, slik at teksten blir sentrert midt i ruten

Test deg selv: bytt ut redraw_all -funksjonen med denne. Du skal da se bildet vist under når du kjører programmet.

def redraw_all(app, canvas):
    board = [
        [0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 9,10,11, 0,-1, 0],
        [0, 0, 0, 8, 0, 0, 0, 0, 0],
        [0, 0, 0, 7, 6, 5, 0, 0, 0],
        [0, 0, 0, 0, 0, 4, 0, 0, 0],
        [0, 0, 0, 1, 2, 3, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0],
    ]
    draw_board(canvas, 25, 25, app.width-25, app.height-25, board, True)

Illustrasjon av fullført steg 1

Dersom du endrer argumentet True til False i funksjonskallet, skal samme bilde tegnes, men uten teksten i rutene.

Steg 2: tegn en modell

I dette steget skal vi opprette en modell for snake-spillet vårt, og få visningen til å tegne denne modellen.

Vi vil representere et spill med snake som en 2D-liste med heltall, hvor tallet 0 betyr at en rute er tom, tallet -1 betyr at det er et eple på en gitt posisjon, mens et tall høyere enn 0 betyr at slangen befinner seg på dette området. Slangen sitt hode er på den posisjonen på brettet med det høyeste tallet, og resten av kroppen til slangen følger deretter med synkende tall.

Modellen består av en rekke med variabler som befinner seg i app -objektet. I forrige steg testet vi draw_board ved å kalle funksjonen med en 2D-liste board som ble opprettet direkte i redraw_all. Vi skal nå endre dette til at argumentene til draw_board i stedet bli hentet fra app.

[
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0,-1, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 1, 2, 3, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0],
]

Når du er ferdig med dette steget, skal følgende vises når du kjører programmet:

Illustrasjon av fullført steg 2

Steg 3: slå av og på debug-modus

I dette steget skal vi la spilleren slå av og på debug-modus ved å trykke på d på tastaturet.

For å endre en boolsk variabel til det motsatte av det den er, kan du bruke not -operatoren. Sjekk ut eksempelet:

x = True
x = not x
print(x)

Når du er ferdig med dette steget kan du slå av og på debug-informasjon ved å trykke på d på tastaturet.

Illustrasjon av fullført steg 3

Steg 4: flytt på slangen

I dette steget skal vi la slangen gå ett steg hver gang brukeren trykker på mellomromstasten. Senere skal vi få programmet til å flytte slangen av seg selv, men i utviklingsfasen er det fint å kunne jobbe i «sakte film,» og da er det fint å bruke mellomromstasten.

Steg 4a: utvidelse av modellen

Først må vi utvide modellen vår slik at den inneholder informasjon om hvilken retning slangen skal gå i, slangens lengde, samt informasjon om slagehodet sin posisjon. I funksjonen app_started:

En tuple er en liste som ikke kan muteres. De aller fleste ikke-destruktive operasjoner på lister fungerer helt likt for tupler. Les også om tupler i kursnotater om lister.

# Opprett en tuple
pos = (3, 4)
print(pos) # (3, 4)

# "Pakk ut" en tuple i variabler
row, col = pos
print(f'{row} {col}') # 3 4

Når vi er i debug-modus, er det fint om vi kan se verdiene til disse variablene.

Steg 4b: selve forflyttningen
Tilstand førTilstand etter
før forflyttningetter forflytning

Nå kommer vi til selve forflyttningen; bildene over viser tilstanden før og etter en forflytning i retning «east». Vi oppretter en egen funksjon move_snake(app) for å utføre flyttingen. Denne funksjonen skal utføre følgende:

I før/etter -bildene over ser vi at verdien app.head_pos endres fra (3, 4) til (3, 5). Den endringen gjør vi her.

Test hjelpefunksjonen din ved å kopiere denne testen inn i uke_08_snake.py (legg testen inn før kallet til run_app, men etter at funksjonen get_next_head_position er definert)

print("Tester get_next_head_position...", end="")
assert((3, 9) == get_next_head_position(3, 8, "east"))
assert((3, 7) == get_next_head_position(3, 8, "west"))
assert((2, 8) == get_next_head_position(3, 8, "north"))
assert((4, 8) == get_next_head_position(3, 8, "south"))
assert((1, 6) == get_next_head_position(1, 5, "east"))
assert((1, 4) == get_next_head_position(1, 5, "west"))
assert((0, 5) == get_next_head_position(1, 5, "north"))
assert((2, 5) == get_next_head_position(1, 5, "south"))
print("OK")

Benytt if-setninger og sjekk hva direction er. Legg til eller trekk fra 1 på row eller col alt ettersom. Se «test deg selv underveis» over.

I før/etter -bildene over ser vi at verdien i rute (3, 2) endres fra 1 til 0, verdien i rute (3, 3) endres fra 2 til 1 og verdien i rute (3, 4) endres fra 3 til 2. Disse endringene gjør vi her.

PS: Vi sier at en verdi er positiv dersom den er strengt større enn 0; altså er 0 ikke en positiv verdi.

Test hjelpefunksjonen din ved å kopiere denne testen inn i uke_08_snake.py (legg testen inn før kallet til run_app, men etter at funksjonen subtract_one_from_all_positives er definert)

print("Tester subtract_one_from_all_positives...", end="")
a = [[2, 3, 0], [1, -1, 2]]
subtract_one_from_all_positives(a)
assert([[1, 2, 0], [0, -1, 1]] == a)
print("OK")

I før/etter -bildene over ser vi at verdien i ruten (3, 5) endres fra 0 til 3. Den endringen gjør vi her.

  • Har du husket å oppdatere app.head_pos med returverdien fra kallet til get_next_head_position?

  • Slangehodet sin posisjon som rad og kolonne er row, col = app.head_pos.

  • Muter app.board[row][col] slik at verdien blir app.snake_size.

Til slutt gjenstår det å gjøre et kall til move_snake når brukeren trykker på mellomrom

Steg 5: endre retning

I dette steget skal vi gjøre det mulig å endre retningen slangen beveger seg ved at spilleren trykker på piltastene på tastaturet.

Du skal nå kunne endre retning slangehodet beveger seg ved hjelp av piltastene. Selve flyttingen krever fremdeles at spilleren trykker på mellomromstasten.

Steg 6: spis epler og bli stor

I dette steget skal vi implementere funksjonalitet som gjør at slangen vokser når den spiser et eple.

Tilstand førTilstand etter
før spisingetter spising

I før/etter -bildene over, legg merke til at når slangen spiser:

  • økes verdien app.snake_size fra 3 til 4,
  • posisjonen hvor hodet flytter til (1, 6) endrer verdi fra -1 til 4, som er den nye verdien til app.snake_size, og
  • ingen verdier tilhørende slangen (positive verdier) i app.board synker.

For å gjennomføre dette steget, må vi modifisere koden vi skrev i move_snake tidligere:

import random

# Et tilfeldig tall mellom 0 og 10 (ikke inkludert 10)
print(random.choice(range(10)))

# Et tilfeldig element i en liste
print(random.choice(["a", "b", "c"]))

Test hjelpefunksjonen din ved å kopiere denne testen inn i uke_08_snake.py (legg testen inn før kallet til run_app, men etter at funksjonen add_apple_at_random_location er definert)

print("Tester add_apple_at_random_location...", end="")
for _ in range(100):
    a = [[2, 3, -1, 0], [1, 0, 0, 0]]
    add_apple_at_random_location(a)
    legal_results = [
        [[2, 3, -1, -1], [1, 0, 0, 0]],
        [[2, 3, -1, 0], [1, -1, 0, 0]],
        [[2, 3, -1, 0], [1, 0, -1, 0]],
        [[2, 3, -1, 0], [1, 0, 0, -1]],
    ]
    assert(a in legal_results)
print("OK")

Legg merke til at funksjonen kun skal opprette epler på steder som er ledige, altså har verdien 0. Siden vi her tester en funksjon som produserer tilfeldige resultater, sjekker vi i assert-setningen at resultatet er ett av de fire lovlige resultatene for test-casen vår. Vi kjører også testen mange ganger slik at vi ikke bare passerer testen på grunn av flaks.

Det finnes i hovedsak to måter å løse add_apple_at_random_location på. Velg selv hvilken strategi du ønsker å bruke.

Alternativ A. Denne tilnærmingen er som regel rask og effektiv så lenge slangen er relativt kort, men man har ingen øvre grense for hvor lang tid som kreves i verste fall.

  • Velg en tilfeldig rad mellom 0 og antall rader i grid
  • Velg en tilfeldig kolonne mellom 0 og antall kolonner i grid
  • Sjekk om det er ledig plass (altså verdien 0) i grid på den gitte posisjonen:
    • Hvis ja, legg inn et eple (verdien -1) i posisjonen og avslutt så funksjonen med return
    • Hvis ikke, begynn på nytt (f. eks. ha all koden inn i en while True -løkke)

Alternativ B. Denne tilnærmingen er ikke spesielt effektiv i gjennomsnitt, men har en øvre grense for hvor lang tid den tar. Dersom det ikke er plass til et nytt eple i det hele tatt vil denne algoritmen kunne avsløre det (f. eks. ved å krasje, eller håndtere det på annen måte), mens alternativ A aldri vil terminere, og programmet vil fryse (som tross alt er et dårligere alternativ).

  • Opprett først en liste med alle posisjonene hvor det er mulig å opprette et eple.
  • Velg en tilfeldig posisjon fra listen over muligheter.
  • Legg inn et eple i den valgte posisjonen

For de ambisiøse: det er mulig å kombinere alternativene A og B ved å prøve alternativ A noen få iterasjoner først, og deretter hoppe over til alternativ B dersom ingen gode alternativer ble funnet. Da får vi det beste fra begge verdener.

Steg 7: game over

Foreløpig krasjer spillet dersom slangen går utenfor brettet. Slangen er også i stand til å krysse seg selv. Vi vil at begge disse hendelsene fører oss inn i en game over -tilstand.

print("Tester is_legal_move...", end="")
board = [
        [0, 3, 4],
        [0, 2, 5],
        [0, 1, 0],
        [-1, 0, 0],
    ]
assert(is_legal_move(2, 2, board))
assert(not is_legal_move(1, 3, board)) # Utenfor brettet
assert(not is_legal_move(1, 1, board)) # Krasjer med seg selv
assert(not is_legal_move(0, 2, board)) # Krasjer med seg selv

assert(is_legal_move(0, 0, board))
assert(is_legal_move(3, 0, board)) # Eplets posisjon er lovlig
assert(is_legal_move(3, 2, board))
assert(not is_legal_move(-1, 0, board)) # Utenfor brettet
assert(not is_legal_move(0, -1, board)) # Utenfor brettet
assert(not is_legal_move(3, -1, board)) # Utenfor brettet
assert(not is_legal_move(3, 3, board)) # Utenfor brettet
assert(not is_legal_move(4, 2, board)) # Utenfor brettet
print("OK")

I key_pressed, endre oppførselen slik at:

I redraw_all, endre oppførselen slik at:

Steg 8: gå automatisk

I funksjonen timer_fired, legg til følgende funksjonalitet:

Du skal nå kunne spille spillet i praksis ved å slå av debug-modus.

Vi ber om at du i koden som leveres inn har debug-modus slått på som standard, slik at det blir lettere for oss å rette.

For å øke hvor lang tid det går mellom hvert kall til timer_fired, kan du legge inn linjen app.timer_delay = 200 # milliseconds (eller en annen verdi) i funksjonen app_started.

Steg 9: idéer til frivillige forberdringer

Guide til snake av Torstein Strømme er lisensiert under CC-NC-SA 4.0.