/**
* Дев-панель игры (трек 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}` : '';
}
}