Сетка и движение змейки

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

Зачем нам сетка, если раньше всё двигалось плавно

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

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

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

Результат, к которому придём: к концу урока цыплёнок-змейка будет ползти по полю ровными шагами, поворачивать на стрелки и отказываться разворачиваться сам в себя. Это скелет игры — яблоки и рост тела добавим в следующем уроке.

Сетка — это шахматная доска для пикселей

Представь лист в клеточку из школьной тетради. Каждая клетка — это квадрат фиксированного размера, скажем 20 на 20 пикселей. Всё поле — это просто много таких квадратов в ряд и в столбик. Вместо того чтобы думать в пикселях («цыплёнок на координате 240, 180»), мы думаем в клетках («цыплёнок в столбце 12, ряду 9»). А пиксели вычисляем из клеток только в самый последний момент, когда рисуем кадр.

Клетка хранит координаты в маленьких числах вроде 0, 1, 2, 12. Пиксели получаем умножением: пиксельX = клеткаX * размерКлетки. Думаем в клетках — рисуем в пикселях.

Заводим размеры поля

Договоримся о трёх числах: размер одной клетки и сколько клеток помещается по ширине и высоте. Из них автоматически получится размер canvas.

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

const CELL = 20;        // размер клетки в пикселях
const COLS = 20;        // клеток по горизонтали
const ROWS = 20;        // клеток по вертикали

canvas.width = COLS * CELL;   // 400 пикселей
canvas.height = ROWS * CELL;  // 400 пикселей

Результат: на странице появляется квадратный холст 400×400 пикселей. Визуально это пустой прямоугольник, но мысленно он уже разбит на 20×20 = 400 клеток, как тетрадный лист.

Обрати внимание: мы не пишем размер canvas руками. Мы говорим «двадцать клеток по двадцать пикселей» — и пусть компьютер сам посчитает 400. Если завтра захочешь поле побольше, поменяешь одно число COLS, и всё пересчитается. Это и есть сила мышления в клетках.

Рисуем клетку по её координатам

Чтобы увидеть сетку в деле, нарисуем одну клетку. Допустим, цыплёнок стоит в клетке (5, 5). Переводим клетку в пиксели и рисуем квадратик.

function drawCell(col, row, color) {
  ctx.fillStyle = color;
  ctx.fillRect(col * CELL, row * CELL, CELL, CELL);
}

ctx.fillStyle = '#1b1b2f';
ctx.fillRect(0, 0, canvas.width, canvas.height); // фон

drawCell(5, 5, '#ffd23f'); // жёлтый квадрат-цыплёнок

Результат: на тёмно-синем поле появляется жёлтый квадрат ровно в шестом столбце и шестом ряду (нумерация-то с нуля). Мы задали клетку, а функция сама умножила её на 20 и нарисовала в нужном месте.

Тело змейки — это просто список клеток

А теперь — главный трюк всего урока. Многие новички думают, что змейка — это что-то сложное, со специальной структурой данных. Нет. Тело змейки — это обычный массив, где каждый элемент — это одна клетка тела. Голова — первый элемент массива, хвост — последний.

Метафора такая: представь очередь людей, держащихся за плечи. Каждый человек стоит на своей клетке. Первый в очереди — голова, она решает, куда идти. Остальные просто повторяют путь того, кто впереди. Чтобы вся очередь сделала шаг, не нужно толкать каждого по отдельности — достаточно поставить нового человека в начало очереди и убрать последнего. Со стороны кажется, что вся змея сдвинулась, а на самом деле мы изменили только два конца.

let chicken = [
  { x: 8, y: 10 },  // голова
  { x: 7, y: 10 },  // шея
  { x: 6, y: 10 },  // хвост
];

Результат: в памяти лежит змейка из трёх клеток, вытянутая горизонтально. Голова в клетке (8, 10), за ней два сегмента левее. Пока на экране ничего — это только данные.

Заметь имя переменной: chicken. Это наш сквозной герой курса. В Понге он сидел на ракетке, в платформере прыгал, а здесь его тело — целый массив клеток. Имя одно и то же специально, чтобы код перетекал из урока в урок.

Рисуем всё тело за один проход

Раз тело — это массив, рисуем его обычным циклом. Каждый сегмент — это вызов нашей drawCell.

function drawChicken() {
  for (let i = 0; i < chicken.length; i++) {
    const segment = chicken[i];
    // голову красим ярче, чем тело
    const color = i === 0 ? '#ffd23f' : '#f08a24';
    drawCell(segment.x, segment.y, color);
  }
}

Результат: на поле появляется змейка из трёх квадратов в ряд. Первый квадрат (голова) ярко-жёлтый, два следующих — оранжевые. Получается узнаваемое тело с выделенной головой.

Движение шагами по таймеру, а не каждый кадр

Вот тут начинается самое интересное и неочевидное. В прошлых играх мы двигали героя каждый кадр — 60 раз в секунду. Если так двигать змейку, она пролетит всё поле за долю секунды, и играть будет невозможно. Змейка должна ползти медленно и заметными скачками: клетка, пауза, ещё клетка, пауза.

Игровой цикл крутится 60 раз в секунду и рисует кадр. Но двигаем змейку мы гораздо реже — например, раз в 150 миллисекунд. Между шагами кадры просто перерисовывают одну и ту же картинку.

Метафора: представь, что игровой цикл — это твоё сердцебиение, оно стучит постоянно и быстро. А шаг змейки — это твой вдох: он происходит на каждые несколько ударов сердца, не на каждый удар. Мы используем дельта-время (помнишь, из прошлого урока?) не для плавности, а как секундомер: накапливаем прошедшее время и, когда накопилось достаточно, делаем один шаг.

Как сделать один шаг

Шаг — это и есть тот самый трюк с очередью. Считаем, куда должна попасть новая голова, ставим её в начало массива и убираем последний элемент. Направление храним в векторе скорости — паре чисел, насколько сдвинуться по горизонтали и вертикали.

let velocity = { x: 1, y: 0 }; // ползём вправо: +1 клетка по X

function step() {
  const head = chicken[0];
  const newHead = {
    x: head.x + velocity.x,
    y: head.y + velocity.y,
  };
  chicken.unshift(newHead); // добавили голову в начало
  chicken.pop();            // убрали хвост в конце
}

Результат: при каждом вызове step() змейка сдвигается ровно на одну клетку вправо. Длина остаётся прежней (три клетки), потому что сколько добавили спереди, столько убрали сзади. Со стороны это выглядит как ползущая змея.

Разберём построчно, потому что это сердце игры. Берём текущую голову chicken[0]. Считаем координату новой головы, прибавив к старой вектор скорости. unshift вставляет новую голову в начало массива — теперь змейка стала на одну клетку длиннее. А pop выкидывает последний элемент — хвост. Итог: длина та же, но вся змея сместилась на шаг.

Запускаем шаги по таймеру внутри игрового цикла

Теперь собираем игровой цикл на requestAnimationFrame и встраиваем секундомер шага.

const STEP_MS = 150;   // один шаг каждые 150 мс
let acc = 0;           // накопитель времени
let last = 0;

function loop(now) {
  const dt = now - last;  // дельта-время в мс
  last = now;
  acc += dt;

  // пока накопилось достаточно — делаем шаги
  while (acc >= STEP_MS) {
    step();
    acc -= STEP_MS;
  }

  // рисуем каждый кадр
  ctx.fillStyle = '#1b1b2f';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  drawChicken();

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

Результат: цыплёнок-змейка из трёх клеток ползёт вправо ровными скачками примерно семь раз в секунду, упираясь в правый край поля. Картинка перерисовывается 60 раз в секунду, но сама змея двигается только когда накопитель acc дорастает до 150 мс.

Почему while, а не if? Если игра подвиснет и один кадр займёт, скажем, 400 мс, в накопителе окажется время на два-три шага сразу. Цикл while сделает их все и догонит, чтобы змейка не «отставала». С if мы бы делали максимум один шаг за кадр и теряли время при лагах.

Поворот и запрет разворота назад

Змейка ползёт — пора рулить. Ловим стрелки (как в уроке про управление) и меняем вектор скорости. Стрелка вправо — это {x: 1, y: 0}, вверх — {x: 0, y: -1} (ось Y на canvas растёт вниз!), и так далее.

Но есть коварная ловушка. Если змейка ползёт вправо, а игрок жмёт влево, голова попытается зайти на клетку шеи — и змейка мгновенно врежется сама в себя. В настоящей Змейке так нельзя: разворот на 180 градусов запрещён. Как это поймать? Очень просто: новое направление не должно быть прямо противоположным текущему. Противоположные векторы дают в сумме ноль по обеим осям.

function setDirection(nx, ny) {
  // запрет разворота: новое направление не должно
  // быть противоположно текущему
  if (nx === -velocity.x && ny === -velocity.y) return;
  velocity = { x: nx, y: ny };
}

window.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowUp')    setDirection(0, -1);
  if (e.key === 'ArrowDown')  setDirection(0, 1);
  if (e.key === 'ArrowLeft')  setDirection(-1, 0);
  if (e.key === 'ArrowRight') setDirection(1, 0);
});

Результат: цыплёнок поворачивает на стрелки и ползёт в новую сторону. Если он движется вправо и ты жмёшь влево — ничего не происходит, разворот отклоняется. А вот повернуть вверх или вниз можно в любой момент.

Логика проверки: если ползём вправо, velocity.x равен 1. Противоположное направление — влево, это nx = -1. Проверка nx === -velocity.x как раз ловит этот случай: -1 === -1, и мы просто выходим из функции, не меняя направление.

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

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

  • Двигают змейку каждый кадр. Если вызвать step() прямо в loop без таймера, змея улетит за экран за секунду. Шаг должен происходить по накопителю времени, а не на каждый requestAnimationFrame.
  • Путают unshift и push. Голова — это начало массива (unshift + pop). Если перепутать и делать push новой головы, она окажется в хвосте, и змейка «поползёт задом наперёд» в данных, а рисоваться будет криво.
  • Меняют velocity сразу несколько раз за один шаг. Если игрок быстро нажмёт вверх, а потом вниз между шагами, оба нажатия пройдут проверку разворота по очереди (вверх разрешён, потом вниз противоположен уже вверху — отклонится, но если порядок другой, можно проскочить в себя). Надёжнее запоминать желаемое направление и применять его один раз в начале step(). Это починим в следующем уроке.
  • Забывают, что ось Y растёт вниз. На canvas верх — это маленькие Y. Поэтому «вверх» — это y: -1, а не +1. Перепутаешь знак — стрелки вверх и вниз поменяются местами.
  • Не очищают экран перед рисованием. Если забыть залить фон в начале кадра, старые позиции змейки не сотрутся, и за цыплёнком потянется сплошной след-«червяк». Иногда это даже красиво, но это баг, а не фича.

Мини-практика: разгон змейки

Базовый скелет готов — теперь руки чешутся что-нибудь добавить. Вот задание, которое ты доделаешь сам, опираясь на код урока:

  1. Сделай так, чтобы по нажатию клавиши Пробел змейка ускорялась: уменьшай STEP_MS со 150 до 80. Подсказка: STEP_MS придётся объявить через let, а не const.
  2. Добавь вторую клавишу, которая возвращает обычную скорость. Получится «режим турбо», который игрок включает на свой страх и риск.
  3. Усложнение: нарисуй тонкую сетку поверх поля — серые линии по границам клеток через каждые CELL пикселей. Так нагляднее видно, по каким клеткам ползёт цыплёнок. Пригодится ctx.strokeRect в цикле по всем COLS и ROWS.

Если справишься с сеткой — ты по-настоящему понял связь «клетка ↔ пиксель», и дальше будет только легче.

Итоги и что дальше

Сегодня ты заложил весь фундамент Змейки. Давай зафиксируем, что теперь умеет твой цыплёнок:

  • Поле разбито на сетку клеток, и мы думаем в клетках, а пиксели вычисляем умножением только при отрисовке.
  • Тело змейки — это массив сегментов, голова в начале, хвост в конце.
  • Движение — это шаг по таймеру: добавили голову через unshift, убрали хвост через pop. Шаги идут по накопителю дельта-времени, а не каждый кадр.
  • Повороты меняют вектор скорости, а разворот на 180 градусов запрещён проверкой на противоположное направление.

Но змейка пока бессмертна и не растёт — она просто ползает кругами. В следующем уроке мы накидаем на поле яблоки, научим цыплёнка их есть (вот где пригодится сравнение клеток!), и при поедании перестанем убирать хвост — тогда змея начнёт расти. А ещё добавим проигрыш при столкновении со стеной и с собственным телом. Самое вкусное впереди — до встречи в следующем уроке!

Проверьте себя
1. Как устроено тело змейки в нашем коде?
AЭто одна переменная с координатами головы
BЭто массив, где каждый элемент — клетка тела, голова первая
CЭто специальный объект Snake из библиотеки
DЭто строка, где каждый символ — сегмент
2. Что делает шаг змейки на одну клетку?
AСдвигает координаты каждого сегмента по очереди в цикле
BДобавляет новую голову в начало (unshift) и убирает хвост в конце (pop)
CУдаляет весь массив и создаёт новый
DУвеличивает x головы на размер клетки в пикселях
3. Почему змейку двигают по таймеру, а не каждый кадр?
AКаждый кадр requestAnimationFrame не вызывается
BИначе при 60 кадрах в секунду змейка улетит за экран мгновенно
CТаймер экономит память
DCanvas не умеет рисовать чаще, чем раз в 150 мс
4. Как мы запрещаем разворот змейки на 180 градусов?
AПроверяем, что новое направление не противоположно текущему вектору скорости
BЗапрещаем нажимать две стрелки сразу
CСтавим паузу при каждом повороте
DСравниваем длину змейки с размером поля
5. Куда указывает вектор скорости {x: 0, y: -1} на canvas?
AВправо
BВниз
CВверх
DВлево
6. Почему в игровом цикле шаги делают через while (acc >= STEP_MS), а не через if?
Awhile работает быстрее, чем if
Bif нельзя использовать внутри requestAnimationFrame
CЕсли кадр подвис и накопилось время на несколько шагов, while догонит их все
Dwhile автоматически очищает накопитель acc