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

Python — Racing

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

О практикуме

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

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

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

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

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

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

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

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

ТемаНовая механика
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СтолкновенияНесколько машин, отталкивание
12HUDСкорость, круг, время
13Состояния заездаМеню, отсчёт, пауза
14Класс GameМодули, тонкий main.py
15Позиция и мини-картаМесто в гонке, radar
16Nitro и следы шинБуст, полировка

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

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

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

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

Архитектура

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

Игровой цикл

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

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

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

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

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

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

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

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

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

КонстантаЗначениеСмысл
SCREEN_W, SCREEN_H960, 540Окно 16:9
FPS60Кадров в секунду
TRACK_CX, TRACK_CYцентр экранаЦентр овала
OUTER_RX, OUTER_RY420, 220Внешний эллипс
INNER_RX, INNER_RY220, 110Внутренний эллипс
CAR_W, CAR_H44, 22Габарит машины
MAX_SPEED8.0Пикселей за кадр
TOTAL_LAPS3Кругов до финиша

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

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). Это упрощение без бокового сноса; для аркады его достаточно.

ПеременнаяЕдиницаРоль
speedpx/кадрСкalar скорости вдоль angle; отрицательная — задний ход
angleградусыКурс; — вправо, 90° — вниз
ACCELERATIONpx/кадр²Прирост при газе
FRICTION0…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Решаем, куда едем
2moveМеняем позицию
3track.clamp_carНе даём уехать с асфальта
4progress.updateСекторы после финальной позиции
5ИИ соперников + clamp_carСоперники в тех же правилах
6collide_withРазводим пересечения
7Проверка finishedФиниш после логики

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

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

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

сектор 3 (верх)

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

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

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

Состояния 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

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

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

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

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

Точки строятся по параметрическому овалу (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:

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("Racing — этап 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((30, 120, 40))
pygame.display.flip()
clock.tick(FPS)

pygame.quit()
sys.exit()

Запуск:

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:

SCREEN_W = 960
SCREEN_H = 540
FPS = 60

TRACK_CX = SCREEN_W // 2
TRACK_CY = SCREEN_H // 2

OUTER_RX = 420
OUTER_RY = 220
INNER_RX = 220
INNER_RY = 110

COLOR_GRASS = (34, 139, 34)
COLOR_ASPHALT = (60, 60, 65)
COLOR_LAWN = (28, 100, 28)
COLOR_LINE = (240, 240, 240)

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

import sys
import pygame
import config as C

pygame.init()
screen = pygame.display.set_mode((C.SCREEN_W, C.SCREEN_H))
pygame.display.set_caption("Racing — этап 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(C.COLOR_GRASS)
pygame.display.flip()
clock.tick(C.FPS)

pygame.quit()
sys.exit()

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

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

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

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

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

def draw_track(surface):
outer = pygame.Rect(
C.TRACK_CX - C.OUTER_RX,
C.TRACK_CY - C.OUTER_RY,
C.OUTER_RX * 2,
C.OUTER_RY * 2,
)
inner = pygame.Rect(
C.TRACK_CX - C.INNER_RX,
C.TRACK_CY - C.INNER_RY,
C.INNER_RX * 2,
C.INNER_RY * 2,
)
pygame.draw.ellipse(surface, C.COLOR_ASPHALT, outer)
pygame.draw.ellipse(surface, C.COLOR_LAWN, inner)
pygame.draw.ellipse(surface, C.COLOR_LINE, outer, 3)
pygame.draw.ellipse(surface, C.COLOR_LINE, inner, 2)
draw_start_line(surface)


def draw_start_line(surface):
"""Шахматная черта на нижней дуге — старт/финиш."""
y = C.TRACK_CY + (C.INNER_RY + C.OUTER_RY) // 2
x0 = C.TRACK_CX - 36
square = 8
for i in range(9):
color = (255, 255, 255) if i % 2 == 0 else (20, 20, 20)
pygame.draw.rect(surface, color, (x0 + i * square, y - 6, square, 12))
pygame.draw.line(surface, (255, 220, 0), (x0 - 4, y), (x0 + 9 * square + 4, y), 2)

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

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

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

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

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

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

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

import math


class Car:
def __init__(self, x, y, angle, color):
self.x = x
self.y = y
self.angle = angle
self.speed = 0.0
self.color = color
self.width = 44
self.height = 22

def draw(self, surface):
body = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
pygame.draw.rect(body, self.color, (0, 0, self.width, self.height), border_radius=4)
pygame.draw.rect(body, (200, 220, 255), (self.width - 14, 4, 10, self.height - 8), border_radius=2)
rotated = pygame.transform.rotate(body, -self.angle)
rect = rotated.get_rect(center=(int(self.x), int(self.y)))
surface.blit(rotated, rect)

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

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

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

import math
import sys
import pygame
import config as C


class Car:
def __init__(self, x, y, angle, color):
self.x = x
self.y = y
self.angle = angle
self.speed = 0.0
self.color = color
self.width = 44
self.height = 22

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))

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

def draw(self, surface):
body = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
pygame.draw.rect(body, self.color, (0, 0, self.width, self.height), border_radius=4)
pygame.draw.rect(body, (200, 220, 255), (self.width - 14, 4, 10, self.height - 8), border_radius=2)
rotated = pygame.transform.rotate(body, -self.angle)
rect = rotated.get_rect(center=(int(self.x), int(self.y)))
surface.blit(rotated, rect)


def draw_track(surface):
outer = pygame.Rect(
C.TRACK_CX - C.OUTER_RX, C.TRACK_CY - C.OUTER_RY,
C.OUTER_RX * 2, C.OUTER_RY * 2,
)
inner = pygame.Rect(
C.TRACK_CX - C.INNER_RX, C.TRACK_CY - C.INNER_RY,
C.INNER_RX * 2, C.INNER_RY * 2,
)
pygame.draw.ellipse(surface, C.COLOR_ASPHALT, outer)
pygame.draw.ellipse(surface, C.COLOR_LAWN, inner)
pygame.draw.ellipse(surface, C.COLOR_LINE, outer, 3)
pygame.draw.ellipse(surface, C.COLOR_LINE, inner, 2)


pygame.init()
screen = pygame.display.set_mode((C.SCREEN_W, C.SCREEN_H))
pygame.display.set_caption("Racing — этап 5")
clock = pygame.time.Clock()

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

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

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

screen.fill(C.COLOR_GRASS)
draw_track(screen)
player.draw(screen)
pygame.display.flip()
clock.tick(C.FPS)

pygame.quit()
sys.exit()

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


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

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

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

import pygame
import config as C


def ellipse_norm(x, y, rx, ry):
dx = x - C.TRACK_CX
dy = y - C.TRACK_CY
return (dx / rx) ** 2 + (dy / ry) ** 2


class Track:
EPS = 0.02

def is_on_track(self, x, y):
outer = ellipse_norm(x, y, C.OUTER_RX, C.OUTER_RY)
inner = ellipse_norm(x, y, C.INNER_RX, C.INNER_RY)
return inner >= 1.0 - self.EPS and outer <= 1.0 + self.EPS

def clamp_car(self, car):
x, y = car.x, car.y
inner = ellipse_norm(x, y, C.INNER_RX, C.INNER_RY)
outer = ellipse_norm(x, y, C.OUTER_RX, C.OUTER_RY)
dx = x - C.TRACK_CX
dy = y - C.TRACK_CY
hit = False

if outer > 1.0:
scale = (1.0 - self.EPS) / (outer ** 0.5)
x = C.TRACK_CX + dx * scale
y = C.TRACK_CY + dy * scale
hit = True
elif inner < 1.0:
scale = (1.0 + self.EPS) / (inner ** 0.5)
x = C.TRACK_CX + dx * scale
y = C.TRACK_CY + dy * scale
hit = True

if hit:
car.speed *= 0.55
car.x, car.y = x, y

def draw(self, surface):
outer = pygame.Rect(
C.TRACK_CX - C.OUTER_RX, C.TRACK_CY - C.OUTER_RY,
C.OUTER_RX * 2, C.OUTER_RY * 2,
)
inner = pygame.Rect(
C.TRACK_CX - C.INNER_RX, C.TRACK_CY - C.INNER_RY,
C.INNER_RX * 2, C.INNER_RY * 2,
)
pygame.draw.ellipse(surface, C.COLOR_ASPHALT, outer)
pygame.draw.ellipse(surface, C.COLOR_LAWN, inner)
pygame.draw.ellipse(surface, C.COLOR_LINE, outer, 3)
pygame.draw.ellipse(surface, C.COLOR_LINE, inner, 2)

Создайте пустой game/__init__.py. В main.py:

from game.track import Track

track = Track()
# ...
player.move()
track.clamp_car(player)
# ...
track.draw(screen)

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

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

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

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

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

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

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

game/progress.py:

import math
import config as C


def sector_at(x, y):
dx = x - C.TRACK_CX
dy = y - C.TRACK_CY
angle = math.degrees(math.atan2(dy, dx)) % 360
return int(angle // 90) # 0: право, 1: низ, 2: лево, 3: верх


class RaceProgress:
def __init__(self, start_x, start_y):
self.sector = sector_at(start_x, start_y)
self.next_sector = (self.sector + 1) % 4
self.lap = 0
self.total_laps = C.TOTAL_LAPS

def update(self, x, y):
current = sector_at(x, y)
if current == self.next_sector:
self.sector = current
self.next_sector = (self.next_sector + 1) % 4
if self.next_sector == 0:
self.lap += 1

@property
def finished(self):
return self.lap >= self.total_laps

def progress_score(self):
"""0.0 … 1.0 — доля текущего круга для таблицы позиций (этап 15)."""
return self.lap + self.sector / 4.0

В 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:

import time


class RaceProgress:
def __init__(self, start_x, start_y):
self.sector = sector_at(start_x, start_y)
self.next_sector = (self.sector + 1) % 4
self.lap = 0
self.total_laps = C.TOTAL_LAPS
self.lap_start = time.perf_counter()
self.current_lap_time = 0.0
self.last_lap_time = 0.0
self.best_lap_time = None

def update(self, x, y):
current = sector_at(x, y)
if current == self.next_sector:
self.sector = current
self.next_sector = (self.next_sector + 1) % 4
if self.next_sector == 0:
self.last_lap_time = time.perf_counter() - self.lap_start
if self.best_lap_time is None or self.last_lap_time < self.best_lap_time:
self.best_lap_time = self.last_lap_time
self.lap += 1
self.lap_start = time.perf_counter()
self.current_lap_time = time.perf_counter() - self.lap_start

Функция форматирования в 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:

import math
import config as C


def build_oval_waypoints(n=32):
points = []
mid_rx = (C.OUTER_RX + C.INNER_RX) / 2
mid_ry = (C.OUTER_RY + C.INNER_RY) / 2
for i in range(n):
t = (i / n) * 2 * math.pi
x = C.TRACK_CX + math.cos(t) * mid_rx
y = C.TRACK_CY + math.sin(t) * mid_ry
points.append((x, y))
return points


class WaypointFollower:
def __init__(self, car, waypoints, speed=4.5):
self.car = car
self.waypoints = waypoints
self.index = 0
self.speed = speed

def update(self):
tx, ty = self.waypoints[self.index]
dx = tx - self.car.x
dy = ty - self.car.y
dist = math.hypot(dx, dy)
if dist < 16:
self.index = (self.index + 1) % len(self.waypoints)
tx, ty = self.waypoints[self.index]
dx = tx - self.car.x
dy = ty - self.car.y
dist = math.hypot(dx, dy)
if dist > 0:
self.car.x += (dx / dist) * self.speed
self.car.y += (dy / dist) * self.speed
self.car.angle = math.degrees(math.atan2(dy, dx))
self.car.speed = self.speed

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Когда переключать waypoint12–20 px
rival.index = i * 8Разводка машин на стартеСдвиг 6–10 точек

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

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

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

def collide_with(self, other):
r1 = pygame.Rect(0, 0, self.width, self.height)
r1.center = (int(self.x), int(self.y))
r2 = pygame.Rect(0, 0, other.width, other.height)
r2.center = (int(other.x), int(other.y))
if r1.colliderect(r2):
dx = self.x - other.x
dy = self.y - other.y
if dx == 0 and dy == 0:
dx = 1
length = math.hypot(dx, dy)
push = 3.0
self.x += (dx / length) * push
self.y += (dy / length) * push
self.speed *= 0.85
other.speed *= 0.85

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

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

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

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

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

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

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

game/hud.py:

import pygame
import config as C


class HUD:
def __init__(self):
self.font = pygame.font.SysFont("consolas", 22)
self.font_small = pygame.font.SysFont("consolas", 16)
self.panel = pygame.Surface((240, 150), pygame.SRCALPHA)
self.panel.fill((0, 0, 0, 140))

def draw(self, surface, player, progress, format_time, position=1, total_racers=3):
surface.blit(self.panel, (8, 8))
lines = [
f"P{position}/{total_racers}",
f"Speed: {abs(player.speed) * 10:.0f} km/h",
f"Lap: {min(progress.lap + 1, progress.total_laps)}/{progress.total_laps}",
f"Current: {format_time(progress.current_lap_time)}",
f"Last: {format_time(progress.last_lap_time)}",
]
if progress.best_lap_time is not None:
lines.append(f"Best: {format_time(progress.best_lap_time)}")
y = 14
for text in lines:
surf = self.font.render(text, True, (240, 240, 250))
surface.blit(surf, (16, y))
y += 22

hint = "WASD — drive | P pause | R restart | Shift nitro"
hint_surf = self.font_small.render(hint, True, (200, 200, 210))
surface.blit(hint_surf, (C.SCREEN_W // 2 - hint_surf.get_width() // 2, C.SCREEN_H - 28))

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

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

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

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

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

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

from enum import Enum, auto


class State(Enum):
MENU = auto()
COUNTDOWN = auto()
RACING = auto()
PAUSED = auto()
FINISHED = auto()


class Game:
def __init__(self):
self.state = State.MENU
self.countdown = 3.0

def handle_event(self, event):
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_RETURN and self.state == State.MENU:
self.state = State.COUNTDOWN
self.countdown = 3.0
elif event.key == pygame.K_p and self.state == State.RACING:
self.state = State.PAUSED
elif event.key == pygame.K_p and self.state == State.PAUSED:
self.state = State.RACING

def update(self, dt):
if self.state == State.COUNTDOWN:
self.countdown -= dt
if self.countdown <= 0:
self.state = State.RACING

В 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):

import math
import pygame
import config as C


class Car:
def __init__(self, x, y, angle, color):
self.x = x
self.y = y
self.angle = angle
self.speed = 0.0
self.color = color
self.width = 44
self.height = 22

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))

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

def collide_with(self, other):
r1 = pygame.Rect(0, 0, self.width, self.height)
r1.center = (int(self.x), int(self.y))
r2 = pygame.Rect(0, 0, other.width, other.height)
r2.center = (int(other.x), int(other.y))
if r1.colliderect(r2):
dx = self.x - other.x
dy = self.y - other.y
if dx == 0 and dy == 0:
dx = 1
length = math.hypot(dx, dy)
push = 3.0
self.x += (dx / length) * push
self.y += (dy / length) * push
self.speed *= 0.85
other.speed *= 0.85

def draw(self, surface):
body = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
pygame.draw.rect(body, self.color, (0, 0, self.width, self.height), border_radius=4)
pygame.draw.rect(body, (200, 220, 255), (self.width - 14, 4, 10, self.height - 8), border_radius=2)
rotated = pygame.transform.rotate(body, -self.angle)
rect = rotated.get_rect(center=(int(self.x), int(self.y)))
surface.blit(rotated, rect)

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

import sys
import pygame
import config as C
from game.states import Game

pygame.init()
screen = pygame.display.set_mode((C.SCREEN_W, C.SCREEN_H))
pygame.display.set_caption("Racing")
clock = pygame.time.Clock()
game = Game()

running = True
while running:
dt = clock.tick(C.FPS) / 1000.0
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
else:
game.handle_event(event)

game.update(dt)
game.draw(screen)
pygame.display.flip()

pygame.quit()
sys.exit()

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

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

import math
import time
import pygame
from enum import Enum, auto

import config as C
from game.car import Car
from game.track import Track
from game.progress import RaceProgress
from game.ai import build_oval_waypoints, WaypointFollower
from game.hud import HUD


class State(Enum):
MENU = auto()
COUNTDOWN = auto()
RACING = auto()
PAUSED = auto()
FINISHED = auto()


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


class Game:
def __init__(self):
self.state = State.MENU
self.countdown = 3.0
self.track = Track()
self.hud = HUD()
self.font_big = pygame.font.SysFont("consolas", 36)
self.font = pygame.font.SysFont("consolas", 22)
self._build_race()

def _start_position(self):
y = C.TRACK_CY + (C.INNER_RY + C.OUTER_RY) // 2
return C.TRACK_CX, y

def _build_race(self):
sx, sy = self._start_position()
self.player = Car(sx, sy, -90, (220, 50, 50))
self.progress = RaceProgress(sx, sy)
wps = build_oval_waypoints()
self.rivals = [
WaypointFollower(Car(sx + 80, sy, -90, (50, 120, 220)), wps, speed=4.0),
WaypointFollower(Car(sx - 60, sy, -90, (220, 180, 50)), wps, speed=4.5),
]
for i, rival in enumerate(self.rivals):
rival.index = (i * 8) % len(wps)

def reset_race(self):
self._build_race()
self.state = State.COUNTDOWN
self.countdown = 3.0

def handle_event(self, event):
if event.type != pygame.KEYDOWN:
return
if event.key == pygame.K_RETURN and self.state == State.MENU:
self.reset_race()
elif event.key == pygame.K_r and self.state in (State.RACING, State.FINISHED, State.PAUSED):
self.reset_race()
elif event.key == pygame.K_p and self.state == State.RACING:
self.state = State.PAUSED
elif event.key == pygame.K_p and self.state == State.PAUSED:
self.state = State.RACING

def update(self, dt):
if self.state == State.COUNTDOWN:
self.countdown -= dt
if self.countdown <= 0:
self.state = State.RACING
return
if self.state != State.RACING:
return

keys = pygame.key.get_pressed()
self.player.apply_input(keys)
self.player.steer(keys)
self.player.move()
self.track.clamp_car(self.player)
self.progress.update(self.player.x, self.player.y)

for rival in self.rivals:
rival.update()
self.track.clamp_car(rival.car)
self.player.collide_with(rival.car)

if self.progress.finished:
self.state = State.FINISHED

def draw(self, surface):
surface.fill(C.COLOR_GRASS)
self.track.draw(surface)
for rival in self.rivals:
rival.car.draw(surface)
self.player.draw(surface)
self.hud.draw(surface, self.player, self.progress, format_time)

if self.state == State.MENU:
t = self.font_big.render("Racing", True, (255, 255, 255))
hint = self.font.render("Enter — старт, Esc — выход", True, (200, 200, 210))
surface.blit(t, t.get_rect(center=(C.SCREEN_W // 2, C.SCREEN_H // 2 - 30)))
surface.blit(hint, hint.get_rect(center=(C.SCREEN_W // 2, C.SCREEN_H // 2 + 20)))
elif self.state == State.COUNTDOWN:
n = max(1, int(self.countdown + 0.99))
text = self.font_big.render(str(n), True, (255, 220, 0))
surface.blit(text, text.get_rect(center=(C.SCREEN_W // 2, C.SCREEN_H // 2)))
elif self.state == State.PAUSED:
text = self.font_big.render("PAUSE", True, (255, 255, 255))
surface.blit(text, text.get_rect(center=(C.SCREEN_W // 2, C.SCREEN_H // 2)))
elif self.state == State.FINISHED:
msg = self.font_big.render("FINISH!", True, (100, 255, 120))
best = ""
if self.progress.best_lap_time:
best = f"Best lap: {format_time(self.progress.best_lap_time)}"
sub = self.font.render(best + " R — заново", True, (230, 230, 240))
surface.blit(msg, msg.get_rect(center=(C.SCREEN_W // 2, C.SCREEN_H // 2 - 20)))
surface.blit(sub, sub.get_rect(center=(C.SCREEN_W // 2, C.SCREEN_H // 2 + 24)))

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

SCREEN_W = 960
SCREEN_H = 540
FPS = 60

TRACK_CX = SCREEN_W // 2
TRACK_CY = SCREEN_H // 2
OUTER_RX = 420
OUTER_RY = 220
INNER_RX = 220
INNER_RY = 110

MAX_SPEED = 8.0
MIN_SPEED = -3.0
ACCELERATION = 0.18
BRAKE = 0.28
FRICTION = 0.96
TURN_SPEED = 3.2
STEER_MIN_SPEED = 0.4

TOTAL_LAPS = 3

COLOR_GRASS = (34, 139, 34)
COLOR_ASPHALT = (60, 60, 65)
COLOR_LAWN = (28, 100, 28)
COLOR_LINE = (240, 240, 240)

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

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

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

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

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

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

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

def rival_progress(rival, waypoint_count):
return rival.index / waypoint_count


def race_position(player_progress, rivals, waypoint_count):
scores = [("player", player_progress.progress_score())]
for i, rival in enumerate(rivals):
lap_equiv = rival.index / waypoint_count * 4 # грубо: 4 сектора на круг
scores.append((f"rival{i}", lap_equiv))
scores.sort(key=lambda x: x[1], reverse=True)
for rank, (name, _) in enumerate(scores, start=1):
if name == "player":
return rank
return len(scores)

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

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

game/minimap.py:

import pygame
import config as C


class Minimap:
def __init__(self):
self.size = 120
self.x = C.SCREEN_W - self.size - 12
self.y = 12

def _scale(self, px, py):
sx = self.x + self.size // 2 + int((px - C.TRACK_CX) * 0.12)
sy = self.y + self.size // 2 + int((py - C.TRACK_CY) * 0.12)
return sx, sy

def draw(self, surface, player, rivals):
rect = pygame.Rect(self.x, self.y, self.size, self.size)
pygame.draw.rect(surface, (0, 0, 0, 160), rect)
pygame.draw.rect(surface, (200, 200, 210), rect, 2)
pygame.draw.ellipse(
surface, (80, 80, 85),
rect.inflate(-20, -20), 1,
)
for rival in rivals:
rx, ry = self._scale(rival.car.x, rival.car.y)
pygame.draw.circle(surface, rival.car.color, (rx, ry), 4)
px, py = self._scale(player.x, player.y)
pygame.draw.circle(surface, (255, 80, 80), (px, py), 5)

В 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:

def __init__(self, ...):
# ...
self.nitro = C.NITRO_MAX

def apply_input(self, keys):
nitro_active = keys[pygame.K_LSHIFT] and self.nitro > 0
accel = C.ACCELERATION + (C.NITRO_BOOST if nitro_active else 0)
max_spd = C.MAX_SPEED_NITRO if nitro_active else C.MAX_SPEED
if keys[pygame.K_w] or keys[pygame.K_UP]:
self.speed += accel
# ... тормоз, трение ...
self.speed = max(C.MIN_SPEED, min(max_spd, self.speed))
if nitro_active:
self.nitro = max(0, self.nitro - C.NITRO_DRAIN)
else:
self.nitro = min(C.NITRO_MAX, self.nitro + C.NITRO_RECHARGE)

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

Следы шин

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

class SkidMarks:
def __init__(self, limit=80):
self.points = []
self.limit = limit

def add(self, x, y, speed, turn_delta):
if abs(speed) > 5 and abs(turn_delta) > 0.5:
self.points.append((int(x), int(y)))
if len(self.points) > self.limit:
self.points.pop(0)

def draw(self, surface):
for i in range(1, len(self.points)):
pygame.draw.line(surface, (40, 40, 45), self.points[i - 1], self.points[i], 2)

В 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низкаяПлохо тормозит

Пресеты

СтильACCELFRICTIONTURNMAX_SPEED
Аркада (по умолчанию)0.180.963.28.0
Симуляция-lite0.100.982.26.5
Хардкор0.220.943.89.5

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

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

game/debug_draw.py:

import math
import pygame
import config as C
from game.progress import sector_at


def draw_sectors(surface, font):
colors = [(255, 100, 100), (100, 255, 100), (100, 100, 255), (255, 255, 100)]
for s in range(4):
a0 = math.radians(s * 90)
a1 = math.radians((s + 1) * 90)
x0 = C.TRACK_CX + math.cos(a0) * (C.INNER_RX + 40)
y0 = C.TRACK_CY + math.sin(a0) * (C.INNER_RY + 40)
x1 = C.TRACK_CX + math.cos(a1) * (C.OUTER_RX - 20)
y1 = C.TRACK_CY + math.sin(a1) * (C.OUTER_RY - 20)
pygame.draw.line(surface, colors[s], (x0, y0), (x1, y1), 2)
label = font.render(str(s), True, colors[s])
surface.blit(label, (x0, y0))


def draw_waypoints(surface, waypoints):
for i, (wx, wy) in enumerate(waypoints):
pygame.draw.circle(surface, (255, 180, 0), (int(wx), int(wy)), 3)
if i % 4 == 0:
pygame.draw.circle(surface, (255, 255, 255), (int(wx), int(wy)), 6, 1)


def draw_car_debug(surface, car, font):
s = sector_at(car.x, car.y)
t = font.render(f"sec={s} sp={car.speed:.1f}", True, (255, 255, 255))
surface.blit(t, (int(car.x) - 20, int(car.y) - 30))

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

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


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

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

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

Чек-лист готового прототипа

  • Овал рисуется, машина ездит по асфальту с трением и поворотом.
  • Выезд за бордюр наказывается потерей скорости.
  • Круги считаются только через 4 сектора по порядку.
  • Есть текущий, последний и лучший круг.
  • 1–3 соперника едут по waypoints.
  • Меню → отсчёт → гонка → финиш после 3 кругов.
  • Пауза и перезапуск работают.
  • HUD показывает позицию; мини-карта отображает машины.
  • Nitro по Shift с перезарядкой; следы при заносе.

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

СимптомПричинаРешение
Машина «скользит» бокомПоворот при нулевой скоростиПроверьте STEER_MIN_SPEED
Круги не растутСекторы проходятся не по порядкуЕзжайте по периметру овала
ИИ уезжает с трассыСлишком высокая speed у followerУменьшите до 3.5–4.5
Окно «мигает»Забыли clock.tickВызов в конце цикла
ImportError: gameНет game/__init__.pyСоздайте пустой файл
Nitro не кончаетсяНет NITRO_DRAIN в циклеСписывайте только при Shift
Позиция всегда P1race_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))

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


См. также

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

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