/**
* Дев-панель игры (трек 28): список объектов сцены + вьювер движка.
* Движок даёт ModelViewer (орбита мышью, клипы, статы) — панель только
* рисует DOM. Каталог берётся из реестра объектов (registry.ts): объекты
* регистрируются там же, где создаются — devview ничего не перечисляет.
* F3 (или «) — открыть/закрыть; панель не ставит мир на паузу — время и мир
* живут, метка часов следует за потоком времени.
*/
import { ModelViewer, mannequin, resolveTiles } from '@rpg/engine';
import type { MannequinPalette, ModelStats, WeatherSpec } from '@rpg/engine';
/** Снапшот погоды панели: активный spec + счётчики слоёв + режим. */
export interface WeatherPanelInfo extends WeatherSpec {
rainDrops?: number;
snowFlakes?: number;
/** Правки панели активны (null/undefined — база области). */
manual?: Partial<WeatherSpec> | null;
}
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 —
* игра показывает подсказку/обновляет HUD. 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,
private readonly getWeather: () => WeatherPanelInfo,
private readonly setWeather: (spec: Partial<WeatherSpec> | null) => void,
private readonly onLightning: () => 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();
});
}
// Погода: слайдер тянет правку сразу (поверх базы области), сброс —
// вернуть погоду области; счётчики слоёв обновляет живая погода
const bind = (id: string, patch: (v: number) => Partial<WeatherSpec>): void => {
const el = document.getElementById(id) as HTMLInputElement;
el.addEventListener('input', () => {
this.setWeather(patch(Number(el.value)));
this.refreshWeather();
});
};
bind('dev-w-cloud', (v) => ({ cloud: v }));
bind('dev-w-rain', (v) => ({ rain: v }));
bind('dev-w-snow', (v) => ({ snow: v }));
bind('dev-w-fog', (v) => ({ fog: v }));
bind('dev-w-wind', (v) => ({ windStrength: v }));
bind('dev-w-dir', (deg) => ({ windDir: (deg * Math.PI) / 180 })); // слайдер в градусах
document.getElementById('dev-w-reset')!.addEventListener('click', () => {
this.setWeather(null);
this.refreshWeather();
});
document.getElementById('dev-w-lightning')!.addEventListener('click', () => {
this.onLightning();
});
}
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.refreshWeather();
this.onOpenChange(true);
}
/** Слайдер/метка из текущих часов игры (полная синхронизация). */
refreshClock(): void {
(document.getElementById('dev-clock-slider') as HTMLInputElement).value =
String(this.getMinutes());
this.refreshClockLabel();
}
/** Только метка времени: тик игры тянет её за часами (слайдер не трогаем —
* не мешаем тянущему его пользователю). */
refreshClockLabel(): void {
const m = this.getMinutes();
const h = Math.floor(m / 60);
const mm = m % 60;
document.getElementById('dev-clock')!.textContent = `${h}:${mm < 10 ? '0' : ''}${mm}`;
}
/** Слайдеры/счётчики из активной погоды (база области + правки панели). */
refreshWeather(): void {
const w = this.getWeather();
const set = (id: string, v: string): void => {
document.getElementById(id)!.textContent = v;
};
const input = (id: string, v: number): void => {
(document.getElementById(id) as HTMLInputElement).value = String(v);
};
input('dev-w-cloud', w.cloud);
set('dev-w-cloud-v', w.cloud.toFixed(2));
input('dev-w-rain', w.rain);
set('dev-w-rain-v', w.rain.toFixed(2));
input('dev-w-snow', w.snow);
set('dev-w-snow-v', w.snow.toFixed(2));
input('dev-w-fog', w.fog);
set('dev-w-fog-v', w.fog.toFixed(2));
input('dev-w-wind', w.windStrength);
set('dev-w-wind-v', w.windStrength.toFixed(2));
input('dev-w-dir', Math.round((w.windDir * 180) / Math.PI));
set('dev-w-dir-v', `${Math.round((w.windDir * 180) / Math.PI / 5) * 5}°`);
const counts = `дождь: ${w.rainDrops ?? 0} · снег: ${w.snowFlakes ?? 0}`;
set('dev-w-info', w.manual ? `${counts} · правка панели` : `${counts} · база области`);
}
/** Закрыть всё (панель и вьювер). */
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}` : '';
}
}