Еда и свободная клетка
Новая голова плюс старое тело строит движение за один проход — и та же идея множества свободных клеток решает, куда честно поставить еду.
Элегантная альтернатива циклу по сегментам
Вместо того чтобы двигать каждый существующий сегмент по отдельности (раздел 19.17), можно построить новый список целиком — новая голова спереди, старое тело позади, без последнего элемента (если змейка не растёт):
def move_snake(snake, new_head, *, grow):
if grow:
return [new_head, *snake]
return [new_head, *snake[:-1]]
Хвост при этом естественным образом «отваливается» — snake[:-1]
отбрасывает последний элемент. Никакого цикла, никакого риска перепутать порядок обновления:
результат строится за один проход, а не мутирует существующие позиции одну за другой.
Еда — случайная свободная клетка
В разделе 19.3 клетка для яблока выбиралась без проверки — при достаточно длинной змейке яблоко иногда оказывалось бы прямо внутри тела. Честная версия выбирает только среди по-настоящему свободных клеток:
def choose_food(snake, rng, *, half=280, step=20):
occupied = set(snake)
free = tuple(
cell for cell in all_cells(half=half, step=step) if cell not in occupied
)
if not free:
return None
return rng.choice(free)
choose_food() принимает готовый random.Random, а не читает глобальный random модуль напрямую. В реальной игре передают random.Random() — настоящую случайность; в тестах — random.Random(seed) с фиксированным зерном, чтобы результат был предсказуем и его можно было проверить assert-ом.Инвариант: еда — только из свободных клеток
Контракт choose_food() можно записать формулой:
food ∈ FREE_CELLS, где
FREE_CELLS = ALL_CELLS − SNAKE_CELLS. Это должно оставаться
верным ВСЕГДА, включая крайний случай, до которого редко доходят на практике, но который
обязан быть предусмотрен: змейка выросла настолько, что заняла буквально каждую клетку
поля. Тогда FREE_CELLS пусто, и честного ответа, где поставить
еду, не существует.
snake[0], когда свободных клеток не осталось: тогда food == snake[0], и инвариант «еда никогда не внутри змейки» нарушается прямо в самом же коде, который эту еду выбирает. Честный ответ — None: свободной клетки для еды в самом деле нет, и вызывающий код (раздел 19.32) обязан явно это обработать, а не притворяться, что еда по-прежнему существует.В SnakeApp.game_tick() это выглядит так: если змейка съела
еду и choose_food() вернула None,
игра переходит в терминальное состояние GameStatus.WON —
поле заполнено целиком, дальше двигаться некуда, и это победа, а не ошибка.
state.food в этом состоянии тоже становится
None, и отрисовка (раздел 19.29) обязана явно проверить это
перед тем, как рисовать еду на экране.