Проигрыш и рестарт
Змейка из цыплёнка уже ползает по полю и растёт, поедая зёрнышки — но проиграть в ней пока невозможно. Сегодня добавим смерть, экран Game Over и кнопку «сыграть снова».
Проигрыш в Змейке — это две простые проверки головы: «не упёрлась ли она в стену?» и «не въехала ли она сама в себя?». Если да — переключаем игру в состояние Game Over, рисуем надпись, а по нажатию клавиши сбрасываем всё в начало и запускаем заново.
В прошлом уроке про еду и рост мы научили цыплёнка-змейку есть зёрнышки и удлиняться на один сегмент за каждое. Уже почти настоящая игра: голова цыплёнка ведёт за собой хвост, поле постепенно заполняется телом, азарт растёт вместе с длиной. Но есть одна странность — проиграть нельзя. Веди голову прямо в стену — она спокойно проедет насквозь или застрянет. Заверни цыплёнка в клубок так, что голова утыкается в собственный хвост — и ничего не происходит. А ведь весь смысл Змейки именно в том, что один неверный поворот — и всё, конец, начинай сначала.
Зачем игре проигрыш
Подумай, почему в Змейку вообще интересно играть по второму разу. Не потому, что зёрнышки красивые. А потому, что есть риск: чем длиннее твой цыплёнок, тем теснее ему на поле, тем легче загнать себя в угол и впечататься в собственный хвост. Проигрыш — это та самая стенка, об которую разбивается твой рекорд, и именно она заставляет в следующий раз вести змейку аккуратнее. Без проигрыша нет рекорда, без рекорда нет повода нажать «ещё раз».
Вспомни любую игру, в которую ты залипал в метро: бесконечный раннер, тетрис, тот же 2048. Везде один и тот же крючок — ты ошибся, тебе показали твой результат, и тут же дали кнопку «попробовать снова». Палец сам тянется нажать. Этот круг «играл — проиграл — увидел счёт — перезапустил» называют кор-лупом — главной петлёй, ради которой в игру хочется возвращаться. Сегодня мы замкнём этот круг для нашей Змейки.
Вот к чему придём в конце урока. Ведёшь цыплёнка по полю, растёшь, всё хорошо — и вдруг направил голову прямо в правую стену. Бац: поле затемняется, по центру крупными буквами загорается Game Over, под ним строчка поменьше — «Нажми пробел, чтобы сыграть снова». Жмёшь пробел — экран очищается, цыплёнок снова коротенький, в три сегмента, ползёт из центра, счёт обнулён. Игра ожила и стала честной.
Игра, у которой есть настроение
Сейчас наша Змейка всегда находится в одном-единственном режиме: «играем». Голова двигается, тело тянется, зёрнышки появляются. Но когда цыплёнок врежется, игра должна вести себя совсем иначе — замереть, показать надпись, ждать нажатия. Это уже другой режим, другое настроение игры.
Игровое состояние (scene) — это режим, в котором сейчас находится игра: меню, собственно игра, пауза или экран проигрыша. Между этими режимами игра переключается, и в каждом ведёт себя по-своему.
Метафора простая: представь, что игра — это твой телефон. В режиме «звонок» он показывает кнопки «принять/сбросить», в режиме «камера» — видоискатель и кнопку спуска, в режиме «блокировка» — только часы. Одно устройство, но разные экраны под разные ситуации. Наша Змейка точно так же будет жить в двух режимах: 'playing' (играем) и 'gameover' (проиграли). Заведём для этого простую переменную:
// текущий режим игры: 'playing' или 'gameover'
let scene = 'playing';Результат: на экране пока ничего не меняется — мы просто завели переключатель режимов, который по умолчанию стоит в положении «играем».
Дальше весь смысл такой: каждый кадр мы смотрим на scene. Если 'playing' — двигаем цыплёнка и проверяем, не врезался ли он. Если 'gameover' — змейку больше не трогаем, а вместо игрового поля рисуем экран проигрыша. Один взгляд на эту переменную — и игра знает, как себя вести.
Пример 1. Удар о стену
Начнём с самого очевидного способа проиграть — въехать головой в стену. Напомню, как устроено наше поле из прошлых уроков: оно разбито на клетки размером CELL пикселей, а змейка — это массив сегментов chicken.body, где самый первый элемент chicken.body[0] и есть голова. Каждый шаг голова сдвигается на одну клетку в текущем направлении.
Поле у нас, допустим, COLS клеток в ширину и ROWS в высоту. Значит, допустимые координаты головы по горизонтали — от 0 до COLS - 1, а по вертикали — от 0 до ROWS - 1. Стоит голове выйти за эти границы — она ткнулась в стену. Проверим это:
function hitsWall(head) {
return (
head.x < 0 ||
head.x >= COLS ||
head.y < 0 ||
head.y >= ROWS
);
}Результат: на экране ничего не происходит — это вспомогательная функция. Она получает голову head (объект с координатами клетки x и y) и возвращает true, если та вылезла за любую из четырёх стен.
Разберём по строчкам. Голова ушла левее нулевого столбца (head.x < 0) — упёрлась в левую стену. Доехала до столбца с номером COLS или дальше (head.x >= COLS) — это уже за правым краем, ведь последний разрешённый столбец имеет номер COLS - 1. То же самое по вертикали с head.y. Четыре условия через || («или») значат: достаточно нарушить хотя бы одну границу, чтобы вся функция вернула true.
Теперь подключим эту проверку в шаг игры. Помнишь, что в Змейке мы двигаемся не каждый кадр, а раз в несколько кадров — иначе цыплёнок носился бы как угорелый. Вот как выглядит шаг с проверкой стены:
function step() {
// вычисляем, куда встанет голова на этом шаге
const head = {
x: chicken.body[0].x + chicken.dir.x,
y: chicken.body[0].y + chicken.dir.y,
};
// упёрлись в стену? — конец игры
if (hitsWall(head)) {
scene = 'gameover';
return;
}
// иначе — обычный ход: голова вперёд
chicken.body.unshift(head);
chicken.body.pop();
}Результат: теперь, если новая позиция головы оказывается за краем поля, игра не делает ход, а переключает scene в 'gameover' и выходит из функции через return. Цыплёнок замирает на последней живой клетке у самой стены — он не «вмазывается» в неё наполовину, мы ловим столкновение до того, как сдвинули тело.
Обрати внимание на хитрость: мы сначала вычисляем будущую позицию головы в переменную head, проверяем её — и только если всё нормально, реально двигаем змейку через unshift (добавить голову спереди) и pop (убрать хвост сзади). Это та же связка, что мы собрали в прошлом уроке. Проверять надо именно планируемую клетку, а не текущую: смысл в том, чтобы поймать столкновение за миг до удара.
Пример 2. Цыплёнок съел сам себя
Второй способ проиграть хитрее и обиднее — врезаться головой в собственный хвост. Чем длиннее цыплёнок, тем чаще это случается: завернул слишком круто, и голова налетела на тело. Нам нужно проверить, не совпадает ли клетка новой головы с какой-нибудь клеткой, где тело уже лежит.
Тело — это массив chicken.body, и каждый его сегмент это объект с координатами x и y. Значит, надо пройтись по сегментам и спросить про каждый: «голова встанет туда же, где ты?» Если хоть один ответит «да» — цыплёнок откусил себя.
function hitsSelf(head, body) {
// проверяем каждый сегмент тела
return body.some(
(seg) => seg.x === head.x && seg.y === head.y
);
}Результат: функция-помощник, на экране ничего не меняется. Метод some пробегает по всем сегментам тела и возвращает true, как только находит сегмент, чьи координаты seg.x и seg.y в точности совпали с будущей клеткой головы.
Метод some — это вежливый способ сказать «есть ли в массиве хоть один элемент, для которого условие верно?». Он сам останавливается, как только нашёл совпадение, и не проверяет остаток зря. Условие внутри — seg.x === head.x && seg.y === head.y — срабатывает, только когда совпали обе координаты: голова и сегмент стоят на одной и той же клетке.
Добавим эту проверку в step рядом со стеной:
function step() {
const head = {
x: chicken.body[0].x + chicken.dir.x,
y: chicken.body[0].y + chicken.dir.y,
};
// врезались в стену ИЛИ в самого себя
if (hitsWall(head) || hitsSelf(head, chicken.body)) {
scene = 'gameover';
return;
}
chicken.body.unshift(head);
chicken.body.pop();
}Результат: теперь Змейка проигрывает в обоих случаях — и когда голова цыплёнка вылетает за поле, и когда она пытается встать на клетку, занятую собственным телом. В любой из этих ситуаций scene становится 'gameover', и ход не делается.
Тут есть тонкость, на которой спотыкаются почти все. Самый кончик хвоста chicken.body на этом же шаге собирается уехать вперёд — мы ведь делаем pop() сразу после unshift. То есть клетка, где сейчас лежит последний сегмент хвоста, через мгновение освободится, и голова могла бы спокойно туда встать. Но мы проверяем head по старому телу, где хвост ещё на месте. На практике для учебной Змейки это почти не мешает — но если захочешь честную проверку, исключи последний сегмент: проверяй не весь body, а body.slice(0, -1). Это срез массива «все сегменты, кроме последнего».
Пример 3. Экран Game Over и рисование
Цыплёнок умеет умирать — пора это показать. Когда scene равен 'gameover', мы перестаём двигать змейку и вместо игрового поля рисуем экран проигрыша. Удобнее всего разделить рисование на два режима прямо в функции отрисовки кадра:
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (scene === 'playing') {
drawField(); // поле, зёрнышко и змейку
drawSnake();
drawScore();
} else if (scene === 'gameover') {
drawGameOver(); // тёмная плашка и надпись
}
}Результат: пока игра идёт, на холсте видно поле, зёрнышко, цыплёнка-змейку и счёт; как только наступил проигрыш, всё это сменяется экраном Game Over. Один if по scene решает, какую картинку показать.
Теперь сам экран проигрыша. Нарисуем поверх холста полупрозрачную тёмную плашку, чтобы поле «погасло», а сверху белым текстом — заголовок и подсказку про рестарт. Рисуем мы через контекст 2D — тот самый объект ctx, полученный из canvas.getContext('2d'), через который идут все команды рисования.
function drawGameOver() {
// 1. затемняем поле полупрозрачным чёрным
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 2. крупная надпись по центру
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.font = 'bold 48px sans-serif';
ctx.fillText('Game Over', canvas.width / 2, canvas.height / 2 - 10);
// 3. подсказка помельче
ctx.font = '20px sans-serif';
ctx.fillText(
'Нажми пробел, чтобы сыграть снова',
canvas.width / 2,
canvas.height / 2 + 30
);
// 4. итоговый счёт
ctx.fillText(
'Счёт: ' + chicken.score,
canvas.width / 2,
canvas.height / 2 + 60
);
}Результат: поле затягивается полупрозрачной тёмной пеленой, сквозь которую еле проступает застывшая змейка, а по центру белым горят три строки: крупное «Game Over», под ним подсказка про пробел и итоговый счёт цыплёнка.
Разберём ключевые команды. ctx.fillStyle = 'rgba(0, 0, 0, 0.6)' задаёт чёрный цвет с прозрачностью 60% — поэтому поле не исчезает совсем, а как бы притухает под ним. ctx.textAlign = 'center' говорит холсту: точку, которую я передам в fillText, считай серединой текста, а не его левым краем — поэтому достаточно указать canvas.width / 2, и надпись сама встанет по центру. ctx.font мы меняем перед каждой надписью: сначала жирные 48 пикселей для заголовка, потом 20 — для строчек поменьше.
Пример 4. Рестарт по нажатию клавиши
Осталось замкнуть кор-луп — дать игроку нажать клавишу и начать заново. Сначала напишем функцию, которая возвращает игру в стартовое состояние: короткий цыплёнок из трёх сегментов в центре, нулевой счёт, направление вправо и режим 'playing'.
function resetGame() {
chicken.body = [
{ x: 5, y: 10 },
{ x: 4, y: 10 },
{ x: 3, y: 10 },
];
chicken.dir = { x: 1, y: 0 }; // движемся вправо
chicken.score = 0;
spawnFood(); // новое зёрнышко
scene = 'playing'; // снова играем
}Результат: на экране ничего не происходит в момент вызова, но эта функция приводит всё состояние игры к стартовому: цыплёнок снова коротенький и стоит в центре, голова смотрит вправо, счёт обнулён, на поле появляется свежее зёрнышко, режим переключён обратно в «играем».
Главное здесь — мы заново выставляем все данные, которые менялись по ходу партии: тело, направление, счёт, зёрнышко и сам scene. Забудешь хоть одно — и после рестарта вылезет странность: например, не сбросишь счёт, и новый цыплёнок стартует с чужими очками. Поэтому держи в голове правило: рестарт обязан вернуть к началу каждую переменную состояния.
Теперь повесим вызов на клавишу. У нас уже есть обработчик нажатий с прошлых уроков — там мы ловили стрелки для поворота цыплёнка. Добавим в него реакцию на пробел, но только когда игра в режиме 'gameover':
document.addEventListener('keydown', (e) => {
// на экране проигрыша пробел перезапускает игру
if (scene === 'gameover') {
if (e.code === 'Space') {
resetGame();
}
return; // в gameover стрелки не нужны
}
// обычное управление поворотами (как в прошлых уроках)
if (e.code === 'ArrowUp') chicken.dir = { x: 0, y: -1 };
if (e.code === 'ArrowDown') chicken.dir = { x: 0, y: 1 };
if (e.code === 'ArrowLeft') chicken.dir = { x: -1, y: 0 };
if (e.code === 'ArrowRight') chicken.dir = { x: 1, y: 0 };
});Результат: на экране Game Over нажатие пробела мгновенно стирает тёмную плашку и запускает новую партию — короткий цыплёнок снова ползёт из центра. Во время игры пробел игнорируется, а стрелки по-прежнему поворачивают змейку.
Заметь порядок проверок: сначала смотрим, не в 'gameover' ли мы. Если да — реагируем только на пробел и через return выходим, не доходя до стрелок. Это важно: пока цыплёнок мёртв, поворачивать его бессмысленно, поэтому стрелки в этом режиме мы просто не слушаем. А во время игры мы до пробела даже не доходим — первый if не сработал, и управление идёт как обычно.
Частые ошибки и подводные камни
Эти грабли подстерегают почти каждого, кто впервые добавляет проигрыш в Змейку. Разберём заранее, чтобы ты их обошёл.
- Сравнивают со стеной как
head.x > COLSвместо>=. Клетки нумеруются с нуля, поэтому последний разрешённый столбец —COLS - 1. Если проверятьhead.x > COLS, голова успеет встать на несуществующий столбецCOLSи только потом «проиграть» — змейка на кадр выедет за границу. Правильная граница —head.x >= COLS. - Двигают змейку до проверки, а не после. Если сначала сделать
unshift/pop, а уже потом проверять голову, ты ловишь столкновение, когда цыплёнок уже въехал в стену или в себя. Сначала вычисляем будущую клеткуhead, проверяем её — и только потом двигаем тело. - Забывают
returnпослеscene = 'gameover'. Безreturnфункцияstepпродолжит работу и всё равно сдвинет змейку, хотя игра уже должна была остановиться. Один пропущенныйreturn— и цыплёнок делает лишний шаг сквозь стену. - В
resetGameсбрасывают не всё. Самая обидная ошибка: вернули тело и режим, но забыли обнулитьchicken.scoreили сменить направление. После рестарта цыплёнок стартует со старым счётом или мчится в ту же стену. Сбрасывай все переменные состояния разом. - Слушают пробел всегда, и он мешает во время игры. Если повесить
resetGame()на пробел без проверкиscene, то случайное нажатие пробела посреди партии обнулит твоего длинного цыплёнка. Перезапуск должен срабатывать только в режиме'gameover'.
Мини-практика: «ещё чуть-чуть до рекорда»
Базовый проигрыш и рестарт у тебя уже собраны. Теперь доведи игру до ума сам — вот три шага по нарастанию сложности.
- Запомни лучший счёт. Заведи снаружи цикла переменную
let best = 0;. В момент проигрыша, прежде чем показать экран, сравни:if (chicken.score > best) best = chicken.score;. Выведи на экране Game Over вторую строку — «Рекорд: » плюсbest. Теперь рекорд переживает рестарты, и тебе захочется его побить. - Пауза перед смертью. Сейчас при ударе игра мгновенно прыгает в Game Over — глаз не успевает понять, обо что цыплёнок убился. Попробуй на полсекунды оставить застывшую змейку на поле, прежде чем затемнять экран. Подсказка: заведи режим
'dying'и счётчик кадров; пока счётчик не дотикал — рисуем замершую змейку, потом переключаем в'gameover'. - Честная проверка хвоста. Вспомни тонкость из примера 2: кончик хвоста на этом шаге уезжает, и голова могла бы туда встать. Замени
hitsSelf(head, chicken.body)на проверку поchicken.body.slice(0, -1)и поиграй — почувствуешь, что в самых тесных поворотах цыплёнок стал чуть-чуть везучее.
Если застрял — вернись к примерам выше и собери код по кусочку: сначала добейся, чтобы рекорд показывался, потом возьмись за паузу. Маленькими шагами «поменял — посмотрел, что вышло».
Итоги
Сегодня Змейка из цыплёнка стала настоящей игрой, в которую хочется переигрывать. Мы:
- завели игровое состояние
sceneс режимами'playing'и'gameover'— игра теперь знает, как себя вести; - научились ловить два проигрыша: удар головы о стену (выход за границы
0..COLS-1и0..ROWS-1) и столкновение головы с собственным телом черезsome; - нарисовали экран Game Over — полупрозрачную плашку и надпись через
ctx.fillTextс выравниванием по центру; - замкнули кор-луп: функция
resetGameвозвращает всё состояние к старту, а пробел в режиме'gameover'запускает новую партию.
Главное, что ты унёс: проигрыш — это не магия, а пара проверок головы плюс переключатель режима. А рестарт — это аккуратный сброс всех переменных состояния к начальным значениям. Эти же идеи — «состояние сцены» и «сброс к старту» — пригодятся в любой твоей будущей игре, от Понга до платформера.
Дальше начинается самое сочное: мы возьмёмся за платформер, где цыплёнок наконец-то прыгает. Там тебя ждёт гравитация — постоянное ускорение вниз, которое каждый кадр тянет героя к земле. Готовь пальцы к пробелу: скоро цыплёнок взлетит.