Статьи

Аудит кода: 14 классических игр на Python с GitHub — от Pacman до 2048

2025-07-27 20:25 Синтаксис Python

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

Поэтому я решил сделать аудит этого репозитория. Мы не будем критиковать автора — создание даже таких, на первый взгляд, простых игр требует времени и усилий. Наша цель — использовать этот код как учебный материал. Мы разберем как удачные решения, так и слабые места, а главное — посмотрим, как можно было бы сделать лучше. Это будет полезно и тем, кто только начинает свой путь в разработке, и тем, кто уже имеет опыт, но хочет наточить свой навык «чтения» и анализа чужого кода.

Общий взгляд на репозиторий

Первое, что бросается в глаза — это разнообразие. Здесь собраны хиты разных эпох и жанров, от текстового «Виселица» до графических Pacman и Tetris. Для реализации использовался целый зоопарк технологий, что само по себе интересно.

Вот краткая сводка по играм и используемым библиотекам:

Игра Библиотека/Фреймворк Краткое описание
2048 pygame Классическая головоломка со слиянием плиток.
Checkers (Шашки) pygame Реализация шашек с ООП подходом.
Dino-Game pygame Клон знаменитой игры из Google Chrome.
Flappy Bird pygame Тот самый "убийца нервных клеток".
Hangman (Виселица) built-in (консоль) Классическая игра в угадывание слов.
Memory game pygame Игра на развитие памяти с поиском парных карточек.
Minesweeper (Сапер) tkinter Стандартный "Сапер" на нативном GUI-фреймворке.
Pacman pygame Довольно проработанная версия Pacman.
Racing game pygame Простая 2D-гонка с уклонением от препятствий.
Rock Paper Scissors tkinter "Камень, ножницы, бумага" с графическим интерфейсом.
Snake (Змейка) turtle Классическая "Змейка" на модуле turtle.
Tetris pygame, kezmenu Тетрис с использованием сторонней библиотеки для меню.
Whack-a-Mole pygame Игра "Ударь крота".
Wordle colorama (консоль) Консольная версия популярной игры в слова.

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

Что хорошо? (Сильные стороны проекта)

Прежде чем переходить к разбору полетов, важно отметить сильные стороны.

1. Широта охвата и наглядность

Репозиторий — отличная демонстрация возможностей Python для создания игр. Новичок может посмотреть и сравнить, как одна и та же задача решается с помощью разных инструментов: pygame для игр с активной анимацией, tkinter для более статичного GUI и даже turtle для простейших вещей.

2. Простота и прямолинейность кода

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

3. Функциональность

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

А теперь перейдем к самой интересной части — анализу того, что можно было бы улучшить.

Зоны роста: Что можно улучшить?

Здесь мы разберем основные "болевые точки", которые повторяются в нескольких проектах. Это те самые грабли, на которые наступают многие начинающие разработчики.

1. Отсутствие единой структуры и управления зависимостями

Это первая и самая очевидная проблема. Чтобы запустить любую из игр, нужно сначала угадать, какие библиотеки ей нужны. В репозитории нет файла requirements.txt.

[!DANGER] Проблема Пользователь, скачавший репозиторий, не может просто взять и запустить проект. Ему придется вручную ставить pygame, colorama и, возможно, что-то еще, сталкиваясь с ошибками ModuleNotFoundError.

Как сделать лучше?

  1. Создать requirements.txt. Файл легко создается командой pip freeze > requirements.txt в активированном виртуальном окружении, где установлены все зависимости.
  2. Структурировать проект. Каждая игра находится в своей папке, и это хорошо. Но можно пойти дальше: создать общий main.py в корне проекта, который бы позволял выбирать и запускать любую игру из списка.

2. Глобальные переменные и «магические числа»

Это классика. Во многих файлах (например, Flappy Bird/game.py или Dino-Game/dino.py) ключевые параметры игры заданы как глобальные переменные или просто числа, разбросанные по коду.

Взглянем на фрагмент из Flappy Bird:

# Flappy Bird/game.py

# Все игровые переменные
window_width = 600
window_height = 499
# ...
elevation = window_height * 0.8
game_images = {} 
framepersecond = 32
pipeimage = 'images/pipe.png'
# ...

def flappygame(): 
    # ...
    pipeVelX = -4
    # bird velocity 
    bird_velocity_y = -9
    bird_Max_Vel_Y = 10
    bird_Min_Vel_Y = -8
    birdAccY = 1
    bird_flap_velocity = -8
    bird_flapped = False
    # ...

[!WARNING] Проблема

  • Магические числа: Что значат 4, 9, 10? Без глубокого погружения в код понять это невозможно. Через месяц даже сам автор забудет их смысл.
  • Сложность настройки: Если мы захотим изменить скорость игры или гравитацию, нам придется искать эти значения по всему файлу, рискуя что-то пропустить.
  • Глобальное состояние: Глобальные переменные (window_width, game_images) создают неявные зависимости. Функции становятся непредсказуемыми, их сложно тестировать и переиспользовать.

Как сделать лучше?

  1. Вынести константы: Все "магические числа" должны быть вынесены в константы с говорящими именами в начало файла.
  2. Использовать классы или dataclass для конфигурации: Создать класс GameConfig, который будет хранить все настройки игры (размеры окна, скорости, ускорения). Этот объект конфигурации можно передавать в функции или методы, делая их зависимости явными.
  3. Инкапсулировать логику в класс: Вместо набора глобальных переменных и функций создать класс FlappyBirdGame. Все переменные состояния (score, bird_velocity_y) станут его атрибутами (self.score, self.bird_velocity_y), а функции — методами.

Пример рефакторинга:

# Предлагаемый рефакторинг

class GameConfig:
    WINDOW_WIDTH = 600
    WINDOW_HEIGHT = 499
    FPS = 32
    GRAVITY = 1
    BIRD_FLAP_VELOCITY = -8
    PIPE_VELOCITY_X = -4
    # ... и так далее

class FlappyBirdGame:
    def __init__(self, config: GameConfig):
        self.config = config
        self.score = 0
        self.bird_velocity_y = 0
        self.is_flapped = False
        # ... инициализация других состояний

    def _handle_input(self, event):
        if event.type == KEYDOWN and (event.key == K_SPACE or event.key == K_UP):
            if self.vertical > 0:
                self.bird_velocity_y = self.config.BIRD_FLAP_VELOCITY
                self.is_flapped = True

    def _update_state(self):
        # ... логика обновления состояния, используя self.config и self. ...
        
    def run(self):
        # Основной игровой цикл
        while True:
            # ...

Такой подход делает код чище, понятнее и гораздо проще для модификации.

3. Смешение логики, состояния и отображения

Еще одна распространенная проблема, особенно в проектах на pygame. Код, отвечающий за игровую логику (как движется объект), состояние (где он находится) и отображение (как его нарисовать), часто перемешан в одном большом цикле.

Это видно в игре Pacman. Несмотря на то, что код разбит на файлы (player.py, enemies.py, game.py), сама архитектура остается процедурной. Класс Game в game.py — это огромный монолит, который управляет всем: событиями, логикой, отрисовкой.

[!WARNING] Проблема

  • Высокая связанность (High Coupling): Изменение логики отрисовки может сломать игровую логику, и наоборот.
  • Низкая переиспользуемость: Нельзя просто так взять класс Player и использовать его в другой игре, потому что он тесно связан с Game, horizontal_blocks и другими элементами.
  • Сложность тестирования: Как протестировать логику движения игрока, не запуская весь графический движок? В текущей реализации — почти никак.

Как сделать лучше?

Следовать принципу разделения ответственности (Separation of Concerns).

  1. Модель (Model): Классы, которые описывают состояние и логику игры, не зная ничего об отрисовке. PlayerModel должен знать свою позицию, скорость, но не то, как его рисовать. GameModel должен содержать матрицу уровня, положение всех врагов, счет.
  2. Представление (View): Классы, отвечающие только за отрисовку. PlayerView получает PlayerModel и рисует спрайт в нужной координате. GameView рисует поле, врагов, счет, основываясь на данных из GameModel.
  3. Контроллер (Controller): Модуль, который связывает всё вместе. Он обрабатывает ввод от пользователя (клавиатура, мышь), вызывает методы у Модели для изменения ее состояния, а затем говорит Представлению перерисовать экран на основе обновленной Модели.

Этот подход (вариация на тему MVC/MVP) позволяет разрабатывать, тестировать и модифицировать каждую часть системы независимо.

4. Прямые манипуляции с координатами вместо физической модели

В гоночной игре (Racing game/main.py) или в "Змейке" (Snake/game.py) движение реализовано прямым изменением координат в игровом цикле.

# Racing game/main.py
class Car:
    # ...
    def move_left(self):
        if self.x > (SCREEN_WIDTH - ROAD_WIDTH) // 2:
            self.x -= self.speed
# ...
# В игровом цикле:
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
    car.move_left()

[!NOTE] Проблема

Это работает для простых игр, но не масштабируется. Если мы захотим добавить ускорение, инерцию, трение или столкновения между несколькими объектами, такой код превратится в "лапшу" из if-ов.

Как сделать лучше?

Даже для простой 2D-игры можно ввести простейшую физическую модель.

  1. Векторы: Использовать векторы (pygame.math.Vector2) для представления позиции, скорости и ускорения.
  2. Обновление на основе времени: Вместо self.x -= self.speed использовать self.position += self.velocity * dt, где dt — это время, прошедшее с последнего кадра. Это делает движение независимым от FPS (частоты кадров).

Пример рефакторинга:

# Предлагаемый рефакторинг для Car
import pygame

class Car:
    def __init__(self):
        # ...
        self.position = pygame.math.Vector2((SCREEN_WIDTH - CAR_WIDTH) // 2, SCREEN_HEIGHT - CAR_HEIGHT - 10)
        self.velocity = pygame.math.Vector2(0, 0)
        self.acceleration = pygame.math.Vector2(0, 0)
        self.max_speed = 5
        self.engine_force = 1.0
        self.friction = -0.1 # Сила трения

    def update(self, dt):
        # Физика: F = ma. Здесь a = F/m. Примем массу за 1.
        # Сила трения пропорциональна скорости
        friction_force = self.velocity * self.friction
        self.acceleration += friction_force
        
        # Обновляем скорость и позицию
        self.velocity += self.acceleration * dt
        # Ограничиваем максимальную скорость
        if self.velocity.length() > self.max_speed:
            self.velocity.scale_to_length(self.max_speed)
            
        self.position += self.velocity * dt
        
        # Сбрасываем ускорение от двигателя каждый кадр
        self.acceleration = pygame.math.Vector2(0, 0)

    def apply_engine_force(self, direction_vector):
        self.acceleration += direction_vector * self.engine_force

# В игровом цикле:
dt = clock.tick(FPS) / 1000.0 # Время в секундах

keys = pygame.key.get_pressed()
direction = pygame.math.Vector2(0, 0)
if keys[pygame.K_LEFT]:
    direction.x = -1
if keys[pygame.K_RIGHT]:
    direction.x = 1

car.apply_engine_force(direction)
car.update(dt)

Да, этот код сложнее, но он закладывает фундамент для гораздо более реалистичного и расширяемого поведения объектов.

5. Непоследовательное применение ООП и архитектурные просчеты

Во многих играх, например, в "Шашках" (Checkers), автор сделал похвальную попытку применить объектно-ориентированный подход. Есть классы Board, Tile, Piece, и даже наследование Pawn и King от Piece. Это абсолютно правильное направление мысли, которое отделяет этот код от простого процедурного скрипта.

Однако дьявол, как всегда, в деталях. Давайте заглянем в файл Checkers/Piece.py:

# Checkers/Piece.py
import pygame

class Piece:
    # ...
    def _move(self, tile):
        # ... много логики ...
        
        # Pawn promotion
        if self.notation == 'p':
            if self.y == 0 or self.y == 7:
                from King import King  # <---- ВОТ ОНО
                tile.occupying_piece = King(
                    self.x, self.y, self.color, self.board
                )
        return True
    # ...

Импорт модуля King внутри метода _move — это классический "код с запашком" (code smell).

[!WARNING] Проблема: Локальный импорт и циклические зависимости

  1. Причина: Почему автор так сделал? Почти наверняка, чтобы избежать циклической зависимости. Смотрите: Piece.py должен знать о King, чтобы превратить в него пешку. Но King.py импортирует Piece, так как наследуется от него (class King(Piece):). Если бы from King import King стоял вверху файла Piece.py, Python выдал бы ошибку ImportError.

  2. Скрытые зависимости: Такой импорт скрывает реальные зависимости модуля. Глядя на начало файла, невозможно понять, что ему на самом деле нужен King.

  3. Сигнал о проблеме в архитектуре: Сам факт возникновения такой ситуации — это флаг, который кричит: "Что-то не так с вашей архитектурой!". Классы слишком тесно связаны и знают друг о друге больше, чем должны.

Как сделать лучше?

Есть несколько путей решения, от простого к более архитектурно верному.

  1. Forward References: Python позволяет элегантно решать проблему циклических зависимостей для тайп-хинтинга. Хотя здесь хинтинга нет, принцип похож. Мы можем делегировать создание "Короля" классу Board, который знает обо всех типах фигур.

  2. Делегирование ответственности (Архитектурное решение): Это самый правильный путь. Класс Piece не должен отвечать за свою "прокачку". Его дело — двигаться. А вот доска (Board) — идеальное место для этой логики.

Пример рефакторинга:

# В файле Board.py
# Импортируем все типы фигур в одном месте
from Pawn import Pawn
from King import King

class Board:
    # ...
    def promote_pawn_if_needed(self, piece, tile):
        """Проверяет, нужно ли превращать пешку в короля, и делает это."""
        if isinstance(piece, Pawn) and (tile.y == 0 or tile.y == 7):
            # Заменяем объект пешки на объект короля
            tile.occupying_piece = King(tile.x, tile.y, piece.color, self)

    def handle_click(self, pos):
        # ...
        # Вместо того, чтобы piece._move() сам себя превращал,
        # он просто возвращает True в случае успеха
        if self.selected_piece._move(clicked_tile):
            # А уже доска решает, нужно ли превращение
            self.promote_pawn_if_needed(clicked_tile.occupying_piece, clicked_tile)
            # ... остальная логика

В этом случае Piece.py больше вообще не нужно знать о существовании King.py. Мы разрываем порочную связь, и каждый класс занимается своим делом. Piece двигается, Board управляет правилами и состоянием игры.

6. Управление ресурсами (ассетами) и «хардкод» путей

В большинстве графических игр ресурсы (картинки, звуки) загружаются прямо по месту их использования с жестко заданными путями.

Пример из Dino-Game/dino.py:

# ...
jump_sound = pygame.mixer.Sound('resources/jump.wav')
die_sound = pygame.mixer.Sound('resources.bak/die.wav')
checkPoint_sound = pygame.mixer.Sound('resources/checkPoint.wav')

def load_image(name, sx=-1, sy=-1, colorkey=None):
    fullname = os.path.join('resources', name)
    img = pygame.image.load(fullname)
    # ...

[!DANGER] Проблема

  1. Хрупкость: Такой код будет работать, только если вы запускаете скрипт из корня папки Dino-Game. Попытка запустить его из родительской директории (python Dino-Game/dino.py) приведет к ошибке FileNotFoundError, потому что он будет искать папку resources не там, где нужно.
  2. Отсутствие централизации: Пути к ресурсам разбросаны по всему коду. Если мы решим переименовать папку resources в assets, нам придется править код во многих местах.
  3. Повторная загрузка: Нет никакой гарантии, что один и тот же ресурс не будет загружен в память несколько раз в разных частях программы.

Как сделать лучше?

  1. Использовать абсолютные пути на основе расположения файла: Python позволяет легко получить путь к текущему исполняемому файлу. Это делает загрузку ресурсов независимой от того, откуда был запущен скрипт.

    import os
    
    # Получаем абсолютный путь к директории, где лежит наш скрипт
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    
    # Строим надежный путь к ресурсу
    def get_asset_path(filename):
        return os.path.join(BASE_DIR, 'resources', filename)
    
    # Используем
    jump_sound = pygame.mixer.Sound(get_asset_path('jump.wav'))
    
  2. Создать менеджер ресурсов (Asset Manager): Это еще один шаг к хорошей архитектуре. Менеджер ресурсов — это класс, который отвечает за загрузку и хранение всех ассетов. Он гарантирует, что каждый ресурс загружается только один раз (ленивая загрузка по требованию) и предоставляет удобный интерфейс для доступа к нему.

Пример простого менеджера ресурсов:

import pygame
import os

class AssetManager:
    def __init__(self, base_path=None):
        self._images = {}
        self._sounds = {}
        if base_path is None:
            # Автоматически определяем путь к папке 'assets' рядом со скриптом
            self.base_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets')
        else:
            self.base_path = base_path

    def get_image(self, filename, scale=None, colorkey=None):
        # Используем filename как ключ, чтобы не загружать повторно
        if filename not in self._images:
            path = os.path.join(self.base_path, 'images', filename)
            try:
                image = pygame.image.load(path).convert()
                self._images[filename] = image
            except pygame.error as e:
                print(f"Cannot load image: {path}")
                raise SystemExit(e)
        
        # Возвращаем копию, чтобы избежать случайных изменений оригинала
        img = self._images[filename].copy()
        
        if scale:
            img = pygame.transform.scale(img, scale)
        if colorkey:
            img.set_colorkey(colorkey)
            
        return img

    def get_sound(self, filename):
        if filename not in self._sounds:
            path = os.path.join(self.base_path, 'sounds', filename)
            try:
                self._sounds[filename] = pygame.mixer.Sound(path)
            except pygame.error as e:
                print(f"Cannot load sound: {path}")
                raise SystemExit(e)
        return self._sounds[filename]

# Использование в игре:
# assets = AssetManager()
# player_img = assets.get_image('player.png', scale=(50, 50))
# jump_sfx = assets.get_sound('jump.wav')

Такой подход не только решает проблему с путями, но и централизует управление ресурсами, делая код чище и профессиональнее.

Заключение: от работающего кода к хорошему коду

Разбор этого репозитория — прекрасная иллюстрация пути, который проходит каждый разработчик. Сначала мы учимся писать код, который работает. Это само по себе большое достижение. Игры в этом сборнике — прекрасный пример такого кода.

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

Ключевые выводы из нашего аудита:

  1. Конфигурация — отдельно, код — отдельно. Выносите "магические числа" в константы или классы конфигурации.
  2. Разделяйте ответственности. Старайтесь изолировать игровую логику (модель) от отрисовки (представление).
  3. Думайте об архитектуре. Даже в маленьком проекте правильная структура (например, разрыв циклических зависимостей) сэкономит вам массу времени в будущем.
  4. Централизуйте управление ресурсами. Не "хардкодьте" пути. Создайте надежную систему для загрузки ассетов.

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