Newer
Older
rpg / v2 / apps / game / src / devview.ts
/**
 * Дев-панель игры (трек 28): список объектов сцены + вьювер движка.
 * Движок даёт ModelViewer (орбита мышью, клипы, статы) — панель только
 * рисует DOM. Каталог берётся из реестра объектов (registry.ts): объекты
 * регистрируются там же, где создаются — devview ничего не перечисляет.
 * F3 (или «) — открыть/закрыть; мир стоит, пока открыта панель/вьювер.
 */
import { ModelViewer, mannequin, resolveTiles } from '@rpg/engine';
import type { MannequinPalette, ModelStats } from '@rpg/engine';
import { registryAll, registryGet, type RegistryBuild, type RegistryEntry } from './registry';

/** Каталог дев-панели (мосту тоже нужен): id + подпись в порядке регистрации. */
export function devEntries(): Array<{ id: string; label: string }> {
    return registryAll().map((e) => ({ id: e.id, label: e.label }));
}

/** Построить объект каталога (мосту). */
export function buildDevEntry(id: string): RegistryBuild | null {
    return registryGet(id)?.build() ?? null;
}

/**
 * Панель: DOM-список (#dev) + вьювер-оверлей (#devview). onOpenChange —
 * игра ставит мир на паузу (как в диалоге). getMinutes/setMinutes — часы
 * суток (слайдер и пресеты секции «Время суток»; мир на паузе, время
 * течёт только после закрытия панели).
 */
export class DevPanel {
    private viewer: ModelViewer | null = null;
    private raf = 0;
    private last = 0;
    private spinOn = false;

    constructor(
        private readonly onOpenChange: (open: boolean) => void,
        private readonly getMinutes: () => number,
        private readonly setMinutes: (m: number) => void,
    ) {
        document.getElementById('dev-open')!.addEventListener('click', () => this.closeViewer());
        const playBtn = document.getElementById('dev-play')!;
        playBtn.addEventListener('click', () => {
            this.playing = !this.playing;
            this.viewer?.play(this.playing);
            playBtn.textContent = this.playing ? '⏸' : '⏵';
        });
        document.getElementById('dev-spin')!.addEventListener('change', (e) => {
            this.spinOn = (e.target as HTMLInputElement).checked;
            this.viewer?.setSpin(this.spinOn ? 0.8 : 0);
        });
        document.getElementById('dev-skin')!.addEventListener('change', (e) => {
            const i = Number((e.target as HTMLSelectElement).value);
            this.applySkin(i);
        });
        // Время суток: слайдер тянет часы сразу, пресеты — быстрая перемотка
        const slider = document.getElementById('dev-clock-slider') as HTMLInputElement;
        slider.addEventListener('input', () => {
            this.setMinutes(Number(slider.value));
            this.refreshClock();
        });
        for (const btn of document.querySelectorAll<HTMLButtonElement>('#dev-presets button')) {
            btn.addEventListener('click', () => {
                this.setMinutes(Number(btn.dataset.min));
                this.refreshClock();
            });
        }
    }

    private playing = true;
    private currentSkins: Array<[string, MannequinPalette]> = [];
    private currentEntry: RegistryEntry | null = null;

    /** Открыта ли панель (список или вьювер). */
    get open(): boolean {
        const el = document.getElementById('dev');
        return el !== null && !el.hidden;
    }

    /** Показать список объектов (время суток — синхронизировать с часами). */
    openPanel(): void {
        document.getElementById('dev')!.hidden = false;
        const list = document.getElementById('dev-list')!;
        list.replaceChildren();
        for (const e of devEntries()) {
            const btn = document.createElement('button');
            btn.textContent = e.label;
            btn.addEventListener('click', () => this.openViewer(e.id));
            list.appendChild(btn);
        }
        this.refreshClock();
        this.onOpenChange(true);
    }

    /** Слайдер/метка из текущих часов игры (время течёт вне панели). */
    refreshClock(): void {
        const m = this.getMinutes();
        (document.getElementById('dev-clock-slider') as HTMLInputElement).value = String(m);
        const h = Math.floor(m / 60);
        const mm = m % 60;
        document.getElementById('dev-clock')!.textContent = `${h}:${mm < 10 ? '0' : ''}${mm}`;
    }

    /** Закрыть всё (панель и вьювер). */
    close(): void {
        this.closeViewer();
        document.getElementById('dev')!.hidden = true;
        this.onOpenChange(false);
    }

    /** Статы текущего объекта вьювера (мост/гейт). */
    stats(): ModelStats | null {
        return this.viewer?.stats() ?? null;
    }

    /** Открыть вьювер с объектом каталога. */
    openViewer(id: string): void {
        const entry = registryGet(id);
        if (!entry) return;
        const built = entry.build();
        this.closeViewer();
        const canvas = document.getElementById('dev-canvas') as HTMLCanvasElement;
        this.viewer = new ModelViewer(canvas, { width: 480, height: 360, viewSize: 24 });
        this.currentEntry = entry;
        this.currentSkins = built.skins ?? [];
        const select = document.getElementById('dev-skin') as HTMLSelectElement;
        select.replaceChildren();
        select.hidden = (this.currentSkins?.length ?? 0) < 2;
        (this.currentSkins ?? []).forEach(([name], i) => {
            const opt = document.createElement('option');
            opt.textContent = name;
            opt.value = String(i);
            select.appendChild(opt);
        });
        this.applySkin(0); // скины: перекрасит; без скинов — модель напрямую
        if (!built.skins) this.viewer.setModel(built.model, { rig: built.rig, clip: built.clip, tiles: built.tiles });
        (document.getElementById('dev-play') as HTMLButtonElement).textContent = '⏸';
        (document.getElementById('dev-spin') as HTMLInputElement).checked = false;
        this.spinOn = false;
        (document.getElementById('dev-stats') as HTMLDivElement).textContent = this.statText();
        document.getElementById('devview')!.hidden = false;
        this.playing = true;
        this.last = performance.now();
        const step = (now: number): void => {
            if (!this.viewer) return; // вьювер закрыт — цикл закончен
            this.viewer.render(Math.min(0.1, (now - this.last) / 1000));
            this.last = now;
            this.raf = requestAnimationFrame(step);
        };
        this.raf = requestAnimationFrame(step);
    }

    /** Закрыть вьювер (панель списка остаётся). */
    private closeViewer(): void {
        if (!this.viewer) return;
        cancelAnimationFrame(this.raf);
        this.viewer.dispose();
        this.viewer = null;
        document.getElementById('devview')!.hidden = true;
    }

    /** Перекрасить модель скином i (пересборка модели + облака). */
    private applySkin(i: number): void {
        if (!this.viewer || !this.currentEntry) return;
        const built = this.currentEntry.build();
        const skins = built.skins ?? this.currentSkins;
        if (!skins || skins.length === 0) return;
        const palette = skins[Math.min(i, skins.length - 1)][1];
        const { model, rig } = mannequin(palette);
        this.viewer.setModel(model, { rig, clip: built.clip, tiles: resolveTiles(model) });
    }

    /** Статы для подписи под вьювером. */
    private statText(): string {
        const s = this.viewer?.stats();
        return s ? `${s.voxels} вокс · ${s.size.join('×')} · костей: ${s.bones} · тайлей: ${s.tiles}` : '';
    }
}