Еда, рост и счёт

Учим Змейку класть еду в случайную свободную клетку, замечать момент поедания, удлинять тело цыплёнка-змейки и показывать растущий счёт прямо на холсте.

Змейка растёт не тем, что мы добавляем ей хвост, а тем, что в кадре поедания мы не убираем хвост. Это главный фокус урока — всё остальное лишь обёртка вокруг этой одной строчки.

В прошлом уроке про сетку и движение твоя змейка-цыплёнок уже ползала по клеткам: голова шагала вперёд, тело тянулось следом, а на поворотах ничего не ломалось. Но ползать по пустому полю скучно — это как открыть любимую игру и обнаружить, что в ней нечего собирать. Сегодня мы дадим цыплёнку цель: красную еду, за которой он гоняется, от которой растёт, и счётчик, который тикает вверх с каждым укусом. К концу урока у тебя будет настоящий кор-луп Змейки — петля «увидел еду, дополз, съел, вырос, ищи следующую», ради которой в эту игру и хочется играть.

Зачем это нужно

Представь Змейку без еды. Цыплёнок ездит по экрану, и... всё. Нет азарта, нет «ещё одну попытку», нет того момента, когда ты ведёшь длинющую змею через узкий коридор и боишься вдохнуть. Вся соль Змейки в простом цикле: появилась еда — дотянись — стань длиннее — повтори. Чем длиннее змейка, тем теснее на поле и тем выше ставки. Это и есть кор-луп: маленькая петля действий, ради которой в игру интересно играть снова и снова.

Чтобы этот цикл заработал, нам нужно решить три задачки. Первая: куда положить еду, чтобы она не появилась прямо под телом змейки (иначе её не съесть — она уже «внутри»). Вторая: как поймать момент, когда голова цыплёнка наехала на еду. Третья — самая хитрая и самая красивая: как заставить змейку вырасти ровно на одну клетку, не пересчитывая всё тело заново.

К концу урока у тебя будет три готовых куска кода: функция placeFood(), которая честно роняет еду в свободную клетку, проверка поедания в update() и приём «не отрезай хвост в кадре роста». Плюс счёт score, нарисованный прямо на холсте. Эти куски ты переиспользуешь дальше — змейка с едой и счётом станет частью финальной аркады про цыплёнка.

Как змейка растёт: фокус с хвостом

Сначала вспомним, как вообще движется змейка из прошлого урока. Её тело — это массив клеток snake, где snake[0] — голова. Каждый шаг мы делаем две вещи: добавляем новую голову впереди (через unshift) и убираем последнюю клетку хвоста (через pop). Длина массива не меняется — змейка как будто «перетекает» на клетку вперёд, словно гусеница.

Обычный шаг змейки = добавить голову спереди + убрать хвост сзади. Длина остаётся прежней, змейка просто сдвигается.

А теперь — фокус. Представь поезд из вагонов. Чтобы поезд проехал вперёд, локомотив тянет всё вперёд, а последний вагон уезжает с того места, где стоял. Но если в момент движения ты прицепишь ещё один вагон спереди и при этом не отцепишь задний — поезд станет на вагон длиннее. Вот и весь секрет роста змейки: в обычном кадре мы добавляем голову и убираем хвост, а в кадре поедания — добавляем голову, но хвост оставляем на месте.

Кадр роста = добавить голову спереди, а хвост не убирать. Массив становится на одну клетку длиннее — змейка выросла.

Это гораздо умнее, чем «вычислить новую клетку хвоста и приклеить её». Нам вообще не нужно знать, где должен оказаться новый хвостовой сегмент: он просто остаётся там, где был, а новый кусок появляется со стороны головы. Одна пропущенная команда pop() — и змейка длиннее.

Где взять еду: случайная свободная клетка

Еда в Змейке — это одна клетка поля, у неё есть координаты в клетках: food.x и food.y. Поле у нас разбито на сетку cols × rows (столбцы и строки из прошлого урока). Самый простой способ выбрать клетку — кинуть кубик: случайный столбец и случайная строка.

Но есть подвох: что, если кубик выпадет ровно на клетку, где сейчас лежит тело змейки? Тогда еда окажется «внутри» цыплёнка, и съесть её будет невозможно. Поэтому мы будем кидать кубик до тех пор, пока не попадём в свободную клетку — ту, которую тело не занимает.

Разбор на примерах

Пример 1. Кладём еду в случайную клетку

Начнём с простого: положим еду в случайную клетку, пока без проверки на тело. Случайное целое число от 0 до n - 1 даёт связка Math.floor(Math.random() * n).

const cols = 20;   // столбцов в сетке
const rows = 20;   // строк в сетке

let food = { x: 0, y: 0 };

function placeFood() {
  food.x = Math.floor(Math.random() * cols);   // случайный столбец 0..19
  food.y = Math.floor(Math.random() * rows);   // случайная строка 0..19
}

placeFood();
console.log(food);

Результат: в консоль выведется объект вроде { x: 7, y: 13 } — каждый запуск даёт новые координаты клетки. Это ещё не рисунок на холсте, а просто выбор клетки, куда мы потом нарисуем красный квадратик еды. Координаты тут в клетках, а не в пикселях: чтобы получить пиксели, мы позже умножим их на размер клетки cell.

Разберём по строчкам:

  • Math.random() даёт дробное число от 0 до почти 1. Умножаем на cols — получаем число от 0 до почти 20.
  • Math.floor(...) отбрасывает дробную часть, оставляя целое от 0 до 19 — ровно номера наших столбцов.
  • То же самое для строки через rows. Вместе food.x и food.y задают клетку на поле.

Пример 2. Еда не появляется на теле змейки

Теперь чиним подвох. Будем кидать кубик в цикле, пока выпавшая клетка не окажется свободной от тела змейки. Тело — это массив snake из объектов { x, y } в клетках, голова — snake[0].

let snake = [
  { x: 10, y: 10 },   // голова цыплёнка
  { x: 9, y: 10 },
  { x: 8, y: 10 }
];

function isOnSnake(x, y) {
  return snake.some(part => part.x === x && part.y === y);
}

function placeFood() {
  do {
    food.x = Math.floor(Math.random() * cols);
    food.y = Math.floor(Math.random() * rows);
  } while (isOnSnake(food.x, food.y));   // повторяем, пока клетка занята телом
}

placeFood();
console.log(food, isOnSnake(food.x, food.y));

Результат: в консоль выведется что-то вроде { x: 4, y: 17 } false — координаты еды и false рядом, потому что клетка гарантированно свободна. Сколько бы раз ты ни запускал, еда никогда не ляжет на одну из трёх клеток тела (10,10, 9,10, 8,10).

Главное здесь — цикл do...while: он сначала делает попытку, а потом проверяет условие. То есть кубик кидается хотя бы один раз, а если не повезло и попали в тело — кидается снова. Функция isOnSnake через snake.some(...) отвечает на вопрос «есть ли хоть одна клетка тела с такими координатами?»: some возвращает true, как только найдёт совпадение по x и y одновременно.

Пример 3. Засчитываем поедание и растим змейку

А вот и сердце урока — функция update(), которая каждый шаг двигает змейку и проверяет, не наехала ли голова на еду. Управление и направление dir ({ x, y } — куда смотрит голова) у тебя уже есть из прошлого урока.

let score = 0;
let dir = { x: 1, y: 0 };   // движемся вправо

function update() {
  // 1. считаем, где окажется новая голова
  const head = {
    x: snake[0].x + dir.x,
    y: snake[0].y + dir.y
  };

  // 2. прицепляем новую голову спереди
  snake.unshift(head);

  // 3. голова попала на еду?
  if (head.x === food.x && head.y === food.y) {
    score += 1;       // +1 очко
    placeFood();      // новая еда в свободной клетке
    // хвост НЕ убираем — змейка стала длиннее!
  } else {
    snake.pop();      // обычный шаг: убираем хвост
  }
}

Результат: на холсте цыплёнок-змейка ползёт вперёд как обычно, но в тот кадр, когда голова наезжает на красную клетку еды, змейка становится на сегмент длиннее, счёт в углу увеличивается на единицу, а на поле тут же появляется новая еда в другом месте. Визуально это выглядит так, будто цыплёнок «проглотил» точку и она прибавилась к его хвосту.

Разберём по шагам, потому что это ключевой код всей игры:

  1. Считаем новую голову. Берём текущую голову snake[0] и сдвигаем её на направление dir. Получаем клетку, в которую змейка шагнёт.
  2. Добавляем голову спереди через unshift — теперь массив на клетку длиннее, чем был.
  3. Проверяем поедание. Сравниваем клетку головы с клеткой еды по обеим координатам сразу (head.x === food.x && head.y === food.y).
  4. Если съели — прибавляем очко, кладём новую еду и пропускаем pop(). Хвост остаётся — змейка выросла.
  5. Если не съели — вызываем snake.pop(), убирая хвост. Длина возвращается к прежней — обычный шаг.

Обрати внимание: рост — это просто отсутствие pop() в ветке if. Мы не вычисляем новый хвост, не дописываем сегмент вручную — мы лишь не отрезаем старый. Вот и весь фокус с поездом из метафоры.

Пример 4. Рисуем еду и счёт на холсте

Осталось показать всё это игроку. Нарисуем красную клетку еды и выведем счёт текстом в углу. Размер клетки в пикселях — cell, контекст 2D — context (всё из прошлого урока).

const cell = 20;   // размер клетки в пикселях

function draw() {
  context.clearRect(0, 0, canvas.width, canvas.height);

  // еда — красный квадрат в своей клетке
  context.fillStyle = '#e23b3b';
  context.fillRect(food.x * cell, food.y * cell, cell, cell);

  // тело цыплёнка-змейки
  context.fillStyle = '#f4b400';
  for (const part of snake) {
    context.fillRect(part.x * cell, part.y * cell, cell - 1, cell - 1);
  }

  // счёт в левом верхнем углу
  context.fillStyle = '#222';
  context.font = '20px sans-serif';
  context.fillText('Счёт: ' + score, 10, 24);
}

Результат: на холсте видно жёлтую змейку-цыплёнка, красный квадратик еды в случайной клетке и надпись «Счёт: 0» в левом верхнем углу, которая растёт по мере поедания. Между сегментами тела заметны тонкие щели в один пиксель (мы рисуем cell - 1), так змейка читается как цепочка клеток, а не сплошная полоса.

Что здесь важно:

  • Координаты еды и тела хранятся в клетках, а рисуем мы в пикселях, поэтому умножаем на cellfood.x * cell переводит номер столбца в пиксель.
  • context.fillText('Счёт: ' + score, 10, 24) рисует текст; третий аргумент 24 — это координата базовой линии текста, поэтому надпись чуть опущена от самого верха.
  • Цвет задаётся перед рисованием через fillStyle и держится, пока его не сменишь, — поэтому мы меняем его на чёрный перед текстом.

Частые ошибки и подводные камни

На еде и росте змейки спотыкаются почти все новички. Вот грабли, на которые наступают чаще всего.

1. Добавлять хвост вместо того, чтобы не убирать его

Самая частая попытка вырастить змейку — вычислить, где должен быть новый хвостовой сегмент, и вручную приклеить его через push. Это сложно и легко наврать с координатами. Правильный приём проще: в кадре поедания просто не вызывай snake.pop(). Голову мы и так добавили — значит, массив уже стал длиннее. Рост — это пропущенный pop(), а не лишний push().

2. Сравнивать только одну координату головы и еды

Если написать if (head.x === food.x), забыв про head.y, то змейка будет «съедать» еду, просто оказавшись с ней в одном столбце — даже за десять клеток выше. Поедание засчитываем, только когда совпали обе координаты: head.x === food.x && head.y === food.y. Клетка — это пара чисел, и сравнивать надо пару.

3. Класть еду без проверки на тело

Если кидать кубик один раз без цикла do...while, еда рано или поздно ляжет прямо под телом змейки. Голова туда уже не доедет (тело там и так есть), и игра «зависнет» без новой еды. Всегда перекидывай кубик, пока isOnSnake возвращает true.

4. Менять направление и сразу проверять поедание не в том порядке

Если ты сначала вызываешь snake.pop(), а уже потом проверяешь, съел ли цыплёнок еду, логика роста ломается: хвост уже отрезан, и «нерост» не отличить от роста. Порядок важен: сначала добавь голову, затем проверь поедание и реши в этой же развилке, убирать хвост или нет. Одна ветка if/else — одно решение про хвост.

5. Путать координаты в клетках и в пикселях

Еда хранится в клетках (food.x = 7 — это седьмой столбец), а рисуется в пикселях (7 * cell). Если случайно сравнить пиксельную координату головы с клеточной координатой еды — совпадения не будет никогда, и еду станет невозможно съесть. Держи всю логику игры в клетках, а умножение на cell делай только в момент рисования.

Мини-проект: добавь бонусную еду

Теперь твоя очередь. Возьми код из примеров 2–4 и доделай Змейку так, чтобы изредка появлялась золотая бонусная еда, которая даёт сразу +5 очков и растит змейку на одну клетку, как обычная.

  1. Опиши бонус как отдельный объект. Заведи let bonus = null; и функцию placeBonus(), которая, как placeFood, выбирает свободную клетку (через isOnSnake) и кладёт туда бонус: bonus = { x, y }.
  2. Появляйся редко. В момент поедания обычной еды с небольшой вероятностью роняй бонус: if (Math.random() < 0.2) placeBonus(); — примерно каждый пятый укус.
  3. Засчитывай бонус. В update() после проверки обычной еды добавь проверку на бонус: если bonus не null и голова совпала с ним по обеим координатам — прибавь score += 5, обнули bonus = null и так же пропусти pop() в этом кадре.
  4. Рисуй бонус. В draw(), если bonus не null, нарисуй его золотым ('#ffd400') квадратом в клетке bonus.x * cell, bonus.y * cell.

Подсказки, чтобы получилось:

  • Бонус живёт не всегда — это нормально, что половину игры его нет (bonus === null). Все проверки бонуса оборачивай в if (bonus), чтобы не лезть в координаты у null.
  • Рост от бонуса работает ровно так же, как от обычной еды: пропущенный pop(). Не пытайся растить змейку на пять клеток — за +5 очков она вырастет на одну, и это честно.
  • Когда заработает — попробуй сделать бонус мигающим: рисуй его не каждый кадр, а через раз, по чётности счётчика кадров. Это уже почти настоящая аркада про цыплёнка.

Если бонус честно появляется в свободной клетке, даёт +5 и исчезает после поедания — поздравляю, ты расширил кор-луп Змейки. Эту же логику еды и счёта ты переиспользуешь в финальной аркаде про цыплёнка.

Итоги

Сегодня ты превратил пустое поле в настоящую Змейку с целью и счётом. Вот что теперь у тебя в арсенале:

  • Случайная еда: Math.floor(Math.random() * n) даёт случайную клетку, а цикл do...while с isOnSnake гарантирует, что еда не ляжет на тело.
  • Поедание: засчитываем, когда голова совпала с едой по обеим координатам — head.x === food.x && head.y === food.y.
  • Рост: главный фокус — в кадре поедания добавить голову и не вызывать pop(). Змейка вырастает на клетку сама собой.
  • Счёт: переменная score растёт при каждом укусе и рисуется на холсте через context.fillText.

Главный принцип, который ты унесёшь: рост змейки — это не добавленный хвост, а пропущенное отрезание хвоста. Одна несработавшая команда pop() — и цыплёнок стал длиннее. Простой приём, а игра сразу ожила: появился кор-луп, ради которого в Змейку и хочется играть.

В следующем уроке мы добавим в Змейку проигрыш: научим игру замечать, когда цыплёнок врезался в стену или укусил сам себя, и показывать экран «Игра окончена». Расти ты уже умеешь — пора научиться и проигрывать, чтобы у победы появилась цена.

Проверьте себя
1. Как заставить змейку вырасти на одну клетку в кадре поедания?
AВызвать snake.push() с новым хвостом
BДобавить голову через unshift и не вызывать pop()
CУдвоить весь массив snake
DУвеличить переменную score
2. Почему еду кладут в цикле do...while с проверкой isOnSnake?
AЧтобы еда появлялась быстрее
BЧтобы еда не легла на клетку, занятую телом змейки
CЧтобы счёт рос сам собой
DТак требует синтаксис canvas
3. Как правильно проверить, что голова наехала на еду?
Ahead.x === food.x
Bhead.x === food.x && head.y === food.y
Chead.x + head.y === food.x + food.y
Dhead.x === food.y
4. Что даёт Math.floor(Math.random() * cols) при cols = 20?
AДробное число от 0 до 20
BЦелое число от 0 до 19 — номер случайного столбца
CВсегда число 20
DСлучайное число от 1 до 20
5. Координата food.x равна 7, а размер клетки cell равен 20. По какому x рисовать еду на холсте?
A7 — координата уже в пикселях
B7 * 20 = 140 — координату в клетках умножаем на размер клетки
C20 / 7
D7 + 20 = 27
6. В каком порядке в update() нужно решать про хвост?
AСначала pop(), потом проверка поедания
BСначала добавить голову, затем проверить поедание и в той же развилке решить, убирать хвост или нет
CХвост убирать всегда, независимо от еды
DПорядок не важен