# Ядро: Engine, GameLoop, EventBus, Tween, GameState, StateMachine, Settings

## Engine

Собирает все подсистемы и владеет игровым циклом:

```ts
const engine = new Engine({ virtualWidth: 480, virtualHeight: 270, scale: 3, background: 0x0b0b12, fixedFps: 60, maxFps: 30, parent });
await engine.init();   // WebGL, ввод, оверлей fade-переходов, FPS-счётчик
await engine.start();  // запуск цикла
```

Доступные подсистемы: `engine.renderer`, `engine.camera`, `engine.scenes`,
`engine.input`, `engine.tweens`, `engine.events`.

Опции: `maxFps` (по умолчанию 0 — без лимита) — лок частоты отрисовки;
`autoResize` (по умолчанию true) — пересчитывать целочисленный масштаб
при ресайзе окна.

**Зачистка при выгрузке**: `engine.destroy()` — полная (exit текущей сцены,
эффекты/твины/события, ввод, FPS-метр, слушатели окна, `app.destroy`).
Для HMR-пересборок и тестов; после destroy движок не годится. Лёгкий
`engine.stop()` останавливает только цикл.

## GameLoop (fixed timestep)

Логика — фиксированный шаг 60 Гц; рендер после каждого шага. `dt` в `Scene.update(dt)`
всегда `1/60`. После лага («вкладка спала») выполняется максимум 5 шагов за кадр —
игра замедляется, а не «телепортируется».

**Лок кадров** (`maxFps`, например 30): срезанные rAF-кадры не делают ни шагов,
ни рендера, но время не теряется — копится в аккумулятор, и шаги выполняются
пачкой на отрисованном кадре. Рендер — не чаще лимита, логика — те же 60 Гц.

## FPS-счётчик (F3)

Дебаг-плашка в левом верхнем углу (`engine.fpsMeter.view`): среднее FPS за окно
~полсекунды. Скрыт по умолчанию, переключается по **F3** — движок слушает клавишу
сам (независимо от привязок ввода приложения; F3 в игре может быть занята своим
дебаг-оверлеем — они переключаются вместе, как слои отладки).

Вручную цикл обычно не трогают; Engine.tick делает по порядку:
`input.update()` (опрос геймпада) → `tweens.update(dt)` → `scenes.update(dt)` → `input.endTick()`.

## EventBus

Типизированные по темам события — связь систем без прямых зависимостей:

```ts
engine.events.on<{ gold: number }>('loot:picked', ({ gold }) => console.log(gold));
const off = engine.events.on('quest:done', () => { ... });
engine.events.emit('loot:picked', { gold: 5 });
off(); // отписка
```

## Tween (твины и таймеры)

Обновляется циклом автоматически. Все методы возвращают handle с `cancel()`.

```ts
engine.tweens.to(sprite, { x: 100, alpha: 1 }, {
    duration: 0.5,
    ease: cubicOut,      // linear, quadIn/Out, cubicIn/Out, sineInOut
    delay: 0.2,          // стартовые значения снимаются ПОСЛЕ delay
    onDone: () => console.log('готово')
});

engine.tweens.delay(1.5, () => spawnEnemy()); // таймер, сек
engine.tweens.cancelFor(sprite);              // отменить всё для объекта
```

Гарантии: значения не выходят за цель (зажим на последнем кадре), `onDone` вызывается
один раз, тик, пересекающий конец `delay`, не «съедает» часть движения.

## Плавные значения: `core/fade.ts` (FadeState)

Твины двигают **вьюхи**; для «плавных значений» без вьюхи (ambient-цвет, альфа
виньетки, оверлей переходов) есть `FadeState<T>` — линейный переход значения к
цели за заданное время, headless-тестируемый:

```ts
const fade = new FadeState(lerpAmbient, 0xffffff);
fade.to(0x54586a, 1.5);   // цель + секунды
fade.update(dt);          // каждый тик
fade.value;               // интерполированное (по завершении — точно цель)
fade.active; fade.target; fade.origin;
```

Повторный `to()` в середине фейда стартует от текущего интерполированного
значения — без прыжка к исходной точке. Не заводите свои пары
`fadeLeft/fadeTotal` — используйте этот класс.

## Реестр эффектов: `engine.fx`

Объекты, тикаемые движком сами между твин-ами и логикой сцены — эффекты,
аниматоры, вспышки (см. `anim.md`):

```ts
engine.fx.add(emitter);   // Updatable: { update(dt), destroyed? }
engine.fx.clear();        // при выходе из сцены, если она не разрушает дерево
```

Записи с `destroyed === true` (Pixi `destroy()` выставляет флаг сам) не
тикаются и вычищаются из реестра автоматически — разрушенный Pixi-объект
касаться нельзя (transform обнулён).

Для one-shot визуальных эффектов предпочтителен **`FxLayer`** (`render/fx/`,
см. `render.md`): слой добавляется в `engine.fx` один раз, сам тикает
и вычищает всё, что спавнит (`fx.ring/floatText/fade/pop/flash/burst/trail`).
Прямое `engine.fx.add(emitter)` оставлено для движковых эффектов вне сцены.
`SpriteFlash` теперь имеет `destroyed` (следует за спрайтом) — его можно
писать в реестр напрямую; повторный `start()` продолжает работать.

## GameState

Состояние прохождения: флаги и именованные переменные. Жанронезависимо — какие флаги
есть, решает игра. Сериализуется в сейв (`GameStateData`).

```ts
const state = new GameState();
state.setFlag('met_elder');
state.setVar('gold', 30);
state.getNumber('gold');      // 30 (getString/getBool — аналогично)
state.hasFlag('met_elder');   // true

// Сейв/загрузка + миграции при изменении формата:
state.dataVersion = 2;
state.addMigration((d) => ({ ...d, version: 2, vars: { ...d.vars, gold: 0 } }));
const data = state.serialize();       // положить в сейв
state.load(data);                     // миграции применятся автоматически
```

Контракт миграций: **миграция сама повышает `version`** в возвращаемых
данных; регистрируются от старых версий к новым, применяются по порядку
при `load`. Цикл загрузки останавливается, как только данные догнали
`dataVersion`, — миграция, забывшая повысить version, не даст следующей
миграции примениться к уже повышенным данным дважды.

## StateMachine

Конечный автомат для состояний сущностей (idle/walk/attack), AI и т.п.:

```ts
const sm = new StateMachine((state, event) => console.warn(`${state} не знает событие ${event}`));
sm.add('idle', { enter, exit, update: (dt) => {...} });
sm.add('chase', { enter, exit, update });
sm.transition('idle', 'seePlayer', 'chase');
sm.transition('chase', 'losePlayer', 'idle');

sm.change('idle');            // exit старого -> enter нового
sm.handleEvent('seePlayer');  // переход по таблице
sm.update(dt);                // тикает текущее состояние; sm.time — время в состоянии
```

Переход «из любого состояния» — для событий вроде hurt/die, чтобы не регистрировать
их из каждого состояния (обычные переходы имеют приоритет):

```ts
sm.transitionAny('die', 'dead');
```

## Cooldown

Кулдаун на фиксированном шаге (секунды, без `performance.now()`):

```ts
const cd = new Cooldown(0.45);
cd.trigger();          // запустить (false, если ещё не готов)
cd.triggerForce();     // перезапустить принудительно
cd.update(dt);         // тикать каждый шаг
cd.ready;              // готов?
cd.timeRemaining;      // секунд до готовности
cd.progress;           // 1..0 — удобно для полоски перезарядки
```

## Settings

Настройки игрока (громкости, язык, прочее) в отдельном слоте хранилища — не попадают
в игровые сейвы:

```ts
const settings = new Settings(localStorage, 'settings'); // или любой StorageLike
settings.load();

settings.update({ master: 0.8, music: 0.6 });
settings.data.master;           // 0.8
settings.update({ extra: { hintSeen: true } });
settings.getExtra('hintSeen');

const off = settings.onChange((s) => audio.setBusVolume('master', s.master));
```

Хранилище может быть недоступно (приватный режим) — Settings работает в памяти,
не падает.