---
title: "Сцены и переходы на JavaScript: меню, уровень, пауза, экран победы и передача счёта"
description: "Как разделить браузерную 2D-игру на сцены на JavaScript: менеджер сцен с методами enter и exit, стек для паузы поверх уровня, затемнение при переходе за 0,3 с с защитой от двойного нажатия, передача счёта в экран победы через параметры вместо глобальных переменных и уборка обработчиков при выходе из сцены. Код проверен в Node.js 24."
slug: sceny-i-perehody-na-javascript-menyu-uroven-pobeda
date: 2026-11-06
updated: 2026-11-06
scheduled_at: "2026-11-06T19:00:00+03:00"
rubric: praktika-i-uroki
tags: ["sceny", "javascript", "arhitektura-igry", "novichkam", "shkolnikam"]
reading_time: 9
draft: false
summary: "Игру удобно разделить на сцены — меню, уровень, пауза, победа, — у каждой из которых есть enter при входе, update и draw в каждом кадре и exit при выходе. Менеджер сцен держит стек: пауза кладётся поверх уровня и снимается, не сбрасывая его. Переход идёт через затемнение 0,3 с, во время которого повторные нажатия игнорируются, а счёт передаётся в экран победы параметром. Всё, что сцена подписала при входе, она отписывает при выходе."
---

## Коротко

Браузерную игру удобно разделить на сцены — меню, уровень, пауза, экран победы, — у каждой из которых есть `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, вывод совпадает с комментариями.

```js
// Простой источник событий вместо 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. Движок в разработке и публично пока не открыт, заявку можно оставить на [странице раннего доступа](/early-access?utm_source=blog&utm_medium=article&utm_campaign=sceny-i-perehody-na-javascript-menyu-uroven-pobeda) (подача заявки не гарантирует приглашение).

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

**Читайте также:**
- [Плавные переходы (tween) и функции плавности на JavaScript: ease-in, ease-out и платформа по маршруту](/blog/plavnye-perehody-tween-i-funkcii-plavnosti-na-javascript)
- [Конечный автомат персонажа в платформере: от флагов isJumping к состояниям, иерархии и стеку](/blog/konechnyj-avtomat-personazha-platformera)
- [Модульная архитектура логики 2D-игры без циклических зависимостей](/blog/modulnye-listy-sobytij-i-funkcii-v-2d-proekte)
