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.

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: 'Выход', onSelect: () => window.close() }
]);
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, покрыт тестами.

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) => { /* квесты, сейв, сцены */ };

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

runner.active; // идёт ли диалог

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

  • Узел с text — реплика: показывается, advance() уходит по next (нет next — конец диалога).
  • Узел без text — «действие»: применяет эффекты и уходит по next (или завершает). Так строятся hub-узлы и проверки без реплик.
  • choices — варианты игрока; показываются только прошедшие условия; pick(index) применяет эффекты выбора и идёт по его next.
  • Условия на узлах и выборах: when (все флаги установлены), whenNot (ни один не установлен), whenVar: { key, op, value } (eq/ne/gt/lt/ge/le). Не прошедший условие узел пропускается: диалог уходит по его next или завершается.
  • Эффекты (setFlags, clearFlags, setVars) применяются при входе в узел и при выборе варианта.
  • Циклы из узлов без текста обрываются безопасно (MAX_STEPS).

View

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

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

DialogueBox

Готовая нижняя панель для реплик (рисует имя, текст, подсказку «далее»):

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

const box = new DialogueBox({ width: 480, height: 270, margin: 8 });
uiRoot.addChild(box.view);
box.show({ speaker: 'Ирвин', text: 'Привет.' });
box.hide();