Игровые состояния
Учим игру жить режимами: одно меню, один экран игры, одна пауза и один 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 раз в секунду, но в режиме меню он каждый кадр лишь перерисовывает неподвижную картинку. Игра «дышит», но ждёт команды.
Разберём, что здесь происходит, по шагам:
- Диспетчер в update(). Мы обновляем игровую логику только когда
scene === 'play'. Во всех остальных режимахupdate()ничего не делает — поэтому на паузе, в меню и на экране проигрыша всё застывает само собой. Не надо отдельно «выключать» движение: его просто никто не вызывает. - Диспетчер в draw(). Рисовать, наоборот, надо в каждом режиме — иначе экран будет пустым. По
sceneмы выбираем нужную картинку: заставку, игру, паузу или Game Over. - Хитрость с паузой. На паузе мы вызываем
drawPlay()(чтобы под надписью было видно замороженную игру), а сверху —drawPauseOverlay()с полупрозрачной плашкой и словом «Пауза». Игра не исчезает, она замирает. - Цикл всегда один.
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 — тогда противоречивых режимов просто не может возникнуть.
Мини-проект: добавь экран победы
Теперь твоя очередь. Возьми код из примеров и добавь в игру пятое состояние — экран победы, когда цыплёнок дожил до конца уровня.
- Заведи новое состояние
'win'. Просто новое значение дляscene— никакой особой магии не нужно. - Придумай условие победы. Например, цыплёнок набрал 10 очков или продержался определённое время. Проверяй это условие в
updatePlay()и при выполнении пишиscene = 'win'— ровно так же, как мы делали с'gameover'. - Нарисуй экран победы. Добавь в диспетчер
draw()веткуelse if (scene === 'win')и функциюdrawWin(), которая черезcontext.fillTextвыводит «Победа!» и финальный счёт. - Дай переиграть. В обработчике клавиш добавь: если
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()), которая сначала наводит порядок в данных, а потом меняет состояние.
Главный принцип, который ты унесёшь: одна переменная — один режим — никаких противоречий. Пока «канал» хранится в одном месте, игра не может одновременно идти и стоять на паузе. А весь хаос — двигающийся в меню цыплёнок, считающийся на паузе счёт — исчезает сам собой, потому что код просто не вызывается вне своего состояния.
В следующем уроке мы разберём сущности: как описывать цыплёнка, врагов и монетки единым набором данных и поведением, чтобы добавлять новых героев в игру, не переписывая её каждый раз. Состояния задают, когда игра живёт, а сущности — кто в ней живёт.