Nauč se Python > Materiály > Začátečnický kurz > Grafika > Praktické cvičení: Pong

Pong #

Dnes si prohloubíme znalosti programování grafických aplikací, které jsme získali na lekci o Pygletu, na reálném problému.

Naprogramujeme si s pomocí knihovny Pyglet jednu z prvních videoher, Pong. Pong vydala společnost Atari jako svůj první titul v roce 1972 a odstartovala tak boom herního průmyslu.

Na Youtube se můžeš podívat na video, které ukazuje jak se Pong hraje.

Konstanty a stav hry #

Hra Pong má jednoduchá pravidla. Musíme je ale umět vyjádřit v Pythonu a to není úplně jednoduché. Pojdmě si srovnat v hlavě, co ve hře máme.

  • Hrací pole ve tvaru obdélníku s půlící čárou.
  • Míček létající určitou rychlostí po hracím poli.
  • Dvě pálky pohybující se vertikálně na krajích pole.
  • Dvě počítadla skóre.

Hra bude pro 2 hráče, nebudeme programovat chování počítače. Každý z hráčů může ovládat svou pálku stiskem kláves. Jeden hráč šipkou nahoru a šipkou dolů a druhý hráč klávesami W a S.

Stav hry dokážeme v Pythonu vyjádřit pomocí proměnných a konstant. To dává smysl, protože některé věci se ve hře mění (poloha pálek, poloha míčku, rychlost míčku, skóre) a některé ne (velikost hrací plochy, velikost pálek a míčku, poloha a velikost počítadel skóre). Ze složitějších datových struktur použijeme seznam (list, ten už známe) a množinu (set), která je velmi podobná matematické množině. Zjednodušeně je to seznam, který se nestará o pořadí a stejné prvky v něm mohou být právě jednou.

Možná přemýšlíš nad tím, v jakých jednotkách můžeme měřit vzdálenost a rychlost v takovéto hře na počítači. Na obrazovce je nešikovné měřit vzdálenost např. v centimetrech. Nicméně každá obrazovka se skládá z jednotlivých svítících bodů tzn. pixelů. V grafické aplikaci jako je Pong můžeme tedy měřit vzdálenost dvou míst na obrazovce jako počet pixelů mezi těmito dvěma místy. Souřadný systém Pygletu je založený právě na pixelech, přičemž pixel se souřadnicemi [0, 0] je na obrazovce vlevo dole. Rychlost můžeme jednoduše měřit v pixelech za sekundu.

Založte si nový skript. Společně nadefinujeme konstanty, které budeme v tvorbě hry potřebovat. Normálně bychom definovali konstanty postupně, až bychom je potřebovali, ale pro jednoduchost to udělejme společně a najednou. Ukážeme si na tom, jak začít převádět problém z reálného světa do Pythonu.

# Velikost okna (v pixelech)
SIRKA = 900
VYSKA = 600

VELIKOST_MICE = 20
TLOUSTKA_PALKY = 10
DELKA_PALKY = 100
RYCHLOST = 200  # v pixelech za sekundu
RYCHLOST_PALKY = RYCHLOST * 1.5  # taky v pixelech za sekundu

DELKA_PULICI_CARKY = 20
VELIKOST_FONTU = 42
ODSAZENI_TEXTU = 30

Nyní nadefinujeme proměnné potřebné v naší hře: poloha míčku, poloha pálek, rychlost míče, stisknuté klávesy a skóre obou hráčů. Budou to globální proměnné, za což by nás profesionální programátor pokáral, ale nám to v tuhle chvíli ulehčí práci.

pozice_palek = [VYSKA // 2, VYSKA // 2]  # vertikalni pozice dvou palek
pozice_mice = [0, 0]  # x, y souradnice micku -- nastavene v reset()
rychlost_mice = [0, 0]  # x, y slozky rychlosti micku -- nastavene v reset()
stisknute_klavesy = set()  # sada stisknutych klaves
skore = [0, 0]  # skore dvou hracu

Vykreslení hrací plochy #

Nejprve si v Pygletu otevřeme okno velikosti hrací plochy.

import pyglet
...
window = pyglet.window.Window(width=SIRKA, height=VYSKA)
pyglet.app.run()  # vse je nastaveno, at zacne hra

Než začneme dělat interaktivní část hry reagující na vstupy od uživatele, je třeba umět vůbec vykreslit prvky na hrací ploše. Podobně jako jsme v lekci o Pygletu měli funkci vykresli(), která vykreslila hada, budeme mít v Pongu funkci stejného jména, která vykreslí prvky na hrací ploše.

Většina z tvarů jsou obdélníky, takže nejprve navrhněme funkci nakresli_obdelnik, která dostane souřadnice a velikost obdélníku a vykreslí ho. Na to má pyglet v modulu pyglet.shapes třídu Rectangle, která se používá následovně:

from pyglet import gl
...
def nakresli_obdelnik(x, y, sirka, vyska):
    """Nakresli obdelnik na danych souradnicich

    Nazorny diagram::

                sirka (velikost v ose X)
              |<------>|

              +--------+     -
              |KRESLIME|     ^
              |*TENHLE*|     | vyska (velikost v ose Y)
              |OBDELNIK|     v
          y - +--------+     -
              :
              x
    """
    obdelnik = pyglet.shapes.Rectangle(x=x, y=y, width=sirka, height=vyska)
    obdelnik.draw()

Teď začneme pracovat na funkci vykresli() Nejprve ji vytvoř prázdnou a zaregistruj ji v Pygletu na událost on_draw, jak jsme to dělali v lekci o Pygletu. To znamená, že se tato funkce zavolá pokaždé, když Pyglet překreslí okno. Pokud se mezitím např. změnila poloha míčku, funkce ho vykreslí o kousek jinde. Tím vlastně vytváříme dynamiku hry. Analogicky jsme to dělali s hadem, tady máme jen víc grafických prvků.

...
def vykresli():
    """Vykresli stav hry"""
    window.clear()  # smaz obsah okna (vybarvi na cerno)

window = pyglet.window.Window(width=SIRKA, height=VYSKA)
window.push_handlers(
    on_draw=vykresli,  # na vykresleni okna pouzij funkci `vykresli`
)
pyglet.app.run()  # vse je nastaveno, at zacne hra

Zatím máme v těle funkce jen volání, která vyčistí plochu, do které kreslíme a nastaví barvu kreslení na bílou.

Teď zkus sám/sama do funkce vykresli() přidat vykreslení míčku na správné pozici, kterou získáš z příslušné globální proměnné. Míček je v našem případě jen malý čtvereček jehož velikost máme uloženou v konstantě. Pozor na to, že pozice_mice určuje střed míčku, ne roh.

Řešení

Po míčku zkus vykreslit obě pálky. V proměnné pozice_palek máme vertikální polohu první a druhé pálky, ale horizontální poloha je konstantní. Jaké souřadnice musíš předat funkci nakresli_obdelnik, aby se pálka vykreslila správně a na správném místě? Princip určení souřadnic je podobný jako u vykreslení míčku.

Řešení

Přehlednosti hry pomůže půlící čára uprostřed. Jak ji ale namalovat? Nebudeme vymýšlet zbytečné složitosti. Namalujme ji jako sérii obdélníčků táhnoucích se odshora dolů. Chce to jen vygenerovat seznam souřadnic, které budou mít dostatečné rozestupy, a na každé z nich vykreslit obdélníček. Kterou funkci z Pythonu bys použil/a na získání tohoto seznamu souřadnic?

Řešení

Co nám ještě chybí? Počítadlo skóre pro oba hráče. K tomu se musíme naučit vykreslovat v Pygletu text. V Pygletu je modul text, který obsahuje objekt Label (Nápis). Ten se hodí k vykreslení hodnoty skóre. Objekt musíme nejdřív vytvořit. To uděláme kulatými závorkami za jménem objektu, jako bychom volali funkci, a uložíme si ho do proměnné: napis = Label().

def nakresli_text(text, x, y, pozice_x):
    """Nakresli dany text na danou pozici

    Argument ``pozice_x`` muze byt "left" nebo "right", udava na kterou stranu
    bude text zarovnany
    """
    napis = pyglet.text.Label(
        text,
        font_size=VELIKOST_FONTU,
        x=x, y=y, anchor_x=pozice_x
    )
    napis.draw()

Teď zkus tuto funkci použít ve funkci vykresli() k nakreslení skóre. K určení pozice textu použij konstanty SIRKA, VYSKA, ODSAZENI_TEXTU a VELIKOST_FONTU.

Řešení

Hurá, teď už máme vykreslené hrací pole. Pojďme ho rozhýbat.

Dynamika hry #

Teď to začne být zajímavé. Nejdřív rozhýbeme pálky, protože je to jednodušší, pak míček.

Vstup od uživatele #

Potřebujeme pohybovat s pálkami podle vstupu od uživatele. Dokud bude uživatel držet např. klávesu S, levá pálka pojede dolů. V Pygletu jsme se naučili pracovat s událostí on_text, ta nám ale v tomto případě nebude stačit. K realizaci pohybu pálek budeme potřebovat 2 typy událostí, které ještě neznáme - on_key_press a on_key_release.

Pyglet zavolá funkci registrovanou na událost on_key_press stejně jako při vykreslování okna zavolal funkci vykresli(), zaregistrovanou na události on_draw. Přidáme právě stisknutou klávesu do množiny stisknutých kláves v globální proměnné stisknute_klavesy jako n-tici (směr, číslo pálky), např. tedy ('nahoru', 0), což bude vyjadřovat, že levá pálka má jet nahoru. Při události on_key_release odebereme právě stisknutou klávesu z množiny stisknute_klavesy. Tím zajistíme, že v daný okamžik bude množina stisknute_klavesy obsahovat všechny klávesy, které uživatel drží, a budeme podle toho moct pohnout s pálkami.

Troufneš si napsat funkce stisk_klavesy(symbol, modifikatory) a pusteni_klavesy(symbol, modifikatory)? Poznamenejme, že do množiny stisknute_klavesy můžeš přidat prvek metodou add(prvek) a pak odebrat metodou discard(prvek). Obě berou jako argument prvek, který se má přidat nebo odstranit, v našem případě konkrétní n-tici.

Budeš potřebovat zjistit, kterou klávesu uživatel stisknul. Kód stisknuté klávesy předá Pyglet našim funkcím v argumentu symbol. Je to ale nic neříkající číslo. Z pyglet.window můžeš naimportovat modul key, který obsahuje konstanty jednotlivých kláves. Můžeš pak porovnat, zda symbol odpovídá např. klávese jako symbol == key.UP.

Řešení

Proč vlastně používáme k odebrání n-tice metodu discard() místo metody remove(), kterou známe ze seznamů a množiny ji také mají? Nezpůsobí totiž chybu, když se pokusíme odebrat prvek, který v množině není. To by se mohlo stát, kdyby uživatel stiskl jednu z funkčních kláves a teprve pak se přepnul do našeho okna a pak jí pustil.

Zaregistruj si obě funkce na příslušné události:

...
window = pyglet.window.Window(width=SIRKA, height=VYSKA)
window.push_handlers(
    on_draw=vykresli,  # na vykresleni okna pouzij funkci `vykresli`
    on_key_press=stisk_klavesy,
    on_key_release=pusteni_klavesy,
)
pyglet.app.run()

Pohyb pálek #

Když už jsme dokázali zpracovat vstup od uživatele, můžeme podle něj pohnout s pálkami. Pohyb předmětů budeme provádět ve funkci obnov_stav(dt), která bude registrována na tik hodin v Pygletu. Argument dt je čas od posledního zavolání funkce Pygletem.

def obnov_stav(dt):
    for cislo_palky in (0, 1):
        # pohyb podle klaves (viz funkce `stisk_klavesy`)
        if ('nahoru', cislo_palky) in stisknute_klavesy:
            pozice_palek[cislo_palky] += RYCHLOST_PALKY * dt
        if ('dolu', cislo_palky) in stisknute_klavesy:
            pozice_palek[cislo_palky] -= RYCHLOST_PALKY * dt

        # dolni zarazka - kdyz je palka prilis dole, nastavime ji na minimum
        if pozice_palek[cislo_palky] < DELKA_PALKY / 2:
            pozice_palek[cislo_palky] = DELKA_PALKY / 2
        # horni zarazka - kdyz je palka prilis nahore, nastavime ji na maximum
        if pozice_palek[cislo_palky] > VYSKA - DELKA_PALKY / 2:
            pozice_palek[cislo_palky] = VYSKA - DELKA_PALKY / 2

Podívejme se na tento kus kódu. Procházíme v cyklu obě pálky a ptáme se, zda je v množině stisknutých kláves n-tice reprezentující pohyb dané pálky nahoru nebo dolů. Když ano, pohneme pálkou v daném směru (přičteme nebo odečteme od vertikální polohy pálky změnu polohy, což je čas od posledního zavolání, který známe, vynásobený rychlostí pálky nastavené v konstantě).

V druhé části musíme zajistit, aby pálka nevyjela z hracího pole. Z minulých hrátek s hadem víme, že to se může stát velmi snadno. Pálku malujeme kolem jejího středu, což znamená, že když se pálka přiblíží na na y-ovou pozici DELKA_PALKY / 2, začíná překračovat dolní hranici hracího pole. V tom případě její pozici zafixujeme na nejnižší možné souřadnici. Analogicky to provedeme, když se blíží hornímu okraji.

Zaregistruj vytvořenou funkci na tik hodin jako

...
pyglet.clock.schedule(obnov_stav)
pyglet.app.run()

a podívej se na výsledek.

Rozehrání #

Než začneme míček odrážet od stěn, musíme ho nejprve uvést do pohybu. Vystřelíme ho ze středu hrací plochy do náhodného směru. Toto se také stane v momentě, kdy jeden z hráčů skóruje a hra se rozehrává znovu. Proto tohle rozehrání zabalíme do funkce reset(). Zavolejte ji, než se spustí hra.

Jak bude tato funkce vypadat? Nejprve přesuň míček do středu hrací plochy nastavením proměnné pozice_mice. Potom je třeba simulovat hod mincí pomocí volání funkce random.randint(0, 1). Tím rozhodneme, zda se míček rozletí doprava nebo doleva. Míček rozpohybujeme horizontálním směrem přičtením požadované rychlosti k rychlost_mice[0]. Ve vertikálním směru rychlost_mice[1] se bude míček pohybovat zcela náhodně přičtením náhodné rychlosti.

Řešení

Nic se zatím ale nestane, protože funkce obnov_stav(dt) zatím nepracuje se změnou rychlosti. Musíme v ní tedy nastavit proměnnou poloha_micku podle současné rychlosti míčku a času uplynulého od posledního zavolání funkce podle fyzikálního vztahu s = v t, tedy že dráha je rovna rychlosti vynásobené časem. Přidej tedy do funkce obnov_stav(dt) následující kód:

def obnov_stav(dt):
    ...
    # POHYB MICKU
    pozice_mice[0] += rychlost_mice[0] * dt
    pozice_mice[1] += rychlost_mice[1] * dt

Zkus, co se teď stane při spuštění hry. Míček by měl vyletět pokaždé do jiného směru.

Odrážení míčku #

Míček nám teď nekontrolovaně vyletí z hřiště. Musíme tedy zařídit, aby se odrážel od stěn. Jelikož úhel dopadu se rovná úhlu odrazu, stačí otočit znaménko y-ové složky rychlosti. Do funkce obnov_stav(dt) musíme přidat kontroly na polohu míčku a případně změnit jeho směr, pokud je moc nízko nebo moc vysoko.

def obnov_stav(dt):
    ...
    # Odraz micku od sten
    if pozice_mice[1] < VELIKOST_MICE // 2:
        rychlost_mice[1] = abs(rychlost_mice[1])
    if pozice_mice[1] > VYSKA - VELIKOST_MICE // 2:
        rychlost_mice[1] = -abs(rychlost_mice[1])

Teď nám zbývá odraz od pálky, případně resetování hry, pokud míček padne mimo pálku jednoho hráče a ten druhý tak získá bod. Opět tedy budeme přidávat kód do funkce obnov_stav(dt).

Prvním krokem je poznamenání mezí na y-ové ose, kde se musí míček nacházet, aby byl úspěšně odražen – to je mezi horním a dolním koncem pálky:

def obnov_stav(dt):
    ...
    palka_min = pozice_mice[1] - VELIKOST_MICE / 2 - DELKA_PALKY / 2
    palka_max = pozice_mice[1] + VELIKOST_MICE / 2 + DELKA_PALKY / 2

Nyní když míček narazí do pravé nebo levé stěny se umíme zeptat, zda je pálka na správné pozici a my máme odrazit míček nebo zda hráč prohrál kolo a my máme přičíst jeho soupeři bod a restartovat hru.

def obnov_stav(dt):
    ...
    # odrazeni vlevo
    if pozice_mice[0] < TLOUSTKA_PALKY + VELIKOST_MICE / 2:
        if palka_min < pozice_palek[0] < palka_max:
            # palka je na spravnem miste, odrazime micek
            rychlost_mice[0] = abs(rychlost_mice[0])
        else:
            # palka je jinde nez ma byt, hrac prohral
            skore[1] += 1
            reset()

    # odrazeni vpravo
    if pozice_mice[0] > SIRKA - (TLOUSTKA_PALKY + VELIKOST_MICE / 2):
        if palka_min < pozice_palek[1] < palka_max:
            rychlost_mice[0] = -abs(rychlost_mice[0])
        else:
            skore[0] += 1
            reset()

Závěr #

Hurá, prokousali jsme se k zdárnému konci Pongu! Máš teď plně funkční interaktivní grafickou hru zakládající se na reálné předloze. :)

Můžeš si stáhnout celý kód hry a porovnat ho se svým řešením.