Jak vytvořit Pacmana v Pythonu - 1. část
30.11.2022
Jak vytvořit Pacmana v Pythonu - 1. část

​​Pacman je kultovní plošinovka, kterou zná pravděpodobně každý. Jméno “Pac-man" pochází z japonského slova “paku", které označuje otevírání a zavírání úst. Tvůrce Toru Iwatani se inspiroval u japonské pohádky o bytosti, která ochraňuje děti před monstry tím, že monstra požírá. Při tvorbě hry použil jako odrazový můstek klíčová slova z příběhu, a sloveso “jíst" se stalo základem všeho.

Monstra jsou znázorněna jako čtyři duchové, kteří na hráče útočí v postupných vlnách, podobně jako ve Space Invaders. Každý z duchů má také unikátní osobnost. V pohádce je ještě jeden důležitý element, totiž koncept životní síly “kokoro", která bytosti umožňovala požírat monstra. Ve hře je tato energie znázorněna jako power-up cookiesky, které Pacmanovi uděluji krátkodobou schopnost požírat monstra.

V návodu vás nejprve provedu základním nastavením, pak vytvoříme herní objekty pro zeď bludiště, Pacmana a duchy, zajistíme hledání cesty bludištěm, duchům přidáme náhodný pohyb, u hráče implementujeme ovládání šipkami a nakonec do bludiště rozmístíme jídlo ve formě cookies. Vše budu doprovázet obrázky a gify pro lepší znázornění.

Základní nastavení

Výsledná hra má přibližně 300 řádků kódu, proto zde uvádím pouze nejdůležitější části. Kód v úplné podobě je dostupný na mém github repozitáři. Prvním krokem je instalace potřebných balíčků. Budeme potřebovat pygame, numpy a tcod. Nainstalujte si všechny přes nástroj pip (jak na to najdete v článku o Python aplikacích). Pokud používáte vývojové prostředí jako např. PyCharm (doporučuji), instalace proběhne po kliknutí na hlášku o chybějícím balíčku.

Nejprve si vytvoříme herní okno, podobným způsobem jako v předchozím návodu na hru Space Invaders (ta měla pouhých 100 řádků). Zde připravím parametry pro specifikaci velikosti okna, název hry, obnovovací frekvenci a několik datových polí, které budou držet reference na herní objekty a hráče. Funkce tick všechny herní objekty opakovaně prochází a volá jejich vnitřní logiku a vykreslování. Pak zbývá už jen překreslit celou herní plochu a zajistit zpracování vstupních událostí, jako jsou kliknutí myši a vstup z klávesnice. K tomu bude sloužit funkce _handle_events.


import pygame # importy balíků
import numpy as np
import tcod

class GameRenderer:
    def __init__(self, in_width: int, in_height: int):
        pygame.init()
        self._width = in_width
        self._height = in_height
        self._screen = pygame.display.set_mode((in_width, in_height))
        pygame.display.set_caption('Pacman')
        self._clock = pygame.time.Clock()
        self._done = False
        self._game_objects = []
        self._walls = []
        self._cookies = []
        self._hero: Hero = None

    def tick(self, in_fps: int):
        black = (0, 0, 0)
        while not self._done:
            for game_object in self._game_objects:
                game_object.tick()
                game_object.draw()

            pygame.display.flip()
            self._clock.tick(in_fps)
            self._screen.fill(black)
            self._handle_events()
        print("Game over")

    def add_game_object(self, obj: GameObject):
        self._game_objects.append(obj)


    def add_wall(self, obj: Wall):
        self.add_game_object(obj)
        self._walls.append(obj)

    def _handle_events(self):
        pass # dodelame

Vytvoření třídy pro zeď pak bude vypadat jednoduše. Barvu pro zdi volím modrou podle originálního Pacmana (parametr color - Blue 255, zbytek 0). 

   
   
class Wall(GameObject):
	def __init__(self, in_surface, x, y, in_size: int, in_color=(0, 0, 255)):
		super().__init__(in_surface, x * in_size, y * in_size, in_size, in_color)

Kód pro vykreslování a objekt pro zdi máme připraven. Při psaní se ujistěte, že třídy Wall a GameObject jsou nad třídou GameRenderer, aby je třída “viděla". Dalším krokem je vykreslení bludiště na obrazovku. Ale ještě předtím musíme vytvořit jednu pomocnou třídu.

Třída game controller

Bludiště ve formě ASCII znaků uložím do proměnné v nové třídě PacmanGameController. Použiju velikost bludiště jako v originále - 28x31 dlaždic. Později budu muset zajistit, aby duchové mohli správně hledat cestu v bludišti a případně i najít hráče. Bludiště nejprve načtu jako znaky a převedu ho na pole jedniček a nul, kde zeď bude nula a průchodný prostor bude jedna. Tyto hodnoty slouží algoritmu hledání cesty jako tzv. cost funkce. Nula značí nekonečnou cenu průchodu, proto nebudou takto označené položky pole považovány za průchodné. Všimněte si pole reachable_spaces, které drží průchozí části bludiště. K tomu ale až později, jako první musím připravit struktury třídy. Bludiště v ASCII podobě můžete zkopírovat z mého githubu. Ve znakovém zápisu jsem použil “X" pro zeď, “P" pro Pacmana a “G" pro ducha. 

   
   
class PacmanGameController:
    def __init__(self):
        self.ascii_maze = [
            "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
            "XP           XX            X",
            "X XXXX XXXXX XX XXXXX XXXX X",
            "X XXXX XXXXX XX XXXXX XXXX X",
            "X XXXX XXXXX XX XXXXX XXXX X",
            "X                          X",
            "X XXXX XX XXXXXXXX XX XXXX X",
            "X XXXX XX XXXXXXXX XX XXXX X",
            "X      XX    XX    XX      X",
            "XXXXXX XXXXX XX XXXXX XXXXXX",
            "XXXXXX XXXXX XX XXXXX XXXXXX",
            "XXXXXX XX          XX XXXXXX",
            "XXXXXX XX XXXXXXXX XX XXXXXX",
            "XXXXXX XX X   G  X XX XXXXXX",
            "          X G    X          ",
            "XXXXXX XX X   G  X XX XXXXXX",
            # zkraceno pro clanek
            "XXXXXXXXXXXXXXXXXXXXXXXXXXXX",
        ]

        self.numpy_maze = []
        self.cookie_spaces = []
        self.reachable_spaces = []
        self.ghost_spawns = []

        self.size = (0, 0)
        self.convert_maze_to_numpy()
        #self.p = Pathfinder(self.numpy_maze) # pouzijeme pozdeji


    def convert_maze_to_numpy(self):
        for x, row in enumerate(self.ascii_maze):
            self.size = (len(row), x + 1)
            binary_row = []
            for y, column in enumerate(row):
                if column == "G":
                    self.ghost_spawns.append((y, x))

                if column == "X":
                    binary_row.append(0)
                else:
                    binary_row.append(1)
                    self.cookie_spaces.append((y, x))
                    self.reachable_spaces.append((y, x))
            self.numpy_maze.append(binary_row)

Vykreslení bludiště

Vše nutné pro vykreslení bludiště je připraveno, takže už jen stačí vytvořit instance našich tříd PacmanGameController, projít 2D pole s pozicemi zdí a na těchto místech vytvořit objekt Wall (používám neuvedenou funkci add_wall, opět nahlédněte do úplného kódu na mém githubu). Obnovovací frekvenci nastavuji na 120 snímků za vteřinu. 

   
   
if __name__ == "__main__":
    unified_size = 32
    pacman_game = PacmanGameController()
    size = pacman_game.size
    game_renderer = GameRenderer(size[0] * unified_size, size[1] * unified_size)

    for y, row in enumerate(pacman_game.numpy_maze):
        for x, column in enumerate(row):
            if column == 0:
                game_renderer.add_wall(Wall(game_renderer, x, y, unified_size))

    game_renderer.tick(120)

 pacman

Přidání duchů

V originálním Pacmanovi byli čtyři duchové jmény Blinky, Pinky, Inky a Clyde, každý s individuálním charakterem a schopnostmi. Koncept hry je založen na japonské pohádce (více zde a zde) a originální jména v japonštině i naznačují jejich schopnosti (např. Pinky má japonské jméno Lupič, Blinky je zase Stín). Pro naši hru ale nebudeme zacházet do takových detailů a každý z duchů bude používat jen základní behaviorální smyčku jako v originále - tzn. módy Chase, Scatter a Frightened. Tyto AI módy si popíšeme a zpracujeme ve druhém díle.

Třída pro ducha bude jednoduchá, většinu chování zdědí od rodičovské třídy MovableObject (nahlédněte na github, ta třída je o něco složitější a obsahuje logiku pro pohyb ve čtyřech směrech, následování trasy a ověřování kolize se zdí). 

   
   
class Ghost(MovableObject):
    def __init__(self, in_surface, x, y, in_size: int, in_game_controller, in_color=(255, 0, 0)):
        super().__init__(in_surface, x, y, in_size, in_color, False)
        self.game_controller = in_game_controller

 

Do třídy PacmanGameController přidám hodnoty RGB barev jednotlivých duchů a ve funkci main vygeneruji čtyři barevné duchy. Připravím si také statickou funkci pro převod souřadnic, která jednoduše převede souřadnice bludiště (např. x=16 y=16 je přibližně střed bludiště, a pronásobením s velikostí buňky, neboli tile, dostanu souřadnici na herní ploše v pixelech). 

   
   
# v PacmanGameController
self.ghost_colors = [
        (255, 184, 255),
        (255, 0, 20),
        (0, 255, 255),
        (255, 184, 82)
    ]

# ve funkci main
for i, ghost_spawn in enumerate(pacman_game.ghost_spawns):
    translated = translate_maze_to_screen(ghost_spawn)
    ghost = Ghost(game_renderer, translated[0], translated[1], unified_size, pacman_game,
                  pacman_game.ghost_colors[i % 4])
    game_renderer.add_game_object(ghost)

# obecné funkce pro převod souřadnic, umístěte na začátek kódu
def translate_screen_to_maze(in_coords, in_size=32):
    return int(in_coords[0] / in_size), int(in_coords[1] / in_size)

def translate_maze_to_screen(in_coords, in_size=32):
    return in_coords[0] * in_size, in_coords[1] * in_size

V této fázi se již budou po spuštění hry vykreslovat čtyři duchové v bludišti. Dále je chceme rozpohybovat. 

Hledání cesty bludištěm

Nyní přichází možná nejsložitější část. Hledání cesty ve 2D prostoru, nebo grafu, je obtížný problém. Implementovat algoritmus pro vyřešení takového problému by dalo na další článek, proto použijeme hotové řešení. Nejefektivnějším algoritmem pro hledání cesty je A* algoritmus. Ten nám poskytne balíček tcod, který jsme instalovali na začátku.

Vytvořím teď třídu Pathfinder. V konstruktoru inicializuji numpy pole s cenou průchodu (pole jedniček a nul, popsané výše) a vytvořím třídní proměnnou pf, která bude držet instanci A* pathfinderu. Funkce get_path nám pak po zavolání se souřadnicemi v bludišti (odkud, kam) vypočítá a vrátí trasu ve formě jednotlivých kroků polem. 

   
   
class Pathfinder:
    def __init__(self, in_arr):
        cost = np.array(in_arr, dtype=np.bool_).tolist()
        self.pf = tcod.path.AStar(cost=cost, diagonal=0)

    def get_path(self, from_x, from_y, to_x, to_y) -> object:
        res = self.pf.get_path(from_x, from_y, to_x, to_y)
        return [(sub[1], sub[0]) for sub in res]

Do funkce main přidám úsek pro demonstraci vyhledání trasy. Volím souřadnice začátku trasy [1,1] a cíle trasy [24,24].

   
   
# Vykreslení cesty
red = (255, 0, 0)
green = (0, 255, 0)
_from = (1, 1)
_to = (24, 24)
path_array = pacman_game.p.get_path(_from[1], _from[0], _to[1], _to[0])
#
print(path_array)
# [(1, 2), (1, 3), (1, 4), (1, 5), (2, 5), (3, 5), (4, 5), (5, 5), (6, 5), (6, 6), (6, 7) ...
#
white = (255, 255, 255)
for path in path_array:
    game_renderer.add_game_object(Wall(game_renderer, path[0], path[1], unified_size, white))
#
from_translated = translate_maze_to_screen(_from)
game_renderer.add_game_object(
    GameObject(game_renderer, from_translated[0], from_translated[1], unified_size, red))
#
to_translated = translate_maze_to_screen(_to)
game_renderer.add_game_object(
    GameObject(game_renderer, to_translated[0], to_translated[1], unified_size, green))

Ve hře vypadá vykreslení nejkratší trasy takto:

pacman  

Náhodný pohyb duchů

Ve třídě PacmanGameController vytvářím novou funkci pro zvolení náhodného bodu z pole dosažitelných míst reachable_spaces. Každý duch tuto funkci použije po tom, co dorazí do cíle. Jednoduše si tak duchové donekonečna volí cestu ze své aktuální pozice v bludišti do náhodného cíle. Složitější chování, jako útěk a honění hráče, implementujeme v dalším díle. 

   
   
def request_new_random_path(self, in_ghost: Ghost):
    random_space = random.choice(self.reachable_spaces)
    current_maze_coord = translate_screen_to_maze(in_ghost.get_position())

    path = self.p.get_path(current_maze_coord[1], current_maze_coord[0], random_space[1],
                           random_space[0])
    test_path = [translate_maze_to_screen(item) for item in path]
    in_ghost.set_new_path(test_path)

Duchovi přidáme ve třídě Ghost novou logiku pro následování trasy. Funkce reached_target je volaná každý snímek a kontroluje, zda duch již dorazil do cíle. Pokud ano, zjistí jakým směrem je další krok cesty bludištěm a podle toho začne měnit svoji pozici buď nahoru, dolů, doleva nebo doprava (logika pro pohyb je volaná v rodičovské třídě MovableObject). 

   
   
def reached_target(self):
    if (self.x, self.y) == self.next_target:
        self.next_target = self.get_next_location()
    self.current_direction = self.calculate_direction_to_next_target()

def set_new_path(self, in_path):
    for item in in_path:
        self.location_queue.append(item)
    self.next_target = self.get_next_location()

def calculate_direction_to_next_target(self) -> Direction:
    if self.next_target is None:
        self.game_controller.request_new_random_path(self)
        return Direction.NONE
    diff_x = self.next_target[0] - self.x
    diff_y = self.next_target[1] - self.y
    if diff_x == 0:
        return Direction.DOWN if diff_y > 0 else Direction.UP
    if diff_y == 0:
        return Direction.LEFT if diff_x < 0 else Direction.RIGHT
    self.game_controller.request_new_random_path(self)
    return Direction.NONE

def automatic_move(self, in_direction: Direction):
    if in_direction == Direction.UP:
        self.set_position(self.x, self.y - 1)
    elif in_direction == Direction.DOWN:
        self.set_position(self.x, self.y + 1)
    elif in_direction == Direction.LEFT:
        self.set_position(self.x - 1, self.y)
    elif in_direction == Direction.RIGHT:
        self.set_position(self.x + 1, self.y)



Duchové se teď vytvoří na pozicích určených písmenem “G" v původním ASCII bludišti a začnou si hledat náhodnou cestu. Já jsem zavřel tři duchy do klece - jako v původním Pacmanovi budou vypouštěni postupně - a jeden bloudí bludištěm:

pacman

Přidání hráče a jeho ovládání

Pro hráče dělám třídu Hero. Většina logiky pro ovládání hráče i duchů je řešena ve funkci MovableObject, proto stačí pouze pár funkcí pro upřesnění chování. V originále se Pacman hýbe ve čtyřech směrech, šipkami ovládáme jeho chůzi bludištěm. Pokud nezmáčkneme žádnou směrovou klávesu, bude pokračovat posledním validním směrem. Pokud zmáčkneme klávesu ve směru, kterým ještě nelze jít, směr se uloží a použije se při příští dostupné zatáčce. Stejné chování replikuji do naší hry a přidal jsem i Pacmanovu schopnost teleportovat se z jednoho konce bludiště na druhý - prostě zkontroluji, jestli je mimo herní plochu zleva nebo zprava, a podle toho nastavím jeho pozici na opačnou stranu bludiště. Pacman má taky upravenou funkci pro vykreslování, musíme ho vykreslit s poloviční velikostí, kterou by normálně zabíral jako čtverec (pygame.rect).

   
   
class Hero(MovableObject):
    def __init__(self, in_surface, x, y, in_size: int):
        super().__init__(in_surface, x, y, in_size, (255, 255, 0), False)
        self.last_non_colliding_position = (0, 0)

    def tick(self):
        # TELEPORT
        if self.x < 0:
            self.x = self._renderer._width

        if self.x > self._renderer._width:
            self.x = 0

        self.last_non_colliding_position = self.get_position()

        if self.check_collision_in_direction(self.direction_buffer)[0]:
            self.automatic_move(self.current_direction)
        else:
            self.automatic_move(self.direction_buffer)
            self.current_direction = self.direction_buffer

        if self.collides_with_wall((self.x, self.y)):
            self.set_position(self.last_non_colliding_position[0], self.last_non_colliding_position[1])

        self.handle_cookie_pickup()

    def automatic_move(self, in_direction: Direction):
        collision_result = self.check_collision_in_direction(in_direction)

        desired_position_collides = collision_result[0]
        if not desired_position_collides:
            self.last_working_direction = self.current_direction
            desired_position = collision_result[1]
            self.set_position(desired_position[0], desired_position[1])
        else:
            self.current_direction = self.last_working_direction

    def handle_cookie_pickup(self):
        collision_rect = pygame.Rect(self.x, self.y, self._size, self._size)
        cookies = self._renderer.get_cookies()
        game_objects = self._renderer.get_game_objects()
        for cookie in cookies:
            collides = collision_rect.colliderect(cookie.get_shape())
            if collides and cookie in game_objects:
                game_objects.remove(cookie)

    def draw(self):
        half_size = self._size / 2
        pygame.draw.circle(self._surface, self._color, (self.x + half_size, self.y + half_size), half_size)

Třídu Hero instancuji na konci funkce main. Pozice nastavuji na souřadnici [1,1] - unified_size je velikost jedné dlaždice. Do GameRenderer třídy ještě musíme přidat zpracování vstupních události, abychom mohli herní postavu ovládat.

   
   
# ve třídě GameRenderer
def add_hero(self, in_hero):
        self.add_game_object(in_hero)
        self._hero = in_hero

def _handle_events(self):
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            self._done = True

    pressed = pygame.key.get_pressed()
    if pressed[pygame.K_UP]:
        self._hero.set_direction(Direction.UP)
    elif pressed[pygame.K_LEFT]:
        self._hero.set_direction(Direction.LEFT)
    elif pressed[pygame.K_DOWN]:
        self._hero.set_direction(Direction.DOWN)
    elif pressed[pygame.K_RIGHT]:
        self._hero.set_direction(Direction.RIGHT)


# na konci funkce main
pacman = Hero(game_renderer, unified_size, unified_size, unified_size)
game_renderer.add_hero(pacman)
game_renderer.tick(120)

Po spuštění můžeme Pacmana vodit bludištěm!

 pacman

Přidání cookies

Nebyl by to Pacman bez cookies v bludišti. Z herního hlediska určují míru prozkoumanosti světa a některé cookies i obrací schopnosti duchů a Pacmana. Jsou tedy ultimátní odměnou pro hráče a hlavním ukazatelem jeho postupu úrovní. V dnešních hrách se běžně odměňuje chování, které chce herní designér podporovat ve hráči. Krásným příkladem je např. letošní Elden Ring, kde dostane odměnu každý, kdo prozkoumává všechny kouty světa. Čím nebezpečnější a odlehlejší, tím větší odměna. Naopak hry jako novodobý Assassin's Creed podporují plnění úkolů, takže máte při hraní pocit, že jste v práci, a ne ve hře.

Přidání cookies bude nejsnadnější věcí celého návodu, a proto jsem ji nechal na konec, jako třešničku na dortu. Vytvořím třídu Cookie. Její instance bude mít vždy velikost čtyři pixely, žlutou barvu a kruhový tvar. Ve funkci main vytvořím cookies na všech dlaždicích, které jsme na začátku uložili do pole cookie_spaces (totožné s reachable_spaces). Hráčovi přidám funkci handle_cookie_pickup, ve které si neustále ověřuji, jestli nedochází ke kolizi hráče s nějakou cookie. Pokud tomu tak je, cookie odstraním z pole a ta se přestane vykreslovat.

   
   
class Cookie(GameObject):
    def __init__(self, in_surface, x, y):
        super().__init__(in_surface, x, y, 4, (255, 255, 0), True)

# ve třídě GameRenderer přidat:
def add_cookie(self, obj: GameObject):
        self._game_objects.append(obj)
        self._cookies.append(obj)

# ve třídě Hero přidat:
def handle_cookie_pickup(self):
    collision_rect = pygame.Rect(self.x, self.y, self._size, self._size)
    cookies = self._renderer.get_cookies()
    game_objects = self._renderer.get_game_objects()
    for cookie in cookies:
        collides = collision_rect.colliderect(cookie.get_shape())
        if collides and cookie in game_objects:
            game_objects.remove(cookie)

# ve funkci main:
for cookie_space in pacman_game.cookie_spaces:
    translated = translate_maze_to_screen(cookie_space)
    cookie = Cookie(game_renderer, translated[0] + unified_size / 2, translated[1] + unified_size / 2)
    game_renderer.add_cookie(cookie)

Výsledek našeho snažení:

pacman  

Malá zajímavost za závěr - v originální hře se Pacman zastaví na dobu jednoho snímku po každém požití cookie, takže ho duchové snáze dohoní v počátku hry, když je ještě pole zaplněné. V příštím díle zpracujeme podobnou herní mechaniku a můžete se těšit i na umělou inteligenci duchů, počítání score, zvuky, animace, textury, power-upy, screen-shake efekty, životy a koncové stavy hry.


Jan Jileček