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)
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:
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:
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!
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í:
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