Игровые состояния

Учим игру жить режимами: одно меню, один экран игры, одна пауза и один Game Over — и аккуратное переключение между ними по событиям игрока.

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

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

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

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

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

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

Как игра понимает, в каком она режиме

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

Наша игра устроена так же. Есть «ручка» — обычная переменная, назовём её scene. В ней лежит название текущего канала: строка 'menu', 'play', 'pause' или 'gameover'. По-научному это называется игровое состояние (scene) — режим игры, между которыми она переключается.

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

Дальше всё просто. В игровом цикле (помнишь, это бесконечное «обновить — нарисовать», которое крутит requestAnimationFrame) мы каждый кадр смотрим на scene и спрашиваем: «На каком мы канале?». Если 'play' — двигаем цыплёнка и врагов, рисуем уровень. Если 'pause' — ничего не двигаем, рисуем застывшую картинку и надпись «Пауза». Если 'menu' — показываем заставку. Если 'gameover' — затемняем экран и пишем счёт. А «крутит ручку» игрок: нажал пробел в меню — записали в scene значение 'play', и игра поехала.

Четыре состояния нашей игры

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

СостояниеЗначение sceneЧто происходит
Меню'menu'Заставка с названием и подсказкой «Нажми пробел». Никто не двигается.
Игра'play'Сама игра: цыплёнок бегает, считается счёт, тикает время.
Пауза'pause'Всё застыло, поверх — надпись «Пауза». Логика не обновляется.
Проигрыш'gameover'Экран «Game Over» со счётом и подсказкой «Нажми R, чтобы заново».

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

Пример 1. Заводим переменную состояния

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

// текущее состояние игры — «канал», на котором мы сейчас
let scene = 'menu';

// сквозной цыплёнок из прошлых уроков
const chicken = {
  x: 80, y: 200,
  w: 40, h: 40,
  vx: 0, vy: 0
};

let score = 0;   // счёт текущей попытки

Результат: на экране пока ничего не видно — мы только подготовили данные. В памяти живёт переменная scene со значением 'menu' (игра стартует с заставки), знакомый цыплёнок и счётчик очков score. Вся дальнейшая логика урока будет смотреть на scene и решать, что делать.

Обрати внимание на два момента. Во-первых, scene объявлена через let, а не const — ведь её значение будет меняться, когда игрок переключает режимы. Во-вторых, мы храним состояние строкой ('menu', 'play'…). Можно было бы числами (0, 1, 2), но строки читаются глазами куда понятнее: if (scene === 'pause') сразу ясно, а if (scene === 2) заставляет вспоминать, что там за двойка.

Пример 2. Update и draw по состоянию

Теперь сердце урока. В игровом цикле мы каждый кадр смотрим на scene и вызываем только тот код, который относится к текущему режиму. Удобнее всего сделать по маленькой функции update и draw на каждое состояние, а главный update() и draw() пусть работают диспетчерами — раздают работу нужной функции.

function update() {
  // обновляем только тот режим, в котором находимся
  if (scene === 'play') {
    updatePlay();
  }
  // в menu, pause и gameover логику НЕ трогаем — всё застыло
}

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

  if (scene === 'menu') {
    drawMenu();
  } else if (scene === 'play') {
    drawPlay();
  } else if (scene === 'pause') {
    drawPlay();   // рисуем застывшую игру…
    drawPauseOverlay();   // …и поверх надпись «Пауза»
  } else if (scene === 'gameover') {
    drawGameOver();
  }
}

function loop() {
  update();
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Результат: игра запускается и зависает на меню — потому что scene === 'menu', и update() ничего не двигает, а draw() рисует только заставку. Цыплёнок стоит на месте. Цикл крутится 60 раз в секунду, но в режиме меню он каждый кадр лишь перерисовывает неподвижную картинку. Игра «дышит», но ждёт команды.

Разберём, что здесь происходит, по шагам:

  1. Диспетчер в update(). Мы обновляем игровую логику только когда scene === 'play'. Во всех остальных режимах update() ничего не делает — поэтому на паузе, в меню и на экране проигрыша всё застывает само собой. Не надо отдельно «выключать» движение: его просто никто не вызывает.
  2. Диспетчер в draw(). Рисовать, наоборот, надо в каждом режиме — иначе экран будет пустым. По scene мы выбираем нужную картинку: заставку, игру, паузу или Game Over.
  3. Хитрость с паузой. На паузе мы вызываем drawPlay() (чтобы под надписью было видно замороженную игру), а сверху — drawPauseOverlay() с полупрозрачной плашкой и словом «Пауза». Игра не исчезает, она замирает.
  4. Цикл всегда один. loop() не знает ни про какие состояния — он просто зовёт update() и draw(). Вся развилка спрятана внутри них. Это и есть красота подхода: requestAnimationFrame крутится всегда, а что именно делать — решает scene.

Маленькие функции вроде updatePlay и drawMenu мы здесь не расписываем целиком — updatePlay двигает цыплёнка и считает счёт ровно так, как в прошлых уроках, а функции рисования просто выводят текст и прямоугольники через контекст 2D. Главное, что ты видишь скелет: один диспетчер на обновление, один на отрисовку.

Пример 3. Переключаем состояния по событиям игрока

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

window.addEventListener('keydown', (e) => {
  if (scene === 'menu' && e.code === 'Space') {
    startGame();          // из меню — в игру
  } else if (scene === 'play' && e.code === 'KeyP') {
    scene = 'pause';      // ставим на паузу
  } else if (scene === 'pause' && e.code === 'KeyP') {
    scene = 'play';       // снимаем с паузы
  } else if (scene === 'gameover' && e.code === 'KeyR') {
    startGame();          // заново после проигрыша
  }
});

function startGame() {
  score = 0;              // сбрасываем счёт
  chicken.x = 80;         // ставим цыплёнка на старт
  chicken.y = 200;
  chicken.vx = 0;
  chicken.vy = 0;
  scene = 'play';         // и только теперь — в игру
}

Результат: теперь меню оживает. Жмёшь пробел — заставка пропадает, цыплёнок начинает бегать (мы в 'play'). Жмёшь P — игра застывает с надписью «Пауза», ещё раз P — продолжается с того же места. А после проигрыша клавиша R сбрасывает счёт, ставит цыплёнка на старт и запускает новую попытку.

Ключевая идея этого блока: каждая клавиша работает по-разному в зависимости от текущего состояния. Пробел в меню запускает игру, но в режиме 'play' мы его здесь не обрабатываем — там пробел занят прыжком. P и на паузу ставит, и снимает — но только смотря из какого режима жмём. Поэтому в каждом if мы первым делом проверяем scene, а уже потом — какую клавишу нажали. Сначала «где мы», потом «что нажали».

Отдельно про функцию startGame(). Заметь: переход в игру — это не только строчка scene = 'play'. Перед ней мы обнуляем состояние: сбрасываем счёт и ставим цыплёнка на стартовую позицию. Иначе после проигрыша новая попытка началась бы со старым счётом и цыплёнком там, где он умер. Поэтому переключение состояния часто оборачивают в функцию: она не просто меняет «канал», но и готовит сцену к этому каналу. Эта тема — про проигрыш и рестарт в Змейке, которую ты уже проходил, — здесь та же логика, только теперь рестарт встроен в общую систему состояний.

Пример 4. Уход в проигрыш изнутри игры

Остался последний переход — в 'gameover'. Его дёргает не игрок с клавиатуры, а сама игра, когда случается что-то фатальное: цыплёнок врезался во врага или упал в пропасть. Значит, переключать состояние мы будем прямо внутри updatePlay().

function updatePlay() {
  // ... тут движение цыплёнка, врагов, начисление счёта ...

  // проверяем фатальное событие
  if (chicken.y > canvas.height) {
    scene = 'gameover';   // цыплёнок упал за нижний край — конец
  }

  for (const enemy of enemies) {
    if (hitAABB(chicken, enemy)) {
      scene = 'gameover';   // столкнулись с врагом — конец
    }
  }
}

Результат: как только цыплёнок проваливается за нижний край экрана или его ловит враг, scene мгновенно становится 'gameover'. Со следующего же кадра update() перестаёт двигать игру (ведь теперь scene !== 'play'), а draw() рисует экран проигрыша со счётом. Игра сама поймала свою смерть и сменила канал.

Тут важно понять, насколько это удобно. Чтобы закончить игру, нам не нужно ничего «выключать», расставлять флаги isGameOver по всему коду или городить проверки в десяти местах. Мы просто пишем в одну переменную scene = 'gameover' — и вся система состояний сама подхватывает: движение останавливается (его не вызывают), рисуется нужный экран, клавиша R уже готова перезапустить. Одна переменная дирижирует всем.

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

Идея с одной переменной кажется простой, но новички спотыкаются о неё снова и снова. Вот что ломается чаще всего.

1. Двигать игру даже на паузе

Самая обидная ошибка — вызывать updatePlay() всегда, забыв обернуть его в if (scene === 'play'). Тогда на паузе цыплёнок продолжит бегать, враги — двигаться, а счёт — капать, хотя на экране висит надпись «Пауза». Пауза должна именно замораживать логику, а замораживается она просто тем, что update в этом режиме никто не зовёт. Логику обновляем только в 'play'.

2. Забыть нарисовать остальные состояния

Зеркальная ошибка: расписать draw только для 'play', а про меню, паузу и Game Over забыть. Тогда во всех остальных режимах экран будет просто чёрным (или пустым после clearRect), и игрок решит, что игра зависла. Рисовать, в отличие от обновления, надо в каждом состоянии — иначе экран пустой.

3. Менять состояние, не подготовив сцену

Если при рестарте написать только scene = 'play', но забыть сбросить счёт и вернуть цыплёнка на старт, новая попытка начнётся с хвостом от прошлой: старый счёт, цыплёнок где-то за экраном, прежняя скорость. Поэтому переход в игру оборачивают в startGame(), которая сначала наводит порядок в данных, а уже потом переключает scene.

4. Обрабатывать клавиши без проверки состояния

Если в обработчике keydown не проверять scene, одна и та же клавиша начнёт творить дичь во всех режимах сразу. Например, пробел будет одновременно и запускать игру из меню, и заставлять цыплёнка прыгать, и снимать с паузы. Всегда сначала спрашивай «в каком мы состоянии», и только потом реагируй на клавишу.

5. Хранить состояние в нескольких переменных

Иногда новички заводят сразу isMenu, isPlaying, isPaused, isGameOver — по булевой переменной на режим. И почти сразу ловят баг, когда две из них случайно становятся true одновременно: игра «и на паузе, и идёт». Канал должен быть один. Держи состояние в одной переменной scene — тогда противоречивых режимов просто не может возникнуть.

Мини-проект: добавь экран победы

Теперь твоя очередь. Возьми код из примеров и добавь в игру пятое состояние — экран победы, когда цыплёнок дожил до конца уровня.

  1. Заведи новое состояние 'win'. Просто новое значение для scene — никакой особой магии не нужно.
  2. Придумай условие победы. Например, цыплёнок набрал 10 очков или продержался определённое время. Проверяй это условие в updatePlay() и при выполнении пиши scene = 'win' — ровно так же, как мы делали с 'gameover'.
  3. Нарисуй экран победы. Добавь в диспетчер draw() ветку else if (scene === 'win') и функцию drawWin(), которая через context.fillText выводит «Победа!» и финальный счёт.
  4. Дай переиграть. В обработчике клавиш добавь: если scene === 'win' и нажали R — снова зови startGame(). Так и с экрана победы, и с экрана проигрыша рестарт работает одинаково.

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

  • Не дублируй код рестарта. И 'gameover', и 'win' по R должны звать одну и ту же startGame() — так ты убедишься, что подготовка сцены живёт в одном месте.
  • Если что-то не переключается, первым делом выведи scene в углу экрана через context.fillText(scene, 10, 20). Видеть текущий «канал» прямо на экране — лучший способ отладить состояния.
  • Сохрани этот скелет диспетчеров. Систему состояний ты переиспользуешь в финальном уроке курса, когда будешь собирать всю аркаду про цыплёнка воедино.

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

Итоги

Сегодня ты дал игре понятие «что сейчас происходит». Вот что теперь у тебя в арсенале:

  • Состояние — это одна переменная scene, в которой лежит текущий режим: 'menu', 'play', 'pause' или 'gameover'. В любой момент режим ровно один.
  • Update и draw — диспетчеры. По значению scene они вызывают только тот код, что относится к текущему режиму: логику обновляем лишь в 'play', а рисуем — в каждом состоянии.
  • Переключение — это запись в переменную. scene = 'pause' мгновенно замораживает игру, потому что её update больше не вызывается. Никаких флагов по всему коду не нужно.
  • Переход часто готовит сцену. Рестарт оборачивают в функцию (startGame()), которая сначала наводит порядок в данных, а потом меняет состояние.

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

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

Проверьте себя
1. Где хранится информация о том, в каком режиме сейчас находится игра?
AВ нескольких булевых переменных: isMenu, isPlaying, isPaused
BВ одной переменной scene со значением вроде 'menu' или 'play'
CВ canvas через getContext
DНигде — игра сама догадывается по координатам цыплёнка
2. Почему на паузе игра застывает сама собой, без специального «выключения» движения?
AПотому что requestAnimationFrame останавливается
BПотому что update() вызывает updatePlay() только при scene === 'play', а на паузе его просто никто не зовёт
CПотому что canvas очищается
DПотому что цыплёнок удаляется из памяти
3. Чем update-диспетчер отличается от draw-диспетчера по состояниям?
AОни одинаковы и обрабатывают все состояния
BЛогику (update) обновляем только в 'play', а рисовать (draw) надо в каждом состоянии
Cdraw работает только в 'play', а update — везде
DОба работают только в меню
4. Почему рестарт оборачивают в функцию startGame(), а не пишут просто scene = 'play'?
AТак короче писать
BЧтобы перед сменой состояния подготовить сцену: сбросить счёт и вернуть цыплёнка на старт
CПотому что scene нельзя менять напрямую
DЧтобы игра шла быстрее
5. Кто переключает игру в состояние 'gameover'?
AИгрок нажатием клавиши R
BСама игра изнутри updatePlay(), когда случается фатальное событие (столкновение или падение)
CБраузер при сворачивании вкладки
DФункция draw() при отрисовке
6. Почему в обработчике keydown первым делом проверяют scene, а потом уже клавишу?
AТак быстрее работает код
BЧтобы одна клавиша работала по-разному в разных режимах и не творила дичь сразу везде
CПотому что иначе клавиша не нажмётся
DЧтобы canvas перерисовался