/**
* Дев-панель игры (трек 28): список объектов сцены + вьювер движка.
* Движок даёт ModelViewer (орбита мышью, клипы, статы) — панель только
* перечисляет объекты и рисует DOM. Персонажи — манекены со скинами
* (переключение палитры), природа — генераторы карты, строения целиком.
* F3 (или «) — открыть/закрыть; мир стоит, пока открыта панель/вьювер.
*/
import {
ModelViewer, mannequin, walkClip, resolveTiles, encodeModel, VoxelGrid,
} from '@rpg/engine';
import type { VoxelModel, VoxelRig, AnimClip, CloudTiles, MannequinPalette, ModelStats } from '@rpg/engine';
import { mapTreeModel, mapBushModel, mapBoulderModel, houseModel, wellModel, towerModel, machineModel } from './areas';
import { deerVoxels } from './deer';
import { NPCS, type NpcDef } from './data/npcs';
/** Объект вьювера: модель + рига/клип + слой тайлей + скины (палитры). */
interface DevEntry {
id: string;
label: string;
build(): {
model: VoxelModel;
rig?: VoxelRig;
clip?: AnimClip;
tiles?: CloudTiles;
/** Скины: [имя, палитра] — переключение перекрашивает модель. */
skins?: Array<[string, MannequinPalette]>;
};
}
/** Все знакомые персонажи + герой: манекены с walk-циклом и текстурой лица. */
function characterEntries(): DevEntry[] {
const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.4, sway: 0.25 });
const heroPalette: MannequinPalette = {
skin: '#c9a882', hair: '#6b4d36', torso: '#7a5a40', legs: '#4a4440',
arms: '#5f4a38', boots: '#3a3532', belt: '#8a6a3a',
};
const skins: Array<[string, MannequinPalette]> = [
['Путник (герой)', heroPalette],
...NPCS.map((n: NpcDef): [string, MannequinPalette] => [n.name, n.colors]),
];
return skins.map(([name, palette]) => ({
id: `char:${name}`,
label: name,
build: () => {
const { model, rig } = mannequin(palette);
return { model, rig, clip: WALK, tiles: resolveTiles(model) };
},
}));
}
/** Модель из списка вокселей [x,y,z,цвет] (палитра — по уникальным цветам). */
function gridFromVoxels(list: Array<[number, number, number, string]>): VoxelModel {
let max = [0, 0, 0] as [number, number, number];
for (const [x, y, z] of list) max = [Math.max(max[0], x), Math.max(max[1], y), Math.max(max[2], z)];
const g = new VoxelGrid(max[0] + 1, max[1] + 1, max[2] + 1);
const palette: Record<number, string> = {};
let next = 1;
for (const [x, y, z, color] of list) {
let slot = Object.entries(palette).find(([, c]) => c === color)?.[0];
if (slot === undefined) {
palette[next] = color;
slot = String(next);
next++;
}
g.set(x, y, z, Number(slot));
}
return encodeModel(g, palette);
}
/** Олень: модель из позы покоя deerVoxels (без риги — кубы на месте). */
function deerEntry(): DevEntry {
return {
id: 'deer',
label: 'Олень',
build: () => {
// мировые координаты → сетка от минимальных границ
const vox = deerVoxels(0, 0, false);
const list = vox.map((v) => [Math.round(v.pos[0]), Math.round(v.pos[1]), Math.round(v.pos[2]), v.color] as [number, number, number, string]);
const xs = list.map((v) => v[0]), ys = list.map((v) => v[1]), zs = list.map((v) => v[2]);
const shift = [1 - Math.min(...xs), 1 - Math.min(...ys), 1 - Math.min(...zs)];
const shifted = list.map(([x, y, z, c]) => [x + shift[0], y + shift[1], z + shift[2], c] as [number, number, number, string]);
return { model: gridFromVoxels(shifted) };
},
};
}
/** Природа/строения: генераторы карты по одному экземпляру. */
function worldEntries(): DevEntry[] {
const mk = (id: string, label: string, build: () => VoxelModel): DevEntry => ({
id, label,
build: () => {
const model = build();
return { model, tiles: resolveTiles(model) }; // скины строений — текстурный слой
},
});
return [
mk('tree0', 'Дерево — круглая крона', () => mapTreeModel(0, 11)),
mk('tree1', 'Дерево — колонна', () => mapTreeModel(1, 22)),
mk('tree2', 'Ель', () => mapTreeModel(2, 33)),
mk('bush0', 'Куст', () => mapBushModel(0, 44)),
mk('bush1', 'Куст высокий', () => mapBushModel(1, 55)),
mk('bush2', 'Куст малый', () => mapBushModel(2, 66)),
mk('boulder0', 'Валун — низкий', () => mapBoulderModel(0, 77)),
mk('boulder1', 'Валун — средний', () => mapBoulderModel(1, 88)),
mk('boulder2', 'Валун — крупный', () => mapBoulderModel(2, 99)),
mk('house', 'Дом — кладка', () => houseModel(0, 1)),
mk('house1', 'Дом — доски', () => houseModel(0, 4)),
mk('house2', 'Дом — фахверк, высокий', () => houseModel(26, 2)), // 26 = HOUSE_V3 (tall); сиды 1/4/2 — стили кладка/доски/фахверк
mk('well', 'Колодец', () => wellModel(0)),
mk('tower', 'Башня', () => towerModel(0)),
mk('machine', 'Старая Машина', () => machineModel(0)),
deerEntry(),
];
}
/** Каталог дев-панели (мосту тоже нужен). */
export function devEntries(): Array<{ id: string; label: string }> {
return [...characterEntries(), ...worldEntries()].map((e) => ({ id: e.id, label: e.label }));
}
/** Построить объект каталога (мосту). */
export function buildDevEntry(id: string): ReturnType<DevEntry['build']> | null {
const all = [...characterEntries(), ...worldEntries()];
return all.find((e) => e.id === 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 currentBuild: (() => ReturnType<DevEntry['build']>) | 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 built = buildDevEntry(id);
if (!built) return;
this.closeViewer();
const canvas = document.getElementById('dev-canvas') as HTMLCanvasElement;
this.viewer = new ModelViewer(canvas, { width: 480, height: 360, viewSize: 24 });
this.currentBuild = () => buildDevEntry(id)!;
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.currentBuild) return;
const built = this.currentBuild();
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}` : '';
}
}