diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index ff964b3..cc89724 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -318,7 +318,9 @@ this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, { width: Game.VIRTUAL_W, height: Game.VIRTUAL_H, - margin: 8 + margin: 8, + typewriter: 40, + moodColors: { sad: 0x9aaad8, angry: 0xd89a9a, warm: 0xd8c79a } }); this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); @@ -523,7 +525,10 @@ } const input = this.game.engine.input; if (this.dialogue.active) { - // Во время диалога клик/пробел только листают реплики. + // Во время диалога up/down листают варианты, клик/пробел — дальше/выбор. + this.dialogue.update(dt); + if (input.isActionJustPressed('up')) this.dialogue.moveCursor(-1); + if (input.isActionJustPressed('down')) this.dialogue.moveCursor(1); if (input.getPointer().justPressed || input.isActionJustPressed('advance')) { this.dialogue.advance(); } diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts index 854b300..fa01a53 100644 --- a/apps/game/src/systems/DialogueSystem.ts +++ b/apps/game/src/systems/DialogueSystem.ts @@ -54,11 +54,29 @@ this.runner.start(graph); } - /** Клик/пробел: следующая реплика (во время выбора игнорируется). */ + /** Клик/пробел: догнать печать → активировать вариант под курсором → дальше. */ advance(): void { + if (this.box.revealing) { + this.box.skipReveal(); + return; + } + if (this.runner.waitingForChoice) { + this.box.activate(); + return; + } this.runner.advance(); } + /** Листать варианты up/down (клавиатура/геймпад). */ + moveCursor(delta: number): void { + this.box.moveCursor(delta); + } + + /** Печать текста; звать из update сцены. */ + update(dt: number): void { + this.box.update(dt); + } + /** Выбрать показанный вариант (агентный мост; индексы — из agentState.choices). */ pickChoice(index: number): void { this.runner.pick(index); diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index 721533a..8eab16e 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -232,17 +232,44 @@ ## DialogueBox -Готовая нижняя панель для реплик (рисует имя, текст, подсказку «далее»): +Готовая нижняя панель для реплик (рисует имя, текст, варианты, подсказку +«далее»). Высота панели растёт от контента вверх. Опции v2: ```ts import { DialogueBox } from '@rpg/engine'; -const box = new DialogueBox({ width: 480, height: 270, margin: 8 }); +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: 'Привет.' }); +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` поверх всего diff --git a/packages/engine/src/dialogue/DialogueRunner.ts b/packages/engine/src/dialogue/DialogueRunner.ts index cff0238..c8025cd 100644 --- a/packages/engine/src/dialogue/DialogueRunner.ts +++ b/packages/engine/src/dialogue/DialogueRunner.ts @@ -82,11 +82,19 @@ text: string; /** Следующий узел (по умолчанию — конец диалога). */ next?: string; + /** Настроение реплики (игра мапит на цвет/портрет). */ + mood?: string; + /** Свободные метки для агента/инструментов (на геймплей не влияют). */ + tags?: string[]; + /** Ключ строки в таблице локализации; переопределяет inline text. */ + textKey?: string; } export interface DialogueNode extends DialogueEffects, DialogueConditions { /** Имя говорящего (опционально). */ speaker?: string; + /** Ключ имени говорящего (локализация, как textKey). */ + speakerKey?: string; /** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */ text?: string; /** Варианты ответа игрока. */ @@ -95,6 +103,12 @@ next?: string; /** Явный конец диалога (для конечных узлов без choices/next). */ end?: boolean; + /** Настроение реплики (игра мапит на цвет/портрет). */ + mood?: string; + /** Свободные метки для агента/инструментов (на геймплей не влияют). */ + tags?: string[]; + /** Ключ строки в таблице локализации; переопределяет inline text. */ + textKey?: string; } export interface DialogueGraph { @@ -110,6 +124,11 @@ ); } +/** Узел-реплика: есть inline text или ключ строки. */ +function hasText(n: { text?: string; textKey?: string }): boolean { + return n.text !== undefined || n.textKey !== undefined; +} + /** Сравнение переменной по op; undefined (переменной нет) не проходит gt/lt/ge/le. */ function checkVar(state: GameState, cond: VarCondition): boolean { const v = state.getVar(cond.key); @@ -156,7 +175,13 @@ /** Вид: игра рисует реплику и варианты своими средствами. */ export interface DialogueView { /** Показать реплику. choices пуст, если выбора нет. */ - show(node: { speaker?: string; text: string; choices: { index: number; text: string }[] }): void; + show(node: { + speaker?: string; + text: string; + mood?: string; + tags?: string[]; + choices: { index: number; text: string }[]; + }): void; hide(): void; } @@ -351,7 +376,7 @@ .map((c: DialogueChoice, index: number) => ({ c, index })) .filter(({ c }) => this.checkConditions(c)) .map(({ c, index }) => ({ index, text: c.text })); - if (n.text !== undefined) { + if (hasText(n)) { this.show(n, choices); } else if (choices.length > 0) { // выбор без реплики: показываем только варианты @@ -365,7 +390,7 @@ return; } - if (n.text !== undefined) { + if (hasText(n)) { this.pendingNext = n.next; this.show(n, []); return; @@ -391,8 +416,19 @@ this.finish(); } + /** Текст реплики: textKey резолвится хуком (fallback — сам ключ). */ + private resolveText(n: { text?: string; textKey?: string }): string { + if (n.textKey !== undefined) return this.hooks?.resolve?.(n.textKey) ?? n.textKey; + return n.text ?? ''; + } + + private resolveSpeaker(n: { speaker?: string; speakerKey?: string }): string | undefined { + if (n.speakerKey !== undefined) return this.hooks?.resolve?.(n.speakerKey) ?? n.speakerKey; + return n.speaker; + } + private show( - n: { speaker?: string; text?: string }, + n: DialogueNode, choices: { index: number; text: string }[], textlessChoice = false ): void { @@ -400,8 +436,10 @@ this.shownChoices = choices; this.awaitingChoice = choices.length > 0; this.view?.show({ - speaker: n.speaker, - text: textlessChoice ? '' : (n.text ?? ''), + speaker: this.resolveSpeaker(n), + text: textlessChoice ? '' : this.resolveText(n), + mood: n.mood, + tags: n.tags, choices }); } diff --git a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts index 86af597..02b9606 100644 --- a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts +++ b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts @@ -6,6 +6,8 @@ interface ShownLine { speaker?: string; text: string; + mood?: string; + tags?: string[]; choices: { index: number; text: string }[]; } @@ -408,4 +410,45 @@ expect(dr.result).toBeNull(); expect(dr.path).toEqual(['a']); }); + + it('mood/tags доходят до view', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view); + dr.start({ + start: 'a', + nodes: { a: { text: 'Грустно.', mood: 'sad', tags: ['epilogue'] } } + }); + expect(shown[0]?.mood).toBe('sad'); + expect(shown[0]?.tags).toEqual(['epilogue']); + expect(shown[0]?.text).toBe('Грустно.'); + }); + + it('textKey резолвится через hooks.resolve; без хука — сам ключ', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view, { + resolve: (key) => (key === 'elder.greet' ? 'Здравствуй, путник.' : key) + }); + dr.start({ + start: 'a', + nodes: { + a: { textKey: 'elder.greet', speakerKey: 'name.elder', next: 'b' }, + b: { textKey: 'missing.key' } + } + }); + expect(shown[0]?.text).toBe('Здравствуй, путник.'); + expect(shown[0]?.speaker).toBe('name.elder'); // ключ без перевода — сам ключ + dr.advance(); + expect(shown[1]?.text).toBe('missing.key'); + }); + + it('узел только с textKey — реплика (не действие)', () => { + const state = new GameState(); + const { view, shown } = makeView(); + const dr = new DialogueRunner(state, view, { resolve: () => 'Строка' }); + dr.start({ start: 'a', nodes: { a: { textKey: 'only.key' } } }); + expect(shown[0]?.text).toBe('Строка'); + expect(dr.active).toBe(true); + }); }); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9219732..7fb5b21 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -180,6 +180,7 @@ // ui export { DialogueBox, type DialogueLine, type DialogueBoxOptions } from './ui/DialogueBox'; +export { revealText, type RevealState } from './ui/reveal'; export { PixelText, type PixelTextOptions, ensurePixelFont, setPixelTextResolution } from './ui/PixelText'; export { Panel, type PanelOptions } from './ui/Panel'; export { Button, type ButtonOptions } from './ui/Button'; diff --git a/packages/engine/src/ui/DialogueBox.ts b/packages/engine/src/ui/DialogueBox.ts index 0d48c6b..1ac7969 100644 --- a/packages/engine/src/ui/DialogueBox.ts +++ b/packages/engine/src/ui/DialogueBox.ts @@ -1,10 +1,16 @@ import { Container, Graphics } from 'pixi.js'; import { PixelText } from './PixelText'; +import { ListCursor } from './listCursor'; +import { revealText } from './reveal'; /** * Универсальное окно диалога: имя говорящего, текст, варианты ответа, подсказка «далее». * Жанронезависимо: игра сама решает, чей это диалог и что идёт дальше. * Подходит как view для DialogueRunner. + * + * v2: typewriter (чистый revealText), цвета настроений, клавиатурный курсор + * выбора (ListCursor), высота панели растёт от контента вверх. + * В update обязательно звать update(dt), если задан typewriter. */ export interface DialogueLine { speaker: string; @@ -26,8 +32,16 @@ onChoice?: (index: number) => void; /** Размер шрифта (виртуальные пиксели). */ size?: number; + /** Скорость печати, символов в секунду; не задана — текст сразу. */ + typewriter?: number; + /** mood реплики → цвет текста (по умолчанию белый). */ + moodColors?: Record; } +const COLOR_BODY = 0xffffff; +const COLOR_CHOICE = 0xcccccc; +const COLOR_CHOICE_ACTIVE = 0xf0d878; + export class DialogueBox { readonly view: Container; visible = false; @@ -42,6 +56,15 @@ private readonly height: number; private readonly margin: number; private readonly size: number; + private readonly cps: number; + private readonly moodColors: Record; + + // typewriter + private fullText = ''; + private revealElapsed = 0; + private revealingNow = false; + // клавиатурный курсор по вариантам + private cursor: ListCursor = new ListCursor(0); constructor(options: DialogueBoxOptions) { this.width = options.width; @@ -50,6 +73,8 @@ // 8–9px у VT323 нечитаемы (тонкие штрихи размазываются) — минимум 10. this.size = Math.max(options.size ?? 10, 10); this.onChoice = options.onChoice ?? null; + this.cps = options.typewriter ?? 0; + this.moodColors = options.moodColors ?? {}; this.view = new Container(); this.view.visible = false; @@ -60,67 +85,168 @@ this.bodyText = new PixelText({ text: '', size: this.size, - color: 0xffffff, + color: COLOR_BODY, wordWrapWidth: options.width - options.margin * 4, lineHeight: this.size + 3 }); this.hintText = new PixelText({ text: 'далее ▸', size: this.size, color: 0xaaaaaa }); this.view.addChild(this.panel, this.nameText, this.bodyText, this.hintText); - this.layout(); - } - - private layout(): void { - const w = this.width; - const h = this.height; - const m = this.margin; - this.panel.rect(m, h - m - 54, w - m * 2, 54).fill({ color: 0x101018, alpha: 0.92 }); - this.panel.rect(m, h - m - 54, w - m * 2, 54).stroke({ color: 0x8899aa, width: 1 }); - this.nameText.position.set(m + 5, h - m - 52); - this.bodyText.position.set(m + 5, h - m - 42); - this.hintText.position.set(w - m - 42, h - m - 10); + this.relayout(); } /** - * Показать реплику. Если есть choices — рисуются кликабельные варианты - * (текст реплики обычно скрыт: runner показывает вопрос предыдущим узлом). + * Показать реплику. Если есть choices — рисуются варианты с курсором + * (клик и moveCursor/activate). mood подкрашивает текст, typewriter + * печатает текст постепенно (обновлять update(dt)). */ - show(line: { speaker?: string; text: string; choices?: DialogueChoiceItem[] }): void { + show(line: { + speaker?: string; + text: string; + mood?: string; + choices?: DialogueChoiceItem[]; + }): void { this.nameText.text = line.speaker ?? ''; - this.bodyText.text = line.text; - this.hintText.visible = !line.choices || line.choices.length === 0; + this.fullText = line.text; + this.bodyText.style.fill = (line.mood && this.moodColors[line.mood]) || COLOR_BODY; this.view.visible = true; this.visible = true; - for (const c of this.choiceViews) c.label.destroy(); - this.choiceViews = []; - if (line.choices && line.choices.length > 0) { - const m = this.margin; - let y = this.height - m - 42; - for (const item of line.choices) { - const label = new PixelText({ - text: `▸ ${item.text}`, - size: this.size, - color: 0xcccccc, - wordWrapWidth: this.width - m * 4 - }); - label.position.set(m + 5, y); - label.eventMode = 'static'; - label.cursor = 'pointer'; - label.on('pointerover', () => (label.style.fill = 0xf0d878)); - label.on('pointerout', () => (label.style.fill = 0xcccccc)); - label.on('pointertap', () => this.onChoice?.(item.index)); - this.view.addChild(label); - this.choiceViews.push({ item, label }); - y += label.height + 3; - } + this.rebuildChoices(line.choices ?? []); + + this.cursor = new ListCursor(this.choiceViews.length); + this.refreshChoiceColors(); + + // печать с начала; без typewriter — весь текст сразу + this.revealElapsed = 0; + this.revealingNow = this.cps > 0 && this.fullText.length > 0; + if (this.revealingNow) this.bodyText.text = ''; + else this.bodyText.text = this.fullText; + + this.relayout(); + } + + /** Печать и анимации; звать из update сцены при typewriter (dt в секундах). */ + update(dt: number): void { + if (!this.revealingNow) return; + this.revealElapsed += dt * 1000; + const r = revealText(this.fullText, this.revealElapsed, this.cps); + this.bodyText.text = this.fullText.slice(0, r.shown); + if (r.done) { + this.revealingNow = false; + this.relayout(); // панель подросла — подсказка встанет под текст } } + /** Идёт ли печать текста. */ + get revealing(): boolean { + return this.revealingNow; + } + + /** Показать текст целиком (пропуск печати). */ + skipReveal(): void { + if (!this.revealingNow) return; + this.revealingNow = false; + this.bodyText.text = this.fullText; + this.relayout(); + } + + /** Листать выбор курсором (up/down); без вариантов — no-op. */ + moveCursor(delta: number): void { + if (this.choiceViews.length === 0) return; + this.cursor.move(delta); + this.refreshChoiceColors(); + } + + /** Выбрать вариант под курсором (Enter); без вариантов — no-op. */ + activate(): void { + if (this.choiceViews.length === 0 || this.cursor.index < 0) return; + const current = this.choiceViews[this.cursor.index]; + if (current) this.onChoice?.(current.item.index); + } + + /** Индекс варианта под курсором (-1 — выбора нет). */ + get cursorIndex(): number { + return this.choiceViews.length > 0 ? this.cursor.index : -1; + } + hide(): void { this.view.visible = false; this.visible = false; for (const c of this.choiceViews) c.label.destroy(); this.choiceViews = []; + this.revealingNow = false; + this.fullText = ''; + } + + // --- внутреннее --- + + private rebuildChoices(choices: DialogueChoiceItem[]): void { + for (const c of this.choiceViews) c.label.destroy(); + this.choiceViews = []; + for (const item of choices) { + const label = new PixelText({ + text: `▸ ${item.text}`, + size: this.size, + color: COLOR_CHOICE, + wordWrapWidth: this.width - this.margin * 4 + }); + label.eventMode = 'static'; + label.cursor = 'pointer'; + label.on('pointerover', () => { + const i = this.choiceViews.findIndex((c) => c.label === label); + if (i >= 0) { + this.cursor.index = i; + this.refreshChoiceColors(); + } + }); + label.on('pointerout', () => this.refreshChoiceColors()); + label.on('pointertap', () => this.onChoice?.(item.index)); + this.view.addChild(label); + this.choiceViews.push({ item, label }); + } + } + + private refreshChoiceColors(): void { + this.choiceViews.forEach((c, i) => { + c.label.style.fill = i === this.cursor.index ? COLOR_CHOICE_ACTIVE : COLOR_CHOICE; + }); + } + + /** Панель от контента: растёт вверх от нижнего края. */ + private relayout(): void { + const w = this.width; + const h = this.height; + const m = this.margin; + const hintVisible = this.choiceViews.length === 0 && !this.revealingNow; + this.hintText.visible = hintVisible; + + this.panel.clear(); + const pad = 6; + const nameH = this.nameText.text ? this.nameText.height + 2 : 0; + const bodyH = this.fullText || this.bodyText.text ? this.bodyText.height : 0; + let choicesH = 0; + for (const c of this.choiceViews) choicesH += c.label.height + 3; + const hintH = hintVisible ? this.hintText.height + 2 : 0; + const panelH = Math.round(pad * 2 + nameH + bodyH + choicesH + hintH); + const top = h - m - panelH; + + this.panel.rect(m, top, w - m * 2, panelH).fill({ color: 0x101018, alpha: 0.92 }); + this.panel.rect(m, top, w - m * 2, panelH).stroke({ color: 0x8899aa, width: 1 }); + + let y = top + pad; + if (nameH > 0) { + this.nameText.position.set(m + 5, y); + y += nameH; + } + this.bodyText.position.set(m + 5, y); + y += bodyH; + for (const c of this.choiceViews) { + c.label.position.set(m + 5, y); + y += c.label.height + 3; + } + if (hintVisible) { + this.hintText.position.set(w - m - 5 - this.hintText.width, y + 2); + } } } \ No newline at end of file diff --git a/packages/engine/src/ui/__tests__/reveal.test.ts b/packages/engine/src/ui/__tests__/reveal.test.ts new file mode 100644 index 0000000..6e45892 --- /dev/null +++ b/packages/engine/src/ui/__tests__/reveal.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { revealText } from '../reveal'; + +describe('revealText', () => { + it('в начале не показано ничего', () => { + expect(revealText('привет', 0, 10)).toEqual({ shown: 0, done: false }); + }); + + it('печатает пропорционально времени', () => { + // 20 cps: 250мс → 5 символов + expect(revealText('привет мир', 250, 20)).toEqual({ shown: 5, done: false }); + expect(revealText('привет мир', 550, 20)).toEqual({ shown: 10, done: true }); + }); + + it('перелистывание назад невозможно: показ не больше длины', () => { + expect(revealText('ok', 10000, 5).shown).toBe(2); + }); + + it('cps <= 0 — весь текст сразу', () => { + expect(revealText('привет', 0, 0)).toEqual({ shown: 6, done: true }); + expect(revealText('привет', 0, -5)).toEqual({ shown: 6, done: true }); + }); + + it('пустой текст — сразу готов', () => { + expect(revealText('', 0, 10)).toEqual({ shown: 0, done: true }); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/ui/reveal.ts b/packages/engine/src/ui/reveal.ts new file mode 100644 index 0000000..1882bac --- /dev/null +++ b/packages/engine/src/ui/reveal.ts @@ -0,0 +1,21 @@ +/** + * Печать текста по символам (typewriter) — чистая логика без Pixi: + * тестируется в node, DialogueBox только применяет результат к PixelText. + */ + +export interface RevealState { + /** Сколько символов показано. */ + shown: number; + /** Печатание закончилось. */ + done: boolean; +} + +/** + * Сколько символов текста `full` видно через `elapsedMs` при скорости + * `cps` (символов в секунду). cps <= 0 — текст показан сразу. + */ +export function revealText(full: string, elapsedMs: number, cps: number): RevealState { + if (cps <= 0 || full.length === 0) return { shown: full.length, done: true }; + const shown = Math.min(full.length, Math.floor((elapsedMs * cps) / 1000)); + return { shown, done: shown >= full.length }; +} \ No newline at end of file