Демо «Руси-тян: Бака!» — визуальная новелла на $.timeline

Одна героиня, пять локаций, три выбора, две концовки, тряска экрана, вспышки и семь поз — и всё это описано данными, без единого клика по сцене.

./build/russiano2d --game demos --scene russi_vn   # сразу новелла
./build/russiano2d --game demos                    # кнопка «Руси-тян (ВН)»
Управление Что делает
Space / Enter / клик дальше (досрочно допечатать реплику)
↑ / ↓ выбрать вариант ответа
Ctrl (держать) быстрый пропуск: реплики летят
A авто-режим чтения
R начать новеллу заново
Esc в меню демо

Прогресс (флаг trust, открытые концовки) живёт в $.store и переживает перезапуск: у новеллы свой файл demos/vn_save.json, чтобы не подмешиваться в сохранение основной игры.


1. Из чего состоит новелла

Демо — один файл index.js: он объявляет ассеты, вызывает $.animatedTimelineScene2d(spec) и дорисовывает HUD. Всё остальное делает движок.

$.animatedTimelineScene2d({
    id: 'russi_vn',              // имя таймлайна и (по умолчанию) сцены
    scene: 'russi_vn',           // какую сцену $.scene зарегистрировать
    exitScene: 'launcher',       // куда уйти по Esc с карточки концовки
    speed: 55,                   // скорость печатной машинки, символов в секунду
    style: 'vn_line',            // стиль текста из $.font
    location: 'room',            // с какой локации начинать
    locations: { room: { bg: '…', music: '…', title: 'Комната · 03:07' } },
    cast: { russi: { name: 'Руси-тян', poses: { neutral: '…', angry: '…' } } },
    dialogTheme: { panel: '#0b1220e6', speaker: '#ffb3d9' },
    enter() { /* HUD */ }, exit() { /* уборка */ }, update(dt) { /* хоткеи */ },
    script: [ /* биты — см. §3 */ ],
});

$.timeline — это и есть новая функция высокого уровня: модуль src/highlevel/timeline.js. Полный справочник — docs/highlevel/timeline.md.


2. Локации, герои, позы

Локация — фон, музыка и необязательный оттенок («настроение»). Оттенок рисуется между фоном и героями, поэтому не съедает цвета персонажа.

locations: {
    roof: {
        title: 'Крыша · закат',                 // всплывает табличкой при входе
        bg: 'demos/assets/art/vn/bg/rooftop_sunset.png',
        music: 'demos/assets/audio/music/menu.ogg',
        volume: 0.5,
        mood: { color: '#ff8a4c', alpha: 0.14 }, // необязательно
        fade: 500,                               // мс перекрёстного затухания
    },
}

Герой — имя, позы и место на экране. Позы задаются таблицей, поэтому переключение — это один ключ в бите, а не отдельная анимация:

cast: {
    russi: {
        name: 'Руси-тян',
        poses: { neutral: '…neutral.png', angry: '…angry.png', blush: '…blush.png' },
        pose: 'neutral',   // с какой начать
        x: 0.62,           // центр по горизонтали, доля ширины окна
        bottom: 1.02,      // низ спрайта, доля высоты (1.02 — чуть ниже кадра)
        height: 0.94,      // высота героя, доля высоты окна
        idle: true,        // дыхание
        mirror: false,     // отразить по горизонтали
    },
}

Позы вырезаны по общей рамке (tools/make_vn_sprites.py), поэтому смена позы не двигает героиню ни на пиксель — это и делает возможной «анимацию» из статичных картинок.


3. Биты: язык сценария

Скрипт — массив объектов. Бит может совмещать несколько действий: сначала выполняются мгновенные (поза, звук, тряска), потом то, что занимает время (реплика, пауза).

Бит Что делает Ждёт?
{ say: '…' } реплика героини (её имя подставляется само) да, нажатия игрока
{ narrate: '…' } текст без имени говорящего да
{ say: '…', who: 'russi', pose: 'angry' } реплика сразу с позой да
{ choose: [{ text, goto, add, set, do, if }] } варианты ответа да, выбора
{ location: 'roof' } смена локации с затуханием нет
{ pose: 'blush' } сменить позу нет
{ show: 'russi', from: 'left' } выход героя (left/right/bottom/fade) да
{ hide: 'russi', to: 'right' } уход героя да
{ anim: 'bounce' } акцент: pop, bounce, nod, lean, away, sigh, step, tremble, shiver да
{ shake: 14, ms: 450 } тряска экрана (камера) нет
{ flash: { color: '#ff5a5a', alpha: 0.45, ms: 300 } } вспышка поверх интерфейса нет
{ fade: '#000000', ms: 400 } / { fade: null } затемнение и проявление да
{ zoom: 1.2, ms: 600 } наезд камеры нет
{ sfx: '…' } / { music: '…' } звук и музыка нет
{ wait: 300 } пауза да
{ set: { trust: 1 } } / { add: { trust: 1 } } записать флаг в $.store нет
{ if: 'trust >= 2', then: [...], else: [...] } ветка по содержимому
{ goto: 'love' } / { label: 'love' } переход и метка нет
{ do: ($, tl) => { … } } свой код нет
{ emit: 'имя', data: {} } своё событие нет
{ ending: { id, title, text, mood } } концовка и финальная карточка конец прогона

Условие понимает четыре формы: функцию, true/false, флаг ('has_pass', '!has_pass') и сравнение ('trust >= 2', 'route == "love"').

Реплика-строка — сахар: 'Бака!' то же самое, что { say: 'Бака!' }.

Пример из демо — ссора из-за Python:

{ label: 'python' },
{ anim: 'tremble' },
{ shake: 14, ms: 450 },
{ sfx: sfx.glitch },
{ flash: { color: '#ff5a5a', alpha: 0.45, ms: 300 } },
{ pose: 'angry' },
{ say: 'ПИТОН?! Ты… ты… БАКА!!!' },
{ shake: 18, ms: 500 },
{ say: 'Python — это язык, на котором аналитики считают таблички! А тут ДВИЖОК!' },

4. UI — на RmlUi

Новелла ничего не рисует узлами <ui.*>: панель реплики, кнопки выбора и весь HUD — это документы RmlUi, обычные HTML-подобная разметка и CSS. Перенос строк, шрифт, рамку, скругления и подсветку кнопки под курсором делает RmlUi, а игра только пишет значения:

// реплика: разметка и стили наши, состояние — из $.dialog
dialogView: { kind: 'rml', doc: 'demos/ui/vn-dialog.rml' },
locationCard: false,                      // табличку локации рисует vn-hud.rml

// HUD
const hud = $.ui.doc('demos/ui/vn-hud.rml').show();
hud.style('vn-meter-fill', 'width', '60%');   // руси-метр
hud.text('vn-place', 'Крыша · закат');        // табличка локации
hud.cls('vn-auto', 'off', false);             // индикатор авто-режима

Файлы: demos/ui/vn-dialog.rml + .rcss, demos/ui/vn-hud.rml + .rcss. Кнопок выбора в разметке ровно шесть — как maxChoices у $.dialog: элементы создаются один раз, поэтому подписка на клик не теряется при смене реплики.

5. Озвучка героини

Голос подключается по простому правилу: файл на реплику, имя = id реплики (<dir>/<id>.mp3). Нет файла — реплика идёт молча, поэтому озвучку можно дописывать по одной и в любом порядке.

voice: { dir: 'demos/russi_vn/voice', ext: 'mp3', volume: 1 },

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

echo '{"cmd":"eval","code":"JSON.stringify($.timeline.lines())"}' | \
    ./build/russiano2d --game demos --scene russi_vn --agent --headless

Готовые материалы лежат в voice/: lines_for_minimax.txt — 49 реплик по строке на каждую (порядок = порядок в новелле), lines.json — карта «файл ↔ реплика», README.md — таблица. Нарация (6 строк) не озвучивается: это голос за кадром, а не героиня.

6. Своя новелла за десять минут

  1. Заведите каталог demos/моя_новелла/index.js с экспортом install($) и допишите строку './моя_новелла/index.js' в MODULES в demos/main.js. Кнопка в меню появится сама, если добавить подпись в TITLES в demos/launcher.js.
  2. Положите картинки: позы — в demos/assets/art/vn/<герой>/, локации — в demos/assets/art/vn/bg/. Позы удобно готовить скриптом tools/make_vn_sprites.py (вырезает фон, ровняет по общей рамке), локации — tools/make_vn_backgrounds.py.
  3. Объявите стиль текста до новеллы: $.font.define('vn_line', { size: 22, color: '#eef3ff' });
  4. Опишите сцену вызовом $.animatedTimelineScene2d({ … }) — минимум это id, cast, locations и script.
  5. Проверьте без человека: юнит-тесты модуля и прогон через агента.
build/_deps/quickjs-build/qjs tests/js/timeline_test.mjs          # логика модуля
python3 tools/vn_playthrough.py --route love --out build/vn_shots # вся новелла до концовки

Скрипт tools/vn_playthrough.py проходит новеллу в агентском режиме, сам нажимает «дальше», выбирает варианты по маршруту и складывает скриншоты ключевых моментов — так демо проверяется целиком, а не «должно работать».


7. Что под капотом

Слой Кто отвечает
панель реплики, выборы, руси-метр, подсказки, табличка локации RmlUi — demos/ui/vn-dialog.rml и vn-hud.rml (+ .rcss)
текст, печатная машинка, выборы, Enter/↑/↓ $.dialog (таймлайн только листает реплики)
фон и локации два спрайта в мире + перекрёстное затухание
герой, позы, дыхание, вход/выход, акценты «риг» персонажа + $.tween
тряска экрана $.camera.shake (камера приколота к центру окна, поэтому мир = экран)
вспышки и затемнения свои ui.panel поверх интерфейса
музыка и звук $.sound
флаги и концовки $.store

Отладка: $.timeline.state() отдаёт снимок прогона (локация, поза, ожидание, число битов, флаги), а $.agent.expose('vn_trust') и родственные поля попадают в снимок агента — через них новеллу видят тесты:

echo '{"cmd":"eval","code":"JSON.stringify($.timeline.state())"}' | \
    ./build/russiano2d --game demos --scene russi_vn --agent --headless

8. Ассеты

  • позы Руси-тян — присланные картинки, обработанные tools/make_vn_sprites.py (фон вырезан в альфу, общая рамка, ×0.5);
  • класс — CC0-фото «Classroom 002» (OpenGameArt);
  • небо для заката — CC0-кадр из «40 game backgrounds, painted style» (OpenGameArt);
  • комната, ночная улица, школьный двор и крыша — процедурная графика tools/make_vn_backgrounds.py (та же CC0, что и движок);
  • музыка — два трека автора проекта (vn_tension.mp3, vn_afternoon.mp3, исходные WAV 28 и 29 МБ сжаты в 2,9 МБ), плюс CC0-записи Juhani Junkala для звуков (см. demos/assets/CREDITS.md).