Движок поставляет 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 с пикселями устройства и не «мылятся» при растяжении экрана.
Базовый стиль 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), покрыт тестами.
Панель поверх вызывающей сцены (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 тикает только верхнюю) — «пауза под меню» получается бесплатно; это осознанный контракт: флага «обновлять сцену под меню» в движке нет. Игра использует это и для времени суток: в сумке (InventoryScene, список с курсором — пункты из inventory.all, Enter — useItemLine → строка-результат в панели) часы заморожены, в диалогах/катсценах — идут (тик часов стоит до ранних выходов LocationScene.update).
Рантайм диалогов отделён от отрисовки: движок ходит по графу и применяет эффекты к 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 такие условия ложны.Условия — чистые данные, и проверять их можно без запуска диалога — для квестовых стадий, 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) применяются при входе в узел и при выборе варианта.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
Порядок применения эффектов: setFlags → clearFlags → setVars → do[] (каждый op — отдельный вызов onEffect). Неизвестный kind — ошибка валидатора контента, рантайм его игнорирует.
Завершение диалога отдаёт и результат — как граф был пройден:
runner.onFinish = (graph, result) => {
result.lastNodeId; // последний показанный узел
result.path; // ids показанных узлов по порядку
result.picks; // [{ nodeId, index, text }] — выборы игрока
};
runner.result; // то же самое остаётся доступным после завершения
Любой объект с двумя методами — например, обёртка над DialogueBox:
const view: DialogueView = {
show: ({ speaker, text, choices }) => box.show({ speaker: speaker ?? '', text }),
hide: () => box.hide()
};
runner.setView(view); // можно заменить в любой момент
Графы игры лежат в 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 в графе стадии).
Визуальный редактор — npm run dialogues (tools/dialogue-editor/, порт 5199): слева список графов, в центре SVG-канва (карточки узлов, рёбра next и choices разными цветами, сироты пунктиром), справа инспектор узла (текст/speaker/mood/tags, условия, setFlags/setVars/do[], выборы). Сохранение (PUT) сперва валидирует граф тем же checkGraph (422 с Invariant[] на ошибках) и защищено mtime-гардом (409 — файл менялся на диске); запись атомарная (.tmp → rename), формат 4 пробела + \n. Сервер — node http без зависимостей; валидация и dry-run — спавн vite-node tools/dialogues/probe.ts (реестры — registries.ts).
Готовая нижняя панель для реплик (рисует имя, текст, варианты, подсказку «далее»). Высота панели растёт от контента вверх. Опции 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) — полноценная реплика, не «действие».Отладочная плашка (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); // каждый тик
Дебаг-просмотр спрайта: текстура, увеличенная целым множителем (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); // каждый тик — «живой» кадр анимации