Newer
Older
rpg / docs / engine / ui-and-dialogue.md

UI и диалоги: PixelText, Panel, Button, MenuList, DialogueRunner

PixelText (пиксельный шрифт)

Движок поставляет VT323 (OFL, есть кириллица) в packages/engine/assets/fonts/. Загрузка — через FontFace API, до создания текста:

import { ensurePixelFont, PixelText } from '@rpg/engine';

await ensurePixelFont('fonts/VT323-Regular.ttf'); // URL решает приложение
const label = new PixelText({
    text: 'Пепел оседает...',
    size: 10,                 // виртуальные пиксели, целые
    color: 0x8a8a9a,
    wordWrapWidth: 200,       // опционально: перенос
    align: 'center'
});

Если шрифт не загрузился (офлайн, ошибка) — фолбэк monospace, приложение не падает. Чтобы заменить шрифт: свой TTF + ensurePixelFont(url, 'МойШрифт') — PixelText принимает fontFamily.

Resolution растеризации текста движок синхронизирует с масштабом канваса сам (setPixelTextResolution, вызывается Renderer'ом) — глифы рендерятся 1:1 с пикселями устройства и не «мылятся» при растяжении экрана.

Panel, Button, MenuList

Базовый стиль UI движка: тёмные панели с однопиксельной рамкой, жёлтый акцент для фокуса.

import { Panel, Button, MenuList } from '@rpg/engine';

const panel = new Panel({ width: 160, height: 120, fill: 0x101018, border: 0x8899aa });
uiRoot.addChild(panel);

Кнопка сама рисует состояния (normal/hover/pressed/focused) и обрабатывает мышь; клавиатурная активация — через MenuList или вручную button.activate():

const button = new Button({
    label: 'Новая игра',
    width: 120, height: 16, size: 10,
    onSelect: () => startGame()
});
button.focused = true; // клавиатурный фокус (визуально — рамка акцентного цвета)

Меню со списком: игра читает ввод и двигает курсор — движок не привязан к раскладке:

const menu = new MenuList({ width: 120, height: 14, gap: 2 });
menu.position.set(180, 120);
menu.setItems([
    { label: 'Продолжить', onSelect: () => loadGame() },
    { label: 'Новая игра', onSelect: () => newGame() },
    { label: '— пусто —', disabled: true } // недоступный пункт: курсор пропускает, клик игнорируется
]);
uiRoot.addChild(menu);

// в update сцены:
if (input.isActionJustPressed('up')) menu.moveCursor(-1);
if (input.isActionJustPressed('down')) menu.moveCursor(1);
if (input.isActionJustPressed('advance')) menu.activate();

Навигация зациклена (после последнего пункта — первый). Логика курсора — чистый класс ListCursor (с предикатом выбираемости для disabled), покрыт тестами.

Меню-сцены: MenuSceneBase + menuInput

Панель поверх вызывающей сцены (push/pop) с клавиатурной навигацией — не копируйте «up/down/confirm/cancel → moveCursor/activate + гард перехода» в каждую сцену; наследуйтесь от MenuSceneBase:

class SettingsScene extends MenuSceneBase {
    constructor(game: Game, onBack: () => void) {
        super(
            { input: game.engine.input, inputBlocked: () => game.scenes.transitioning },
            { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', left: 'left', right: 'right' }
        );
        this.onBack = onBack;
    }
    protected build(): void {
        // панель, заголовок, this.menu = new MenuList(...), uiRoot.addChild(this.view)
    }
    protected onAdjust(delta: number, index: number): void { /* громкость ±0.1 */ }
    protected onCancel(): void { this.onBack(); }
}

Хуки сцены: onMove/onConfirm (дефолты — menu.moveCursor/activate), onAdjust (left/right для значений вроде громкости), onCancel (Esc), onAction(action) (extra: удаление сейва, открытие сумки). Имена действий движок не знает — карта MenuActionMap задаётся сценой (бинды живут в игре). Чтение ввода — чистая функция readMenuInput(input, index, map): порядок за тик cancel → move → (adjust XOR confirm) → extra; нажатые left/right подавляют confirm, чтобы Enter в настройках не давал двойной шаг.

Сцены под push-сценой не обновляются (SceneManager.update тикает только верхнюю) — «пауза под меню» получается бесплатно; это осознанный контракт: флага «обновлять сцену под меню» в движке нет.

DialogueRunner (графы диалогов)

Рантайм диалогов отделён от отрисовки: движок ходит по графу и применяет эффекты к GameState, игра рисует реплики своим view. Граф — обычные данные (TS или JSON):

import { DialogueRunner, type DialogueGraph } from '@rpg/engine';

const graphs: Record<string, DialogueGraph> = {
    elder_first: {
        start: 'greet',
        nodes: {
            greet: {
                speaker: 'Ирвин',
                text: 'Ты пришёл с востока? Тогда слушай...',
                next: 'quest',
            },
            quest: {
                speaker: 'Ирвин',
                text: 'Поможешь лугам?',
                choices: [
                    { text: 'Да', next: 'accept', setFlags: ['quest_bells_taken'] },
                    { text: 'Не сейчас', next: 'later' },
                    { text: 'Уже помог', when: ['quest_bells_done'], next: 'thanks' }
                ]
            },
            accept: { text: 'Спасибо. Возьми эту свечу.', setFlags: ['met_elder'] },
            later: { text: 'Жду.' },
            thanks: { text: 'Ты уже сделал больше, чем я смел просить.' }
        }
    }
};

Рантайм:

const runner = new DialogueRunner(gameState, dialogueBoxView);
runner.onFinish = (graph, result) => { /* квесты, сейв, сцены */ };

runner.start(graphs['elder_first']); // показать первый узел
// в update: по клику/Space
if (runner.active) input.isActionJustPressed('advance') && runner.advance();
runner.pick(choiceIndex);            // выбор варианта (из view)

runner.active; // идёт ли диалог
runner.path;   // показанные узлы по порядку
runner.result; // { lastNodeId, path, picks } после завершения

Механика узлов

  • Узел с text — реплика: показывается, advance() уходит по next (нет next — конец диалога).
  • Узел без text — «действие»: применяет эффекты и уходит по next (или завершает). Так строятся hub-узлы и проверки без реплик.
  • choices — варианты игрока; показываются только прошедшие условия; pick(index) применяет эффекты выбора и идёт по его next.
  • Условия на узлах и выборах: when (все флаги установлены), whenNot (ни один не установлен), whenVar: { key, op, value } (eq/ne/gt/lt/ge/le), whenVars (несколько переменных, AND), hasItem (все предметы в сумке). Не прошедший условие узел пропускается: диалог уходит по его next или завершается.
  • Предметы движок знать не может: hasItem резолвится через DialogueWorld ({ hasItem(id): boolean }), который игра передаёт третьим аргументом конструктора или setWorld(). Без world такие условия ложны.

evalConditions: проверка условий без рантайма

Условия — чистые данные, и проверять их можно без запуска диалога — для квестовых стадий, dry-run и валидатора:

import { evalConditions, type DialogueConditions, type DialogueWorld } from '@rpg/engine';

const ready: DialogueConditions = { when: ['quest_bells_taken'], whenVars: [{ key: 'flowers', op: 'ge', value: 3 }] };
if (evalConditions(ready, gameState, world)) { /* ветка доступна */ }
  • Эффекты (setFlags, clearFlags, setVars) применяются при входе в узел и при выборе варианта.
  • Циклы из узлов без текста обрываются безопасно (MAX_STEPS).

Игровые эффекты (do[]) и результат onFinish

setFlags/setVars движок применяет сам (это про GameState). Всё, что знает только конкретная игра — дать предмет, звук, всплывашку, сюжетное действие — пишется в графе данными (do[]), а движок лишь эмитит операции; исполнение — на стороне игры (EffectSink). Никаких Inventory в движке:

// в графе: узел или выбор может нести do[]
{ text: 'Держи ткань.', do: [{ kind: 'giveItem', id: 'cloth', count: 1 }] }

// в игре — приёмник эффектов
runner.onEffect = (op, at) => sink.apply(op, at);
// kind: 'giveItem'|'takeItem'|'sound'|'toast'|'custom' + id/count/text/payload

Порядок применения эффектов: setFlagsclearFlagssetVarsdo[] (каждый op — отдельный вызов onEffect). Неизвестный kind — ошибка валидатора контента, рантайм его игнорирует.

Завершение диалога отдаёт и результат — как граф был пройден:

runner.onFinish = (graph, result) => {
    result.lastNodeId; // последний показанный узел
    result.path;       // ids показанных узлов по порядку
    result.picks;      // [{ nodeId, index, text }] — выборы игрока
};
runner.result; // то же самое остаётся доступным после завершения

View

Любой объект с двумя методами — например, обёртка над DialogueBox:

const view: DialogueView = {
    show: ({ speaker, text, choices }) => box.show({ speaker: speaker ?? '', text }),
    hide: () => box.hide()
};
runner.setView(view); // можно заменить в любой момент

Графы в JSON и dry-run

Графы игры лежат в JSON (data/dialogues/*.json, реестр — dialogues.ts): их можно править руками или визуальным редактором, формат — 4 пробела + \n. Правила графов — чистые функции data/dialogueRules.ts (реестры — параметры, одна истина для валидатора, CLI и редактора):

import { checkGraph, analyzeGraph, type GraphRefs } from '.../data/dialogueRules';

const refs: GraphRefs = { flags: new Set(Object.keys(FLAGS)), vars: ..., items: ..., customs: ..., strings: new Set() };
checkGraph('elder_first', graph, refs); // Invariant[]: ссылки, next, сироты (warn), циклы без текста (error), тихие концы (warn)
analyzeGraph(graph);                    // { reachable, orphans, textlessCycles, silentEnds }

Прогон глазами — npm run dialogues:dry [<id>]: каждый граф гоняется через DialogueRunner на пресетах состояния (fresh / quest-taken+flowers / quest-done) и печатает реплики, сироты, циклы и битые ссылки (exit 1 при ошибках). Новые инварианты валидатора: textless-cycle (error), silent-end (warn), string-unknown (textKey вне реестра строк), quest-stage-unreachable (doneFlag стадии не выставляется setFlags в графе стадии).

DialogueBox

Готовая нижняя панель для реплик (рисует имя, текст, варианты, подсказку «далее»). Высота панели растёт от контента вверх. Опции v2:

import { DialogueBox } from '@rpg/engine';

const box = new DialogueBox({
    width: 480, height: 270, margin: 8,
    typewriter: 40,                                  // символов в секунду (не задан — текст сразу)
    moodColors: { sad: 0x9aaad8, angry: 0xd89a9a },  // mood реплики → цвет текста
    onChoice: (index) => runner.pick(index)
});
uiRoot.addChild(box.view);
box.show({ speaker: 'Ирвин', text: 'Привет.' });     // + mood?, choices?
box.update(dt); // при typewriter — каждый тик сцены
box.hide();

Управление выбором с клавиатуры: box.moveCursor(±1) листает варианты, box.activate() выбирает под курсором (Enter), box.cursorIndex — для снапшота. Печать текста: box.revealing идёт ли, skipReveal() показать сразу. Чистая логика печати — revealText(full, elapsedMs, cps) из @rpg/engine (тестируется в node).

Настроение, метки и ключи строк

Узлы и выборы несут презентационные метаданные, которые движок просто проводит до view и снапшота:

  • mood — настроение реплики; игра мапит его на цвет (moodColors) или портрет. На геймплей не влияет.
  • tags — свободные метки для агента/инструментов (снапшот, фильтры).
  • textKey / speakerKey — ключи локализации: при показе текст резолвится хуком hooks.resolve (DialogueRunner третий аргумент или setHooks). Inline-текст первичен: textKey переопределяет его; нет резолва — показывается сам ключ. Узел с одним textKey (без text) — полноценная реплика, не «действие».

DebugOverlay

Отладочная плашка (fps + произвольные строки). Добавьте view в uiRoot поверх всего и вызывайте update(dt) каждый тик; видимость переключайте через view.visible (в демо — по F3):

import { DebugOverlay } from '@rpg/engine';

const debug = new DebugOverlay(false); // скрыт, пока не понадобится
uiRoot.addChild(debug.view);

debug.setLines(['tile 14,14', 'hp 5']); // строки статуса (fps подставляется сам)
debug.update(dt);                       // каждый тик

SpriteDebugView

Дебаг-просмотр спрайта: текстура, увеличенная целым множителем (nearest), с сеткой по границам пикселей — для правки пропорций и проверки кадров анимации:

import { SpriteDebugView } from '@rpg/engine';

const view = new SpriteDebugView({ zoom: 8 }); // 1 тексель = 8 px
view.view.position.set(390, 120);
uiRoot.addChild(view.view);

view.setTexture(sprite.texture); // каждый тик — «живой» кадр анимации