Python — Ping Pong
О практикуме
Pong (Ping Pong) — одна из первых аркад: две ракетки отбивают мяч по горизонтали; проигрывает тот, кто пропустил мяч за свою линию. В этом практикуме соберём полноценный прототип на Python 3 и Pygame — без спрайтов из оригинала Atari, на цветных прямоугольниках, зато с разбором физики отскока, счёта и игровых состояний.
Что получится в конце
- Окно 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 порядок шагов важен — сначала ввод и логика, потом отрисовка.
На каждом кадре внутри обновления выполняется цепочка:
- Прочитать нажатые клавиши (или решение ИИ).
- Сдвинуть ракетки с учётом границ поля.
- Сдвинуть мяч по скорости
(vx, vy). - Проверить столкновения со стенами и ракетками.
- При голе — обновить счёт, сбросить мяч, перейти в режим подачи.
- Проверить победу (например, до 11 очков).
Один кадр — порядок вызовов
Когда логика собрана в класс Game, типичный кадр в состоянии PLAYING выглядит так:
Правило порядка — сначала двигаем ракетки, потом мяч, потом столкновения. Если поменять местами «мяч» и «ракетки», мяч на один кадр окажется внутри ракетки после её сдвига — отсюда двойные отскоки и «прилипание».
Слои приложения
| Слой | Ответственность | Примеры сущностей |
|---|---|---|
| Ввод | События клавиатуры, пауза, выход | 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:
Состояния 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
Диаграмма объектов на кадре
Зависимости и подготовка окружения
Требования
- 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:
import sys
import pygame
pygame.init()
SCREEN_W, SCREEN_H = 960, 540
FPS = 60
screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Ping Pong — этап 0")
clock = pygame.time.Clock()
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
screen.fill((10, 12, 18))
pygame.display.flip()
dt = clock.tick(FPS) / 1000.0
pygame.quit()
sys.exit()
Запуск:
python main.py
Самопроверка этапа 0
- Окно открывается без traceback.
- Фон тёмный, без мерцания.
-
Escи крестик закрывают программу.
На следующих этапах не удаляем цикл — только расширяем тело while.
dt пока нигде не используется — это нормально. С этапа 4 она участвует в каждом движении; удалять строку clock.tick нельзя.Этап 1 — константы и файл настроек
Цель — вынести все числа и цвета в settings.py, чтобы не искать «магические» значения по коду.
settings.py:
SCREEN_W = 960
SCREEN_H = 540
MARGIN = 40
FIELD_W = SCREEN_W - 2 * MARGIN
FIELD_H = SCREEN_H - 2 * MARGIN
PADDLE_W = 14
PADDLE_H = 100
PADDLE_SPEED = 420
BALL_SIZE = 16
BALL_SPEED = 320
WIN_SCORE = 11
FPS = 60
# Цвета (R, G, B)
COLOR_BG = (10, 12, 18)
COLOR_FIELD = (16, 20, 28)
COLOR_LINE = (60, 70, 90)
COLOR_PADDLE_LEFT = (80, 200, 120)
COLOR_PADDLE_RIGHT = (80, 160, 240)
COLOR_BALL = (240, 240, 245)
COLOR_TEXT = (220, 225, 235)
COLOR_ACCENT = (255, 210, 80)
Обновите main.py:
import sys
import pygame
import settings as S
pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Ping Pong — этап 1")
clock = pygame.time.Clock()
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
screen.fill(S.COLOR_BG)
pygame.display.flip()
dt = clock.tick(S.FPS) / 1000.0
pygame.quit()
sys.exit()
Самопроверка
- Импорт
settings as Sработает без ошибок. - Размер окна 960×540.
Этап 2 — игровое поле и центральная линия
Цель — нарисовать «корт»: прямоугольник поля и пунктир по центру, как в классическом Pong.
Добавьте в main.py функцию отрисовки (позже перенесём в game/court.py):
def draw_court(surface):
field = pygame.Rect(S.MARGIN, S.MARGIN, S.FIELD_W, S.FIELD_H)
pygame.draw.rect(surface, S.COLOR_FIELD, field, border_radius=4)
pygame.draw.rect(surface, S.COLOR_LINE, field, width=2, border_radius=4)
center_x = S.MARGIN + S.FIELD_W // 2
dash_h = 16
gap = 12
y = S.MARGIN
while y < S.MARGIN + S.FIELD_H:
pygame.draw.line(
surface,
S.COLOR_LINE,
(center_x, y),
(center_x, min(y + dash_h, S.MARGIN + S.FIELD_H)),
2,
)
y += dash_h + gap
В цикле перед 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 вынесем в модуль):
class Paddle:
def __init__(self, x, color):
y = S.MARGIN + (S.FIELD_H - S.PADDLE_H) // 2
self.rect = pygame.Rect(x, y, S.PADDLE_W, S.PADDLE_H)
self.color = color
self.speed = S.PADDLE_SPEED
def draw(self, surface):
pygame.draw.rect(surface, self.color, self.rect, border_radius=3)
paddle_left = Paddle(S.MARGIN + 8, S.COLOR_PADDLE_LEFT)
paddle_right = Paddle(
S.MARGIN + S.FIELD_W - S.PADDLE_W - 8,
S.COLOR_PADDLE_RIGHT,
)
После 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, отрисовка белого квадрата в центре поля.
class Ball:
def __init__(self):
cx = S.MARGIN + S.FIELD_W // 2
cy = S.MARGIN + S.FIELD_H // 2
self.rect = pygame.Rect(0, 0, S.BALL_SIZE, S.BALL_SIZE)
self.rect.center = (cx, cy)
self.vx = 0.0
self.vy = 0.0
self.color = S.COLOR_BALL
def draw(self, surface):
pygame.draw.rect(surface, self.color, self.rect, border_radius=2)
ball = Ball()
После ракеток:
ball.draw(screen)
Самопроверка
- Мяч ровно в центре поля.
- Размер 16×16 px.
Этап 6 — движение мяча и модули
Цель — мяч летит по диагонали; код раскладываем по файлам game/paddle.py, game/ball.py, game/court.py.
Создайте пустой game/__init__.py.
game/court.py:
import pygame
import settings as S
def draw_court(surface):
field = pygame.Rect(S.MARGIN, S.MARGIN, S.FIELD_W, S.FIELD_H)
pygame.draw.rect(surface, S.COLOR_FIELD, field, border_radius=4)
pygame.draw.rect(surface, S.COLOR_LINE, field, width=2, border_radius=4)
center_x = S.MARGIN + S.FIELD_W // 2
dash_h = 16
gap = 12
y = S.MARGIN
while y < S.MARGIN + S.FIELD_H:
pygame.draw.line(
surface,
S.COLOR_LINE,
(center_x, y),
(center_x, min(y + dash_h, S.MARGIN + S.FIELD_H)),
2,
)
y += dash_h + gap
def field_rect():
return pygame.Rect(S.MARGIN, S.MARGIN, S.FIELD_W, S.FIELD_H)
game/paddle.py:
import pygame
import settings as S
class Paddle:
def __init__(self, x, color):
y = S.MARGIN + (S.FIELD_H - S.PADDLE_H) // 2
self.rect = pygame.Rect(x, y, S.PADDLE_W, S.PADDLE_H)
self.color = color
self.speed = S.PADDLE_SPEED
def move(self, direction, dt):
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))
def draw(self, surface):
pygame.draw.rect(surface, self.color, self.rect, border_radius=3)
game/ball.py:
import pygame
import settings as S
class Ball:
def __init__(self):
self.reset(center=True)
self.color = S.COLOR_BALL
def reset(self, center=False, direction=1):
cx = S.MARGIN + S.FIELD_W // 2
cy = S.MARGIN + S.FIELD_H // 2
self.rect = pygame.Rect(0, 0, S.BALL_SIZE, S.BALL_SIZE)
self.rect.center = (cx, cy)
if center:
self.vx = 0.0
self.vy = 0.0
else:
self.vx = S.BALL_SPEED * direction
self.vy = S.BALL_SPEED * 0.35
def update(self, dt):
self.rect.x += int(self.vx * dt)
self.rect.y += int(self.vy * dt)
def draw(self, surface):
pygame.draw.rect(surface, self.color, self.rect, border_radius=2)
Обновлённый main.py:
import sys
import pygame
import settings as S
from game.court import draw_court
from game.paddle import Paddle
from game.ball import Ball
pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Ping Pong — этап 6")
clock = pygame.time.Clock()
paddle_left = Paddle(S.MARGIN + 8, S.COLOR_PADDLE_LEFT)
paddle_right = Paddle(
S.MARGIN + S.FIELD_W - S.PADDLE_W - 8,
S.COLOR_PADDLE_RIGHT,
)
ball = Ball()
ball.reset(center=False, direction=1)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
dt = clock.tick(S.FPS) / 1000.0
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)
ball.update(dt)
screen.fill(S.COLOR_BG)
draw_court(screen)
paddle_left.draw(screen)
paddle_right.draw(screen)
ball.draw(screen)
pygame.display.flip()
pygame.quit()
sys.exit()
Самопроверка
- Мяч летит вправо-вниз и уходит за край экрана (столкновений ещё нет).
- Импорты из пакета
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:
from game.court import draw_court, field_rect
field = field_rect()
# ...
ball.update(dt, field)
Самопроверка
- Мяч бесконечно отскакивает от верха и низа поля.
- Не «залипает» в углу (если залипает — проверьте, что используете
absдля скорости).
Почему abs(vy) — после удара о стену скорость должна быть направлена от стены. Если мяч вошёл в верхнюю границу с vy = -200, после коррекции нужно vy = +200, то есть положительное значение.
Этап 8 — столкновение с ракеткой (простой отскок)
Цель — при пересечении Rect мяча и ракетки мяч отражается по горизонтали.
Метод в Ball:
def collide_paddle(self, paddle):
if not self.rect.colliderect(paddle.rect):
return False
if self.vx > 0 and self.rect.centerx < paddle.rect.centerx:
return False
if self.vx < 0 and self.rect.centerx > paddle.rect.centerx:
return False
self.vx = -self.vx
if self.rect.centerx < paddle.rect.centerx:
self.rect.left = paddle.rect.left - self.rect.width
else:
self.rect.right = paddle.rect.right + self.rect.width
return True
В main.py после ball.update:
ball.collide_paddle(paddle_left)
ball.collide_paddle(paddle_right)
Пока правая ракетка не двигается — временно сдвиньте её ближе к центру или управляйте ей теми же W/S, чтобы проверить отскок вручную.
|vx * dt| больше ширины ракетки. На базовых скоростях это редкость; если заметили — см. этап 15 (substeps).Самопроверка
- Мяч отражается от ракетки, а не проходит сквозь неё.
- После удара мяч летит в противоположную сторону.
Этап 9 — угол отскока от места удара
Цель — vy зависит от точки контакта на ракетке; партия становится интереснее.
Замените тело collide_paddle на версию с углом:
def collide_paddle(self, paddle):
if not self.rect.colliderect(paddle.rect):
return False
approaching = (self.vx > 0 and paddle.rect.centerx > S.MARGIN + S.FIELD_W // 2) or (
self.vx < 0 and paddle.rect.centerx < S.MARGIN + S.FIELD_W // 2
)
if not approaching:
return False
half = paddle.rect.height / 2
relative_hit = (self.rect.centery - paddle.rect.centery) / half
relative_hit = max(-1.0, min(1.0, relative_hit))
self.vy = S.BALL_SPEED * relative_hit * 0.85
self.vx = -self.vx
speed = (self.vx ** 2 + self.vy ** 2) ** 0.5
if speed > 0:
scale = min(S.BALL_SPEED * 1.15, speed) / speed
self.vx *= scale
self.vy *= scale
if self.vx > 0:
self.rect.left = paddle.rect.right
else:
self.rect.right = paddle.rect.left
return True
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 для режима «против компьютера»:
import settings as S
def ai_follow_ball(paddle, ball, dt, reaction=0.75):
"""reaction: 0..1 — насколько быстро бот догоняет мяч."""
target_y = ball.rect.centery - paddle.rect.height // 2
diff = target_y - paddle.rect.top
max_step = paddle.speed * dt * reaction
if abs(diff) <= max_step:
paddle.rect.top = int(target_y)
elif diff < 0:
paddle.move(-1, dt * reaction)
else:
paddle.move(1, dt * reaction)
top = S.MARGIN
bottom = S.MARGIN + S.FIELD_H - paddle.rect.height
paddle.rect.top = max(top, min(bottom, paddle.rect.top))
Переключатель в 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:
import pygame
import settings as S
def _font(size):
return pygame.font.SysFont("consolas", size)
def draw_score(surface, left, right):
text = _font(36).render(f"{left} : {right}", True, S.COLOR_TEXT)
surface.blit(text, text.get_rect(center=(S.SCREEN_W // 2, 28)))
def draw_center_message(surface, title, subtitle=""):
overlay = pygame.Surface((S.SCREEN_W, S.SCREEN_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 140))
surface.blit(overlay, (0, 0))
t1 = _font(48).render(title, True, S.COLOR_ACCENT)
surface.blit(t1, t1.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 - 20)))
if subtitle:
t2 = _font(22).render(subtitle, True, S.COLOR_TEXT)
surface.blit(t2, t2.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 + 30)))
def draw_menu(surface):
draw_center_message(surface, "PING PONG", "Пробел — начать матч")
def draw_paused(surface):
draw_center_message(surface, "ПАУЗА", "P — продолжить")
def draw_win(surface, winner_name):
draw_center_message(surface, f"Победа: {winner_name}", "R — новый матч")
Логика состояний в main.py (фрагмент):
state = "MENU"
winner = ""
# KEYDOWN:
elif event.key == pygame.K_p and state in ("PLAYING", "PAUSED", "SERVE"):
state = "PAUSED" if state == "PLAYING" else "PLAYING"
elif event.key == pygame.K_r and state == "WIN":
score_left = score_right = 0
ball.reset(center=True)
state = "MENU"
elif event.key == pygame.K_SPACE:
if state == "MENU":
score_left = score_right = 0
serve_direction = 1
ball.reset(center=True)
state = "SERVE"
elif state == "SERVE":
ball.reset(center=False, direction=serve_direction)
state = "PLAYING"
# после гола:
if score_left >= S.WIN_SCORE:
state = "WIN"
winner = "Игрок 1"
elif score_right >= S.WIN_SCORE:
state = "WIN"
winner = "Игрок 2"
# draw:
draw_score(screen, score_left, score_right)
if state == "MENU":
draw_menu(screen)
elif state == "PAUSED":
draw_paused(screen)
elif state == "WIN":
draw_win(screen, winner)
Самопроверка
- Стартовое меню с подсказкой.
-
Pставит и снимает паузу. - При 11 очках — экран победы,
Rсбрасывает матч.
Этап 14 — класс Game и чистый main.py
Цель — собрать разрозненную логику в один класс; в main.py остаётся только цикл.
game/game.py:
import pygame
import settings as S
from game.court import draw_court, field_rect
from game.paddle import Paddle
from game.ball import Ball
from game.hud import draw_score, draw_menu, draw_paused, draw_win
from game.ai import ai_follow_ball
class Game:
def __init__(self, vs_ai=True):
self.vs_ai = vs_ai
self.field = field_rect()
self.state = "MENU"
self.score_left = 0
self.score_right = 0
self.serve_direction = 1
self.winner = ""
self.paddle_left = Paddle(S.MARGIN + 8, S.COLOR_PADDLE_LEFT)
self.paddle_right = Paddle(
S.MARGIN + S.FIELD_W - S.PADDLE_W - 8,
S.COLOR_PADDLE_RIGHT,
)
self.ball = Ball()
def reset_match(self):
self.score_left = 0
self.score_right = 0
self.serve_direction = 1
self.winner = ""
self.ball.reset(center=True)
self.state = "MENU"
def handle_event(self, event):
if event.type != pygame.KEYDOWN:
return True
if event.key == pygame.K_ESCAPE:
return False
if event.key == pygame.K_p and self.state in ("PLAYING", "PAUSED", "SERVE"):
self.state = "PAUSED" if self.state == "PLAYING" else "PLAYING"
elif event.key == pygame.K_r and self.state == "WIN":
self.reset_match()
elif event.key == pygame.K_SPACE:
if self.state == "MENU":
self.reset_match()
self.state = "SERVE"
elif self.state == "SERVE":
self.ball.reset(center=False, direction=self.serve_direction)
self.state = "PLAYING"
return True
def update(self, dt):
if self.state not in ("PLAYING", "SERVE"):
return
keys = pygame.key.get_pressed()
dir_left = (keys[pygame.K_s] - keys[pygame.K_w])
self.paddle_left.move(dir_left, dt)
if self.vs_ai:
if self.state == "PLAYING" and self.ball.vx > 0:
ai_follow_ball(self.paddle_right, self.ball, dt, 0.82)
else:
dir_right = (keys[pygame.K_DOWN] - keys[pygame.K_UP])
self.paddle_right.move(dir_right, dt)
if self.state != "PLAYING":
return
self.ball.update(dt, self.field)
self.ball.collide_paddle(self.paddle_left)
self.ball.collide_paddle(self.paddle_right)
if self.ball.rect.right < self.field.left:
self.score_right += 1
self.serve_direction = -1
self.ball.reset(center=True)
self.state = "SERVE"
elif self.ball.rect.left > self.field.right:
self.score_left += 1
self.serve_direction = 1
self.ball.reset(center=True)
self.state = "SERVE"
if self.score_left >= S.WIN_SCORE:
self.state = "WIN"
self.winner = "Игрок 1"
elif self.score_right >= S.WIN_SCORE:
self.state = "WIN"
self.winner = "Игрок 2" if not self.vs_ai else "Компьютер"
def draw(self, surface):
draw_court(surface)
self.paddle_left.draw(surface)
self.paddle_right.draw(surface)
if self.state in ("PLAYING", "SERVE", "PAUSED", "WIN"):
self.ball.draw(surface)
draw_score(surface, self.score_left, self.score_right)
if self.state == "MENU":
draw_menu(surface)
elif self.state == "PAUSED":
draw_paused(surface)
elif self.state == "WIN":
draw_win(surface, self.winner)
elif self.state == "SERVE":
font = pygame.font.SysFont("consolas", 22)
hint = font.render("Пробел — подача", True, S.COLOR_ACCENT)
surface.blit(hint, hint.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H - 24)))
Финальный main.py:
import sys
import pygame
import settings as S
from game.game import Game
def main():
pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Ping Pong")
clock = pygame.time.Clock()
game = Game(vs_ai=True)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
else:
if not game.handle_event(event):
running = False
dt = clock.tick(S.FPS) / 1000.0
game.update(dt)
screen.fill(S.COLOR_BG)
game.draw(screen)
pygame.display.flip()
pygame.quit()
sys.exit()
if __name__ == "__main__":
main()
Самопроверка
-
main.pyкороче 40 строк. - Перезапуск матча и смена состояния не дублируют код.
Этап 15 (бонус) — substeps и звук
Цель — убрать проскок мяча сквозь ракетку на высокой скорости и добавить минимальную обратную связь через звук.
Substeps — дробление движения
Идея: за один кадр выполнить несколько маленьких шагов физики вместо одного большого.
В settings.py:
PHYSICS_STEPS = 4
В game/ball.py замените update:
def update(self, dt, field, paddles=()):
step_dt = dt / S.PHYSICS_STEPS
for _ in range(S.PHYSICS_STEPS):
self.rect.x += int(self.vx * step_dt)
self.rect.y += int(self.vy * step_dt)
self._bounce_walls(field)
for paddle in paddles:
self.collide_paddle(paddle)
def _bounce_walls(self, field):
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)
В 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:
import pygame
class Audio:
def __init__(self):
self.enabled = True
try:
pygame.mixer.init(frequency=22050, size=-16, channels=2, buffer=512)
self.hit = pygame.mixer.Sound("assets/hit.wav")
self.goal = pygame.mixer.Sound("assets/score.wav")
self.hit.set_volume(0.4)
self.goal.set_volume(0.5)
except (pygame.error, FileNotFoundError):
self.enabled = False
def play_hit(self):
if self.enabled:
self.hit.play()
def play_goal(self):
if self.enabled:
self.goal.play()
Вызывайте 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 микрошагов/с).
Итоговая самопроверка проекта
Дерево готового проекта
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, Tetris).
Сравнение с другими практикумами раздела
| Ping Pong | Battle City | Match3 (план) | |
|---|---|---|---|
| Сложность | Низкая | Средняя | Средняя |
| Главный навык | Столкновения, FSM | Сетка, спавн, пули | Массивы, каскады |
| Файлов к этапу 14 | ~8 | ~12+ | — |
| Идеальная «первая игра» | Да | После Pong | После Python-бasics |
Связанные материалы
- Практикум разработки игр — о разделе — другие учебные треки (Battle City, Tetris, diabloид).
- Разработка игр на Python — Pygame, спрайты, звук, игровой цикл.
- Компьютерные игры — о разделе — жанры и история аркад.
- Python — Battle City — следующий шаг: сетка, враги, пули.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Пошаговый практикум — Battle City на Python и Pygame: архитектура, 16 этапов, полные листинги, сравнение с NES-оригиналом, отладка и расширения. Пошаговый практикум Match-3 на Python и Pygame — архитектура, 14 этапов, консольный прототип, отладка, тесты, подсказки, анимация и спец-фишки. Пошаговый практикум — гоночная мини-игра на Python и Pygame: архитектура, физика, зависимости, 16 этапов до заезда с кругами, таймером, соперниками и полировкой. Пошаговый практикум — Tetris (тетрис) на Python и Pygame: архитектура, 7 тетромино, вращение, линии, очки, уровни, ghost, 7-bag, hold и 20 этапов до играбельного прототипа. Пошаговый практикум — hack and slash в духе Diablo на Python и Pygame: архитектура, гейм-дизайн, зависимости, 18 обязательных этапов и 4 бонусных до полноценного ARPG-прототипа. Пошаговый практикум — карточный roguelike на Python и Pygame: архитектура, формулы боя, 17 этапов кода, моддинг JSON и сверка с AutoBattler (Тени Шпиля). Пошаговый практикум — survivor-like в духе Vampire Survivors на Java (Swing, Java2D): архитектура, гейм-дизайн, Maven, 18 этапов с полным кодом ключевых систем и карта расширений до Java Survivors. Пошаговый практикум — карточный roguelike в браузере на TypeScript, React и Vite: архитектура, dispatch, 16 этапов, cardEffects, PWA и деплой. Эталон — OnlineCardGame («Приключения Урала Батыра»).Python — Battle City
Python — Match3
Python — Racing
Python — Tetris
Python — диаблоид
Python — карточная стратегия
Java — Java Survivors
TypeScript — OnlineCardGame