Недавно на интересный 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 (консоль) |
Консольная версия популярной игры в слова. |
Общее впечатление — это сборник проектов, написанных, скорее всего, в разное время и, возможно, разными людьми или одним человеком на разных этапах обучения. Код варьируется от простых скриптов в одном файле до попыток разбить логику на несколько модулей. Это делает репозиторий идеальным кандидатом для нашего разбора.
Прежде чем переходить к разбору полетов, важно отметить сильные стороны.
Репозиторий — отличная демонстрация возможностей Python для создания игр. Новичок может посмотреть и сравнить, как одна и та же задача решается с помощью разных инструментов: pygame
для игр с активной анимацией, tkinter
для более статичного GUI и даже turtle
для простейших вещей.
В большинстве игр логика написана «в лоб», без лишних абстракций и сложных паттернов. Для начинающего разработчика это огромный плюс. Легко отследить основной игровой цикл, понять, как обрабатываются события и как происходит отрисовка. Нет необходимости продираться через слои архитектуры, чтобы понять, как заставить птичку лететь вверх.
Несмотря на все потенциальные проблемы с кодом (о них ниже), игры работают. Они выполняют свою главную функцию — в них можно играть. Это важное напоминание о том, что работающий продукт, пусть и не идеальный с технической точки зрения, всегда лучше, чем идеально спроектированный, но так и не законченный.
А теперь перейдем к самой интересной части — анализу того, что можно было бы улучшить.
Здесь мы разберем основные "болевые точки", которые повторяются в нескольких проектах. Это те самые грабли, на которые наступают многие начинающие разработчики.
Это первая и самая очевидная проблема. Чтобы запустить любую из игр, нужно сначала угадать, какие библиотеки ей нужны. В репозитории нет файла requirements.txt
.
[!DANGER] Проблема Пользователь, скачавший репозиторий, не может просто взять и запустить проект. Ему придется вручную ставить
pygame
,colorama
и, возможно, что-то еще, сталкиваясь с ошибкамиModuleNotFoundError
.
Как сделать лучше?
requirements.txt
. Файл легко создается командой pip freeze > requirements.txt
в активированном виртуальном окружении, где установлены все зависимости.main.py
в корне проекта, который бы позволял выбирать и запускать любую игру из списка.Это классика. Во многих файлах (например, 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
) создают неявные зависимости. Функции становятся непредсказуемыми, их сложно тестировать и переиспользовать.
Как сделать лучше?
GameConfig
, который будет хранить все настройки игры (размеры окна, скорости, ускорения). Этот объект конфигурации можно передавать в функции или методы, делая их зависимости явными.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:
# ...
Такой подход делает код чище, понятнее и гораздо проще для модификации.
Еще одна распространенная проблема, особенно в проектах на pygame
. Код, отвечающий за игровую логику (как движется объект), состояние (где он находится) и отображение (как его нарисовать), часто перемешан в одном большом цикле.
Это видно в игре Pacman. Несмотря на то, что код разбит на файлы (player.py
, enemies.py
, game.py
), сама архитектура остается процедурной. Класс Game
в game.py
— это огромный монолит, который управляет всем: событиями, логикой, отрисовкой.
[!WARNING] Проблема
- Высокая связанность (High Coupling): Изменение логики отрисовки может сломать игровую логику, и наоборот.
- Низкая переиспользуемость: Нельзя просто так взять класс
Player
и использовать его в другой игре, потому что он тесно связан сGame
,horizontal_blocks
и другими элементами.- Сложность тестирования: Как протестировать логику движения игрока, не запуская весь графический движок? В текущей реализации — почти никак.
Как сделать лучше?
Следовать принципу разделения ответственности (Separation of Concerns).
PlayerModel
должен знать свою позицию, скорость, но не то, как его рисовать. GameModel
должен содержать матрицу уровня, положение всех врагов, счет.PlayerView
получает PlayerModel
и рисует спрайт в нужной координате. GameView
рисует поле, врагов, счет, основываясь на данных из GameModel
.Этот подход (вариация на тему MVC/MVP) позволяет разрабатывать, тестировать и модифицировать каждую часть системы независимо.
В гоночной игре (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-игры можно ввести простейшую физическую модель.
pygame.math.Vector2
) для представления позиции, скорости и ускорения.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)
Да, этот код сложнее, но он закладывает фундамент для гораздо более реалистичного и расширяемого поведения объектов.
Во многих играх, например, в "Шашках" (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] Проблема: Локальный импорт и циклические зависимости
Причина: Почему автор так сделал? Почти наверняка, чтобы избежать циклической зависимости. Смотрите:
Piece.py
должен знать оKing
, чтобы превратить в него пешку. НоKing.py
импортируетPiece
, так как наследуется от него (class King(Piece):
). Если быfrom King import King
стоял вверху файлаPiece.py
, Python выдал бы ошибкуImportError
.Скрытые зависимости: Такой импорт скрывает реальные зависимости модуля. Глядя на начало файла, невозможно понять, что ему на самом деле нужен
King
.Сигнал о проблеме в архитектуре: Сам факт возникновения такой ситуации — это флаг, который кричит: "Что-то не так с вашей архитектурой!". Классы слишком тесно связаны и знают друг о друге больше, чем должны.
Как сделать лучше?
Есть несколько путей решения, от простого к более архитектурно верному.
Forward References: Python позволяет элегантно решать проблему циклических зависимостей для тайп-хинтинга. Хотя здесь хинтинга нет, принцип похож. Мы можем делегировать создание "Короля" классу Board
, который знает обо всех типах фигур.
Делегирование ответственности (Архитектурное решение): Это самый правильный путь. Класс 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
управляет правилами и состоянием игры.
В большинстве графических игр ресурсы (картинки, звуки) загружаются прямо по месту их использования с жестко заданными путями.
Пример из 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] Проблема
- Хрупкость: Такой код будет работать, только если вы запускаете скрипт из корня папки
Dino-Game
. Попытка запустить его из родительской директории (python Dino-Game/dino.py
) приведет к ошибкеFileNotFoundError
, потому что он будет искать папкуresources
не там, где нужно.- Отсутствие централизации: Пути к ресурсам разбросаны по всему коду. Если мы решим переименовать папку
resources
вassets
, нам придется править код во многих местах.- Повторная загрузка: Нет никакой гарантии, что один и тот же ресурс не будет загружен в память несколько раз в разных частях программы.
Как сделать лучше?
Использовать абсолютные пути на основе расположения файла: 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'))
Создать менеджер ресурсов (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')
Такой подход не только решает проблему с путями, но и централизует управление ресурсами, делая код чище и профессиональнее.
Ваша поддержка — это энергия для новых статей и проектов. Спасибо, что читаете!
Разбор этого репозитория — прекрасная иллюстрация пути, который проходит каждый разработчик. Сначала мы учимся писать код, который работает. Это само по себе большое достижение. Игры в этом сборнике — прекрасный пример такого кода.
Следующий этап — научиться писать хороший код. Код, который легко читать, модифицировать, тестировать и расширять. И именно здесь анализ чужих ошибок и поиск лучших решений становятся бесценным инструментом обучения.
Ключевые выводы из нашего аудита:
Этот репозиторий, со всеми его шероховатостями, — отличный учебный полигон. Попробуйте взять одну из игр и отрефакторить ее, применяя описанные выше принципы. Это будет одно из самых полезных упражнений, которые вы можете сделать для своего профессионального роста.