# Кат-сцены: CutsceneRunner

`packages/engine/src/cutscene/CutsceneRunner.ts` — view-агностичный раннер кат-сцен.
Сцена — это список шагов; раннер исполняет их последовательно и сообщает `active`,
чтобы игра поставила геймплей на паузу. Никакой привязки к Pixi/вью — только
эффекты и камера, передаваемые в шагах.

## Шаги

```ts
import { CutsceneRunner, type CutsceneStep } from '@rpg/engine';

const steps: CutsceneStep[] = [
    // Произвольный вызов (диалог, тост, логика сцены) + длительность:
    { kind: 'call', fn: () => scene.showToast('Пепел отступил.'), seconds: 1 },

    // Плавное перемещение камеры (в мировых юнитах) к точке:
    { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: scene.camera },

    // Плавный зум камеры (seconds: 0/нет — мгновенно и параллельно ходу):
    { kind: 'cameraZoom', camera: scene.camera, z: 2, seconds: 1 },

    // Плавное перемещение вьюхи (актёра, объекта) в локальных координатах родителя:
    { kind: 'moveView', view: milaView, x: exitPx.x, y: exitPx.y, seconds: 2 },

    // Разовый эффект (звук, кольцо резонанса, вспышка частиц):
    { kind: 'burst', fn: () => scene.ringAt(tower), seconds: 1 },

    // Поднять флаг прохождения (мгновенно):
    { kind: 'setFlag', flag: 'act1_done', state: game.state },

    // Просто пауза:
    { kind: 'wait', seconds: 0.5 },

    // Ждать внешнее событие (закрылся диалог, актёр дошёл, таймер игры):
    { kind: 'until', predicate: () => !scene.dialogueActive, label: 'диалог' },
];

const runner = new CutsceneRunner();
runner.onComplete = () => console.log('сцена закончилась');
runner.play(steps);
```

## Семантика

- **Последовательность.** Каждый шаг при старте выполняет свой эффект; раннер
  ждёт `seconds` и переходит к следующему.
- **Параллельные шаги.** Шаг с `seconds: 0` (или `undefined` у `burst`/`call`)
  не блокирует: следующий стартует в том же тике. Так делают «проверку воздуха» —
  кольцо и звук в один кадр с продолжением сцены.
- **Ожидание события.** `until` держит шаг, пока `predicate()` не вернёт true
  (проверка каждый тик; уже выполненный предикат шаг не блокирует). Опциональный
  `timeout` (сек) завершает шаг принудительно — без него шаг может длиться вечно,
  поэтому `until` без timeout ставьте только на события, которые гарантированно
  наступят (закрытие диалога, приход актёра). Типовая пара «диалог посреди сцены»:
  `{ kind: 'call', fn: () => scene.talkTo(npc) }` + `{ kind: 'until', predicate: () => !scene.dialogueActive }`.
- **Пауза геймплея** — ответственность сцены: в `update` проверять `runner.active`,
  обновлять раннер и выходить (см. `LocationScene.update`). Исполнитель шага
  `until` (например, ходок актёра) должен тикаться сценой независимо от раннера.
- **Движение камеры и вьюх** — по `sineInOut` (у `moveView` можно свой `ease`);
  точка назначения достигается точно. Интерполяция — чистая
  `applyEase(from, to, k, ease)` из `cutscene/tween`. Координаты `moveView` —
  **локальные px родителя вьюхи**: для актёра в `worldRoot` сцена сама переводит
  мировые координаты (`worldToScreen`). Движок не знает ни Pixi, ни актёров —
  шагу подходит любой объект с `position: {x, y}` (`Movable`).

## Скип

`runner.skip()` дожимает сцену до конца немедленно: шаги-движения ставят вьюхи/камеру
в конечные точки, `until` **форсируются** (скип конечен даже на шаге-ожидании), все
`call`/`burst` выполняются. Агентный мост (`scene:skipCutscene`) пользуется именно им.
Следствие: `until` в катсцене должен быть достижим при обычном проигрывании — скип
не обязан ждать реального события, он его имитирует.

## Наблюдение за шагами

Конструктор принимает наблюдателя — удобно для отладки:

```ts
const runner = new CutsceneRunner((step) => console.log('шаг:', step.kind));
```

## Тесты

`cutscene/__tests__/CutsceneRunner.test.ts` — порядок шагов, паузы, интерполяция
камеры (точное попадание в точку в конце), флаги, порядок наблюдателя.
Конструируется без DOM: `new Camera(viewWidth, viewHeight)`.