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

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

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

```ts
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 движка: тёмные панели с однопиксельной рамкой, жёлтый акцент для фокуса.

```ts
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()`:

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

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

```ts
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):

```ts
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: 'Ты уже сделал больше, чем я смел просить.' }
        }
    }
};
```

Рантайм:

```ts
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:

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

## DialogueBox

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

```ts
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();
```