Ussimäng

Loome siin ussimängu. Mängu tegemiseks tuleb kõigepealt välja mõelda (kirja panna), mida mäng tegema peab ehk millised on nõuded. Kõigepealt sõnastame üldiselt, mida tähendab "ussimäng":

Mängija juhib ussi, kes ei tohi liikuda vastu seinu ega vastu iseenda saba. Uss peab korjama võimalikult palju õunu. Iga õuna korjamisega ussi saba pikeneb.

Kohe oleks mõistlik proovida panna reeglid täpsemalt kirja, sest nendest sõltub, mismoodi ja mida me peaksime programmeerima hakkama.

Ussimängu nõuded

Kirjeldame lihtlausetega, mida meie mäng peab võimaldama:

  • ussil on pikkus

  • algne pikkus on 5

  • ussil on pea

  • ussi pead saab juhtida

  • uss saab liikuda paremale, vasakule, üles, alla - diagonaalis liikuda ei saa

  • uss ei saa liikuda oma saba suunas (seda liikumist mäng ei luba)

  • kui ussi pea liigub vastu seina, saab mäng läbi

  • ekraani ääred on seinad (otsustamise koht, kas need on nähtavad seinad või seinu näha pole, aga sinan vastu ei tohi liikuda)

  • kui uss liigub oma saba pihta, saab mäng läbi

  • maailma tekivad juhuslikku kohta õunad

  • õuna söömisel uss pikeneb 2 ühiku võrra

Proovime mõelda ka tehniliste nõuete peale:

  • uss liigub nähtamatul ruudustikul

  • üks ruut on 30 x 30 pikslit

  • kui ussi pikkus on 5 ruutu, siis liikumisel "pea" liigub ussi liikumise suunas, viimane sama tükk "kaob" ära

  • kui uss sööb õuna ja pikeneb, siis kahe käigu võrra ta saba lihtsalt ei liigu kaasa (see pikeneb nii, et uued tükid tekivad kahe järgmise liikumisega viimase ruudu kohale)

  • õun tekib ruudu sisse (mitte kahe ruudu vahele)

Siin on näide, kuidas uss liigub ruudustikus:

../../_images/snake_movement.png

Ussi pikkus on 5 (roheline pea + 4 saba tükki). Uss liigub paremale. Alustame ülemisest seisust. Ühe liikumise järel tekib olukord "2)", kus pea on liikunud ühe võrra paremale ning saba viimane tükk on "kadunud". Järgmise sammuga on jälle pea liikunud ühe võrra paremale ning viimane saba tükk on "kadunud".

Siin on näide, kuidas toimub õuna söömise puhul pikenemine:

../../_images/snake_eat_movement.png

Alguses on ussi pikkus 5 (pildil samm 1)). Uss liigub paremale ja pea satub õuna ruutu (pildil samm 2)). See tähendab, et uss sööb õuna ära ning pikeneb 2 ruudu võrra. Pikenemine ei toimu kohe, vaid kahe järgmise sammu vältel. Ehk siis õuna söömise hetkel pikeneb uss kokku 1 ruudu võrra. See uus pikendus tekib saba lõppu ehk sinna, kus oli enne saba viimane tükk. Järgmisel sammul (pildil samm 3)) toimub sama - saba siimase tüki positsioonile tekib veel üks uus saba tükk. Nüüd on ussi pikkus kokku 7. Järgmisel sammul (pildil samm 4)) toimub juba tavaline liikumine ehk saba viimane tükk liigub kaasa.

Ussi hoidmine mälus

Vaatame, kuidas oleks mõistlik ussi mälus hoida. Lõpuks peab ekraanile jõudma ussi pilt. Uss võib vabalt koosneda ristkülikutest. Seega täitsa mõistlik ongi ussi hoida mälus mitme ristkülikuna. Need ristkülikud saab hoida näiteks järjendis. Saame kokku leppida, et järjendi esimene element on pea, järgmine element on saba esimene tükk jne. Alguses on järjendi pikkus 5. Alustame olukorrast, kus pikkus on juba 5, ning vaatame, kuidas toimuks liikumine. Hiljem vaatame seda, kuidas mäng võiks alata.

Teeme praegu lihtsustuse, et me liigume mööda sirget ning igal ruudul on indeks (0, 1, 2, jne). Uss liigub vasakult paremale. Tähistame ussi järjendis ristkülikuid nende positsioonidega. Näiteks võib meil olla uss [7, 6, 5, 4, 3].

../../_images/snake_movement_line.png

Nagu pildilt näha, siis järgmisel sammul peaks uss olema [8, 7, 6, 5, 4]. Üks variant oleks mõelda nii, et me liigutame igat tükki ühe võrra. Ehk siis pea liiguks ühe võrra (ristküliku positsioon muutub, meie näites saame +1 teha), järgmine tükk liiguks ühe võrra jne. Eriti lihtne tundub see meie näites, kus kasutame arve. Aga kui nüüd mõelda selle näite peale, mida eelneval pildil nägime, kus uss oli keeranud (liikus alla ja siis keeras paremale), siis seal see tükkide liigutamine nii kerge pole. Mõned tükid peavad liikuma alla, mõned aga paremale. Ja mida pikem uss ja mida rohkem keeramisi, seda keerukamaks see tükkide nihutamine läheb. Pigem võiks teha nii, et lisame uue tüki järjendi etteotsa (ehk siis tekib n-ö uus "pea") ning eemaldame viimase elemendi. Meie positsioonide järjendi näitel tehakse siis sellised operatsioonid:

snake.insert(0, 8)
snake.pop()

Kui mõtleme õuna söömise peale, siis pea liikus edasi, aga saba jäi paigale pikenemise tõttu paariks sammuks. Vaatleme samamoodi ussi [7, 6, 5, 4, 3].

../../_images/snake_eat_movement_line.png

Ühe sammu tulemusena peaksime saama järjendi [8, 7, 6, 5, 4, 3]. Erinevus tavalise liikumisega on see, et me ei eemalda viimast tükki. Seega koodis oleks see lihtsalt pea tüki lisamine:

snake.insert(0, 8)

Kui nüüd proovida need kaks liikumist kokku panna, siis on vaja otsustada, millal on vaja sama tükk eemaldada ja millal mitte. Kuna ussil on kindel pikkus, siis ussi järjendi pikkus peab olema võrdne ussi pikkusega. Kui ussi pikkus on 5, siis ka järjendis on 5 elementi. Kui uss sööb õuna ära, muutub ussi pikkus 2 võrra suuremaks. Seega võime mõelda nii, et kui järjendi pikkus on väiksem kui ussi pikkus, siis järelikult peab saba paigale jääma. Kui järjendi pikkus on suurem kui ussi pikkus, peab saba liikuma (ehk tuleb viimane element eemaldada).

snake.insert(0, 8)
if len(snake) > snake_length:
    snake.pop()

Siin tegime näite ühemõõtmelise väljaku peal arvudega, aga analoogselt saab järjendis hoida kahemõõtmelisi koordinaate või ristkülikuid. Muus osas loogika jääb samaks.

Ruudustik

Mänguplats on tinglikult jagatud ruudustikuks. Visuaalselt jooni tõmbama ei hakka, aga kogu mängu loogika peaks ruudustikku toetama. Vaatame, kuidas me ühte ruutu saame ristkülikuna tähistada.

../../_images/grid.png

Roheline ristkülik on see, mida tahame kuidagi esitada ekraanil. Ruudustiku iga ruudu kõrgus ja laius on 30 pikslit. Nüüd selleks, et arvutada välja punane täpp (mis tähistab ristküliku ülemist vasakut äärt), saame kasutada ruudu x ja y suunalist positsiooni. Ülemine vasak ruut on positsioon 0, 0. Meie roheline ruut on positsioonil 5, 8 (esimesena x-suunaline positsioon, siis y-suunaline positsioon). Punase täpi asukoht on vastavalt (5 * 30, 8 * 30). Ja kuna me teame iga ruudu laiust ja kõrgust (mõlemas suunas 30), saame sealt ristküliku laiuse ja kõrguse.

Seega, meil on mõistlik oma objekte (ussi tükid, õun jms) hoida ruudustiku koordinaatides (nagu joonisel 0, 1, 2 jne on tähistatud). Ja sealt saame hiljem need arvutada vajadusel täpseteks koordinaatideks ekraanil. See annab veel selle mugavuse, et kui hiljem on vaja ruudu suurust muuta (näiteks 30 pealt 40 peale), siis me ei pea arvutusi mitmes kohas muutma hakkama. Aga igal juhul on koodis mõistlik otse 30 asemel kasutada konstanti (muutujat). Sedasi on hiljem mugavam muudatusi teha.

Siin ka üks video, kus oleme teinud ussimängu alge (siin lehel olev näide läheb kaugemale):

Koodi kirjutamine

Ussi liikumine

Alustame sellest, et meil on uss ja seda saab juhtida.

import pygame

pygame.init()
screen = pygame.display.set_mode((600, 600))
running = True
# actual snake pieces with grid coordinates
snake = []
# movement for x and y; start with moving right
snake_direction = (1, 0)
GRID_SIZE_X = 30
GRID_SIZE_Y = 30

# let's add a starting "head"
snake.append((3, 3))
# snake length
snake_length = 5

clock = pygame.time.Clock()

while running:

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_LEFT:
                snake_direction = (-1, 0)
            if event.key == pygame.K_RIGHT:
                snake_direction = (1, 0)
            if event.key == pygame.K_DOWN:
                snake_direction = (0, 1)
            if event.key == pygame.K_UP:
                snake_direction = (0, -1)

    screen.fill((0, 0, 0))
    # move
    # take the current head and use direction to calculate new head
    snake.insert(0, (snake[0][0] + snake_direction[0], snake[0][1] + snake_direction[1]))
    # should we remove the tail?
    if len(snake) >= snake_length:
        snake.pop()

    # let's draw the snake
    for piece in snake:
        pygame.draw.rect(screen, (0, 0, 255), (piece[0] * GRID_SIZE_X, piece[1] * GRID_SIZE_Y, GRID_SIZE_X, GRID_SIZE_Y))

    pygame.display.flip()

    clock.tick(10)

Kui see käima panna, peaks saama nooltega ussi juhtida.

../../_images/snake_preview_simple.png

Uss on pidevalt liikumises. Me peame kuidagi tähistama, kuhu suunas ta liigub. Üks variant on defineerida liikumine näiteks nii, et 1 tähistab üles, 2 tähistab paremale jne. Ja siis vastavalt liikumise väärtusele arvutame uue koordinaadi. Me oleme kasutanud aga teist lähenemist: kasutame liikumie tähistamiseks ennikut (delta_x, delta_y), kus esimene väärtus väärtus tähistab x-suunalist koordinaadi muutust, teine tähistab y-suunalist koordinaadi muutust. Muutus saab olla kas -1 või +1. Seda on hiljem mugav kasutada, kui hakkame uut positsiooni arvutama: new_head_position = (old_head_x + delta_x, old_head_y + delta_y). Meil siis vana pea positsiooni saame snake listi esimesest elemendist: snake[0]. Kuna see on ennik, siis x-koordinaat on vastavalt snake[0][0] ja y-koordinaat snake[0][1]. Ning suund on meil snake_direction muutujas. x-koordinaadi muutus vastavalt snake_direction[0] ja y-koordinaadi muutus snake_direction[1]. Uus positsioon lisatakse järjendi algusesse.

Selleks, et programm oleks dünaamilisem, defineerime kaks konstanti (muutuja, mille nimetus on läbivalt suurte tähtedega; tegelikult on see Pythonis ikka muudetav, aga nimetus annab märku, et mõeldakse pigem muutumatut väärtust): GRID_SIZE_X ja GRID_SIZE_Y. Need tähistavad ühe ruudu laiust (x-suunalist suurust) ja kõrgust (y-suunalist suurust) pikslites. Neid väärtusi kasutame joonistamisel. Sedasi saame mugavalt muuta ühe kasti suurust. Vajadusel saame ka laiuse teha suuremaks kui pikkuse.

Alguses defineerime ära ussi algse pikkuse (5) ja lisame ussi algpositsiooni listi. Algne uss on tegelikult pikkusega 1 ning tal on vaid üks tükk (pea) positsioonil (3, 3). Algne liikumissuund on paremale (0, 1). Edasi juba uss pikeneb vastavalt sellele, mida eelnevalt vaatlesime: kuna ussi järjendi pikkus on väiksem kui ussi pikkus, peab saba pikenema. Selliselt esimese 4 sammuga saavutab uss oma soovitud pikkuse (5).

Õuna söömine

Lisame maailma ühe õuna juhuslikule positsioonile. Ja kui ussi pea satub õuna peale, siis uss pikeneb.

Selleks, et juhuslikku arvu saada, kasutame random moodulit. Lisame eraldi muutuja apple_position, kus hoiame õuna positsiooni (x, y). Kui ussi pea satub õuna positsioonile toimuvad järgmised tegevused:

  • õun tekib uuele positsioonile

  • ussi pikkus suureneb 2 võrra

Uued read on märgitud teise värviga.

import pygame
import random

pygame.init()
screen = pygame.display.set_mode((600, 600))
running = True
# actual snake pieces with grid coordinates
snake = []
# movement for x and y; start with moving right
snake_direction = (1, 0)
GRID_SIZE_X = 30
GRID_SIZE_Y = 30

# let's add a starting "head"
snake.append((3, 3))
# snake length
snake_length = 5
# how much the snake grows when eating
snake_growth = 2

apple_position = (random.randint(0, 20), random.randint(0, 20))

clock = pygame.time.Clock()

while running:

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_LEFT:
                snake_direction = (-1, 0)
            if event.key == pygame.K_RIGHT:
                snake_direction = (1, 0)
            if event.key == pygame.K_DOWN:
                snake_direction = (0, 1)
            if event.key == pygame.K_UP:
                snake_direction = (0, -1)

    screen.fill((0, 0, 0))
    # move
    # take the current head and use direction to calculate new head
    snake.insert(0, (snake[0][0] + snake_direction[0], snake[0][1] + snake_direction[1]))
    # should we remove the tail?
    if len(snake) >= snake_length:
        snake.pop()

    # let's draw the snake
    for piece in snake:
        pygame.draw.rect(screen, (0, 0, 255), (piece[0] * GRID_SIZE_X, piece[1] * GRID_SIZE_Y, GRID_SIZE_X, GRID_SIZE_Y))

    if snake[0] == apple_position:
        apple_position = (random.randint(0, 20), random.randint(0, 20))
        snake_length += snake_growth
    # draw apple
    pygame.draw.rect(screen, (255, 128, 0), (apple_position[0] * GRID_SIZE_X, apple_position[1] * GRID_SIZE_Y, GRID_SIZE_X, GRID_SIZE_Y))

    pygame.display.flip()

    clock.tick(10)

Kui ussi pea (snake[0]) satub samale positsioonile nagu õun (apple_position), siis uss sööb õuna. Sellisel juhul leiame õunale uue juhusliku asukoha ja suurendame ussi pikkust. Kuna meil pikkuse suurenemine on eelnevalt juba koodis sisse arvestatud, siis me ussiga seoses midagi muutma ei pea.

Harjutus

Hetkel võib õun sattuda suvalisele positsioonile, seal hulgas ussi peale. Kuidas teha nii, et õun ei saaks tekkida ussi tüki peale?

Vihjeks: õuna positsiooni tuleb nii mitu korda genereerida, kuni tulemus meid rahuldab. Seega on mõistlik teha while True tsükkel, mille sees genereerime uue õuna asukoha. Kui asukoht sobib, siis lõpetame tsükli ära (break). Kui aga õun satub ussi peale, siis jätkame tsükli kordamist.

Mängu lõpp

Lisame kontrolli, et uss ei saaks vastu äärt ega vastu ennast liikuda. Kui see juhtub, siis mäng saab läbi.

Meil piisab sellest, et kontrollida uut pea asukohta. Ääre kontrollimiseks on mõistlik kasutusele võtta konstant (muutuja), mis ütleb, mitu ruutu meie mängupind x ja y suunal on. Ja tegelikult ruutude arvu ja ruutude suuruse järgi saame arvutada vajaliku akna suuruse. Kui meil mänguala laius on 20 ruutu ja uss tahab liikuda 21. ruudule, siis saab mäng läbi. Samamoodi kui uss üritab liikuda 1. ruudult vasakule, saab mäng läbi.

Selleks, et kontrollida, ega uss vastu enda saba ei liigu, saame kontrollida, kas uus pea asukoht on juba ussi järjendis olemas. Kui näiteks uss tahab liikuda positsioonile (5, 5), aga ussi järjendis on selline positsioon olemas (mis tähendab, et seal paikneb ussi tükk), siis oleme liikunud vastu ennast ja mäng saab läbi.

 1import pygame
 2import random
 3
 4GRID_SIZE_X = 30
 5GRID_SIZE_Y = 30
 6GRID_WIDTH = 20
 7GRID_HEIGHT = 20
 8
 9pygame.init()
10screen = pygame.display.set_mode((GRID_WIDTH * GRID_SIZE_X, GRID_HEIGHT * GRID_SIZE_Y))
11running = True
12# actual snake pieces with grid coordinates
13snake = []
14# movement for x and y; start with moving right
15snake_direction = (1, 0)
16
17# let's add a starting "head"
18snake.append((3, 3))
19# snake length
20snake_length = 5
21# how much the snake grows when eating
22snake_growth = 2
23
24apple_position = (random.randint(0, GRID_WIDTH - 1), random.randint(0, GRID_HEIGHT - 1))
25
26clock = pygame.time.Clock()
27
28while running:
29
30    for event in pygame.event.get():
31        if event.type == pygame.QUIT:
32            running = False
33        elif event.type == pygame.KEYDOWN:
34            if event.key == pygame.K_LEFT:
35                snake_direction = (-1, 0)
36            if event.key == pygame.K_RIGHT:
37                snake_direction = (1, 0)
38            if event.key == pygame.K_DOWN:
39                snake_direction = (0, 1)
40            if event.key == pygame.K_UP:
41                snake_direction = (0, -1)
42
43    screen.fill((0, 0, 0))
44    # move
45    # take the current head and use direction to calculate new head
46    new_head = (snake[0][0] + snake_direction[0], snake[0][1] + snake_direction[1])
47    # check whether we go off the grid?
48    if new_head[0] < 0 or new_head[0] >= GRID_WIDTH or new_head[1] < 0 or new_head[1] >= GRID_HEIGHT:
49        running = False
50    # check whether we go into our tail
51    if new_head in snake:
52        running = False
53
54    snake.insert(0, new_head)
55    # should we remove the tail?
56    if len(snake) >= snake_length:
57        snake.pop()
58
59    # let's draw the snake
60    for piece in snake:
61        pygame.draw.rect(screen, (0, 0, 255), (piece[0] * GRID_SIZE_X, piece[1] * GRID_SIZE_Y, GRID_SIZE_X, GRID_SIZE_Y))
62
63    if snake[0] == apple_position:
64        apple_position = (random.randint(0, GRID_WIDTH - 1), random.randint(0, GRID_HEIGHT - 1))
65        snake_length += snake_growth
66    # draw apple
67    pygame.draw.rect(screen, (255, 128, 0), (apple_position[0] * GRID_SIZE_X, apple_position[1] * GRID_SIZE_Y, GRID_SIZE_X, GRID_SIZE_Y))
68
69    pygame.display.flip()
70
71    clock.tick(10)

Muudetud ja lisatud read on märgitud koodis teise värviga. Alguses loome konstandid ruudustiku suuruse ja iga lahtri suuruse jaoks. Real 10 arvutame akna suuruse vastavalt määratud konstantidele. Real 24 kasutame konstante, et leida juhuslik asukoht õunale. Real 46 arvutame uue ussi pea asukoha eraldi muutujasse, et sellega oleks mugavam operatsioone teha. Rida 48 kontrollib, ega me ei liigu ekraanist välja. Kui uue pea x positsioon on väiksem kui 0 või suurem-võrdne akna laiusega, siis on pea juba ekraanilt väljas ja peame mängu ära lõpetama. Real 51 kontrollime, ega uue pea positsioon pole juba praeguses ussi järjendis. Kui on, siis järelikult toimub liikumine vastu ussi saba. Real 64 on muudetud see, et kasutame konstante, et arvutada uus õuna asukoht.

Harjutus

Praegune kood töötab ussi sabasse liikudes natuke valesti. Probleem on selles, et kui uss liigub, siis ta saba viimane tükk ka "liigub". Kood arvutab praegu uus pea asukoha ning kontrollib kohe, ega see ei satu sellesse positsiooni, mis on juba ussi järjendis. Tegelikult peaks see kontroll toimuma peale liikumist.

../../_images/snake_movement_to_tail.png

Kui selle pildi järgi uss liigub üles, siis meie koodi järgi saab ta surma. Tegelikult aga peaks saba eest ära liikuma. Järgmine samm peaks olema selline:

../../_images/snake_movement_to_tail_step2.png