Debug Labs — типичные ошибки «Змейки»
Каждая ошибка здесь встречается в реальных студенческих проектах — научитесь узнавать симптом раньше, чем откроете отладчик.
Небольшая коллекция типичных ошибок «Змейки» — каждая с симптомом и исправлением. Часть из них вы уже видели раньше в главе; здесь они собраны как справочник.
screen = turtle.Screen()
screen.title("Змейка")
# tracer(0) не вызван
# Игра работает, но каждое движение головы и КАЖДОГО сегмента
# перерисовывается отдельно — заметное мерцание и заметная задержка.
Без tracer(0) Turtle обновляет экран после каждого отдельного вызова goto() — раздел 19.2 уже объяснял, зачем нужно отключить автообновление и делать это вручную, ровно раз за тик.
screen = turtle.Screen()
screen.title("Змейка")
screen.tracer(0)
def render(self):
self.head.goto(*self.state.snake[0])
# ...остальная отрисовка...
# self.screen.update() не вызван
# Модель меняется на каждом тике (можно проверить print()),
# но на экране змейка стоит неподвижно.
С tracer(0) ручной screen.update() — единственный момент, когда изменения долетают до экрана. Тот же принцип, что и в главе 17: изменение модели без вызова отрисовки невидимо пользователю.
def render(self):
self.head.goto(*self.state.snake[0])
# ...остальная отрисовка...
self.screen.update()
while igrovoj_shag():
screen.update()
time.sleep(ZADERZHKA_SEK) # фиксированная задержка
# цикл сам решает, когда именно проверять события — паузу, скорость
# и перезапуск приходится вручную вплетать в его тело
# Игру нельзя поставить на паузу: событие обрабатывается,
# но цикл ничего не проверяет и продолжает крутиться дальше.
# Скорость задана одной константой и по ходу партии не меняется.
Раздел 19.13 разбирал это подробно: screen.update() внутри while ДЕЙСТВИТЕЛЬНО обрабатывает события Tkinter, включая нажатия клавиш, — обработчик паузы технически может сработать. Но сам цикл не спрашивает, нужно ли ему остановиться: паузу, скорость и перезапуск пришлось бы вручную проверять на каждой итерации. screen.ontimer() устроен иначе: каждый тик — это отдельный запланированный вызов, а не итерация одного бесконечного цикла, и решение «делать следующий тик или нет» принимается один раз, явно, а не проверяется заново внутри while.
def game_tick(self):
if self.state.status is not GameStatus.RUNNING:
return
# ...
self.screen.ontimer(self.game_tick, self.state.delay_ms)
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 объясняет разницу подробно.
def game_tick(self):
# ...
self.screen.ontimer(self.game_tick, self.state.delay_ms)
def request_direction(self, direction):
self.state.next_direction = direction # без проверки!
# Змейка едет вправо, игрок нажимает Left —
# голова немедленно врезается в первый сегмент собственного тела.
Без is_reverse() ничто не мешает развернуть змейку прямо в её же тело за один тик — раздел 19.4 (и 19.11) объясняли, почему разворот на 180° должен быть запрещён на уровне ввода, а не только совпадением по случайности.
def request_direction(self, direction):
if is_reverse(self.state.direction, direction):
return
self.state.next_direction = direction
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, иначе вторая клавиша между тиками может протащить нелегальный разворот.
def request_direction(self, direction):
if is_reverse(self.state.direction, direction):
return
self.state.next_direction = direction
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() — иначе математически невозможно попасть точно в клетку еды.
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)
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() честно выбирает случайно среди ВСЕХ клеток, включая занятые телом — что технически случайно, но нечестно по отношению к игроку.
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)
def dvigat_telo():
for indeks in range(len(segmenty) - 1): # неверное направление цикла!
segmenty[indeks].goto(segmenty[indeks + 1].xcor(), segmenty[indeks + 1].ycor())
# Через несколько шагов все сегменты тела
# визуально схлопываются в одну точку.
Раздел 19.17 разбирал это подробно: обновление обязано идти с хвоста к голове, иначе каждый следующий сегмент читает уже перезаписанную, а не старую позицию соседа.
def dvigat_telo():
for indeks in range(len(segmenty) - 1, 0, -1):
segmenty[indeks].goto(segmenty[indeks - 1].xcor(), segmenty[indeks - 1].ycor())
def obnovit_tablo():
tablo.write(f"Счёт: {{schet}}", align="center", font=("Arial", 16, "normal"))
# tablo.clear() не вызван
# После нескольких съеденных яблок надпись на табло
# превращается в нечитаемую кашу из наложенных друг на друга цифр.
Раздел 19.5 объяснял: write() не заменяет предыдущий текст, а рисует поверх него. clear() обязателен перед каждой новой надписью той же черепашки.
def obnovit_tablo():
tablo.clear()
tablo.write(f"Счёт: {{schet}}", align="center", font=("Arial", 16, "normal"))
def is_wall_collision(head, *, half=280):
x, y = head
return abs(x) >= half or abs(y) >= half # >=, не >
# Игра заканчивается на одну клетку раньше настоящей границы —
# змейка никогда не может доехать до последнего легального ряда клеток.
Раздел 19.19 объяснял: GRANICA — координата центра СЕГМЕНТА, который на ней всё ещё целиком помещается на поле. >= ошибочно исключает саму границу из легальной области.
def is_wall_collision(head, *, half=280):
x, y = head
return abs(x) > half or abs(y) > half
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:] — где хвост уже честно отброшен, если змейка не растёт.
new_snake = move_snake(state.snake, head, grow=grow)
if is_self_collision(head, new_snake[1:]):
state.status = GameStatus.GAME_OVER
def restart(self):
self.state = new_game_state(self.rng)
self.render()
# generation не увеличен — старый _on_timer() всё ещё запланирован!
# Через мгновение после Restart на экране внезапно появляется
# фигура/движение от уже несуществующей прошлой игры.
Раздел 19.23 разбирал этот случай — классический баг «двух параллельных таймерных цепочек», знакомый ещё по главе 16. Без счётчика поколений старый _on_timer() ничем не отличим от нового — оба тикают одновременно.
def restart(self):
self._generation += 1
self.state = new_game_state(self.rng, high_score=self.state.high_score)
self.render()
def toggle_pause(self):
self._show_overlay("ПАУЗА", "Space — продолжить")
# state.status не изменён — game_tick() ничего не знает о паузе!
# Оверлей «ПАУЗА» показан, но змейка под ним
# продолжает двигаться и может врезаться в стену.
Раздел 19.22 объяснял: оверлей — это только то, что ВИДНО. Реальная остановка происходит из-за проверки status is not RUNNING в самом начале game_tick() — без смены state.status эта проверка никогда не сработает.
def toggle_pause(self):
if self.state.status is GameStatus.RUNNING:
self.state.status = GameStatus.PAUSED
self._show_overlay("ПАУЗА", "Space — продолжить")
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) создаёт структуру целиком заново — забыть отдельное поле в новом объекте невозможно, оно либо есть в конструкторе, либо код не запустится.
def restart(self):
self._generation += 1
self.state = new_game_state(self.rng, high_score=self.state.high_score)
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 использует значение по умолчанию — ноль.
def restart(self):
self._generation += 1
self.state = new_game_state(self.rng, high_score=self.state.high_score)
@dataclass
class GameState:
snake: list[Position]
head_turtle: turtle.Turtle # объект Turtle внутри модели!
# Тесты из раздела 19.30 падают с ошибкой создания Turtle,
# хотя проверяют только математику координат — окно им не нужно.
Раздел 19.27 предупреждал об этом явно: GameState — домен данных, а не контейнер для виджетов. Как только внутри появляется turtle.Turtle, создать состояние без реального окна становится невозможно — вся польза чистой логики (раздел 19.26) исчезает.
@dataclass
class GameState:
snake: list[Position]
# объекты Turtle живут в SnakeApp, не в GameState
def _on_timer(self, generation):
if generation != self._generation:
return
self.game_tick()
self._schedule_next_tick() # без проверки статуса!
# После Game Over или паузы змейка выглядит остановленной,
# но цепочка ontimer() продолжает тикать вхолостую в фоне.
game_tick() сама по себе безопасна — она просто ничего не делает при status != RUNNING. Но если следующий тик планируется БЕЗУСЛОВНО, цепочка никогда не остановится сама, продолжая впустую расходовать таймер даже после конца игры.
def _on_timer(self, generation):
if generation != self._generation:
return
self.game_tick()
if self.state.status is GameStatus.RUNNING:
self._schedule_next_tick()