# 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`.

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

## 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: '— пусто —', 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), покрыт тестами.

### Скролл длинных списков, значения пунктов

`visibleRows` задаёт окно видимых строк: список из 20+ пунктов скроллится,
курсор у края листает окно (чистая логика — `ListScroll`, покрыт тестами).
Кнопки строятся все сразу (окно только двигает их) — для меню до ~30 строк;
для сотен нужен ленивый пул. Hover мышью/тачем переставляет курсор.

Пункт с `value` рисует значение справа (громкость, счётчик); обновляется
без пересборки списка — `menu.setValue(index, '▮▮▮▯')`:

```ts
const menu = new MenuList({ width: 160, height: 14, visibleRows: 6 });
menu.setItems([{ label: 'Общая', value: '▮▮▮▮▮' }]);
menu.setValue(0, '▮▮▮▯▯');
```

TextInput в движке нет — потребителей пока не появлялось (имя сейва и т.п.),
заведём вместе с первым.

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

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

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

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

Рантайм диалогов отделён от отрисовки: движок ходит по графу и применяет эффекты
к GameState, игра рисует реплики своим view. Модель графа (типы узлов/выборов,
условия) и чистые предикаты (`evalConditions`, `hasConditions` — их берут
валидатор и dry-run) живут в `dialogue/graph.ts`, рантайм обхода — в
`dialogue/DialogueRunner.ts`; оба ре-экспортируются из `@rpg/engine`.
Граф — обычные данные (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, 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 и валидатора:

```ts
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 в
движке:

```ts
// в графе: узел или выбор может нести 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 — ошибка
валидатора контента, рантайм его игнорирует.

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

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

### View

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

```ts
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 и редактора):

```ts
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/`, порт 5299):
слева список графов, в центре 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`).

## DialogueBox

Готовая нижняя панель для реплик (рисует имя, текст, варианты, подсказку
«далее»). Высота панели растёт от контента вверх. При typewriter панель
сразу строится под **полный** текст (место под «далее ▸» резервируется
заранее) — печать идёт внутри готовой панели, ничего не прыгает и не
печатается поверх мира. Имя и текст переносятся по ширине панели. Опции v2:

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

```ts
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), с сеткой
по границам пикселей — для правки пропорций и проверки кадров анимации:

```ts
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); // каждый тик — «живой» кадр анимации
```
