Python — Racing

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

Формат практикума

Материалы трека приводятся к единому формату: полные листинги для копирования на каждом этапе, блок "Разбор" и раздел "Полная ревизия" в конце статьи.

  • Гарантированно запускаемые эталоны для сверки: Battle City, Match3, Ping Pong, Tetris, Racing (#full-revision).
  • В этой статье полная ревизия (#full-revision) покрывает этапы 0–14 — овал, круги, таймер, два соперника, меню, отсчёт, пауза и финиш. Этапы 15–16 (мини-карта, nitro) — по желанию.

Как проходить практикум


О практикуме

Соберём аркадные гонки сверху (top-down) на Python 3 и Pygame — овальная трасса, машина с ускорением и поворотом, столкновения с бордюром, контрольные точки, круги, таймер заезда и простые соперники по waypoints. Графика — цветные фигуры (без внешних спрайтов), зато с полным разбором физики, коллизий и состояний гонки.

Жанр top-down racing — вид "с камеры над трассой", как в ранних Micro Machines или Hot Wheels. Альтернатива для отдельного проекта — вертикальный скроллер (машина внизу, дорога едет на игрока); здесь выбран овал, потому что он наглядно учит непрерывную физику, секторный подсчёт кругов и ИИ по точкам маршрута.

Для кого материал

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

Управление в финальной версии

Клавиша Действие
W или Газ
S или Тормоз / задний ход
A или Поворот влево (при движении)
D или Поворот вправо (при движении)
P Пауза
R Перезапуск заезда
Enter Старт из меню
Shift Nitro (этап 16)
F1 Режим отладки — секторы и waypoints

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

  1. Архитектура — как устроен проект до первой строки кода.
  2. Зависимости и структура папок — окружение и файлы.
  3. Этап 0 — минимальный запуск — окно и игровой цикл.
  4. Этапы 1–14 — базовый прототип, по одной механике за шаг.
  5. Полная ревизия файлов — эталон после этапа 14 для построчной сверки.
  6. Этапы 15–16 — позиция в гонке, nitro и полировка (опционально).
  7. Настройка "руления" — таблица параметров физики.
  8. Отладка на трассе — визуализация секторов и waypoints.
  9. Итоговая структура и самопроверка.

Содержание этапов

Тема Новая механика
0 Минимальный запуск Окно, цикл, clock.tick
1 Конфиг и фон config.py, палитра
2 Овальная трасса Эллипсы, линия разметки
3 Машина игрока Класс Car, поворот спрайта
4 Газ и трение speed, FRICTION
5 Движение и поворот move, steer
6 Бордюр Track.clamp_car
7 Секторы RaceProgress, круги
8 Таймер круга perf_counter, best lap
9 Старт/финиш и сброс Черта, клавиша R
10 ИИ соперник Waypoints по овалу
11 Столкновения Несколько машин, отталкивание
12 HUD Скорость, круг, время
13 Состояния заезда Меню, отсчёт, пауза
14 Класс Game Модули, тонкий main.py
15 Позиция и мини-карта Место в гонке, radar
16 Nitro и следы шин Буст, полировка

Что должно получиться

Механика Описание
Трасса Овальное кольцо — асфальт между внутренним и внешним эллипсом
Машина Ускорение, трение, поворот зависит от скорости
Бордюр Выезд за асфальт — отскок и потеря скорости
Круги Четыре сектора-триггера; полный круг только при проходе по порядку
Таймер Время текущего круга и лучший круг
Соперники 1–3 машины по замкнутому маршруту waypoints
Заезд Обратный отсчёт 3–2–1–GO, меню, пауза, финиш после N кругов
Позиция Место в гонке по прогрессу круга и сектора
Nitro Кратковременный буст по Shift с перезарядкой

Сравнение подходов к гонкам в Pygame

Подход Камера Сложность Чему учит
Top-down (этот практикум) Статичная сверху Средняя Угол, скорость, секторы, waypoints
Вертикальный скроллер Дорога движется вниз Ниже Спавн препятствий, скорость мира
Псевдо-3D (OutRun) Перспектива по линиям дороги Выше Проекция, сегменты трассы
Tilemap-трек Сверху или изометрия Средняя Тайлы, A*, сетка

Архитектура

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

Игровой цикл

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]

На каждом кадре внутри обновления (когда состояние RACING):

  1. Прочитать удерживаемые клавиши (get_pressed).
  2. Применить газ/тормоз и трение к скорости игрока.
  3. Повернуть машину, если скорость выше порога.
  4. Сдвинуть позицию по углу и скорости.
  5. Проверить границы трассы — при выезде вернуть на асфальт и урезать скорость.
  6. Обновить прогресс по секторам и счётчик кругов.
  7. Обновить соперников по waypoints.
  8. Проверить столкновения машин (упрощённо — отталкивание).
  9. Обновить таймеры и проверить финиш.

Слои приложения

Слой Ответственность Примеры
Ввод Клавиатура, пауза, меню KEYDOWN, get_pressed
Трасса Геометрия, коллизии с бордюром Track, эллипсы
Физика Скорость, угол, трение Car.update
Прогресс Секторы, круги, финиш RaceProgress
ИИ Движение соперников WaypointFollower
Правила Состояния заезда, таймер GameState
Представление Трасса, машины, HUD draw_track, draw_hud

Слой правил меняет состояние; слой представления только читает его и рисует кадр.

Координатная система

Вид сверху, ось Y направлена вниз (как в Pygame). Угол машины — в градусах, — вправо, рост угла — по часовой стрелке (стандарт pygame.transform.rotate).

Экран (пиксели)
┌────────────────────────────────────────┐
│  трава (фон)                           │
│    ╭────────────────────────╮          │
│    │  внутренний газон       │         │
│    ╰────────────────────────╯          │
│         асфальт (кольцо)               │
│    старт / финиш — нижняя дуга         │
└────────────────────────────────────────┘

Рекомендуемые константы (все модули берут размеры из config.py):

Константа Значение Смысл
SCREEN_W, SCREEN_H 960, 540 Окно 16:9
FPS 60 Кадров в секунду
TRACK_CX, TRACK_CY центр экрана Центр овала
OUTER_RX, OUTER_RY 420, 220 Внешний эллипс
INNER_RX, INNER_RY 220, 110 Внутренний эллипс
CAR_W, CAR_H 44, 22 Габарит машины
MAX_SPEED 8.0 Пикселей за кадр
TOTAL_LAPS 3 Кругов до финиша

Проверка "машина на асфальте" — через нормализованное расстояние до эллипса:

def ellipse_norm(x, y, cx, cy, rx, ry):
    dx, dy = x - cx, y - cy
    return (dx / rx) ** 2 + (dy / ry) ** 2

Точка на кольце, если inner_norm >= 1.0 и outer_norm <= 1.0 (с небольшим запасом EPS).

Модель физики машины

Машина — material point + heading — храним (x, y, angle, speed). Это упрощение без бокового сноса; для аркады его достаточно.

Переменная Единица Роль
speed px/кадр Скalar скорости вдоль angle; отрицательная — задний ход
angle градусы Курс; — вправо, 90° — вниз
ACCELERATION px/кадр² Прирост при газе
FRICTION 0…1 Множитель каждый кадр; 0.96 ≈ лёгкое сопротивление

Обновление за кадр (состояние RACING):

speed += gas * ACCELERATION - brake * BRAKE
speed *= FRICTION
speed = clamp(speed, MIN_SPEED, MAX_SPEED)
if |speed| > STEER_MIN_SPEED:
    angle += turn_input * TURN_SPEED * sign(speed)
x += cos(radians(angle)) * speed
y += sin(radians(angle)) * speed

Поворот зависит от знака скорости — при заднем ходе руль "инвертируется", как у настоящего автомобиля.

Пиксели и "км/ч"

В HUD скорость показываем как abs(speed) 10 — условные km/h для красоты. Реальная физика завязана на px/кадр и FPS; при смене FPS умножайте ускорение на dt 60, если переходите на обновление через dt.

Порядок обновления кадра

Порядок вызовов фиксирован — иначе круги и столкновения "дрожат":

Шаг Действие Почему именно здесь
1 Ввод → apply_input, steer Решаем, куда едем
2 move Меняем позицию
3 track.clamp_car Не даём уехать с асфальта
4 progress.update Секторы после финальной позиции
5 ИИ соперников + clamp_car Соперники в тех же правилах
6 collide_with Разводим пересечения
7 Проверка finished Финиш после логики

Секторы и круги

Трассу делим на 4 сектора по углу от центра (atan2). Игрок должен пройти секторы 0 → 1 → 2 → 3 → 0 подряд; только тогда засчитывается полный круг. Так нельзя "накрутить" круг, проехав туда-сюда на одном участке.

flowchart LR
    S0["Сектор 0\n(справа)"] --> S1["Сектор 1\n(низ)"]
    S1 --> S2["Сектор 2\n(слева)"]
    S2 --> S3["Сектор 3\n(верх)"]
    S3 --> S0
    S0 -->|"после S3"| LAP["+1 круг"]

Схема секторов на овале (вид сверху, центр — TRACK_CX, TRACK_CY):

              сектор 3 (верх)
                   │
     сектор 2 ─────┼───── сектор 0 (право)
      (лево)       │
              сектор 1 (низ, старт)

Угол считается через atan2(dy, dx) в градусах [0, 360); границы секторов — кратные 90°. Старт на нижней дуге попадает в сектор 1, поэтому RaceProgress инициализируется с next_sector = 2.

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

stateDiagram-v2
    [*] --> MENU
    MENU --> COUNTDOWN : Enter
    COUNTDOWN --> RACING : GO
    RACING --> PAUSED : P
    PAUSED --> RACING : P
    RACING --> FINISHED : все круги пройдены
    RACING --> FINISHED : время вышло (опционально)
    FINISHED --> COUNTDOWN : R
    MENU --> [*] : Esc
    RACING --> [*] : Esc

Состояния MENU, COUNTDOWN, RACING, PAUSED, FINISHED — отдельные ветки в update() и draw().

Структура файлов (целевая)

До этапа 5 достаточно main.py и config.py. Дальше код раскладываем по модулям.

racing/
├── main.py              # точка входа, цикл while
├── config.py            # константы, цвета, FPS
├── assets/              # позже — звуки и спрайты
├── game/
│   ├── __init__.py
│   ├── car.py           # Car — физика и отрисовка
│   ├── track.py         # Track — эллипсы, коллизии, рисование
│   ├── progress.py      # RaceProgress — секторы и круги
│   ├── ai.py            # WaypointFollower для соперников
│   ├── hud.py           # скорость, круг, таймер
│   └── states.py        # Game — состояния заезда
└── requirements.txt

Диаграмма объектов

classDiagram
    class Game {
        +state
        +player Car
        +rivals Car[]
        +track Track
        +progress RaceProgress
        +update()
        +draw()
    }
    class Car {
        +x y angle speed
        +update(keys, track)
        +draw(surface)
    }
    class Track {
        +is_on_track(x, y)
        +clamp_to_track(car)
        +draw(surface)
    }
    class RaceProgress {
        +sector lap
        +on_car_moved(x, y)
    }
    Game --> Car
    Game --> Track
    Game --> RaceProgress
    Car --> Track : коллизии
Почему овал, а не tilemap

Для гонок удобна непрерывная траектория: скорость и угол меняются плавно. Овальное кольцо задаётся двумя эллипсами — минимум геометрии, максимум ощущения "трассы". Позже ту же архитектуру можно перенести на полигональную трассу из точек.

ИИ соперников — waypoints

Соперник не "знает" физику — каждый кадр тянется к следующей точке маршрута:

flowchart LR
    W0[waypoint i] --> W1[waypoint i+1]
    W1 --> W2[...]
    W2 --> W0
    Car[Car x,y] -->|вектор к W[i]| W0

Точки строятся по параметрическому овалу (cos/sin с радиусом между inner и outer). Когда расстояние до точки < порога — индекс увеличивается. Скорость ИИ константа; разнообразие — разный speed и сдвиг стартового index, чтобы машины не ехали в хвост.

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

Термин Значение в проекте
Waypoint Точка (x, y) на маршруте; цель для ИИ
Сектор Четверть овала по углу; триггер прогресса
Круг (lap) Полный проход секторов 0→1→2→3→0
Clamp Возврат машины на допустимый эллипс
HUD Наложенный UI — скорость, круг, таймер
dt Длительность кадра в секундах (tick / 1000)

Зависимости и подготовка окружения

Требования

  • Python 3.10+
  • Pygame 2.5+ — единственная внешняя библиотека

Установка

mkdir racing && cd racing
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.version.ver)"

Файл requirements.txt:

pygame>=2.5.0

Первичная структура

На этапе 0 создайте только main.py. Файл config.py появится на этапе 1, папку game/ — ближе к финалу.

Если Pygame не ставится

Сообщение Что сделать
Microsoft Visual C++ 14.0 is required (старые версии) Обновите pip и Pygame до 2.5+, либо поставьте Build Tools
No module named 'pygame' Активируйте .venv — в приглашении должно быть (.venv)
Окно сразу закрывается Запускайте из терминала python main.py, читайте traceback
Чёрный экран на Linux/WSL Нужен графический сервер (WSLg или X11); в headless CI Pygame не откроет окно
Как проходить практикум

Создайте папку racing/, копируйте код после каждого этапа, запускайте python main.py. Если что-то сломалось — сверьтесь с блоком "Самопроверка" в конце этапа.


Этап 0 — минимальный запускаемый код

Цель — окно, цикл событий, выход по крестику и Esc, стабильные 60 FPS.

Создайте main.py:

Запуск:

python main.py

Самопроверка этапа 0

  • Окно открывается без traceback.
  • Фон зелёный (трава), без мерцания.
  • Esc и крестик закрывают программу.

Зачем clock.tick(FPS)

clock.tick(60) ограничивает частоту кадров и возвращает миллисекунды с прошлого кадра. Без него цикл крутится на максимальной скорости CPU — окно мерцает, а физика на слабом и мощном ПК ведёт себя по-разному. С этапа 13 используем dt = tick / 1000.0 для отсчёта 3–2–1.

На следующих этапах не удаляем цикл — только расширяем тело while.


Этап 1 — конфиг и фон

Цель — вынести настройки в config.py, зафиксировать палитру и размер окна.

config.py:

Обновите main.py:

Самопроверка

  • Импорт config без ошибок.
  • Размер окна 960×540.

Этап 2 — овальная трасса

Цель — нарисовать кольцо асфальта между двумя эллипсами и стартовую разметку в шахматном стиле.

Добавьте в main.py функции отрисовки (позже перенесём в track.py):

В цикле вместо одного fill:

screen.fill(C.COLOR_GRASS)
draw_track(screen)

Самопроверка

  • Видно серое кольцо и зелёный "остров" внутри.
  • Трасса по центру экрана.
  • На нижней дуге — бело-чёрная черта и жёлтая линия финиша.

Этап 3 — машина игрока (статичная)

Цель — класс Car, отрисовка повёрнутого прямоугольника, стартовая позиция на нижней дуге.

Добавьте в main.py (или создайте game/car.py и импортируйте):

Стартовая точка — снаружи внутреннего эллипса, снизу по центру:

start_x = C.TRACK_CX
start_y = C.TRACK_CY + (C.INNER_RY + C.OUTER_RY) // 2
player = Car(start_x, start_y, -90, (220, 50, 50))

В цикле после draw_track(screen):

player.draw(screen)

Самопроверка

  • Красная машина стоит на асфальте внизу овала.
  • "Лобовое стекло" смотрит по направлению движения (вверх при angle = -90).

Этап 4 — газ, тормоз и трение

Цель — изменять speed по клавишам W/S, каждый кадр умножать скорость на коэффициент трения.

В config.py добавьте:

MAX_SPEED = 8.0
MIN_SPEED = -3.0
ACCELERATION = 0.18
BRAKE = 0.28
FRICTION = 0.96

В класс Car добавьте метод:

def apply_input(self, keys):
    if keys[pygame.K_w] or keys[pygame.K_UP]:
        self.speed += C.ACCELERATION
    if keys[pygame.K_s] or keys[pygame.K_DOWN]:
        self.speed -= C.BRAKE
    self.speed *= C.FRICTION
    self.speed = max(C.MIN_SPEED, min(C.MAX_SPEED, self.speed))

В цикле перед отрисовкой:

keys = pygame.key.get_pressed()
player.apply_input(keys)

Пока не двигаем (x, y) — только меняется скорость (на следующем этапе поедем).

Для отладки выведите скорость на экран (временно):

font = pygame.font.SysFont("consolas", 20)
# после apply_input:
txt = font.render(f"speed={player.speed:.2f}", True, (255, 255, 255))
screen.blit(txt, (12, 12))

Самопроверка

  • При зажатом W в заголовке окна или временном print(player.speed) скорость растёт до MAX_SPEED.
  • После отпускания клавиш скорость падает к нулю.

Этап 5 — движение и поворот

Цель — сдвиг по углу, поворот A/D только при достаточной скорости.

В config.py:

TURN_SPEED = 3.2
STEER_MIN_SPEED = 0.4

Методы Car:

def steer(self, keys):
    if abs(self.speed) < C.STEER_MIN_SPEED:
        return
    direction = 1 if self.speed >= 0 else -1
    if keys[pygame.K_a] or keys[pygame.K_LEFT]:
        self.angle -= C.TURN_SPEED * direction
    if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
        self.angle += C.TURN_SPEED * direction

def move(self):
    rad = math.radians(self.angle)
    self.x += math.cos(rad) * self.speed
    self.y += math.sin(rad) * self.speed

В цикле:

player.apply_input(keys)
player.steer(keys)
player.move()

Самопроверка

  • Машина ездит по овалу при удержании W и лёгкой коррекции A/D.
  • На месте (speed ≈ 0) поворот не работает.

Сводный main.py после этапа 5

Если вы собирали файл по частям, сверьте его с этим вариантом:

Физика в двух словах

Скорость — скаляр (пикс/кадр), угол — курс в градусах. Каждый кадр: speed меняется от газа/тормоза и умножается на FRICTION; позиция сдвигается по формулам cos/sin. Это упрощённая модель "танка" — без бокового сноса; для аркады этого достаточно.


Этап 6 — коллизии с бордюром

Цель — не давать машине уехать на газон; при выезде — вернуть на асфальт и урезать скорость.

Создайте game/track.py:

Создайте пустой game/__init__.py. В main.py импортируйте Track, создайте track = Track() и в цикле после player.steer(keys) вызывайте player.move(), затем track.clamp_car(player); перед pygame.display.flip()track.draw(screen) (вместо прежней draw_track в main.py).

Как работает clamp_car

Идея — проецировать точку на ближайший допустимый эллипс. Если outer_norm > 1, координаты сжимают к внешней границе; если outer_norm < 1 для внутреннего газона — выталкивают наружу от inner. Множитель 0.55 к скорости имитирует удар о бордюр.

Угол при ударе

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

Самопроверка

  • При резком выезде машина "отскакивает" на асфальт.
  • Скорость заметно падает после удара о бордюр.

Этап 7 — секторы трассы

Цель — определять, в каком секторе (0–3) находится машина, для будущего подсчёта кругов.

game/progress.py:

В config.py:

TOTAL_LAPS = 3

В main.py после clamp_car:

from game.progress import RaceProgress

progress = RaceProgress(player.x, player.y)
# в цикле:
progress.update(player.x, player.y)

Для отладки выведите сектор шрифтом:

font = pygame.font.SysFont("consolas", 20)
label = font.render(f"sector {progress.sector} lap {progress.lap}", True, (255, 255, 255))
screen.blit(label, (12, 12))

Самопроверка

  • При полном обороте по часовой стрелке lap увеличивается на 1.
  • Срезание через центр не даёт лишний круг без прохода всех секторов.
Почему круг не растёт

Частая причина — езда против часовой или пропуск сектора (срезали внутренний газон и "перепрыгнули" с 1 на 3). Смотрите отладку секторов по F1 в разделе Отладка на трассе.


Этап 8 — таймер круга и лучший круг

Цель — засекать время текущего круга и запоминать лучший результат.

Расширьте RaceProgress:

Функция форматирования в main.py или game/hud.py:

def format_time(seconds):
    ms = int((seconds % 1) * 1000)
    s = int(seconds) % 60
    m = int(seconds) // 60
    return f"{m:02d}:{s:02d}.{ms:03d}"

Самопроверка

  • Таймер текущего круга растёт каждый кадр.
  • После финиша сектора 3→0 время фиксируется как last_lap_time.

Этап 9 — линия старта/финиша и сброс заезда

Цель — нарисовать стартовую черту; по R сбрасывать машину и прогресс.

В Track.draw добавьте черту на нижней дуге:

finish_x = C.TRACK_CX
finish_y = C.TRACK_CY + (C.INNER_RY + C.OUTER_RY) // 2
pygame.draw.line(
    surface, (255, 255, 0),
    (finish_x - 30, finish_y),
    (finish_x + 30, finish_y), 4,
)

Функция сброса в main.py:

def reset_race():
    player.x = C.TRACK_CX
    player.y = C.TRACK_CY + (C.INNER_RY + C.OUTER_RY) // 2
    player.angle = -90
    player.speed = 0
    progress.__init__(player.x, player.y)

Обработка KEYDOWN:

elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
    reset_race()

Самопроверка

  • Жёлтая линия видна на старте.
  • R возвращает машину на линию и обнуляет круги.

Этап 10 — соперник по waypoints

Цель — одна машина ИИ, едущая по заранее заданным точкам по овалу.

game/ai.py:

Look-ahead (опционально) — смотреть на точку дальше по маршруту, чтобы траектория была плавнее:

def _target_index(self, offset=3):
    return (self.index + offset) % len(self.waypoints)

def update(self):
    tx, ty = self.waypoints[self._target_index()]
    # ... остальное как выше, tx/ty от look-ahead ...

В main.py:

from game.ai import build_oval_waypoints, WaypointFollower
from game.car import Car

rival = Car(C.TRACK_CX + 120, C.TRACK_CY, 180, (50, 120, 220))
ai = WaypointFollower(rival, build_oval_waypoints(), speed=4.2)
# в цикле:
ai.update()
rival.draw(screen)

Самопроверка

  • Синяя машина стабильно кружит по овалу без выездов.
  • Скорость соперника постоянная (можно менять в конструкторе).

Настройка ИИ

Параметр Эффект Рекомендация
speed Средняя скорость соперника 3.8–4.8 px/кадр
n в build_oval_waypoints(n) Гладкость траектории 32–48 точек
Порог dist < 16 Когда переключать waypoint 12–20 px
rival.index = i * 8 Разводка машин на старте Сдвиг 6–10 точек

Этап 11 — несколько соперников и простое столкновение

Цель — список соперников; при пересечении прямоугольников — лёгкое отталкивание.

В Car добавьте (метод rect() здесь не нужен — используем axis-aligned Rect по центру):

Создайте список:

rivals = [
    WaypointFollower(Car(...), build_oval_waypoints(), speed=4.0),
    WaypointFollower(Car(...), build_oval_waypoints(), speed=4.6),
]

После движения игрока проверяйте столкновения с каждым rival.car.

Самопроверка

  • При таране машины разъезжаются.
  • Скорость игрока падает после контакта.

Этап 12 — HUD (скорость, круг, таймер)

Цель — панель статуса поверх трассы с полупрозрачным фоном и подсказкой управления.

game/hud.py:

Параметры position и total_racers заполним на этапе 15.

Самопроверка

  • HUD читается на фоне трассы.
  • Номер круга совпадает с логикой RaceProgress.

Этап 13 — меню, отсчёт и пауза

Цель — состояния MENU, COUNTDOWN, RACING, PAUSED, FINISHED.

game/states.py (скелет):

В main.py используйте dt = clock.tick(C.FPS) / 1000.0. Пока state != RACING, не вызывайте player.apply_input (или обнуляйте скорость).

Отрисовка оверлеев:

def draw_menu(surface, font):
    title = font.render("Racing — Enter to start", True, (255, 255, 255))
    surface.blit(title, (C.SCREEN_W // 2 - title.get_width() // 2, C.SCREEN_H // 2))

def draw_countdown(surface, font, value):
    text = font.render(str(max(1, int(value + 0.99))), True, (255, 220, 0))
    rect = text.get_rect(center=(C.SCREEN_W // 2, C.SCREEN_H // 2))
    surface.blit(text, rect)

При progress.finished переключайте state = FINISHED и показывайте итоговое время.

Самопроверка

  • Enter запускает 3–2–1, затем гонка.
  • P ставит паузу и снимает её.
  • После 3 кругов — экран финиша.

Этап 14 — класс Game и чистый main.py

Цель — собрать логику в Game, оставить в main.py только инициализацию и цикл.

Перенесите класс Car в game/car.py (добавьте import config as C и метод collide_with из этапа 11):

Пример финального main.py:

Класс Game внутри game/states.py (или game/game.py) хранит player, track, progress, rivals, hud, методы reset, update, draw.

Пример полной реализации Game (можно скопировать в game/states.py после переноса Car, Track, RaceProgress, HUD, WaypointFollower):

Финальный config.py (все константы в одном месте):

Куда развивать дальше

Этапы 15–16 добавляют позицию, nitro и следы. Полный список идей — в разделе Дальнейшее развитие в конце статьи.

Самопроверка этапа 14

  • main.py короче ~40 строк.
  • Поведение совпадает с этапом 13.
  • Все модули в game/ импортируются без циклических ошибок.

Этап 15 — позиция в гонке и мини-карта

Цель — показывать место игрока (P1/3) и мини-схему овала с точками машин.

Подсчёт позиции

Сравниваем progress_score() игрока и каждого соперника. У соперника "виртуальный" прогресс — индекс waypoint, приведённый к той же шкале:

В Game.update после движения сохраняйте self.position = race_position(...).

Мини-карта (radar)

game/minimap.py:

В Game.draw после HUD: self.minimap.draw(surface, self.player, self.rivals).

Самопроверка

  • При обгоне соперника цифра P1, P2 меняется.
  • На мини-карте видны все машины относительно овала.

Этап 16 — nitro и следы от шин

Цель — кратковременный буст по Shift и визуальные следы при заносе.

Nitro

В config.py:

NITRO_BOOST = 0.35
NITRO_MAX = 100.0
NITRO_DRAIN = 2.5
NITRO_RECHARGE = 0.8
MAX_SPEED_NITRO = 11.0

В Car:

В HUD добавьте полоску nitro — pygame.draw.rect шириной пропорционально player.nitro / NITRO_MAX.

Следы шин

Список последних позиций при высокой скорости и резком повороте:

В Game.update после steer вызывайте skids.add(..., turn_delta=...).

Самопроверка

  • Shift + W даёт заметный разгон; полоска nitro опустошается.
  • Без Shift nitro постепенно восстанавливается.
  • На резких поворотах остаются тёмные следы.

Настройка "руления"

Все ощущения от вождения — в нескольких константах config.py. Меняйте по одной и перезапускайте игру.

Константа "Слишком" Симптом Куда крутить
ACCELERATION высокая Машина "стреляет" ↓ до 0.12–0.15
FRICTION низкая (0.90) Долгое скольжение ↑ до 0.96–0.98
FRICTION высокая (0.99) Тормозит слишком резко
TURN_SPEED высокая Крутится на месте ↓ до 2.5
MAX_SPEED высокая Слетает с овала ↓ или ужесточите clamp
STEER_MIN_SPEED высокая Не поворачивает на малой скорости ↓ до 0.2
BRAKE низкая Плохо тормозит

Пресеты

Стиль ACCEL FRICTION TURN MAX_SPEED
Аркада (по умолчанию) 0.18 0.96 3.2 8.0
Симуляция-lite 0.10 0.98 2.2 6.5
Хардкор 0.22 0.94 3.8 9.5

Отладка на трассе

Добавьте флаг DEBUG = False в config.py и переключение по F1.

game/debug_draw.py:

В Game.handle_event по F1 переключайте C.DEBUG. В draw при DEBUG вызывайте функции выше.

Лог в консоль

На время отладки кругов пишите print(sector, next_sector, lap) только при смене сектора — иначе консоль завалится за секунду.


Полная ревизия файлов

Эталонный проект после этапа 14 (проверен импортом from game.states import Game). Скопируйте дерево в папку racing/ и запустите python main.py — овальная трасса, круги, таймер, два соперника, меню, отсчёт 3–2–1, пауза и финиш после трёх кругов.

Дерево проекта

racing/
├── main.py
├── config.py
├── requirements.txt
└── game/
    ├── __init__.py
    ├── car.py
    ├── track.py
    ├── progress.py
    ├── ai.py
    ├── hud.py
    └── states.py   # class Game

requirements.txt

Разбор. Единственная внешняя зависимость — Pygame 2.5+.

pygame>=2.5.0

config.py

Разбор. Размеры окна, геометрия овала, физика машины, палитра и число кругов — все модули импортируют config as C.

main.py

Разбор. Точка входа — цикл событий, dt, вызовы Game.handle_event, update и draw; импорт from game.states import Game.

game/__init__.py

Разбор. Пустой файл — Python считает game пакетом; без него импорты из game.* не сработают.

game/car.py

Разбор. Физика игрока и соперников — газ, трение, поворот, сдвиг, отталкивание и отрисовка прямоугольника.

game/track.py

Разбор. Овальная трасса — нормализованные эллипсы, clamp_car, линия финиша и draw.

game/progress.py

Разбор. Секторы по atan2, счётчик кругов и таймеры круга (perf_counter).

game/ai.py

Разбор. Маршрут по waypoints на среднем радиусе овала; WaypointFollower двигает машину соперника.

game/hud.py

Разбор. Панель скорости, круга и таймеров поверх трассы; подсказка управления внизу экрана.

game/states.py

Разбор. Класс Game — FSM (State), сборка заезда, обновление кадра, оверлеи меню/отсчёта/паузы/финиша.

Разбор финальной архитектуры

  • Track, ellipse clampellipse_norm проверяет кольцо между inner и outer; clamp_car проецирует машину на границу и режет скорость при ударе о бордюр.
  • RaceProgress, sectors — четыре сектора по углу от центра; круг засчитывается только при проходе 0→1→2→3→0 подряд.
  • WaypointFollower AI — соперники тянутся к точкам овала; разный speed и сдвиг index разводят машины на старте.
  • State enum flowMENUCOUNTDOWN (Enter) → RACINGPAUSED (P) → FINISHED после TOTAL_LAPS; R перезапускает заезд.
  • HUD overlay — полупрозрачная панель со скоростью, кругом и таймерами; позиция P&#123;n&#125;/3 считается в race_position по progress_score и индексу waypoint.

Чек-лист эталона

  • Enter запускает отсчёт 3–2–1, затем гонку.
  • WASD ведут машину, она остаётся на овале.
  • Счётчик круга растёт после прохода четырёх секторов по порядку.
  • Два соперника стабильно едут по трассе.
  • P — пауза, R — перезапуск, финиш после трёх кругов.

Итоговая структура и самопроверка

Дерево проекта

racing/
├── main.py
├── config.py
├── requirements.txt
└── game/
    ├── __init__.py
    ├── car.py
    ├── track.py
    ├── progress.py
    ├── ai.py
    ├── hud.py
    ├── minimap.py      # этап 15
    ├── debug_draw.py   # отладка F1
    └── states.py

Чек-лист готового прототипа (этап 14)

Сверьте поведение с полной ревизией или пройдите пункты ниже.

  • Enter запускает отсчёт 3–2–1, затем гонку.
  • WASD ведут машину, она остаётся на овале.
  • Счётчик круга растёт после прохода четырёх секторов по порядку.
  • Два соперника стабильно едут по трассе.
  • P — пауза, R — перезапуск, финиш после трёх кругов.

После этапов 15–16 дополнительно:

  • HUD с позицией;
  • мини-карта;
  • nitro по Shift;
  • следы при заносе.

Типичные ошибки

Симптом Причина Решение
Машина "скользит" боком Поворот при нулевой скорости Проверьте STEER_MIN_SPEED
Круги не растут Секторы проходятся не по порядку Езжайте по периметру овала
ИИ уезжает с трассы Слишком высокая speed у follower Уменьшите до 3.5–4.5
Окно "мигает" Забыли clock.tick Вызов в конце цикла
ImportError: game Нет game/__init__.py Создайте пустой файл
Nitro не кончается Нет NITRO_DRAIN в цикле Списывайте только при Shift
Позиция всегда P1 race_position не вызывается Обновляйте после progress.update
Мини-карта пустая Забыли Minimap.draw Вызов после HUD
Отладка не видна DEBUG не переключается Обработайте K_F1 в handle_event

Дальнейшее развитие

Идеи после прохождения 16 этапов — каждая опирается на уже готовую архитектуру.

Идея Сложность С чего начать
Полигональная трасса из JSON Средняя Заменить ellipse_norm на "точка внутри полигона"
Два игрока на одной клавиатуре Низкая Второй Car, WASD + стрелки
Звук мотора по speed Низкая pygame.mixer, pitch через set_volume
Таблица рекордов Низкая best_lap_time в records.json
Отражение от бордюра Средняя Нормаль эллипса + отражение скорости
Камера следует за машиной Средняя Сдвиг всех draw на -camera_x, -camera_y
Штрафной таймер за off-track Низкая Счётчик кадров вне асфальта → +1 сек штрафа

Два игрока — эскиз

player1 = Car(sx - 20, sy, -90, (220, 50, 50))
player2 = Car(sx + 20, sy, -90, (50, 220, 100))
# player1: WASD, player2: стрелки — разные ветки apply_input по keys

Запись лучшего круга


import json

from pathlib import Path

RECORDS = Path("records.json")

def save_best(name, seconds):
    data = json.loads(RECORDS.read_text()) if RECORDS.exists() else {}
    if name not in data or seconds < data[name]:
        data[name] = seconds
        RECORDS.write_text(json.dumps(data, indent=2))

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