Python — Ping Pong
Разработчику Начальный уровень
О практикуме
Pong (Ping Pong) — одна из первых аркад: две ракетки отбивают мяч по горизонтали; проигрывает тот, кто пропустил мяч за свою линию. В этом практикуме соберём полноценный прототип на Python 3 и Pygame — без спрайтов из оригинала Atari, на цветных прямоугольниках, зато с разбором физики отскока, счёта и игровых состояний.
Нужны базовые Python (классы, списки, циклы) и знакомство с Pygame из статьи Разработка игр на Python. Для разминки подойдёт один файл Pong в Lab с разбором отскока и Rect. Каждый этап — запускаемый код: после шага проект можно запустить и увидеть новую механику.
Как проходить практикум
- Создайте папку
pong/, виртуальное окружение и установите Pygame — раздел Зависимости. - Идите по этапам 0–14 по порядку: после каждого шага запускайте
python main.pyи отмечайте пункты "Самопроверка". - На этапе 6 разложите код по пакету
game/; с этапа 14 вся логика матча живёт вgame/game.py. - После этапа 14 сверьте проект с эталоном — Полная ревизия файлов: дерево, разбор и готовые листинги.
Что получится в конце
- Окно 960×540 с кортом, двумя ракетками, мячом и счётом до 11.
- Режим 1×1 на одной клавиатуре или против простого ИИ.
- Меню, подача после гола, пауза, экран победы.
- Модули
game/*и короткийmain.py— каркас, на который легко навесить звук, сеть или турнирную таблицу.
Оценка времени — 3–5 часов при прохождении всех этапов подряд; этапы 0–6 можно уложить в один вечер (~1,5 ч).
Перед стартом проверьте
- Python 3.10+ в PATH (
python --version). - Терминал открыт в корне проекта
pong/(там, где лежитmain.py). - Прочитаны разделы "игровой цикл" и
Rectв Разработка игр на Python.
Управление в финальной версии
| Клавиша | Действие |
|---|---|
W / S |
Ракетка игрока слева (вверх / вниз) |
↑ / ↓ |
Ракетка игрока справа (вверх / вниз) |
Пробел |
Подача мяча (из меню и после гола) |
P |
Пауза |
R |
Перезапуск матча |
Esc |
Выход |
Маршрут чтения
- Архитектура — как устроен проект до первой строки кода.
- Зависимости и структура папок — окружение и файлы.
- Этап 0 — минимальный запуск — чёрное окно и игровой цикл.
- Этапы 1–14 — по одной механике за шаг.
- Этап 15 — substeps и звук (бонус, полировка).
- Итоговая структура и самопроверка.
Карта этапов
| Этап | Фокус | Новое поведение в игре |
|---|---|---|
| 0 | Цикл Pygame | Тёмное окно, выход по Esc |
| 1 | settings.py |
Константы в одном файле |
| 2 | Корт | Поле, рамка, пунктир |
| 3 | Paddle |
Две статичные ракетки |
| 4 | Ввод | Левая ракетка на W/S |
| 5 | Ball |
Мяч в центре |
| 6 | Модули | Мяч летит по диагонали |
| 7 | Стены | Отскок от верха и низа |
| 8 | colliderect |
Простой отскок от ракетки |
| 9 | Угол удара | Траектория зависит от точки контакта |
| 10 | Гол | Счёт на экране |
| 11 | SERVE |
Подача по Пробелу |
| 12 | ИИ / 2 игрока | Правая ракетка оживает |
| 13 | FSM | Меню, пауза, победа |
| 14 | Game |
Чистая архитектура |
| 15 | Substeps + звук | Мяч не "проскакивает" сквозь ракетку |
Архитектура
Прежде чем писать код, зафиксируем что из чего состоит и как данные текут по кадру.
Кратко об оригинале
Pong (Atari, 1972) — одна из первых коммерчески успешных аркад. Управление сводится к одной оси на игрока; вся сложность — в тайминге и угле отскока. Учебная версия на Pygame повторяет ту же петлю "ввод → движение → столкновение → счёт", которую позже масштабируют до платформеров и шутеров.
Игровой цикл
Любая игра на Pygame крутит один и тот же цикл. В Pong порядок шагов важен — сначала ввод и логика, потом отрисовка.
flowchart TD
A[Старт] --> B[Инициализация Pygame]
B --> C{running?}
C -->|да| D[Обработка событий]
D --> E[Обновление состояния]
E --> F[Отрисовка]
F --> G[clock.tick FPS]
G --> C
C -->|нет| H[pygame.quit]
На каждом кадре внутри обновления выполняется цепочка:
- Прочитать нажатые клавиши (или решение ИИ).
- Сдвинуть ракетки с учётом границ поля.
- Сдвинуть мяч по скорости
(vx, vy). - Проверить столкновения со стенами и ракетками.
- При голе — обновить счёт, сбросить мяч, перейти в режим подачи.
- Проверить победу (например, до 11 очков).
Один кадр — порядок вызовов
Когда логика собрана в класс Game, типичный кадр в состоянии PLAYING выглядит так:
sequenceDiagram
participant M as main.py
participant G as Game
participant P as Paddle
participant B as Ball
participant H as HUD
M->>G: handle_event(events)
M->>G: update(dt)
G->>P: move(keys, dt)
G->>B: update(dt, field)
G->>B: collide_paddle(left/right)
G->>G: check_goal / win
M->>G: draw(screen)
G->>H: draw_score
M->>M: display.flip()
Правило порядка — сначала двигаем ракетки, потом мяч, потом столкновения. Если поменять местами "мяч" и "ракетки", мяч на один кадр окажется внутри ракетки после её сдвига — отсюда двойные отскоки и "прилипание".
Слои приложения
| Слой | Ответственность | Примеры сущностей |
|---|---|---|
| Ввод | События клавиатуры, пауза, выход | KEYDOWN, get_pressed() |
| Мир | Размеры поля, отступы, центральная линия | Court, константы из settings |
| Акторы | Ракетки и мяч | Paddle, Ball |
| Правила | Счёт, подача, победа, пауза | Game, score_left, score_right |
| Представление | Рисование поля, HUD, экраны | draw_court, draw_hud |
Слой правил не рисует напрямую — он меняет состояние; слой представления только читает состояние и выводит кадр. Так проще менять графику и добавлять сетевую игру позже.
Координатная система
Pygame использует экранные координаты — начало (0, 0) — левый верхний угол, ось X растёт вправо, ось Y — вниз.
Экран (пиксели)
┌────────────────────────────────────────────┐
│ MARGIN │
│ ┌──────────────────────────────────────┐ │
│ │ ● ракетка слева │ │
│ │ · центр │ │
│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ ← пунктир по центру
│ │ ● ракетка справа │ │
│ └──────────────────────────────────────┘ │
│ счёт 3 : 7 │
└────────────────────────────────────────────┘
Рекомендуемые константы (можно менять, но все модули должны брать размеры из одного места):
| Константа | Значение | Смысл |
|---|---|---|
SCREEN_W, SCREEN_H |
960, 540 |
Размер окна (16:9) |
MARGIN |
40 |
Отступ поля от краёв экрана |
PADDLE_W, PADDLE_H |
14, 100 |
Ракетка — узкий высокий прямоугольник |
BALL_SIZE |
16 |
Диаметр мяча (квадрат 16×16) |
PADDLE_SPEED |
420 |
Пикселей в секунду |
BALL_SPEED |
320 |
Базовая скорость мяча |
WIN_SCORE |
11 |
Очков для победы |
FPS |
60 |
Кадров в секунду |
Игровое поле — прямоугольник внутри отступов:
FIELD_RECT = pygame.Rect(MARGIN, MARGIN, SCREEN_W - 2 * MARGIN, SCREEN_H - 2 * MARGIN)
Ракетки прилипают к левому и правому краю поля; мяч отскакивает от верхней и нижней границы FIELD_RECT.
Физика отскока
Классический Pong на Atari использовал два типа столкновений:
- Стены (верх / низ) — инвертировать
vy(скорость по Y меняет знак). - Ракетка — инвертировать
vxи слегка изменитьvyв зависимости от того, куда по высоте ракетки попал мяч.
Чем ближе удар к краю ракетки, тем сильнее угол:
# relative_hit: от -1 (низ ракетки) до +1 (верх ракетки)
relative_hit = (ball.center_y - paddle.center_y) / (paddle.height / 2)
ball.vy = BALL_SPEED * relative_hit * 0.85
ball.vx = -ball.vx # отражение по горизонтали
Так партия остаётся динамичной: прямой удар по центру летит почти горизонтально, удар "с краю" даёт крутой угол.
Нормализация скорости (этап 9) не даёт мячу бесконечно ускоряться от серии ударов под острым углом — модуль вектора (vx, vy) ограничивается BALL_SPEED * 1.15.
Гейм-дизайн и баланс
| Параметр | Значение в практикуме | Зачем |
|---|---|---|
WIN_SCORE = 11 |
Как в настольном пинг-понге (party до 11) | Короткие партии, быстрый feedback |
PADDLE_H = 100 при поле ~460 px |
~22% высоты поля | Достаточно зоны для защиты, но промахи возможны |
BALL_SPEED = 320 |
~¾ ширины поля в секунду | Рally ~2–4 с без ускорения |
PADDLE_SPEED = 420 |
Чуть быстрее мяча по вертикали | Игрок успевает перехватить, но нужен тайминг |
ИИ reaction = 0.82 |
Бот ошибается на резких углах | Одиночная игра остаётся честной |
Подстройка баланса — только правка констант в settings.py; логику Game менять не нужно.
Для аркады достаточно pygame.Rect.colliderect и явной коррекции скорости. Отдельный physics engine (Box2D, Pymunk) здесь лишний: Pong учит цикл, ввод и предсказуемые столкновения.
Конечный автомат состояний
Игра переключается между экранами через явное поле state:
stateDiagram-v2
[*] --> MENU
MENU --> SERVE : Пробел
SERVE --> PLAYING : мяч запущен
PLAYING --> SERVE : гол
PLAYING --> PAUSED : P
PAUSED --> PLAYING : P
PLAYING --> WIN : score >= WIN_SCORE
WIN --> MENU : R
MENU --> [*] : Esc
PLAYING --> [*] : Esc
Состояния MENU, SERVE, PLAYING, PAUSED, WIN — отдельные ветки в update() и draw().
| Состояние | update |
draw |
|---|---|---|
MENU |
только чтение событий | корт + оверлей "Пробел — начать" |
SERVE |
ракетки двигаются, мяч стоит | подсказка "Пробел — подача" |
PLAYING |
полная физика | корт, акторы, счёт |
PAUSED |
как PLAYING, но мяч не обновляется* |
затемнение + "ПАУЗА" |
WIN |
только события | оверлей победителя |
* На этапе 13 проще не вызывать ball.update, если state == "PAUSED" — заморозка без отдельного поля.
Структура файлов (целевая)
К этапу 5 достаточно одного main.py. Дальше проект раскладываем по модулям.
pong/
├── main.py # точка входа, цикл while
├── settings.py # константы, цвета, FPS
├── assets/ # позже — звуки (опционально)
├── game/
│ ├── __init__.py
│ ├── court.py # фон, центральная линия
│ ├── paddle.py # Paddle — движение и отрисовка
│ ├── ball.py # Ball — скорость, столкновения
│ ├── hud.py # счёт, оверлеи меню/паузы
│ ├── ai.py # простой ИИ для одиночной игры (этап 12)
│ └── game.py # класс Game — правила матча
└── requirements.txt
Диаграмма объектов на кадре
classDiagram
class Game {
+state
+paddle_left
+paddle_right
+ball
+score_left
+score_right
+update(dt)
+draw(surface)
+handle_event(event)
}
class Paddle {
+rect
+speed
+move(dy, field)
+draw(surface)
}
class Ball {
+rect
+vx
+vy
+update(dt, field)
+collide_paddle(paddle)
+draw(surface)
}
class Court {
+field_rect
+draw(surface)
}
Game --> Paddle
Game --> Ball
Game --> Court
Ball --> Paddle : отскок
Зависимости и подготовка окружения
Требования
- Python 3.10+ (удобны
match/case; на 3.9 код тоже работает, если заменитьmatchнаif/elif). - Pygame 2.5+ — единственная внешняя библиотека.
Установка
mkdir pong && cd pong
python -m venv .venv
Активация виртуального окружения:
- Windows (PowerShell):
.venv\Scripts\Activate.ps1 - Linux / macOS:
source .venv/bin/activate
pip install pygame
python -c "import pygame; print('Pygame', pygame.ver)"
Файл requirements.txt:
pygame>=2.5.0
Файлы окружения
.gitignore в корне pong/:
.venv/
__pycache__/
*.pyc
.pytest_cache/
Запуск из IDE
Cursor / VS Code: откройте папку pong как workspace, выберите интерпретатор из .venv, запускайте main.py. Рабочая директория должна быть корнем проекта — иначе импорт import settings и from game... сломается.
Если pip install pygame падает, обновите pip (python -m pip install -U pip) и повторите установку. На Python 3.12+ берите Pygame 2.5+. Сообщение ModuleNotFoundError: No module named 'pygame' почти всегда значит, что активировано не то venv или запуск идёт из другой папки.
Первичная структура
На этапе 0 создайте только main.py. Папку game/ добавим на этапе 6.
На всех этапах движение считаем через dt = clock.tick(FPS) / 1000.0 — секунды с прошлого кадра. Так скорость в "пикселях в секунду" остаётся одинаковой на любом мониторе и при просадках FPS.
Этап 0 — минимальный запускаемый код
Цель — окно, цикл событий, выход по крестику и Esc, стабильные 60 FPS.
Создайте main.py:
Запуск:
python main.py
Самопроверка этапа 0
- Окно открывается без traceback.
- Фон тёмный, без мерцания.
-
Escи крестик закрывают программу.
На следующих этапах не удаляем цикл — только расширяем тело while.
На этапе 0 dt пока нигде не используется — это нормально. С этапа 4 она участвует в каждом движении; удалять строку clock.tick нельзя.
Этап 1 — константы и файл настроек
Цель — вынести все числа и цвета в settings.py, чтобы не искать "магические" значения по коду.
settings.py:
Обновите main.py:
Самопроверка
- Импорт
settings as Sработает без ошибок. - Размер окна 960×540.
Этап 2 — игровое поле и центральная линия
Цель — нарисовать "корт": прямоугольник поля и пунктир по центру, как в классическом Pong.
Добавьте в main.py функцию отрисовки (позже перенесём в game/court.py):
В цикле перед flip():
screen.fill(S.COLOR_BG)
draw_court(screen)
pygame.display.flip()
Самопроверка
- Поле с отступом 40 px от краёв окна.
- По центру — вертикальный пунктир.
- Тонкая рамка вокруг поля.
Если пунктир "рваный" — проверьте, что dash_h + gap не больше высоты поля; последний сегмент обрезается через min(y + dash_h, MARGIN + FIELD_H).
Этап 3 — две ракетки (статичные)
Цель — класс Paddle, две ракетки по краям поля, пока без движения.
Добавьте в main.py (этап 6 вынесем в модуль):
После draw_court(screen):
paddle_left.draw(screen)
paddle_right.draw(screen)
Самопроверка
- Левая ракетка зелёноватая, правая — синяя.
- Ракетки по вертикали центрированы в поле.
- Между ракеткой и боковой границей поля есть небольшой зазор (~8 px).
Этап 4 — движение левой ракетки
Цель — управление W / S с ограничением внутри поля.
Добавьте метод в Paddle:
def move(self, direction, dt):
"""direction: -1 вверх, +1 вниз, 0 — стоять."""
dy = direction * self.speed * dt
self.rect.y += int(dy)
top = S.MARGIN
bottom = S.MARGIN + S.FIELD_H - self.rect.height
self.rect.top = max(top, min(bottom, self.rect.top))
В игровом цикле перед отрисовкой:
keys = pygame.key.get_pressed()
direction = 0
if keys[pygame.K_w]:
direction -= 1
if keys[pygame.K_s]:
direction += 1
paddle_left.move(direction, dt)
Для непрерывного движения удобнее pygame.key.get_pressed() — ракетка едет, пока клавиша зажата. События KEYDOWN оставим для одиночных действий (пауза, подача, выход).
Самопроверка
-
W/Sдвигают левую ракетку. - Ракетка не выходит за верх и низ поля.
- Скорость ощущается одинаковой при 60 FPS.
Этап 5 — мяч в центре (без движения)
Цель — класс Ball, отрисовка белого квадрата в центре поля.
После ракеток:
ball.draw(screen)
Самопроверка
- Мяч ровно в центре поля.
- Размер 16×16 px.
Этап 6 — движение мяча и модули
Цель — мяч летит по диагонали; код раскладываем по файлам game/paddle.py, game/ball.py, game/court.py.
Создайте пустой game/__init__.py.
game/court.py:
game/paddle.py:
game/ball.py:
Обновлённый main.py:
Самопроверка
- Мяч летит вправо-вниз и уходит за край экрана (столкновений ещё нет).
- Импорты из пакета
gameработают.
Файл game/__init__.py может быть пустым — он нужен Python, чтобы папка считалась пакетом. Импорт from game.ball import Ball работает только при запуске из родительской папки pong/.
Этап 7 — отскок от верхней и нижней стены
Цель — мяч остаётся внутри поля по вертикали.
Добавьте в Ball.update в game/ball.py:
def update(self, dt, field):
self.rect.x += int(self.vx * dt)
self.rect.y += int(self.vy * dt)
if self.rect.top <= field.top:
self.rect.top = field.top
self.vy = abs(self.vy)
elif self.rect.bottom >= field.bottom:
self.rect.bottom = field.bottom
self.vy = -abs(self.vy)
В main.py сделайте два изменения.
- В импортах добавьте
field_rectрядом сdraw_court:
from game.court import draw_court, field_rect
- Перед циклом
while runningодин раз вычислите прямоугольник поля; внутри цикла, сразу после движения ракеток и перед отрисовкой, передайте его вball.update:
field = field_rect()
running = True
while running:
# ... события, dt, paddle_left.move ...
ball.update(dt, field)
# ... draw_court, flip ...
Переменная field не пересоздаётся каждый кадр — размеры поля из settings.py не меняются во время матча.
Самопроверка
- Мяч бесконечно отскакивает от верха и низа поля.
- Не "залипает" в углу (если залипает — проверьте, что используете
absдля скорости).
Почему abs(vy) — после удара о стену скорость должна быть направлена от стены. Если мяч вошёл в верхнюю границу с vy = -200, после коррекции нужно vy = +200, то есть положительное значение.
Этап 8 — столкновение с ракеткой (простой отскок)
Цель — при пересечении Rect мяча и ракетки мяч отражается по горизонтали.
Метод в Ball:
В main.py после ball.update:
ball.collide_paddle(paddle_left)
ball.collide_paddle(paddle_right)
Пока правая ракетка не двигается — временно сдвиньте её ближе к центру или управляйте ей теми же W/S, чтобы проверить отскок вручную.
За один кадр мяч может "перепрыгнуть" через тонкую ракетку, если |vx * dt| больше ширины ракетки. На базовых скоростях это редкость; если заметили — см. этап 15 (substeps).
Самопроверка
- Мяч отражается от ракетки, а не проходит сквозь неё.
- После удара мяч летит в противоположную сторону.
Этап 9 — угол отскока от места удара
Цель — vy зависит от точки контакта на ракетке; партия становится интереснее.
Замените тело collide_paddle на версию с углом:
Без проверки approaching мяч может "застрять" внутри ракетки и несколько раз подряд вызвать столкновение за один кадр. Мы отражаем только если мяч летит на ракетку.
Самопроверка
- Удар по центру ракетки — почти горизонтальный полёт.
- Удар по краю — заметный наклон траектории.
Эксперимент — временно поставьте relative_hit * 1.2 вместо 0.85: мяч станет слишком "живым" и будет улетать почти вертикально от краёв. Верните 0.85, когда поймёте связь формулы и геймплея.
Этап 10 — гол и счёт
Цель — мяч за левой или правой границей поля даёт очко противнику; счёт хранится в переменных.
В main.py (позже перенесём в Game):
score_left = 0
score_right = 0
После обновления мяча:
if ball.rect.right < field.left:
score_right += 1
ball.reset(center=True)
elif ball.rect.left > field.right:
score_left += 1
ball.reset(center=True)
Временный вывод счёта:
font = pygame.font.SysFont("consolas", 36)
def draw_score(surface, left, right):
text = font.render(f"{left} : {right}", True, S.COLOR_TEXT)
rect = text.get_rect(center=(S.SCREEN_W // 2, 28))
surface.blit(text, rect)
Счёт размещаем над полем (Y ≈ 28), чтобы не перекрывать центральный пунктир. На этапе 13 тот же приём переедет в game/hud.py.
Самопроверка
- Пропущенный мяч увеличивает счёт соперника.
- После гола мяч останавливается в центре.
Этап 11 — подача и состояние SERVE
Цель — после гола нужно нажать Пробел, чтобы запустить мяч в сторону проигравшего подачу.
Логика подачи в классическом Pong: проигравший очко принимает подачу — мяч летит на него, чтобы он мог вернуться в игру. Поэтому после гола слева (score_left += 1) задаём serve_direction = 1 (мяч вправо, к проигравшему справа), и наоборот.
Добавьте переменную состояния:
state = "SERVE" # позже: MENU, PLAYING, PAUSED, WIN
serve_direction = 1
Обработка в цикле событий:
elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
if state == "SERVE":
ball.reset(center=False, direction=serve_direction)
state = "PLAYING"
При голе:
if ball.rect.right < field.left:
score_right += 1
serve_direction = -1
ball.reset(center=True)
state = "SERVE"
elif ball.rect.left > field.right:
score_left += 1
serve_direction = 1
ball.reset(center=True)
state = "SERVE"
Обновление мяча только в PLAYING:
if state == "PLAYING":
ball.update(dt, field)
ball.collide_paddle(paddle_left)
ball.collide_paddle(paddle_right)
# проверка гола — как выше
Подсказка на экране в SERVE:
def draw_hint(surface, message):
f = pygame.font.SysFont("consolas", 22)
t = f.render(message, True, S.COLOR_ACCENT)
surface.blit(t, t.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H - 24)))
# в цикле отрисовки:
if state == "SERVE":
draw_hint(screen, "Пробел — подача")
Самопроверка
- После гола мяч стоит, пока не нажмёте Пробел.
- Подача летит к тому, кто пропустил мяч (он принимает).
Этап 12 — вторая ракетка и простой ИИ
Цель — игрок справа управляется стрелками или простым ботом для одиночной игры.
Управление правой ракеткой:
dir_right = 0
if keys[pygame.K_UP]:
dir_right -= 1
if keys[pygame.K_DOWN]:
dir_right += 1
paddle_right.move(dir_right, dt)
Опционально game/ai.py для режима "против компьютера":
Переключатель в main.py:
VS_AI = True # False — два игрока на одной клавиатуре
# в update:
if VS_AI:
from game.ai import ai_follow_ball
if state == "PLAYING" and ball.vx > 0:
ai_follow_ball(paddle_right, ball, dt, reaction=0.82)
else:
paddle_right.move(dir_right, dt)
Параметр reaction чуть ниже 1.0 делает бота "человечнее". Дополнительно можно двигать бота только когда ball.vx > 0, то есть мяч летит на его половину.
| Сложность | reaction |
Поведение |
|---|---|---|
| Лёгкая | 0.65 | Частые промахи на быстрых углах |
| Нормальная | 0.82 | Баланс для одиночной игры |
| Сложная | 0.95 | Почти идеальный трекинг |
Самопроверка
- Два игрока могут играть на одной клавиатуре.
- В режиме ИИ правая ракетка перехватывает мяч, но иногда ошибается.
Этап 13 — HUD, меню, пауза и победа
Цель — экраны MENU, PAUSED, WIN; победа при WIN_SCORE очках.
Для паузы удобно не обновлять мяч, когда state == "PAUSED", но продолжать рисовать замороженный кадр. В update оборачивайте блок физики мяча:
if state == "PLAYING":
ball.update(dt, field)
# столкновения и гол
Создайте game/hud.py:
Логика состояний в main.py (фрагмент):
Самопроверка
- Стартовое меню с подсказкой.
-
Pставит и снимает паузу. - При 11 очках — экран победы,
Rсбрасывает матч.
Этап 14 — класс Game и чистый main.py
Цель — собрать разрозненную логику в один класс; в main.py остаётся только цикл.
game/game.py:
Финальный main.py:
Самопроверка
-
main.pyкороче 40 строк. - Перезапуск матча и смена состояния не дублируют код.
Этап 15 (бонус) — substeps и звук
Цель — убрать проскок мяча сквозь ракетку на высокой скорости и добавить минимальную обратную связь через звук.
Substeps — дробление движения
Идея: за один кадр выполнить несколько маленьких шагов физики вместо одного большого.
В settings.py:
PHYSICS_STEPS = 4
В game/ball.py замените update:
В Game.update передавайте список ракеток:
self.ball.update(
dt,
self.field,
paddles=(self.paddle_left, self.paddle_right),
)
# collide_paddle отдельно больше не вызываем — всё внутри update
После substeps уберите дублирующие вызовы collide_paddle сразу после ball.update.
Звук без внешних ассетов
Pygame умеет синтезировать простой "бип" через pygame.sndarray. Минимальный вариант — загрузить короткий WAV из freesound.org в assets/hit.wav и assets/score.wav.
game/audio.py:
Вызывайте audio.play_hit() в конце collide_paddle (если вернул True), audio.play_goal() при начислении очка. Если файлов нет — класс тихо отключается (enabled = False).
Добавьте флаг DEBUG = True в settings.py и по F1 рисуйте контуры Rect мяча и ракеток, а также стрелки скорости (vx, vy) — так проще ловить tunneling и двойные столкновения.
Самопроверка этапа 15
- При быстрых rally мяч не проходит сквозь ракетку.
- Звук удара/гола слышен (или игра работает без
assets/). - FPS остаётся стабильным (
PHYSICS_STEPS = 4при 60 FPS — 240 микрошагов/с).
Полная ревизия файлов
Эталонный проект после этапа 14 (проверен импортом from game.game import Game). Сверяйте листинги построчно, если что-то не сходится после прохождения практикума.
Дерево проекта
pong/
├── main.py
├── settings.py
├── requirements.txt
└── game/
├── __init__.py
├── court.py
├── paddle.py
├── ball.py
├── hud.py
├── ai.py
└── game.py
requirements.txt
Разбор. Единственная внешняя зависимость — Pygame 2.5+.
pygame>=2.5.0
settings.py
Разбор. Все размеры окна, поля, скорости и цвета — в одном месте; модули game/* импортируют settings as S.
main.py
Разбор. Только инициализация Pygame, цикл событий и вызовы Game.update / Game.draw — без правил матча.
game/__init__.py
Разбор. Пустой файл — Python считает game пакетом; импорты вида from game.ball import Ball работают при запуске из корня pong/.
game/court.py
Разбор. Отрисовка поля и пунктира; field_rect() возвращает Rect для границ мяча (этап 7).
game/paddle.py
Разбор. Ракетка — Rect, движение по вертикали с clamp внутри поля.
game/ball.py
Разбор. Скорость, отскок от верха/низа (update + field), угол удара от точки контакта на ракетке (collide_paddle).
game/hud.py
Разбор. Счёт над полем и полноэкранные оверлеи меню, паузы и победы.
game/ai.py
Разбор. Правая ракетка следует за ball.rect.centery с коэффициентом reaction меньше 1.
game/game.py
Разбор. FSM (MENU, SERVE, PLAYING, PAUSED, WIN), счёт, подача, ИИ или второй игрок, делегирование отрисовки в court и hud.
Из корня pong/: python -c "from game.game import Game; print('ok')" — без ошибок ModuleNotFoundError.
Итоговая самопроверка проекта
Дерево готового проекта
pong/
├── main.py
├── settings.py
├── requirements.txt
├── .gitignore
├── assets/ # опционально, этап 15
│ ├── hit.wav
│ └── score.wav
└── game/
├── __init__.py
├── court.py
├── paddle.py
├── ball.py
├── hud.py
├── ai.py
├── audio.py # опционально, этап 15
└── game.py
Пройдите чек-лист готового прототипа:
| # | Критерий | Да / нет |
|---|---|---|
| 1 | Окно фиксированного размера, стабильный FPS | |
| 2 | Поле с центральной линией и рамкой | |
| 3 | Две ракетки, левая — W/S, правая — стрелки или ИИ |
|
| 4 | Мяч отскакивает от верха и низа поля | |
| 5 | Угол отскока зависит от места удара по ракетке | |
| 6 | Счёт, подача после гола, победа до 11 очков | |
| 7 | Меню, пауза, экран победы | |
| 8 | Код разбит на модули game/* |
|
| 9 | (Бонус) Substeps или звук |
Словарь терминов
| Термин | Значение в этом практикуме |
|---|---|
| dt | Delta time — секунды с прошлого кадра; умножается на скорость |
| FSM | Finite State Machine — переключение MENU / PLAYING / … |
| HUD | Head-Up Display — счёт и подсказки поверх поля |
| Rect | Прямоугольник Pygame для позиции и colliderect |
| SERVE | Состояние подачи — мяч в центре, ждём Пробел |
| Substep | Дробный шаг физики внутри одного кадра |
| Tunneling | Проскок объекта сквозь препятствие за один большой шаг |
Идеи для расширения (самостоятельно)
- Звук —
pygame.mixerдля удара о ракетку и гола; короткие.wavвassets/. - Ускорение мяча — после каждого успешного отбития умножать скорость на
1.03(с потолком). - Spin / эффект — при зажатом
Shiftпри ударе добавлять кvyбонус (имитация "вращения"). - Сетевой Pong — второй процесс или socket; состояние синхронизируется только позициями ракеток и мяча.
- Режим "squash" — одна ракетка и мяч от стены за спиной игрока.
- Сохранение рекорда — лучший счёт в
highscore.txt. - Турнир до N побед — счётчик выигранных партий, не только очков в партии.
- Экран выбора режима —
1один игрок,2два игрока, до старта матча. - Частицы — при ударе о ракетку 3–5 белых точек, исчезающих за 0,2 с (без спрайтов).
Типичные ошибки
| Симптом | Вероятная причина | Что сделать |
|---|---|---|
| Чёрный экран, нет ошибок | Забыли pygame.display.flip() |
Вызовите flip в конце цикла |
ModuleNotFoundError: settings |
Запуск не из корня pong/ |
cd pong или Run с правильным cwd в IDE |
ModuleNotFoundError: game |
Нет game/__init__.py |
Создайте пустой файл |
| Мяч проходит сквозь ракетку на высокой скорости | Один кадр — слишком большой шаг | Substeps (этап 15) или снизьте BALL_SPEED |
| Мяч "прилипает" к ракетке | Нет проверки направления подлёта | Используйте флаг approaching из этапа 9 |
| Ракетка дёргается | Движение без dt |
Умножайте скорость на dt |
| Счёт растёт несколько раз за один проход | Гол проверяется каждый кадр, пока мяч за полем | После гола сразу reset и state = "SERVE" |
| ИИ непобедим | reaction = 1.0 |
Снизьте до 0.7–0.85 или добавьте задержку реакции |
| Пауза не останавливает мяч | ball.update вызывается в PAUSED |
Оборачивайте физику в if state == "PLAYING" |
| Двойной отскок за кадр | Столкновение до выталкивания из ракетки | Выталкивайте мяч (rect.left = paddle.right) до смены vx |
Связь с историей игр
Pong (Atari, 1972) показал, что простые правила + понятная обратная связь достаточны для залипательной аркады. Тот же каркас — цикл, Rect, состояния — лежит в основе и современных игр; здесь вы отработали его на минимальном примере перед более сложными практикумами (Battle City на GitHub, Tetris).
Сравнение с другими практикумами раздела
| Ping Pong | Battle City | Match3 (план) | |
|---|---|---|---|
| Сложность | Низкая | Средняя | Средняя |
| Главный навык | Столкновения, FSM | Сетка, спавн, пули | Массивы, каскады |
| Файлов к этапу 14 | ~8 | ~12+ | — |
| Идеальная "первая игра" | Да | После Pong | После Python-бasics |
Связанные материалы
- Практикум разработки игр — о разделе — другие учебные треки (Battle City, Tetris, diabloид).
- Разработка игр на Python — Pygame, спрайты, звук, игровой цикл
- Pygame — мини-игры — упрощённый Pong в одном файле
- Компьютерные игры — о разделе — жанры и история аркад.
- Battle City на GitHub — сетка, враги, пули (эталон вне энциклопедии).