Newer
Older
rpg / packages / engine / src / ui / DialogueBox.ts
import { Container, Graphics } from 'pixi.js';
import { PixelText } from './PixelText';

/**
 * Универсальное окно диалога: имя говорящего, текст, варианты ответа, подсказка «далее».
 * Жанронезависимо: игра сама решает, чей это диалог и что идёт дальше.
 * Подходит как view для DialogueRunner.
 */
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;
}

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;

    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.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: 0xffffff,
            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);
    }

    /**
     * Показать реплику. Если есть choices — рисуются кликабельные варианты
     * (текст реплики обычно скрыт: runner показывает вопрос предыдущим узлом).
     */
    show(line: { speaker?: string; text: string; choices?: DialogueChoiceItem[] }): void {
        this.nameText.text = line.speaker ?? '';
        this.bodyText.text = line.text;
        this.hintText.visible = !line.choices || line.choices.length === 0;
        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;
            }
        }
    }

    hide(): void {
        this.view.visible = false;
        this.visible = false;
        for (const c of this.choiceViews) c.label.destroy();
        this.choiceViews = [];
    }
}