05 июня 2023

😛👻 Пишем игру Pac-Man на Python в 300 строк кода

iOS-developer, ИТ-переводчица, пишу статьи и гайды.
В данной статье мы напишем легендарную игру Pac-Man на Python, уместив ее в 300 строчек кода.
😛👻 Пишем игру Pac-Man на Python в 300 строк кода
Данная статья является переводом. Автор: Ing. Jan Jileček. Ссылка на оригинал.

Pac-Man — классический платформер, который, наверное, сегодня известен каждому. Название «Pac-Man» происходит от японского слова «paku», что означает открывание и закрывание рта. Создателя Тору Иватани вдохновила японская история о существе, которое защищает детей от монстров, поедая их. При создании игры он опирался на ключевые слова из легенды, а глагол «съесть» стал основой всего.

😛👻 Пишем игру Pac-Man на Python в 300 строк кода

(Данное изображение и все последующие взяты отсюда.)

Монстры представлены в виде четырех призраков, которые атакуют игрока последовательными «волнами», подобно космическим захватчикам. Каждый призрак также имеет уникальную личность. В истории есть еще один важный элемент, концепция жизненной силы «кокоро», которая позволяла существам поедать монстров. В игре эта энергия представлена ​​​​в виде печенья с усилением, которое дает Pac-Man кратковременную способность поедать монстров.

В этом уроке я сначала проведу вас через базовую настройку, затем мы создадим игровые объекты для стены лабиринта, Pac-Man и призраков, обеспечим поиск пути в лабиринте, зададим призракам случайное движение, реализуем элементы управления стрелками для игрока и наконец, разместим еду в виде печенья по всему лабиринту.

>> basic_settings

Получившаяся игра содержит примерно 300 строк кода, поэтому я перечисляю здесь только самые важные части. Полный код доступен в моем репозитории GitHub. Первым делом установим необходимые пакеты. Нам понадобятся pygame, numpy и tcod. Установите их все с помощью инструмента pip. Если вы используете IDE, такую ​​как PyCharm (я рекомендую ее), установка произойдет после нажатия на сообщение об ошибке отсутствующего пакета.

Во-первых, мы создадим игровое окно, аналогично предыдущему руководству по игре Space Invaders (в котором было всего 100 строк). Здесь я подготовлю параметры для указания размера окна, названия игры, частоты обновления и несколько полей данных, которые будут содержать ссылки на игровые объекты и игрока. Функция tick итеративно проходит по всем игровым объектам и вызывает их внутреннюю логику и рендеринг. Затем остается только перерисовать всю игровую область и обработать события ввода, такие как щелчки мышью и ввод с клавиатуры. Для этой цели будет служить функция _handle_events.

        import pygame # import packages (install with "pip install pygame" in cmd)
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 # we'll implement this later
    
🐍 Библиотека питониста
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»
🐍🎓 Библиотека собеса по Python
Подтянуть свои знания по Python вы можете на нашем телеграм-канале «Библиотека собеса по Python»
🐍🧩 Библиотека задач по Python
Интересные задачи по Python для практики можно найти на нашем телеграм-канале «Библиотека задач по Python»

>> parent_game_object

Затем я создаю родительский игровой объект с именем GameObject, от которого другие классы будут наследовать функциональность. В игре у нас будут объекты для стены (Wall), Pac-Man (Hero), привидения (Ghost) и печенья (Cookie). Для упомянутых выше подвижных игровых объектов позже я создам класс MovableObject, который будет расширением класса GameObject с функциями перемещения.

Во время инициализации объекта я задаю его цвет, форму и положение. Каждый объект также имеет ссылку на поверхность рендеринга _surface, так что он может сам заботиться о своем рендеринге на основной поверхности. Для этой цели у нас есть функция draw, которая вызывается ранее созданным GameRenderer для каждого игрового объекта. В зависимости от параметра is_circle объект отображается либо в виде круга, либо в виде прямоугольника (в нашем случае я использую квадрат со слегка закругленными углами для стен и круг для Pac-Man и печенья).

        class GameObject:
    def __init__(self, in_surface, x, y,
                 in_size: int, in_color=(255, 0, 0),
                 is_circle: bool = False):
        self._size = in_size
        self._renderer: GameRenderer = in_surface
        self._surface = in_surface._screen
        self.y = y
        self.x = x
        self._color = in_color
        self._circle = is_circle
        self._shape = pygame.Rect(self.x, self.y, in_size, in_size)

    def draw(self):
        if self._circle:
            pygame.draw.circle(self._surface,
                               self._color,
                               (self.x, self.y),
                               self._size)
        else:
            rect_object = pygame.Rect(self.x, self.y, self._size, self._size)
            pygame.draw.rect(self._surface,
                             self._color,
                             rect_object,
                             border_radius=4)

    def tick(self):
        pass
    

Создать класс стены будет просто. Для стен выбираю синий цвет согласно оригинальному Pac-Man (параметр цвета — Blue 255, остальное 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)
    

Подготовлен код для рендеринга и объект для стен. При написании следите за тем, чтобы классы Wall и GameObject были выше класса GameRenderer, чтобы класс их «видел». Следующим шагом является визуализация лабиринта на экране. Но перед этим мы должны создать один вспомогательный класс.

>> the_game_controller_class

Я сохраню лабиринт в символах ASCII в переменной в новом классе PacmanGameController. Я буду использовать исходный размер лабиринта — 28x31 тайл. Позже мне нужно будет убедиться, что призраки смогут правильно пройти через лабиринт и, возможно, найти игрока. Сначала я прочитаю лабиринт как символы и преобразую его в матрицу единиц и нулей, где стена равна нулю, а проходимое пространство равно единице. Эти значения служат алгоритму поиска пути в качестве так называемой функции стоимости. Ноль означает бесконечную стоимость прохождения, поэтому элементы в массиве, отмеченные таким образом, не будут считаться проходимыми. Обратите внимание на массив reachable_spaces, который содержит проходимые части лабиринта. Но об этом позже, сначала я должен подготовить структуры классов. Вы можете скопировать лабиринт в формате ASCII с моего GitHub. В обозначении персонажа я использовал X для стены, P для Pac-Man и G для призрака.

        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",
            # shortened for article, full ascii on my github
            "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) # use later


    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)
    

>> rendering_the_maze

Все необходимое для рендеринга лабиринта подготовлено, поэтому осталось только создать экземпляры наших классов PacmanGameController, пройтись по 2D-массиву с позициями стен и создать в этих местах объект Wall (я использую функцию add_wall, которая не показана здесь, еще раз взгляните на полный код на моем GitHub). Я установил частоту обновления 120 кадров в секунду.

        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)
    
😛👻 Пишем игру Pac-Man на Python в 300 строк кода

>> let’s_add_ghosts!

В оригинальном Pac-Man было четыре призрака по имени Блинки, Пинки, Инки и Клайд, каждый со своим характером и способностями. Концепция игры основана на японской сказке (подробнее здесь и здесь), а оригинальные названия на японском языке также предполагают их способности (например, у Пинки японское имя Вор, у Блинки — Тень). Однако для нашей игры мы не будем вдаваться в такие подробности, и каждый призрак будет использовать только базовый поведенческий цикл, как и в оригинале, то есть режимы «преследование», «рассеивание» и «испуг». Мы опишем и обработаем эти режимы ИИ во второй части.

Класс-призрак будет простым, унаследовав большую часть своего поведения от родительского класса MovableObject (посмотрите мой GitHub, этот класс немного сложнее и включает логику для движения в четырех направлениях, следования по маршруту и ​​проверки на столкновения со стенами).

        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
    

Я добавлю значения RGB для цветов каждого призрака в класс PacmanGameController и сгенерирую четыре цветных призрака в основной функции. Я также подготовлю статическую функцию для преобразования координат, которая просто преобразует координаты лабиринта (например, x=16 y=16 приблизительно соответствует центру лабиринта, и умножение на размер ячейки или тайла дает мне координату на поверхность игры в пикселях).

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

# in 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)

# General functions for coordinate conversion, place at the beginning of the code
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
    

На этом этапе четыре призрака будут отображаться в лабиринте при запуске игры. Теперь мы хотим заставить их двигаться.

>> maze_pathfinding

Теперь наступает, пожалуй, самая сложная часть. Поиск пути в двумерном пространстве или графе — сложная задача. Реализация алгоритма решения такой задачи заняла бы отдельную статью, поэтому воспользуемся готовым решением. Наиболее эффективным алгоритмом поиска пути является алгоритм A*. Это обеспечивается пакетом tcod, который мы установили вначале.

Чтобы перемещать призраков, я создам класс с именем Pathfinder. В конструкторе я инициализирую массив numpy со стоимостью прохождения (массив единиц и нулей, описанный ранее) и создам переменную класса pf, которая будет содержать экземпляр навигатора A*. Затем функция get_path рассчитает и вернет путь в виде серии шагов в массиве при вызове с координатами в лабиринте (откуда, куда).

        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]
    

Теперь я добавлю раздел к основной функции, чтобы продемонстрировать поиск пути. Я выбираю начальные координаты [1,1] и пункт назначения маршрута [24,24]. Это необязательный код.

        # draw path - optional
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))
    

В игре рендеринг кратчайшего маршрута выглядит так:

😛👻 Пишем игру Pac-Man на Python в 300 строк кода

>> randomized_ghost_movement

Теперь мы собираемся создать новую функцию в классе PacmanGameController для выбора случайной точки из массива reachable_spaces. Каждый призрак будет использовать эту функцию после того, как достигнет места назначения. Таким образом, призраки будут просто выбирать путь от своего текущего положения в лабиринте до случайного места назначения на неопределенный срок. В следующей части мы реализуем более сложное поведение, такое как погоня и бегство от игрока.

        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)
    

В классе Ghost мы добавляем новую логику для следования по пути. Функция reached_target вызывает каждый кадр и проверяет, достиг ли уже призрак своей цели. Если да, то он определяет, в каком направлении находится следующий шаг пути через лабиринт, и начинает менять свое положение вверх, вниз, влево или вправо (логика движения вызывается в родительском классе 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)
    

Призраки теперь создаются в позициях, обозначенных буквой G в исходном лабиринте ASCII, и начинают искать случайный путь. Я запер в клетке трех призраков — как и в оригинальном Pacman, они будут выпускаться по одному — и один бродит по лабиринту:

😛👻 Пишем игру Pac-Man на Python в 300 строк кода

>> player_controls

Чтобы добавить функциональность плеера, я создам класс под названием Hero. Большая часть логики управления как игроком, так и призраками обрабатывается в классе MovableObject, поэтому нам нужно всего несколько функций для определения поведения игрока. В оригинальном Pacman игрок может двигаться в четырех направлениях, управляемых клавишами со стрелками. Если ни одна клавиша со стрелкой не нажата, игрок продолжит движение в последнем допустимом направлении. Если клавиша нажата в направлении, в котором игрок не может двигаться, это направление сохраняется и используется в следующем доступном ходе. Я воспроизведу это поведение в нашей игре, а также добавлю способность Пакмана телепортироваться из одного конца лабиринта в другой — я просто проверю, находится ли игрок вне игровой зоны с левой или правой стороны, и установлю его положение на противоположную сторону лабиринта соответственно. В Pacman также есть модифицированная функция рендеринга, нам нужно отображать его вдвое меньше, чем он обычно занимает (используя 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)
    

Я создаю экземпляр класса Hero в конце основной функции. Задаю позицию с координатами [1,1]unified_size — размер одного тайла. Также нам нужно добавить обработку входных событий в класс GameRenderer, чтобы мы могли управлять игровым персонажем.

        # in GameRenderer class
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)


# at the end of main function
pacman = Hero(game_renderer, unified_size, unified_size, unified_size)
game_renderer.add_hero(pacman)
game_renderer.tick(120)
    

После запуска игры теперь мы можем управлять игроком — Pacman!

😛👻 Пишем игру Pac-Man на Python в 300 строк кода

>> adding_cookies

Это был бы не Пакман без печенья в лабиринте. С точки зрения игрового процесса они определяют степень исследования мира, а некоторые файлы cookie даже меняют способности призраков и Пакмана. Таким образом, они являются высшей наградой для игроков и основным показателем их прогресса в уровнях. В современных играх поведение, которое геймдизайнер хочет поощрять в игроке, обычно вознаграждается. Прекрасным примером является Elden Ring, где каждый, кто исследует каждый уголок мира, получает вознаграждение. Чем опаснее и отдаленнее, тем больше награда. С другой стороны, такие игры, как Assassin's Creed, поддерживают выполнение задач, поэтому во время игры у вас возникает ощущение, что вы работаете, а не играете.

Добавление файлов cookie будет самым простым во всем уроке, поэтому я оставил его на конец, как вишенку на торте. Я создам класс под названием Cookie. Его экземпляр всегда будет иметь размер четыре пикселя, желтый цвет и круглую форму. В основной функции я создам куки на всех тайлах, которые мы вначале сохранили в массиве cookie_spaces (тот же, что и reachable_spaces). Я добавлю в плеер функцию с именем handle_cookie_pickup, в которой я постоянно проверяю, не сталкивается ли плеер с каким-либо cookie. Если это так, я удалю файл cookie из массива, и он больше не будет отображаться.

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

# in GameRenderer class 
def add_cookie(self, obj: GameObject):
        self._game_objects.append(obj)
        self._cookies.append(obj)

# in Hero class
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)

# in main function:
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)
    

А теперь о результате наших стараний:

😛👻 Пишем игру Pac-Man на Python в 300 строк кода

Небольшой интересный факт напоследок — в оригинальной игре Пакман останавливается на один кадр после того, как съест каждое печенье, поэтому призракам легче его поймать в начале игры, когда поле еще заполнено. В следующей части мы реализуем аналогичную игровую механику, и вы также можете рассчитывать на искусственный интеллект для призраков, отслеживание очков, звуки, анимацию, текстуры, бонусы, эффекты дрожания экрана, жизни и конечные состояния игры.

Источники

МЕРОПРИЯТИЯ

Почему вы решили разрабатывать игры на Python и какой ваш самый любимый игровой движок?

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ