Python — Racing
О практикуме
Соберём аркадные гонки сверху (top-down) на Python 3 и Pygame: овальная трасса, машина с ускорением и поворотом, столкновения с бордюром, контрольные точки, круги, таймер заезда и простые соперники по waypoints. Графика — цветные фигуры (без внешних спрайтов), зато с полным разбором физики, коллизий и состояний гонки.
Жанр top-down racing — вид «с камеры над трассой», как в ранних Micro Machines или Hot Wheels. Альтернатива для отдельного проекта — вертикальный скроллер (машина внизу, дорога едет на игрока); здесь выбран овал, потому что он наглядно учит непрерывную физику, секторный подсчёт кругов и ИИ по точкам маршрута.
math) и знакомство с Pygame из статьи Разработка игр на Python. Каждый этап — запускаемый код: после шага проект можно запустить и увидеть новую механику.Управление в финальной версии
| Клавиша | Действие |
|---|---|
W или ↑ | Газ |
S или ↓ | Тормоз / задний ход |
A или ← | Поворот влево (при движении) |
D или → | Поворот вправо (при движении) |
P | Пауза |
R | Перезапуск заезда |
Enter | Старт из меню |
Shift | Nitro (этап 16) |
F1 | Режим отладки — секторы и waypoints |
Маршрут чтения
- Архитектура — как устроен проект до первой строки кода.
- Зависимости и структура папок — окружение и файлы.
- Этап 0 — минимальный запуск — окно и игровой цикл.
- Этапы 1–14 — базовый прототип, по одной механике за шаг.
- Этапы 15–16 — позиция в гонке, nitro и полировка.
- Настройка «руления» — таблица параметров физики.
- Отладка на трассе — визуализация секторов и waypoints.
- Итоговая структура и самопроверка.
Содержание этапов
| № | Тема | Новая механика |
|---|---|---|
| 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*, сетка |
Архитектура
Прежде чем писать код, зафиксируем из чего состоит гонка и как данные текут по кадру.
Игровой цикл
На каждом кадре внутри обновления (когда состояние RACING):
- Прочитать удерживаемые клавиши (
get_pressed). - Применить газ/тормоз и трение к скорости игрока.
- Повернуть машину, если скорость выше порога.
- Сдвинуть позицию по углу и скорости.
- Проверить границы трассы — при выезде вернуть на асфальт и урезать скорость.
- Обновить прогресс по секторам и счётчик кругов.
- Обновить соперников по waypoints.
- Проверить столкновения машин (упрощённо — отталкивание).
- Обновить таймеры и проверить финиш.
Слои приложения
| Слой | Ответственность | Примеры |
|---|---|---|
| Ввод | Клавиатура, пауза, меню | KEYDOWN, get_pressed |
| Трасса | Геометрия, коллизии с бордюром | Track, эллипсы |
| Физика | Скорость, угол, трение | Car.update |
| Прогресс | Секторы, круги, финиш | RaceProgress |
| ИИ | Движение соперников | WaypointFollower |
| Правила | Состояния заезда, таймер | GameState |
| Представление | Трасса, машины, HUD | draw_track, draw_hud |
Слой правил меняет состояние; слой представления только читает его и рисует кадр.
Координатная система
Вид сверху, ось Y направлена вниз (как в Pygame). Угол машины — в градусах, 0° — вправо, рост угла — по часовой стрелке (стандарт 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 | градусы | Курс; 0° — вправо, 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
Поворот зависит от знака скорости — при заднем ходе руль «инвертируется», как у настоящего автомобиля.
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 подряд; только тогда засчитывается полный круг. Так нельзя «накрутить» круг, проехав туда-сюда на одном участке.
Схема секторов на овале (вид сверху, центр — 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
Диаграмма объектов
ИИ соперников — 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. - Срезание через центр не даёт лишний круг без прохода всех секторов.
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 | Когда переключать waypoint | 12–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)
Самопроверка этапа 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 | низкая | Плохо тормозит | ↑ |
Пресеты
| Стиль | 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:
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 |
| Позиция всегда 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))
Связанные материалы
- Разработка игр на Python — игровой цикл, события, спрайты.
- Python — Battle City — похожая поэтапная структура с коллизиями.
- Практикум разработки игр — о разделе — остальные треки.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Пошаговый практикум — Battle City на Python и Pygame: архитектура, 16 этапов, полные листинги, сравнение с NES-оригиналом, отладка и расширения. Пошаговый практикум Match-3 на Python и Pygame — архитектура, 14 этапов, консольный прототип, отладка, тесты, подсказки, анимация и спец-фишки. Пошаговый практикум — аркада Ping Pong (Pong) на Python и Pygame: архитектура, баланс, зависимости, 14 этапов до прототипа, бонус — substeps и звук. Пошаговый практикум — Tetris (тетрис) на Python и Pygame: архитектура, 7 тетромино, вращение, линии, очки, уровни, ghost, 7-bag, hold и 20 этапов до играбельного прототипа. Пошаговый практикум — hack and slash в духе Diablo на Python и Pygame: архитектура, гейм-дизайн, зависимости, 18 обязательных этапов и 4 бонусных до полноценного ARPG-прототипа. Пошаговый практикум — карточный roguelike на Python и Pygame: архитектура, формулы боя, 17 этапов кода, моддинг JSON и сверка с AutoBattler (Тени Шпиля). Пошаговый практикум — survivor-like в духе Vampire Survivors на Java (Swing, Java2D): архитектура, гейм-дизайн, Maven, 18 этапов с полным кодом ключевых систем и карта расширений до Java Survivors. Пошаговый практикум — карточный roguelike в браузере на TypeScript, React и Vite: архитектура, dispatch, 16 этапов, cardEffects, PWA и деплой. Эталон — OnlineCardGame («Приключения Урала Батыра»).Python — Battle City
Python — Match3
Python — Ping Pong
Python — Tetris
Python — диаблоид
Python — карточная стратегия
Java — Java Survivors
TypeScript — OnlineCardGame