Глава 21 · Проект: космический шутер
Как находить и исправлять ошибки в шутере
Справочник по симптому — плюс подробный разбор трёх самых незаметных ошибок проекта.
По ходу главы уже встретилось больше двадцати способов случайно сломать шутер — от старых, основанных на кадрах, приёмов до тонких ошибок с двойным подсчётом очков. Здесь они собраны в один справочник.
| Симптом | Причина | Где подробнее |
|---|---|---|
| Скорость корабля зависит от FPS | Движение задано «пикселей за кадр», а не px/s через dt | 21.11 |
| Медленное движение вообще не сдвигается | Позиция хранится только в Rect (целые числа) — дробная часть теряется | 21.11 |
| По диагонали корабль быстрее, чем по прямой | Вектор направления не нормализован | 21.11 |
| Корабль вылетает за пределы игрового поля | Забыт clamp по X и/или Y в Player.move() | 21.11 |
| Корабль заезжает под HUD | Границы движения — всё окно, а не self.playfield | 21.12 |
| Пробел стреляет каждый отрисованный кадр | Нет проверки fire_cooldown перед выстрелом | 21.13 |
| Скорострельность зависит от FPS | Интервал между выстрелами измеряется кадрами (-= 1), а не секундами (-= dt) | 21.13 |
| После долгой паузы отладчика — залп из десятков пуль | Интервал между выстрелами реализован через while вместо if | 21.13 |
| Пули летят вечно, игра тормозит сильнее с каждой минутой | Bullet не удаляет себя за пределами игрового поля | 21.4 (удаление пуль за экраном) |
| Появление врагов зависит от FPS | Таймер появления врагов считает кадры, а не секунды | 21.14 |
| После просадки FPS «пропадают» запланированные враги | Таймер появления врагов использует if вместо while | 21.14 |
| Враг появляется наполовину за краем экрана | x_poyavleniya_vraga не учитывает ширину врага | 21.14 |
| Один враг помечен уничтоженным дважды | Список мутируется прямо во время перебора (как и в разделе 21.6) | 21.15 |
| Одному врагу засчитывают очки дважды | Подсчёт идёт по всем парам пуля–враг, а не по множеству уникальных врагов | 21.15 |
| Три одновременных врага отнимают три жизни | Урон применяется за каждое столкновение, а не один раз за кадр | 21.16 |
| Неуязвимость «тикает» на паузе | Таймер неуязвимости обновляется вне update_playing(), не проверяя state | 21.16, 21.18 |
| На экране Game Over корабль всё ещё двигается | handle_input() не проверяет self.state перед обработкой клавиш | 21.18 |
| После перезапуска остаются старые пули или враги | start_new_game() не очищает все группы спрайтов | 21.19 |
| После перезапуска враг появляется мгновенно | spawn_timer не сброшен к начальному интервалу | 21.19 |
| Анимация взрыва «проглатывает» кадры на слабом устройстве | Explosion.update() использует if вместо while | 21.20 |
| Игра подтормаживает перед каждым выстрелом или взрывом | Изображение или звук загружается внутри игрового цикла, а не в AssetStore | 21.10, 21.21 |
| Игра падает без звуковой карты | Sound.play() вызывается без проверки на None | 21.21 |
| HUD перекрыт врагом или взрывом | Порядок отрисовки не выносит HUD и оверлей последними слоями | 21.22 |
| rect и position со временем «расходятся» | Rect обновлён напрямую, а не пересобран из position | 21.11 |
| Тест столкновений то проходит, то падает без изменений в коде | Random используется напрямую, без переданного seed | 21.24 |
Встроенный отладочный оверлей
Нажмите F3, чтобы включить или выключить отладочный оверлей финальной игры.
Он рисует красные границы Rect корабля, врагов и пуль, а внизу
показывает фактический FPS. Так можно увидеть реальные хитбоксы при разборе столкновений и
отличить просадку частоты кадров от ошибки в расчёте движения через
dt. Оверлей меняет только отрисовку: состояние игрового мира,
таймеры и столкновения остаются прежними.
Разберём три самых незаметных ошибки подробнее
Субпиксельное движение теряется на медленных объектах
Симптом: объект со скоростью меньше одного пикселя за кадр вообще не двигается. Ломаная версия:
self.rect.x += self.speed * dt — Rect хранит только целые числа, и дробное приращение округляется в ноль на каждом кадре по отдельности. Почему: округление происходит каждый раз, а не один раз в конце. Исправление: накапливать движение в self.position (Vector2, раздел 21.11), а rect.center пересобирать из неё через round() каждый кадр. Правило: позиция — всегда float, Rect — производная от неё, никогда не наоборот.Один враг засчитан дважды
Симптом: счёт иногда прыгает на 200 вместо 100 за одного разведчика. Ломаная версия:
for pulya, vragi in collisions.items(): schet += sum(v.points for v in vragi) — суммирование по всем парам пуля–враг. Почему: если две пули задели одного врага в одном обновлении, он попадёт в списки обеих пуль. Исправление: собрать всех задетых врагов в set перед подсчётом очков (раздел 21.15). Правило: считать очки по уникальным уничтоженным объектам, а не по числу столкновений.Три врага одновременно отнимают три жизни
Симптом: лобовое столкновение с группой врагов сразу переводит игру в Game Over, хотя жизней было три. Ломаная версия:
for vrag in hit: self.lives -= 1 — урон внутри цикла по столкнувшимся врагам. Почему: цикл списывает жизнь за каждого врага, а не за сам факт столкновения. Исправление: проверить "было ли хоть одно столкновение" один раз после цикла и списать не больше одной жизни за кадр (раздел 21.16). Правило: урон измеряется событием «задели», а не количеством объектов, которые это сделали одновременно.Практика: найдите и исправьте ошибку
Pygame открывает нативное окно Python — выполните локально в VS Code, PyCharm или Jupyter
Практика выполняется локально