Перейти к основному содержимому

Python — Ping Pong

Разработчику Начальный уровень

О практикуме

Pong (Ping Pong) — одна из первых аркад: две ракетки отбивают мяч по горизонтали; проигрывает тот, кто пропустил мяч за свою линию. В этом практикуме соберём полноценный прототип на Python 3 и Pygame — без спрайтов из оригинала Atari, на цветных прямоугольниках, зато с разбором физики отскока, счёта и игровых состояний.

Для кого материал
Нужны базовые Python (классы, списки, циклы) и знакомство с Pygame из статьи Разработка игр на Python. Каждый этап — запускаемый код: после шага проект можно запустить и увидеть новую механику.

Что получится в конце

  • Окно 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Выход

Маршрут чтения

  1. Архитектура — как устроен проект до первой строки кода.
  2. Зависимости и структура папок — окружение и файлы.
  3. Этап 0 — минимальный запуск — чёрное окно и игровой цикл.
  4. Этапы 1–14 — по одной механике за шаг.
  5. Этап 15 — substeps и звук (бонус, полировка).
  6. Итоговая структура и самопроверка.

Карта этапов

ЭтапФокусНовое поведение в игре
0Цикл PygameТёмное окно, выход по Esc
1settings.pyКонстанты в одном файле
2КортПоле, рамка, пунктир
3PaddleДве статичные ракетки
4ВводЛевая ракетка на W/S
5BallМяч в центре
6МодулиМяч летит по диагонали
7СтеныОтскок от верха и низа
8colliderectПростой отскок от ракетки
9Угол удараТраектория зависит от точки контакта
10ГолСчёт на экране
11SERVEПодача по Пробелу
12ИИ / 2 игрокаПравая ракетка оживает
13FSMМеню, пауза, победа
14GameЧистая архитектура
15Substeps + звукМяч не «проскакивает» сквозь ракетку

Архитектура

Прежде чем писать код, зафиксируем что из чего состоит и как данные текут по кадру.

Кратко об оригинале

Pong (Atari, 1972) — одна из первых коммерчески успешных аркад. Управление сводится к одной оси на игрока; вся сложность — в тайминге и угле отскока. Учебная версия на Pygame повторяет ту же петлю «ввод → движение → столкновение → счёт», которую позже масштабируют до платформеров и шутеров.

Игровой цикл

Любая игра на Pygame крутит один и тот же цикл. В Pong порядок шагов важен — сначала ввод и логика, потом отрисовка.

На каждом кадре внутри обновления выполняется цепочка:

  1. Прочитать нажатые клавиши (или решение ИИ).
  2. Сдвинуть ракетки с учётом границ поля.
  3. Сдвинуть мяч по скорости (vx, vy).
  4. Проверить столкновения со стенами и ракетками.
  5. При голе — обновить счёт, сбросить мяч, перейти в режим подачи.
  6. Проверить победу (например, до 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_H960, 540Размер окна (16:9)
MARGIN40Отступ поля от краёв экрана
PADDLE_W, PADDLE_H14, 100Ракетка — узкий высокий прямоугольник
BALL_SIZE16Диаметр мяча (квадрат 16×16)
PADDLE_SPEED420Пикселей в секунду
BALL_SPEED320Базовая скорость мяча
WIN_SCORE11Очков для победы
FPS60Кадров в секунду

Игровое поле — прямоугольник внутри отступов:

FIELD_RECT = pygame.Rect(MARGIN, MARGIN, SCREEN_W - 2 * MARGIN, SCREEN_H - 2 * MARGIN)

Ракетки прилипают к левому и правому краю поля; мяч отскакивает от верхней и нижней границы FIELD_RECT.

Физика отскока (упрощённая модель)

Классический Pong на Atari использовал два типа столкновений:

  1. Стены (верх / низ) — инвертировать vy (скорость по Y меняет знак).
  2. Ракетка — инвертировать 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 менять не нужно.

Rect и ручная физика
Для аркады достаточно pygame.Rect.colliderect и явной коррекции скорости. Отдельный physics engine (Box2D, Pymunk) здесь лишний: Pong учит цикл, ввод и предсказуемые столкновения.

Конечный автомат состояний

Игра переключается между экранами через явное поле state:

Состояния MENU, SERVE, PLAYING, PAUSED, WIN — отдельные ветки в update() и draw().

Состояниеupdatedraw
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... сломается.

Pygame на Windows
Если 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.

Delta time
На всех этапах движение считаем через 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
На этапе 0 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)

get_pressed и KEYDOWN
Для непрерывного движения удобнее 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
Файл 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, чтобы проверить отскок вручную.

Tunneling — проскок сквозь ракетку
За один кадр мяч может «перепрыгнуть» через тонкую ракетку, если |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 или звук

Словарь терминов

ТерминЗначение в этом практикуме
dtDelta time — секунды с прошлого кадра; умножается на скорость
FSMFinite State Machine — переключение MENU / PLAYING / …
HUDHead-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 PongBattle CityMatch3 (план)
СложностьНизкаяСредняяСредняя
Главный навыкСтолкновения, FSMСетка, спавн, пулиМассивы, каскады
Файлов к этапу 14~8~12+
Идеальная «первая игра»ДаПосле PongПосле Python-бasics

Связанные материалы


См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").

Освоение главы0%