Движок поставляет 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: 'Выход', 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, покрыт тестами.
Рантайм диалогов отделён от отрисовки: движок ходит по графу и применяет эффекты к 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) применяются при входе в узел и при выборе варианта.Любой объект с двумя методами — например, обёртка над DialogueBox:
const view: DialogueView = {
show: ({ speaker, text, choices }) => box.show({ speaker: speaker ?? '', text }),
hide: () => box.hide()
};
runner.setView(view); // можно заменить в любой момент
Готовая нижняя панель для реплик (рисует имя, текст, подсказку «далее»):
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();
Отладочная плашка (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); // каждый тик — «живой» кадр анимации