# Таймлайн-сцены — `$.timeline` (AnimatedTimelineScene2d)

Подсистема `timeline.js` — **анимированная таймлайн-сцена 2D**: диалоги и
визуальные новеллы описываются одним массивом «битов», а движок сам ведёт
фон, героев, камеру, музыку и концовки.

```js
$.ready(() => {
    $.animatedTimelineScene2d({
        id: 'meeting',
        locations: {
            roof: { title: 'Крыша', bg: 'art/roof.png', music: 'music/evening.ogg' },
        },
        cast: {
            russi: { name: 'Руси-тян', poses: { neutral: 'art/n.png', angry: 'art/a.png' },
                     x: 0.6, bottom: 1.0, height: 0.94 },
        },
        script: [
            { location: 'roof' },
            { show: 'russi', from: 'left' },
            { say: 'Ты опять всё сломал.', pose: 'angry' },
            { shake: 10, ms: 400 },
            { choose: [
                { text: 'Прости', goto: 'ok', add: { trust: 1 } },
                { text: 'Это не я', goto: 'bad' },
            ] },
            { label: 'ok' },
            { say: 'Ладно. Иди сюда.' },
            { ending: { id: 'ok', title: 'Помирились', text: 'Она улыбнулась.' } },
        ],
    });

    $.timeline.play('meeting');
});
```

---

## 1. Место среди других подсистем

| | `$.dialog` | `$.scene` | `$.timeline` |
|---|---|---|---|
| Что описывает | граф реплик и выборов | что живёт на экране | сцену целиком: фон, героев, реплики, камеру, концовки |
| Единица | реплика (`node`) | сцена (`enter/exit/update`) | бит (`beat`) |
| Ветвление | `to`/`next` в графе | нет | `goto`/`label`/`if` |
| Текст | свой, полноценный | — | отдаёт `$.dialog` |
| Камера и тряска | — | — | `$.camera.shake`, `zoom` |
| Концовки | — | — | `{ ending }` + флаг в `$.store` |

Таймлайн **не дублирует** диалоги: каждая реплика становится обычной репликой
`$.dialog`, поэтому печатная машинка, страницы, выборы, клавиатура, `$.i18n` и
события работают как в `dialog.md`, а таймлайн отвечает за то, что происходит
вокруг текста.

---

## 2. Объявление

### `$.timeline.define(id, spec)` → объект управления

### `$.animatedTimelineScene2d(spec)` → то же самое

Литеральное имя типа сцены: `$.animatedTimelineScene2d(spec)` — синоним
`$.timeline.define(spec.id, spec)`. Оба возвращают объект управления прогоном.

`spec`:

| Поле | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `id` | string | — (обязательно) | имя таймлайна |
| `script` | массив | — (обязательно) | биты (§4) |
| `scene` | string | `id` | имя сцены в `$.scene` |
| `title` | string | `id` | заголовок (для отладки и карточки) |
| `location` | string | первая из `locations` | с какой локации начать |
| `locations` | объект | `{}` | локации (§3) |
| `cast` | объект | `{}` | персонажи (§3) |
| `hero` | string | первый из `cast` | кто говорит по умолчанию |
| `backdrop` | цвет | `'#070a12'` | цвет мира за фоном |
| `speed` | число | `$.dialog` | скорость печатной машинки, символов в секунду |
| `style` | string | — | стиль `$.font` для текста |
| `dialogTheme` | объект | — | цвета штатной панели диалога (§3.3) |
| `dialogView` | объект | — | рисовать реплику документом RmlUi (§3.4) |
| `voice` | объект | — | озвучка реплик файлами (§3.5) |
| `locationCard` | bool | `true` | показывать встроенную табличку локации |
| `exitScene` | string | — | куда уйти по `Esc` с карточки концовки |
| `enter` / `exit` / `update` | функции | — | хуки сцены: HUD, подписки, уборка |
| `onBeat` | функция | — | `(beat, tl)` на каждый бит — для отладки и HUD |
| `afterEnding` | функция | — | `(api, spec)` вместо перезапуска |

Сцена регистрируется сразу, поэтому `--scene <id>` и `$.scene.load(id)`
работают без дополнительного кода. Регистрировать сцену с тем же именем
самому не нужно: `$.scene.add(id, …)` затрёт staging новеллы (фон, героев и
оверлеи) — для своего кода есть хуки `enter`/`exit`/`update`.

---

## 3. Локации, персонажи, тема

### 3.1 Локация

| Поле | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `bg` | путь | — | фон; грузится как обычная текстура |
| `title` | string | — | табличка при входе (исчезает сама) |
| `music` | путь \| `null` | — | музыка; `null` — остановить |
| `volume` | число | `0.6` | громкость музыки |
| `fade` | число, мс | `450` | перекрёстное затухание фона |
| `mood` | `{ color, alpha }` | — | оттенок поверх декораций |
| `sfx` | путь | — | звук входа в локацию |

Фон — два спрайта в мире: новый проявляется, старый гаснет. Оттенок
(`mood`) рисуется **между** фоном и героями, поэтому локация может быть
синей, а персонаж — нет.

### 3.2 Персонаж

| Поле | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `name` | string | ключ | имя в панели диалога |
| `poses` | объект | `{}` | `поза → путь к картинке` |
| `pose` | string | первая | с какой позы начать |
| `x` | число | `0.5` | центр по горизонтали, доля ширины окна |
| `bottom` | число | `1.0` | низ спрайта, доля высоты окна |
| `height` | число | `0.92` | высота спрайта, доля высоты окна |
| `idle` | bool | `true` | дыхание |
| `mirror` | bool | `false` | отразить по горизонтали |
| `tint` | цвет | — | постоянный оттенок спрайта |
| `layer` | число | `10` | слой в мире |

Высота спрайта задаётся долей окна, ширина считается по пропорциям картинки
(`spriteSize`), поэтому подгонять размеры вручную не нужно.

### 3.3 Тема панели диалога

Панель принадлежит `$.dialog` и пересчитывает геометрию каждый кадр, поэтому
таймлайн меняет только цвета — они сохраняются до следующего `play()`:

```js
dialogTheme: {
    panel: '#0b1220e6',       // фон панели
    speaker: '#ffb3d9',       // имя говорящего
    speakerSize: 24,
    text: '#eef3ff',          // реплика
    choice: '#1b2436f0',      // кнопка выбора
    choiceHover: '#3b4a72f0', // подсвеченный выбор
    choiceText: '#e8f0ff',
}
```

### 3.4 Реплика через RmlUi

По умолчанию реплику рисуют узлы `<ui.*>`: движок сам считает ширину строки и
переносит слова. Если хочется настоящую вёрстку — перенос по ширине блока,
шрифт, рамку, подсветку кнопок под курсором, — реплику можно отдать RmlUi:

```js
dialogView: {
    kind: 'rml',
    doc: 'demos/ui/vn-dialog.rml',   // разметка и стили — ваши
    speaker: 'vn-speaker',           // id элемента с именем говорящего
    text: 'vn-text',                 // id элемента с репликой
    choicePrefix: 'vn-choice-',      // кнопки: vn-choice-0 … vn-choice-5
    offClass: 'off',                 // класс скрытой кнопки
    selectedClass: 'selected',       // класс подсвеченного варианта
},
```

Что делает RmlUi: раскладку, перенос строк по ширине блока, шрифт, рамку и
`:hover` на кнопках. Что остаётся за `$.dialog`: печатная машинка, страницы,
выборы, `↑`/`↓`/`Enter`/`Esc` и `$.i18n`. Таймлайн только перекладывает
состояние в документ и прячет штатную панель — поэтому обе реализации видны
игре одинаково (`$.dialog.text()`, `$.timeline.choices()`).

Кнопок в разметке должно быть столько же, сколько `maxChoices` у `$.dialog`
(шесть): элементы создаются один раз, поэтому подписка на клик не теряется при
смене реплики. Клик по кнопке вызывает `$.dialog.choose(i)` — как и клик по
штатной кнопке.

### 3.5 Озвучка реплик

```js
voice: { dir: 'demos/russi_vn/voice', ext: 'mp3', volume: 1, who: 'russi' },
```

Файл ищется по id реплики: `<dir>/<id>.<ext>`, где `id` — тот же, что отдаёт
`$.timeline.lines()`. Нет файла — реплика идёт молча, поэтому озвучку можно
дописывать по одной и в любом порядке. Предыдущая реплика обрывается, когда
начинается следующая.

Список реплик для записи голоса берётся из самого таймлайна:

```js
$.timeline.lines();   // [{ id: 'tl3', speaker: 'Руси-тян', text: '…', choices: 0 }, …]
```

---

## 4. Биты

Бит — объект (или строка-реплика). Мгновенные действия выполняются до того,
как бит начнёт «ждать», поэтому `{ say: '…', pose: 'angry', shake: 8 }`
показывает реплику уже злой и уже с тряской.

### 4.1 Текст

| Ключ | Ждёт | Смысл |
|---|---|---|
| `say` | да | реплика; `who` — кто говорит, иначе `hero`; строка вместо объекта — то же самое |
| `narrate` | да | текст без имени говорящего |
| `choose` | да | варианты ответа; `text` у самого бита необязателен |
| `who` | — | имя персонажа из `cast` |
| `speaker` | — | имя говорящего вручную (сильнее `who`) |
| `portrait` | — | портрет в панели |
| `speed`, `style` | — | переопределить темп и стиль реплики |

Реплика, у которой игрок не нажал «дальше», **останавливает** прогон: биты
после неё не выполняются. Авто-режим (`$.timeline.auto(ms)`) листает сам.

Вариант ответа:

| Поле | Смысл |
|---|---|
| `text` | подпись кнопки |
| `goto` / `to` | метка, куда идти после выбора |
| `set` | записать флаги: `{ route: 'love' }` |
| `add` | прибавить к числу: `{ trust: 1 }` |
| `do` | свой код: `(api, tl) => { … }` |
| `if` / `when` | условие видимости варианта (как у `$.dialog`) |

### 4.2 Сцена и персонажи

| Ключ | Ждёт | Смысл |
|---|---|---|
| `location` | нет | сменить локацию (при `wait: true` — дождаться затухания) |
| `pose` | нет | `{ pose: 'angry', who: 'russi' }` |
| `show` | да | выход героя: `from` = `left`/`right`/`bottom`/`fade`, `ms` |
| `hide` | да | уход: `to` = `left`/`right`, `ms` |
| `anim` | да | акцент: `pop`, `bounce`, `nod`, `lean`, `away`, `sigh`, `step`, `tremble`, `shiver`; `wait: false` — не ждать |
| `wait` | да | пауза, мс |

Реплика показывает скрытого героя сама — говорить в пустоту персонаж не
станет. Акценты не сдвигают точку стояния: после `bounce` герой там же, где
был.

### 4.3 Экран, звук, данные

| Ключ | Ждёт | Смысл |
|---|---|---|
| `shake` | нет | `{ shake: 12, ms: 400 }` или `{ shake: { power, ms } }` — тряска камеры |
| `flash` | нет | `{ flash: { color, alpha, ms } }` — вспышка поверх интерфейса |
| `fade` | да | `{ fade: '#000000cc', ms: 600 }` — затемнить, `{ fade: null }` — проявить |
| `zoom` | нет | `{ zoom: 1.2, ms: 600 }` — наезд камеры |
| `music` | нет | `{ music: null }` — остановить; иначе путь + `volume`/`loop` |
| `sfx` | нет | путь или массив путей |
| `set` / `add` | нет | флаги в `$.store` |
| `do` | нет | свой код: `(api, tl) => { … }` |
| `emit` | нет | событие модуля: `{ emit: 'имя', data: {} }` |

### 4.4 Управление прогоном

| Ключ | Смысл |
|---|---|
| `label` | метка (можно прыгать внутрь ветки `if`) |
| `goto` | переход на метку (сбрасывает вложенность) |
| `if` + `then` / `else` | ветка; `if` понимает функцию, bool, флаг (`'has_pass'`, `'!has_pass'`) и сравнение (`'trust >= 2'`, `'route == "love"'`) |
| `ending` | концовка: `{ id, title, subtitle, text, mood: 'good' \| 'bad', hint }` |

Концовка ставит в `$.store` флаг `ending:<id>`, шлёт события `ending` и `end`
и показывает полноэкранную карточку. Дальше `Space`/`Enter` начинает новеллу
заново, `Esc` уходит в `exitScene` (если задан).

---

## 5. Управление

| Функция | Назначение |
|---|---|
| `$.timeline.define(id, spec)` | объявить таймлайн-сцену |
| `$.animatedTimelineScene2d(spec)` | то же, литеральным именем типа |
| `$.timeline.play(id, opts)` | запустить (`opts.at` — метка старта, `opts.transition`) |
| `$.timeline.stop(reason)` | остановить прогон (диалог закроется) |
| `$.timeline.next()` / `skip()` | дальше / допечатать |
| `$.timeline.choose(i)` / `chooseByText(t)` | выбрать вариант |
| `$.timeline.choices()` | видимые варианты |
| `$.timeline.goto(label)` | прыжок на метку |
| `$.timeline.location(name, opts)` | сменить локацию |
| `$.timeline.pose(who, name, opts)` | сменить позу |
| `$.timeline.hero(who)` | узел персонажа (обёртка `$`) |
| `$.timeline.auto(ms)` / `auto(false)` | авто-режим |
| `$.timeline.speed(v)` | скорость печатной машинки |
| `$.timeline.lines(id?)` | реплики таймлайна по порядку: id, говорящий, текст |
| `$.timeline.state()` | снимок прогона (§6) |
| `$.timeline.running()` / `current()` / `ended()` | состояние |
| `$.timeline.has/list/remove` | реестр таймлайнов |
| `$.timeline.on/off/emit` | события |

### События

| Событие | Когда | `data` |
|---|---|---|
| `start` | прогон начался | `{ id, scene, at }` |
| `beat` | перед каждым битом | `{ beat, count, location }` |
| `location` | смена локации | `{ name, title, background }` |
| `say` | открылась реплика | `{ who, text, node }` |
| `choice` | игрок выбрал вариант | `{ beat, index, text, entry }` |
| `anim` / `show` / `hide` | акцент и выход/уход героя | `{ who, name }` |
| `shake` / `flash` / `fade` / `zoom` | экранные эффекты | параметры эффекта |
| `ending` | концовка достигнута | `{ id, title, spec }` |
| `end` | прогон закончился | `{ id, reason, ending }` |

Те же события приходят и глобально, с префиксом: `$.on('timeline:ending', …)`.

---

## 6. Состояние

```js
$.timeline.state();
// {
//   running: true, id: 'meeting', scene: 'meeting', ended: false,
//   location: 'roof', label: 'ok', waiting: 'say',   // 'say' | 'wait' | 'tween' | 'anim'
//   beats: 12, ticks: 480, time: 8000, depth: 1,
//   actors: [{ who: 'russi', pose: 'angry', visible: true, x, y, alpha, scale }],
//   choices: [{ index, text, to, action }], text: 'Ты опять всё сломал.',
//   auto: 0, flags: { trust: 1 }, ending: null,
// }
```

После концовки `running: false`, а `id`, `ending`, `location`, `beats` и
`ticks` остаются от последнего прогона — агенту и тестам есть что читать.

---

## 7. Кадр

`tickTimeline(dt)` вызывается в общем кадровом цикле `$` сразу после
`tickDialog(dt)`, поэтому пауза бита и печатная машинка идут в ногу. `dt` —
секунды; все длительности в описании — миллисекунды.

Порядок внутри тика: таймеры прогона → пауза бита → отложенный шаг →
дыхание и дрожь героев → синхронизация альф фона и оверлеев → авто-режим →
указатель «дальше» у панели диалога.

Шаг всегда делается **в кадре**, а не внутри обработчика `$.dialog`: запустить
следующую реплику прямо из события диалога нельзя — диалог в этот момент ещё
жив, и «перезапуск» съел бы только что открытую реплику.

---

## 8. Ограничения (честно)

| Чего нет | Почему |
|---|---|
| Скелетной анимации героя | позы — статичные картинки; «анимация» собирается из поз, дыхания и акцентов. Для скелета нужен спрайтовый лист, а не позы |
| Рендера сцены в текстуру | полноэкранные эффекты — это наложение `ui.panel`, а не шейдер |
| Прокрутки длинного текста | столько же, сколько у `$.dialog`: четыре строки, длинный текст режется на страницы |
| Сохранения середины новеллы | прогресс живёт во флагах `$.store`; восстановление разговора — забота игры (`$.timeline.play(id, { at: 'метка' })`) |
| Двух новелл одновременно | прогон один на процесс, как и диалог |
| Отмены бита на полпути | `stop()` останавливает прогон целиком; частичных откатов нет |
| Автоматического перевода текста | как и везде: строка переводится, если совпала с ключом `$.i18n` |

---

## 9. Проверка

```bash
# юнит-тесты модуля: биты, выборы, ветки, метки, концовки, авто-режим
build/_deps/quickjs-build/qjs tests/js/timeline_test.mjs

# живая новелла целиком: агент сам жмёт «дальше» и доходит до концовки
python3 tools/vn_playthrough.py --route love --out build/vn_shots
python3 tools/vn_playthrough.py --route hate --out build/vn_shots
```

Готовый пример на все возможности — демо
[«Руси-тян: Бака!»](demos/russi_vn): пять локаций, семь поз,
три выбора, две концовки, тряска, вспышки и HUD с «руси-метром».
