Коротко
Браузерную игру удобно разделить на сцены — меню, уровень, пауза, экран победы, — у каждой из которых есть enter при входе, update и draw в каждом кадре и exit при выходе. Менеджер сцен держит стек: пауза кладётся поверх уровня и снимается, не сбрасывая его, а смена сцены идёт через затемнение 0,3 с, во время которого повторные нажатия игнорируются. Счёт передаётся в экран победы параметром, а всё, что сцена подписала при входе, она отписывает при выходе.
Содержание
- Что такое сцена
- Менеджер сцен: смена и стек
- Пауза поверх уровня
- Переход с затемнением и защита от двойного нажатия
- Как передать счёт и не оставить мусор
- Код на JavaScript
- Практический сценарий: игра из четырёх экранов
- Когда вам это не подойдёт
Что такое сцена
Сцена — это объект, который отвечает за один экран игры и знает, что делать при входе, в каждом кадре и при выходе. Четыре метода:
| Метод | Когда вызывается | Что делает |
|---|---|---|
enter(params) | при входе | создаёт объекты, подписывается на ввод, получает параметры |
update(dt) | каждый кадр, только верхняя сцена | двигает объекты, проверяет условия |
draw(ctx) | каждый кадр, все сцены стека снизу вверх | рисует |
exit() | при выходе | отписывается от ввода, останавливает звуки |
Без сцен вся игра живёт в одном update с переменной state и десятком if, и каждый новый экран добавляет ветку во все функции сразу.
Менеджер сцен: смена и стек
Менеджер сцен хранит стек активных сцен и даёт три операции: change заменяет весь стек одной сценой, push кладёт сцену поверх, pop снимает верхнюю. Переход из меню в уровень — это change, открытие паузы — push, закрытие паузы — pop. Обновляется только верхняя сцена стека, а рисуются все снизу вверх, поэтому полупрозрачная пауза показывает застывший уровень под собой.
Пауза поверх уровня
Пауза кладётся в стек поверх уровня, поэтому уровень не получает exit, не пересоздаётся и после снятия паузы продолжается с того же кадра. Пока пауза сверху, update уровня не вызывается — враги стоят, таймеры не идут. Если таймеры в вашей игре считаются от системных часов, а не от суммы dt, пауза их не остановит; считайте игровое время только из dt.
Переход с затемнением и защита от двойного нажатия
Смена сцены идёт в два этапа по 0,3 с: экран темнеет, в самый тёмный момент старая сцена получает exit, новая — enter, и экран светлеет. Длительность — наш выбор: короче 0,2 с затемнение похоже на мигание, длиннее 0,5 с игрок начинает ждать.
Пока идёт переход, новые запросы на смену сцены игнорируются. Иначе двойное нажатие «Играть» запустит уровень дважды: два enter, две подписки на клавиатуру и персонаж, который прыгает вдвое выше. Плавную кривую для затемнения можно взять из статьи о функциях плавности по ссылке в конце.
Как передать счёт и не оставить мусор
Счёт, время и номер уровня передают в следующую сцену параметром: manager.change('win', { score: 4200, time: 83 }), и экран победы читает их в enter. Глобальная переменная score опасна тем, что переживает партию: если забыть её обнулить, новая игра начнётся со старым счётом.
Всё, на что сцена подписалась при входе, она должна отписать при выходе. Удобный приём — список функций уборки: каждая подписка в enter кладёт в него функцию, которая её снимает, а exit вызывает их все. Без этого после 10 заходов в уровень одно нажатие пробела вызывает 10 прыжков — в коде ниже это проверено счётчиком обработчиков.
Код на JavaScript
Код содержит менеджер сцен со стеком, переход с затемнением и блокировкой, передачу параметров и уборку подписок. Вместо браузерных событий используется простой источник событий, поэтому код запускается в Node.js 24, вывод совпадает с комментариями.
// Простой источник событий вместо window.addEventListener
function makeEvents() {
const handlers = new Map();
return {
on(name, fn) {
if (!handlers.has(name)) handlers.set(name, new Set());
handlers.get(name).add(fn);
return () => handlers.get(name).delete(fn); // функция уборки
},
emit(name, data) { for (const fn of handlers.get(name) ?? []) fn(data); },
count(name) { return handlers.get(name)?.size ?? 0; },
};
}
function makeSceneManager(scenes, fadeTime = 0.3) {
const stack = [];
let fade = null; // { t, phase: 'out' | 'in', apply }
const log = [];
function enter(name, params) {
const scene = { name, ...scenes[name](), cleanup: [] };
stack.push(scene);
log.push('enter ' + name);
scene.enter?.(params, (dispose) => scene.cleanup.push(dispose));
}
function exitTop() {
const scene = stack.pop();
scene.exit?.();
scene.cleanup.forEach((dispose) => dispose());
log.push('exit ' + scene.name);
}
function start(apply) {
if (fade) return false; // переход уже идёт — повторный запрос игнорируем
fade = { t: 0, phase: 'out', apply };
return true;
}
return {
log,
stack,
get busy() { return fade !== null; },
change: (name, params) => start(() => { while (stack.length) exitTop(); enter(name, params); }),
push: (name, params) => enter(name, params), // пауза открывается сразу, без затемнения
pop: () => exitTop(),
update(dt) {
if (fade) {
fade.t += dt;
if (fade.phase === 'out' && fade.t >= fadeTime) { fade.apply(); fade.phase = 'in'; fade.t = 0; }
else if (fade.phase === 'in' && fade.t >= fadeTime) fade = null;
return; // во время перехода сцены не обновляются
}
stack.at(-1)?.update?.(dt);
},
darkness() { // 0 — светло, 1 — темно
if (!fade) return 0;
const k = Math.min(fade.t / fadeTime, 1);
return fade.phase === 'out' ? k : 1 - k;
},
};
}
// ---- Проверка ----
const events = makeEvents();
let jumps = 0;
let manager;
const scenes = {
menu: () => ({}),
level: () => {
let score = 0, time = 0;
return {
enter(params, track) {
track(events.on('jump', () => { jumps++; score += 100; }));
track(events.on('exitDoor', () => manager.change('win', { score, time: Math.round(time) })));
},
update(dt) { time += dt; },
};
},
pause: () => ({}),
win: () => ({ enter(params) { manager.log.push('счёт ' + params.score + ', время ' + params.time + ' с'); } }),
};
manager = makeSceneManager(scenes);
manager.change('menu');
for (let i = 0; i < 40; i++) manager.update(1 / 60); // 40 кадров ≈ 0,67 с — затемнение 0,3 + 0,3 с завершено
console.log(manager.change('level'), manager.change('level')); // true false — второе нажатие «Играть» проигнорировано
for (let i = 0; i < 40; i++) manager.update(1 / 60);
console.log(events.count('jump')); // 1
for (let i = 0; i < 60 * 41; i++) manager.update(1 / 60); // 41 с игры
manager.push('pause');
for (let i = 0; i < 60 * 30; i++) manager.update(1 / 60); // 30 с на паузе — время уровня стоит
manager.pop();
for (let i = 0; i < 60 * 41; i++) manager.update(1 / 60); // ещё 41 с
events.emit('jump'); events.emit('jump');
events.emit('exitDoor');
for (let i = 0; i < 40; i++) manager.update(1 / 60);
console.log(events.count('jump'), jumps); // 0 2
console.log(manager.log.join(' | '));
// enter menu | exit menu | enter level | enter pause | exit pause | exit level | enter win | счёт 200, время 82 с
// Без уборки: 10 заходов в уровень оставляют 10 обработчиков
const leaky = makeEvents();
for (let i = 0; i < 10; i++) leaky.on('jump', () => {});
console.log(leaky.count('jump')); // 10
В браузере events.on заменяется на addEventListener, а функция уборки — на парный removeEventListener с тем же обработчиком.
Практический сценарий: игра из четырёх экранов
Возьмём игру для кружка: меню с кнопкой «Играть», уровень, пауза по клавише Escape и экран победы со счётом и временем. Входные условия: повторная игра должна начинаться с нуля, а пауза — не сбрасывать уровень. Порядок работы:
- Сцены. Четыре фабрики:
menu,level,pause,win. Каждая возвращает новый объект, поэтому новый заход в уровень создаёт чистое состояние. - Меню. По нажатию «Играть» —
manager.change('level'); повторное нажатие во время затемнения игнорируется. - Пауза.
Escapeв уровне —manager.push('pause'), в паузе —manager.pop(). - Победа. Дверь выхода —
manager.change('win', { score, time }). - Снова. Кнопка «Ещё раз» на экране победы —
manager.change('level'). - Проверка. После 10 партий подряд число обработчиков клавиатуры должно быть 1, а не 10, а время уровня не должно включать время на паузе. В проверке выше 41 + 41 с игры и 30 с паузы дают 82 с.
Как разделить логику уровня на модули, чтобы сцены не тянули друг друга, — в статье про модульную архитектуру по ссылке в конце.
Когда вам это не подойдёт
Стек сцен подходит для игр с понятными отдельными экранами, но не для всех случаев:
- Игра на одном экране без меню. Для мини-игры на 5 минут менеджер сцен — лишний слой; хватит флага «игра идёт или окончена».
- Мир без экранов загрузки. Если игрок переходит между зонами без экранов загрузки, это не смена сцен, а подгрузка частей мира; затемнение здесь мешает.
- Несколько окон одновременно. Инвентарь, карта и диалог поверх уровня, которые открываются в любом порядке, плохо ложатся в стек; там нужен отдельный слой интерфейса со своим списком окон.
- Переход с общим объектом. Если персонаж должен «перетечь» из одной сцены в другую без затемнения, стандартный
exitиenterего уничтожат; такой переход делают внутри одной сцены.
Мы разрабатываем J2DRU — браузерный 2D игровой движок, где сцены, спрайты и игровая логика собираются прямо в браузере, а проект можно углублять на TypeScript. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).
Источники: внешних фактов в статье нет. Длительность затемнения, сцены и все проверки — наш пример и наш прогон в Node.js 24.
Читайте также: