Newer
Older
rpg / v2 / apps / game / src / devview.ts
/**
 * Дев-панель игры (трек 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 —
 * игра ставит мир на паузу (как в диалоге).
 */
export class DevPanel {
    private viewer: ModelViewer | null = null;
    private raf = 0;
    private last = 0;
    private spinOn = false;

    constructor(
        private readonly onOpenChange: (open: boolean) => 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);
        });
    }

    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.onOpenChange(true);
    }

    /** Закрыть всё (панель и вьювер). */
    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}` : '';
    }
}