Сцены и переходы на JavaScript: меню, уровень, пауза, экран победы и передача счёта

Коротко

Браузерную игру удобно разделить на сцены — меню, уровень, пауза, экран победы, — у каждой из которых есть enter при входе, update и draw в каждом кадре и exit при выходе. Менеджер сцен держит стек: пауза кладётся поверх уровня и снимается, не сбрасывая его, а смена сцены идёт через затемнение 0,3 с, во время которого повторные нажатия игнорируются. Счёт передаётся в экран победы параметром, а всё, что сцена подписала при входе, она отписывает при выходе.

Содержание

  1. Что такое сцена
  2. Менеджер сцен: смена и стек
  3. Пауза поверх уровня
  4. Переход с затемнением и защита от двойного нажатия
  5. Как передать счёт и не оставить мусор
  6. Код на JavaScript
  7. Практический сценарий: игра из четырёх экранов
  8. Когда вам это не подойдёт

Что такое сцена

Сцена — это объект, который отвечает за один экран игры и знает, что делать при входе, в каждом кадре и при выходе. Четыре метода:

МетодКогда вызываетсяЧто делает
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 и экран победы со счётом и временем. Входные условия: повторная игра должна начинаться с нуля, а пауза — не сбрасывать уровень. Порядок работы:

  1. Сцены. Четыре фабрики: menu, level, pause, win. Каждая возвращает новый объект, поэтому новый заход в уровень создаёт чистое состояние.
  2. Меню. По нажатию «Играть» — manager.change('level'); повторное нажатие во время затемнения игнорируется.
  3. Пауза. Escape в уровне — manager.push('pause'), в паузе — manager.pop().
  4. Победа. Дверь выхода — manager.change('win', { score, time }).
  5. Снова. Кнопка «Ещё раз» на экране победы — manager.change('level').
  6. Проверка. После 10 партий подряд число обработчиков клавиатуры должно быть 1, а не 10, а время уровня не должно включать время на паузе. В проверке выше 41 + 41 с игры и 30 с паузы дают 82 с.

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

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

Стек сцен подходит для игр с понятными отдельными экранами, но не для всех случаев:

  • Игра на одном экране без меню. Для мини-игры на 5 минут менеджер сцен — лишний слой; хватит флага «игра идёт или окончена».
  • Мир без экранов загрузки. Если игрок переходит между зонами без экранов загрузки, это не смена сцен, а подгрузка частей мира; затемнение здесь мешает.
  • Несколько окон одновременно. Инвентарь, карта и диалог поверх уровня, которые открываются в любом порядке, плохо ложатся в стек; там нужен отдельный слой интерфейса со своим списком окон.
  • Переход с общим объектом. Если персонаж должен «перетечь» из одной сцены в другую без затемнения, стандартный exit и enter его уничтожат; такой переход делают внутри одной сцены.

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

Источники: внешних фактов в статье нет. Длительность затемнения, сцены и все проверки — наш пример и наш прогон в Node.js 24.

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

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

Что такое сцена в игре на JavaScript?

Это объект с методами enter, update, draw и exit, который отвечает за один экран игры: меню, уровень, паузу или экран победы. В каждый момент кадр обновляет и рисует только активные сцены.

Как сделать паузу, чтобы уровень не начинался заново?

Положите сцену паузы в стек поверх уровня. Уровень не получает exit и не обновляется, но рисуется под паузой, а после снятия паузы продолжается с того же кадра.

Как передать счёт из уровня в экран победы?

Параметром при переходе: manager.change('win', { score, time }). Экран победы получает их в enter. Глобальная переменная со счётом легко остаётся от прошлой партии.

Почему после нескольких переходов игра начинает тормозить?

Чаще всего сцена подписывается на события клавиатуры при входе и не отписывается при выходе. После 10 заходов в уровень на одно нажатие срабатывают 10 обработчиков.