Глава 19 · Тестирование

Debug Labs — типичные ошибки «Змейки»

Каждая ошибка здесь встречается в реальных студенческих проектах — научитесь узнавать симптом раньше, чем откроете отладчик.

Небольшая коллекция типичных ошибок «Змейки» — каждая с симптомом и исправлением. Часть из них вы уже видели раньше в главе; здесь они собраны как справочник.

Debug Lab 1: Забыли screen.tracer(0)
bez_tracer.py
screen = turtle.Screen()
screen.title("Змейка")
# tracer(0) не вызван
# Игра работает, но каждое движение головы и КАЖДОГО сегмента
# перерисовывается отдельно — заметное мерцание и заметная задержка.
Что видно на экране

Без tracer(0) Turtle обновляет экран после каждого отдельного вызова goto() — раздел 19.2 уже объяснял, зачем нужно отключить автообновление и делать это вручную, ровно раз за тик.

Исправленный код
s_tracer.py
screen = turtle.Screen()
screen.title("Змейка")
screen.tracer(0)
Debug Lab 2: Забыли screen.update() в конце render()
bez_update.py
def render(self):
    self.head.goto(*self.state.snake[0])
    # ...остальная отрисовка...
    # self.screen.update() не вызван
# Модель меняется на каждом тике (можно проверить print()),
# но на экране змейка стоит неподвижно.
Что видно на экране

С tracer(0) ручной screen.update() — единственный момент, когда изменения долетают до экрана. Тот же принцип, что и в главе 17: изменение модели без вызова отрисовки невидимо пользователю.

Исправленный код
s_update.py
def render(self):
    self.head.goto(*self.state.snake[0])
    # ...остальная отрисовка...
    self.screen.update()
Debug Lab 3: Блокирующий цикл усложняет паузу и скорость
busy_loop.py
while igrovoj_shag():
    screen.update()
    time.sleep(ZADERZHKA_SEK)   # фиксированная задержка
# цикл сам решает, когда именно проверять события — паузу, скорость
# и перезапуск приходится вручную вплетать в его тело
# Игру нельзя поставить на паузу: событие обрабатывается,
# но цикл ничего не проверяет и продолжает крутиться дальше.
# Скорость задана одной константой и по ходу партии не меняется.
Что видно на экране

Раздел 19.13 разбирал это подробно: screen.update() внутри while ДЕЙСТВИТЕЛЬНО обрабатывает события Tkinter, включая нажатия клавиш, — обработчик паузы технически может сработать. Но сам цикл не спрашивает, нужно ли ему остановиться: паузу, скорость и перезапуск пришлось бы вручную проверять на каждой итерации. screen.ontimer() устроен иначе: каждый тик — это отдельный запланированный вызов, а не итерация одного бесконечного цикла, и решение «делать следующий тик или нет» принимается один раз, явно, а не проверяется заново внутри while.

Исправленный код
ontimer_vmesto_busy.py
def game_tick(self):
    if self.state.status is not GameStatus.RUNNING:
        return
    # ...
    self.screen.ontimer(self.game_tick, self.state.delay_ms)
Debug Lab 4: time.sleep() внутри игрового цикла
s_sleep.py
def game_tick(self):
    # ...
    time.sleep(self.state.delay_ms / 1000)
    self.game_tick()
# Окно замирает НАВСЕГДА, а не на delay_ms: тик зовёт сам себя
# без условия выхода, поэтому пауз становится бесконечно много,
# а примерно через 1000 вызовов программа падает с RecursionError.
Что видно на экране

Ошибок здесь сразу две. Первая: time.sleep() блокирует ВЕСЬ процесс, включая цикл событий Tkinter, на котором держится Turtle. Вторая, менее заметная: game_tick() вызывает саму себя напрямую, без всякого условия выхода — это обычная бесконечная рекурсия, и стек кончится раньше, чем игрок дождётся хоть одного отклика. screen.ontimer() решает обе: он ничего не блокирует и не наращивает стек — раздел 19.13 объясняет разницу подробно.

Исправленный код
bez_sleep.py
def game_tick(self):
    # ...
    self.screen.ontimer(self.game_tick, self.state.delay_ms)
Debug Lab 5: Разворот на 180° разрешён
bez_is_reverse.py
def request_direction(self, direction):
    self.state.next_direction = direction   # без проверки!
# Змейка едет вправо, игрок нажимает Left —
# голова немедленно врезается в первый сегмент собственного тела.
Что видно на экране

Без is_reverse() ничто не мешает развернуть змейку прямо в её же тело за один тик — раздел 19.4 (и 19.11) объясняли, почему разворот на 180° должен быть запрещён на уровне ввода, а не только совпадением по случайности.

Исправленный код
s_is_reverse.py
def request_direction(self, direction):
    if is_reverse(self.state.direction, direction):
        return
    self.state.next_direction = direction
Debug Lab 6: Быстрая пара клавиш проносит разворот мимо проверки
proverka_ne_protiv_togo.py
def request_direction(self, direction):
    if is_reverse(self.state.next_direction, direction):   # против next_direction!
        return
    self.state.next_direction = direction
# Змейка едет вправо. Игрок быстро нажимает Up, потом Left —
# Left проходит проверку, потому что next_direction уже стало Up, а не Right.
Что видно на экране

Ловушка тонкая: проверка обязана идти против state.direction — направления, которое СЕЙЧАС применяется на тике — а не против уже изменённого next_direction, иначе вторая клавиша между тиками может протащить нелегальный разворот.

Исправленный код
proverka_protiv_direction.py
def request_direction(self, direction):
    if is_reverse(self.state.direction, direction):
        return
    self.state.next_direction = direction
Debug Lab 7: Еда не выровнена по сетке
neverno_vyrovnennaya_eda.py
def choose_food(snake, rng, *, half=280):
    x = rng.randint(-half, half)   # любое целое, не кратное STEP!
    y = rng.randint(-half, half)
    return (x, y)
# Яблоко появляется в точке вроде (137, -52) —
# голова змейки никогда не попадёт туда ровно, съесть его невозможно.
Что видно на экране

Раздел 19.9 объяснял: легальные позиции — узлы решётки с шагом STEP. choose_food() обязана выбирать из того же множества клеток, что и next_head() — иначе математически невозможно попасть точно в клетку еды.

Исправленный код
vyrovnennaya_eda.py
def choose_food(snake, rng, *, half=280, step=20):
    free = tuple(c for c in all_cells(half=half, step=step) if c not in set(snake))
    return rng.choice(free)
Debug Lab 8: Еда появляется внутри тела змейки
eda_bez_proverki.py
def choose_food(snake, rng, *, half=280, step=20):
    coords = range(-half, half + 1, step)
    return (rng.choice(coords), rng.choice(coords))   # не проверяет snake!
# При достаточно длинной змейке яблоко иногда появляется
# прямо внутри собственного тела — заведомо несъедобное.
Что видно на экране

Раздел 19.18 разбирал именно эту проблему: без исключения занятых клеток choose_food() честно выбирает случайно среди ВСЕХ клеток, включая занятые телом — что технически случайно, но нечестно по отношению к игроку.

Исправленный код
eda_s_proverkoj.py
def choose_food(snake, rng, *, half=280, step=20):
    occupied = set(snake)
    free = tuple(c for c in all_cells(half=half, step=step) if c not in occupied)
    return rng.choice(free)
Debug Lab 9: Тело обновляется от головы к хвосту
ot_golovy_k_hvostu.py
def dvigat_telo():
    for indeks in range(len(segmenty) - 1):   # неверное направление цикла!
        segmenty[indeks].goto(segmenty[indeks + 1].xcor(), segmenty[indeks + 1].ycor())
# Через несколько шагов все сегменты тела
# визуально схлопываются в одну точку.
Что видно на экране

Раздел 19.17 разбирал это подробно: обновление обязано идти с хвоста к голове, иначе каждый следующий сегмент читает уже перезаписанную, а не старую позицию соседа.

Исправленный код
s_hvosta_k_golove.py
def dvigat_telo():
    for indeks in range(len(segmenty) - 1, 0, -1):
        segmenty[indeks].goto(segmenty[indeks - 1].xcor(), segmenty[indeks - 1].ycor())
Debug Lab 10: Табло счёта без clear()
tablo_bez_clear.py
def obnovit_tablo():
    tablo.write(f"Счёт: {{schet}}", align="center", font=("Arial", 16, "normal"))
    # tablo.clear() не вызван
# После нескольких съеденных яблок надпись на табло
# превращается в нечитаемую кашу из наложенных друг на друга цифр.
Что видно на экране

Раздел 19.5 объяснял: write() не заменяет предыдущий текст, а рисует поверх него. clear() обязателен перед каждой новой надписью той же черепашки.

Исправленный код
tablo_s_clear.py
def obnovit_tablo():
    tablo.clear()
    tablo.write(f"Счёт: {{schet}}", align="center", font=("Arial", 16, "normal"))
Debug Lab 11: Граница проверена нестрого — голова не доезжает до края
granica_s_ravno.py
def is_wall_collision(head, *, half=280):
    x, y = head
    return abs(x) >= half or abs(y) >= half   # >=, не >
# Игра заканчивается на одну клетку раньше настоящей границы —
# змейка никогда не может доехать до последнего легального ряда клеток.
Что видно на экране

Раздел 19.19 объяснял: GRANICA — координата центра СЕГМЕНТА, который на ней всё ещё целиком помещается на поле. >= ошибочно исключает саму границу из легальной области.

Исправленный код
granica_strogo.py
def is_wall_collision(head, *, half=280):
    x, y = head
    return abs(x) > half or abs(y) > half
Debug Lab 12: Самостолкновение проверено по СТАРОМУ телу
stolknovenie_po_staromu.py
grow = head == state.food
if is_self_collision(head, state.snake[1:]):   # ещё ДО move_snake()!
    state.status = GameStatus.GAME_OVER
# Змейка не растёт, а игрок пытается заехать в клетку, которую
# хвост как раз освобождает в этом же тике — игра ошибочно завершается.
Что видно на экране

Раздел 19.20 разбирал именно это: клетка старого хвоста всё ещё в state.snake ДО move_snake(). Проверять нужно тело ПОСЛЕ хода — new_snake[1:] — где хвост уже честно отброшен, если змейка не растёт.

Исправленный код
stolknovenie_po_novomu.py
new_snake = move_snake(state.snake, head, grow=grow)
if is_self_collision(head, new_snake[1:]):
    state.status = GameStatus.GAME_OVER
Debug Lab 13: Restart оставляет старую цепочку тиков живой
restart_bez_generation.py
def restart(self):
    self.state = new_game_state(self.rng)
    self.render()
    # generation не увеличен — старый _on_timer() всё ещё запланирован!
# Через мгновение после Restart на экране внезапно появляется
# фигура/движение от уже несуществующей прошлой игры.
Что видно на экране

Раздел 19.23 разбирал этот случай — классический баг «двух параллельных таймерных цепочек», знакомый ещё по главе 16. Без счётчика поколений старый _on_timer() ничем не отличим от нового — оба тикают одновременно.

Исправленный код
restart_s_generation.py
def restart(self):
    self._generation += 1
    self.state = new_game_state(self.rng, high_score=self.state.high_score)
    self.render()
Debug Lab 14: Пауза меняет экран, но игра продолжает двигаться
pauza_tolko_vizualno.py
def toggle_pause(self):
    self._show_overlay("ПАУЗА", "Space — продолжить")
    # state.status не изменён — game_tick() ничего не знает о паузе!
# Оверлей «ПАУЗА» показан, но змейка под ним
# продолжает двигаться и может врезаться в стену.
Что видно на экране

Раздел 19.22 объяснял: оверлей — это только то, что ВИДНО. Реальная остановка происходит из-за проверки status is not RUNNING в самом начале game_tick() — без смены state.status эта проверка никогда не сработает.

Исправленный код
pauza_po_statusu.py
def toggle_pause(self):
    if self.state.status is GameStatus.RUNNING:
        self.state.status = GameStatus.PAUSED
        self._show_overlay("ПАУЗА", "Space — продолжить")
Debug Lab 15: Новая игра не сбрасывает направление
restart_bez_napravleniya.py
def restart(self):
    self._generation += 1
    self.state.snake = [(0, 0)]   # правит поле точечно, а не создаёт state заново
    self.state.score = 0
    # direction/next_direction остались от прошлой игры!
# Новая змейка стоит в центре, но при первом же движении
# уезжает в направлении, в котором закончилась ПРОШЛАЯ игра.
Что видно на экране

Точечные правки существующего state легко забывают одно из полей. new_game_state() (раздел 19.25) создаёт структуру целиком заново — забыть отдельное поле в новом объекте невозможно, оно либо есть в конструкторе, либо код не запустится.

Исправленный код
restart_novym_state.py
def restart(self):
    self._generation += 1
    self.state = new_game_state(self.rng, high_score=self.state.high_score)
Debug Lab 16: Рекорд обнуляется вместе со счётом
restart_teryaet_rekord.py
def restart(self):
    self._generation += 1
    self.state = new_game_state(self.rng)   # high_score не передан — снова 0!
# Игрок набрал 90 очков, проиграл, нажал R —
# табло снова показывает «Рекорд: 0», как будто игра только что установлена.
Что видно на экране

Раздел 19.25 объяснял: high_score обязан быть явно передан из старого state в новый. new_game_state() без аргумента high_score использует значение по умолчанию — ноль.

Исправленный код
restart_s_rekordom.py
def restart(self):
    self._generation += 1
    self.state = new_game_state(self.rng, high_score=self.state.high_score)
Debug Lab 17: GameState хранит объект Turtle
gamestate_s_turtle.py
@dataclass
class GameState:
    snake: list[Position]
    head_turtle: turtle.Turtle   # объект Turtle внутри модели!
# Тесты из раздела 19.30 падают с ошибкой создания Turtle,
# хотя проверяют только математику координат — окно им не нужно.
Что видно на экране

Раздел 19.27 предупреждал об этом явно: GameState — домен данных, а не контейнер для виджетов. Как только внутри появляется turtle.Turtle, создать состояние без реального окна становится невозможно — вся польза чистой логики (раздел 19.26) исчезает.

Исправленный код
gamestate_bez_turtle.py
@dataclass
class GameState:
    snake: list[Position]
    # объекты Turtle живут в SnakeApp, не в GameState
Debug Lab 18: Тик планирует сам себя, даже когда игра уже не RUNNING
tik_planiruet_vsegda.py
def _on_timer(self, generation):
    if generation != self._generation:
        return
    self.game_tick()
    self._schedule_next_tick()   # без проверки статуса!
# После Game Over или паузы змейка выглядит остановленной,
# но цепочка ontimer() продолжает тикать вхолостую в фоне.
Что видно на экране

game_tick() сама по себе безопасна — она просто ничего не делает при status != RUNNING. Но если следующий тик планируется БЕЗУСЛОВНО, цепочка никогда не остановится сама, продолжая впустую расходовать таймер даже после конца игры.

Исправленный код
tik_planiruet_esli_running.py
def _on_timer(self, generation):
    if generation != self._generation:
        return
    self.game_tick()
    if self.state.status is GameStatus.RUNNING:
        self._schedule_next_tick()
Практика: находим баг «Змейки» по симптому
Автоматическая проверка — функция diagnose(): по симптому находим причину, неизвестный симптом тоже обрабатываем
Открыть практику →