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;
text: string;
}
export interface DialogueChoiceItem {
index: number;
text: string;
}
export interface DialogueBoxOptions {
/** Виртуальные пиксели. */
width: number;
height: number;
/** Отступы панели от краёв экрана. */
margin: number;
/** Выбор варианта ответа (из DialogueRunner.pick). */
onChoice?: (index: number) => void;
/** Размер шрифта (виртуальные пиксели). */
size?: number;
/** Скорость печати, символов в секунду; не задана — текст сразу. */
typewriter?: number;
/** mood реплики → цвет текста (по умолчанию белый). */
moodColors?: Record<string, number>;
}
const COLOR_BODY = 0xffffff;
const COLOR_CHOICE = 0xcccccc;
const COLOR_CHOICE_ACTIVE = 0xf0d878;
export class DialogueBox {
readonly view: Container;
visible = false;
private panel: Graphics;
private nameText: PixelText;
private bodyText: PixelText;
private hintText: PixelText;
private choiceViews: { item: DialogueChoiceItem; label: PixelText }[] = [];
private onChoice: ((index: number) => void) | null;
private readonly width: number;
private readonly height: number;
private readonly margin: number;
private readonly size: number;
private readonly cps: number;
private readonly moodColors: Record<string, number>;
// typewriter
private fullText = '';
private revealElapsed = 0;
private revealingNow = false;
// клавиатурный курсор по вариантам
private cursor: ListCursor = new ListCursor(0);
constructor(options: DialogueBoxOptions) {
this.width = options.width;
this.height = options.height;
this.margin = options.margin;
// 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;
this.view.eventMode = 'static';
this.panel = new Graphics();
this.nameText = new PixelText({ text: '', size: this.size, color: 0xf0d878 });
this.bodyText = new PixelText({
text: '',
size: this.size,
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.relayout();
}
/**
* Показать реплику. Если есть choices — рисуются варианты с курсором
* (клик и moveCursor/activate). mood подкрашивает текст, typewriter
* печатает текст постепенно (обновлять update(dt)).
*/
show(line: {
speaker?: string;
text: string;
mood?: string;
choices?: DialogueChoiceItem[];
}): void {
this.nameText.text = line.speaker ?? '';
this.fullText = line.text;
this.bodyText.style.fill = (line.mood && this.moodColors[line.mood]) || COLOR_BODY;
this.view.visible = true;
this.visible = true;
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);
}
}
}