timeline.md 21,0 КБ
Newer Older
Nikitos's avatar
ф  
Nikitos включено в состав коммита
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# Таймлайн-сцены — `$.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
```

Готовый пример на все возможности — демо
376
[«Руси-тян: Бака!»](../../demos/russi_vn/README.md): пять локаций, семь поз,
Nikitos's avatar
ф  
Nikitos включено в состав коммита
377
три выбора, две концовки, тряска, вспышки и HUD с «руси-метром».