Как сделать Змейку на JS: таймер шага, хвост, переход через край

Коротко

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

Содержание

  1. Из чего состоит Змейка: четыре события
  2. Таймер шага: накопитель времени вместо счёта кадров
  3. Хвост: массив клеток и рост без пересчёта
  4. Переход через край: остаток от деления для отрицательных чисел
  5. Повороты: очередь и запрет разворота на 180°
  6. Еда только в свободных клетках
  7. Таблица: скорость и размер поля
  8. Код на JavaScript с проверками
  9. Практический сценарий: от логики к игре за вечер
  10. Когда вам это не подойдёт

Из чего состоит Змейка: четыре события

Змейку удобно описывать как реакции на четыре события, и почти вся игра помещается в эти реакции:

СобытиеУсловиеДействие
Натикал шаг таймеразмейка живавзять поворот из очереди, сдвинуть голову
Нажата стрелкаповорот не противоположен последнемуположить поворот в очередь
Голова в клетке еды—+1 к росту, новая еда
Голова в клетке телаэто не освобождаемый хвостконец игры

Состояние игры — это массив клеток тела, направление, очередь поворотов, счётчик роста и клетка еды. Отрисовка только читает это состояние.

Таймер шага: накопитель времени вместо счёта кадров

Шаг змейки нужно отсчитывать по времени, а не по числу кадров, иначе на мониторе 144 Гц игра станет в 2,4 раза быстрее, чем на 60 Гц. Каждый кадр прибавляйте его длительность к накопителю; пока накопитель не меньше длины шага, вычитайте шаг и двигайте змейку:

накопитель += dt
пока накопитель ≥ шаг:  накопитель −= шаг; сделать шаг
шагов в секунду = 1 / шаг   (0,14 с → около 7,1)

Ограничьте число шагов за кадр, например пятью: после того как вкладка была свёрнута, накопитель может вырасти до секунд, и змейка «телепортируется» через полполя.

Хвост: массив клеток и рост без пересчёта

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

Важная тонкость: в клетку, которую хвост покидает на этом шаге, голова войти может. Поэтому при проверке столкновения последний сегмент исключают — но только если змейка в этот шаг не растёт.

Переход через край: остаток от деления для отрицательных чисел

Переход через край считается остатком от деления, но в JavaScript оператор % для отрицательных чисел даёт отрицательный результат: -1 % 20 равно -1. Нужна форма, которая всегда возвращает число от 0 до n − 1:

wrap(v, n) = ((v mod n) + n) mod n
wrap(-1, 20) = 19      wrap(20, 20) = 0

Если переход через край не нужен, вместо wrap проверяйте выход за границы и заканчивайте игру — это классический вариант с «глухими» стенами.

Повороты: очередь и запрет разворота на 180°

Повороты складывают в очередь на 2 элемента и проверяют против последнего направления в очереди, а не текущего. Без очереди игрок, который за один шаг быстро нажал «вверх» и «влево», теряет первое нажатие: второе перезаписывает его и оказывается разворотом назад, а змейка врезается в шею. С очередью «вверх» выполнится на этом шаге, «влево» — на следующем.

Противоположное направление определяется просто: сумма векторов направлений равна нулю.

Еда только в свободных клетках

Еду выбирают из списка свободных клеток, а не случайной клеткой с повтором при попадании в тело. Повтор работает на пустом поле, но когда змейка занимает 95% клеток, он может крутиться десятки раз. Список свободных клеток строится за один проход по полю; если он пуст — поле заполнено, и это победа.

Таблица: скорость и размер поля

Скорость задаётся длиной шага, а сложность — сочетанием шага и размера поля.

РежимПоле, клетокШаг, сШагов в секундуВремя пересечь поле по ширине
Учебный20 × 150,2054,0 с
Обычный20 × 150,14≈ 7,12,8 с
Быстрый30 × 200,0812,52,4 с
Ускорениелюбоеминус 0,005 за еду, не меньше 0,06до ≈ 16,7—

Время пересечения — это ширина поля, умноженная на длину шага: 20 × 0,14 = 2,8 с.

Код на JavaScript с проверками

Код содержит всю логику Змейки без отрисовки и шесть проверок. Он запущен в Node.js 24, числа в комментариях совпадают с выводом.

// Змейка без отрисовки: только правила. Поле cols × rows клеток, шаг по таймеру.
const DIRS = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } };

function createSnake(cols, rows, random) {
  const s = {
    cols, rows, random,
    body: [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 3, y: 5 }], // [0] — голова
    dir: "right",
    queue: [],          // до 2 поворотов, нажатых между шагами
    grow: 0,
    alive: true,
    food: null,
  };
  s.food = spawnFood(s);
  return s;
}

// Поворот кладём в очередь; разворот на 180° относительно последнего направления отбрасываем
function turn(s, name) {
  const last = s.queue.length ? s.queue[s.queue.length - 1] : s.dir;
  const a = DIRS[last], b = DIRS[name];
  if (a.x + b.x === 0 && a.y + b.y === 0) return false; // противоположное направление
  if (name === last || s.queue.length >= 2) return false;
  s.queue.push(name);
  return true;
}

// Переход через край: остаток от деления, который работает и для -1
const wrap = function (v, n) { return ((v % n) + n) % n; };

function step(s) {
  if (!s.alive) return;
  if (s.queue.length) s.dir = s.queue.shift();
  const d = DIRS[s.dir];
  const head = { x: wrap(s.body[0].x + d.x, s.cols), y: wrap(s.body[0].y + d.y, s.rows) };
  const ate = head.x === s.food.x && head.y === s.food.y;
  if (ate) s.grow += 1;
  // Хвост освобождает клетку в этом же шаге, поэтому в неё можно войти — если змейка не растёт
  const tailLeaves = s.grow === 0;
  const checkLen = tailLeaves ? s.body.length - 1 : s.body.length;
  for (let i = 0; i < checkLen; i++) {
    if (s.body[i].x === head.x && s.body[i].y === head.y) { s.alive = false; return; }
  }
  s.body.unshift(head);
  if (s.grow > 0) s.grow -= 1; else s.body.pop();
  if (ate) s.food = spawnFood(s);
}

// Еда — только в свободной клетке: перечисляем свободные и выбираем одну
function spawnFood(s) {
  const busy = new Set(s.body.map(function (c) { return c.y * s.cols + c.x; }));
  const free = [];
  for (let i = 0; i < s.cols * s.rows; i++) if (!busy.has(i)) free.push(i);
  if (!free.length) return null; // поле заполнено — победа
  const i = free[Math.floor(s.random() * free.length)];
  return { x: i % s.cols, y: Math.floor(i / s.cols) };
}

// Таймер шага: накапливаем время кадра и делаем столько шагов, сколько «натикало»
function createTicker(stepSec) {
  let acc = 0;
  return function (dt, onStep) {
    acc += dt;
    let n = 0;
    while (acc >= stepSec && n < 5) { acc -= stepSec; onStep(); n++; }
    return n;
  };
}

// Детерминированный генератор для проверки
function lcg(seed) { let x = seed; return function () { x = (x * 1664525 + 1013904223) % 4294967296; return x / 4294967296; }; }

// 1. Переход через край: голова в x = 19 на поле 20 клеток уходит в x = 0
const s = createSnake(20, 15, lcg(7));
s.body = [{ x: 19, y: 5 }, { x: 18, y: 5 }, { x: 17, y: 5 }];
s.food = { x: 10, y: 10 };
step(s);
console.log("после края:", s.body[0]); // { x: 0, y: 5 }

// 2. Быстрое «вверх, затем влево» между шагами срабатывает за два шага, «влево» сразу — нет
console.log(turn(s, "left"));                // false — разворот на 180°
console.log(turn(s, "up"), turn(s, "left")); // true true
step(s); step(s);
console.log("голова:", s.body[0], "направление:", s.dir); // { x: 19, y: 4 } left

// 3. Рост: еда прямо перед головой
s.food = { x: 18, y: 4 };
const before = s.body.length;
step(s); step(s);
console.log("длина:", before, "→", s.body.length); // 3 → 4

// 4. Вход в клетку, которую освобождает хвост, — не столкновение
const t = createSnake(20, 15, lcg(1));
t.body = [{ x: 5, y: 5 }, { x: 6, y: 5 }, { x: 6, y: 6 }, { x: 5, y: 6 }]; // квадрат 2×2
t.dir = "down"; t.food = { x: 0, y: 0 };
step(t);
console.log("жива после входа в клетку хвоста:", t.alive); // true

// 5. Таймер: 0,14 с на шаг, 60 кадров по 1/60 с дают 7 шагов
const tick = createTicker(0.14);
let steps = 0;
for (let i = 0; i < 60; i++) steps += tick(1 / 60, function () {});
console.log("шагов за 1 с:", steps); // 7

// 6. Еда никогда не появляется на теле: 1000 проверок
const u = createSnake(6, 4, lcg(42));
let bad = 0;
for (let i = 0; i < 1000; i++) {
  const f = spawnFood(u);
  if (u.body.some(function (c) { return c.x === f.x && c.y === f.y; })) bad++;
}
console.log("еда на теле:", bad); // 0

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

Практический сценарий: от логики к игре за вечер

Логика выше превращается в играбельную Змейку за 4 шага:

  1. Отрисовка. На холсте размером cols × размер_клетки закрашивайте каждую клетку тела и еды квадратом; например, 20 × 15 клеток по 32 пикселя дают холст 640 × 480.
  2. Цикл. В requestAnimationFrame вычисляйте dt в секундах, вызывайте таймер шага и затем рисуйте состояние.
  3. Ввод. На keydown переводите ArrowUp, ArrowDown, ArrowLeft, ArrowRight в вызовы turn; для телефона добавьте свайпы с порогом 30 пикселей.
  4. Счёт и рестарт. Очки — это длина тела минус 3; при alive === false показывайте итог и пересоздавайте состояние по нажатию пробела.

Отдельно проверьте вкладку в фоне: свернув её на 10 секунд, змейка не должна пролететь поле — за это отвечает предел пяти шагов за кадр.

Когда вам это не подойдёт

Подход «логика по таймеру на сетке» хорош для Змейки и похожих игр, но не для всего:

  • Плавное движение между клетками: если нужно, чтобы змейка скользила, а не прыгала по клеткам, к логике добавляется интерполяция отрисовки между шагами — это отдельная техника, которой здесь нет.
  • Сетевая игра на несколько змеек: при игре по сети шаги нужно синхронизировать между игроками и разрешать одновременные столкновения голов, а это задача сетевого кода.
  • Очень большие поля: при поле 1000 × 1000 клеток перебор свободных клеток на каждый выбор еды станет заметен; понадобится хранить свободные клетки в отдельной структуре.

Мы разрабатываем J2DRU — браузерный 2D игровой движок, где логика собирается из событий, условий и действий, а при необходимости углубляется на TypeScript. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).

Читайте также:

Частые вопросы

Почему змейка движется рывками по клеткам, а не плавно?

В классической Змейке положение меняется только на шаге таймера, например раз в 0,14 с, и всегда ровно на одну клетку. Это упрощает столкновения: достаточно сравнить номера клеток, без пересечения прямоугольников.

Как запретить мгновенный разворот назад?

Поворот сравнивают не с текущим направлением, а с последним направлением в очереди нажатий. Если новый поворот противоположен ему, его отбрасывают. Очередь на 2 поворота позволяет быстро нажать «вверх» и «влево» между шагами и не врезаться в себя.

Как сделать переход через край поля?

Новую координату головы берут по модулю ширины или высоты поля в форме ((x mod n) + n) mod n. Обычный оператор % в JavaScript для -1 вернёт -1, а такая запись вернёт n − 1, то есть противоположный край.

Почему змейка может войти в клетку своего хвоста?

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