Newer
Older
rpg / v2 / apps / demo / src / main.ts
/**
 * Демо-полигон v2: воксельный мир 6×6 чанков (пепельный луг) под скриншот-гейтом.
 * Мир живёт в воксельных единицах, проекция — только в VoxelRenderer.
 * Деревья/валуны — из процедурных генераторов моделей (штамповка с клиппингом);
 * дом — пример ручного пути. Ремешинг по dirty-чанкам: правки моста
 * меняют ровно затронутые чанки. Палитра — слоты арт-библии v1.
 */
import {
    VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, ISO_YAW,
    treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant,
    mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
    DialogueRunner, validateDialogue, nearestInteractable,
} from '@rpg/engine';
import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine';
import * as THREE from 'three';

// Слоты палитры арт-библии: индексы сцены → hex
const PALETTE = {
    colors: {
        1: '#4a5340', // G1 — базовый луг
        2: '#343b2c', // G0 — тень травы (нижний слой)
        3: '#626c4c', // G2 — трава, свет (пятна)
        4: '#5c3b2e', // R1 — стволы/крыша тень
        5: '#7d8662', // G2H — крона, свет
        6: '#5b5b66', // P4 — камень, стены
        7: '#94949e', // P6 — свет камня
        8: '#82503a', // R2 — светлая ржавчина (крыша)
        9: '#d99a32', // B1 — цветок (жизнь!)
        10: '#44454e', // M1 — металл
    },
};

const CHUNKS = 6;          // мир 6×6 чанков (96×96) — демо масштаба движка
const S = CHUNKS * CHUNK_SX; // 96 — сторона площадки

/** Сценка: земля, генераторные деревья/валуны, дом, цветы. */
function buildScene(world: VoxelWorld): { trees: number; boulders: number; treeVariants: number[] } {
    for (let x = 0; x < S; x++)
        for (let z = 0; z < S; z++) {
            world.set(x, 0, z, 2);
            const patch = (x * 7 + z * 13) % 11 < 3; // детерминированные пятна
            world.set(x, 1, z, patch ? 3 : 1);
        }
    // деревья из генератора: [x, z, вариант, сид]; (16,20) — на шве x=CHUNK_SX
    const treeSpots: ReadonlyArray<readonly [number, number, Variant, number]> = [
        [6, 8, 0, 11], [24, 12, 1, 12], [10, 25, 2, 13], [CHUNK_SX, 20, 0, 14],
        [52, 30, 1, 15], [62, 54, 2, 16], [40, 70, 0, 17], [30, 60, 1, 18],
        [70, 40, 2, 19], [86, 22, 0, 20], [80, 80, 1, 21], [50, 88, 2, 22],
    ];
    const treeVariants = [0, 0, 0];
    for (const [tx, tz, v, seed] of treeSpots) {
        stampModel(world, treeModel(v, seed), tx, 2, tz);
        treeVariants[v]++;
    }
    house(world, 44, 44);
    // валуны из генератора: [x, z, вариант, сид]
    const boulderSpots: ReadonlyArray<readonly [number, number, Variant, number]> = [
        [52, 52, 1, 31], [70, 72, 0, 32], [20, 76, 2, 33],
    ];
    for (const [bx, bz, v, seed] of boulderSpots) {
        stampModel(world, boulderModel(v, seed), bx, 2, bz);
    }
    for (const [fx, fz] of [[38, 50], [46, 56], [56, 48], [36, 38]] as const) {
        world.set(fx, 2, fz, 9); // цветок — единственный тёплый цвет
    }
    // табличка (ручной путь, как дом): столбик + доска
    world.set(30, 2, 70, 4);
    for (let x = 29; x <= 31; x++) world.set(x, 3, 70, 8);
    return { trees: treeSpots.length, boulders: boulderSpots.length, treeVariants };
}

/** Дом: стены P4 со светлыми пилястрами, ступенчатая крыша R2. */
function house(world: VoxelWorld, x: number, z: number): void {
    const w = 6, d = 5, hWall = 3;
    for (let i = 0; i < w; i++)
        for (let j = 0; j < d; j++) {
            const wall = i === 0 || j === 0 || i === w - 1 || j === d - 1;
            for (let y = 2; y < 2 + hWall; y++) {
                if (wall) world.set(x + i, y, z + j, (i + j) % 3 === 0 ? 7 : 6);
                else world.set(x + i, y, z + j, 10); // интерьер темнее
            }
        }
    // дверной проём с юга
    for (let y = 2; y < 4; y++) world.set(x + 3, y, z, 0);
    // крыша: три сужающиеся ступени (скат вверх к коньку)
    for (let t = 0; t < 3; t++) {
        const j0 = z + t, j1 = z + d - 1 - t;
        for (let i = 0; i < w; i++)
            for (let j = j0; j <= j1; j++) world.set(x + i, 2 + hWall + t, j, t === 2 ? 4 : 8);
    }
}

/** Валун — из генератора boulderModel (ручной вариант удалён: урок №4). */

// ---- Манекен: тело физики + walk-анимация через Character ----
// AABB — узкий силуэт манекена; махи ног визуально выходят за AABB, это норма
// (деформация поз не участвует в коллизиях).
const { model: MAN, rig: MAN_RIG } = mannequin();
const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 });
const BODY_SIZE: [number, number, number] = [3, 10, 3];
const DT = 1 / 60; // фиксированный шаг физики (детерминизм гейта)

/** Тело гейта физики: отдельный Character со спавном в воздухе. */
let bodyChar: Character | null = null;
let bodyCloud: VoxelCloud | null = null;

const world = new VoxelWorld(24);
const buildStart = performance.now();
const sceneInfo = buildScene(world);

const canvas = document.getElementById('scene') as HTMLCanvasElement;
const renderer = new VoxelRenderer(canvas, { target: [S / 2, 2, S / 2], viewSize: 17 });

// Меш на чанк; ремешинг только dirty-чанков
const chunkMeshes = new Map<string, THREE.Mesh>();
function remeshDirty(): void {
    for (const key of world.takeDirty()) {
        const [cx, cz] = key.split(',').map(Number);
        const old = chunkMeshes.get(key);
        if (old) {
            renderer.scene.remove(old);
            old.geometry.dispose();
        }
        const mesh = meshChunk(world, cx, cz, PALETTE);
        if (mesh.triangleCount > 0) chunkMeshes.set(key, renderer.addMesh(mesh));
        else chunkMeshes.delete(key);
    }
}
remeshDirty();
const buildMs = performance.now() - buildStart;

// ---- Герой: Character + игровой цикл + клавиатура (агент — виртуальным стиком) ----
const hero = new Character(MAN_RIG, MAN, WALK, [40, 60], {
    size: BODY_SIZE,
    speed: 4,
    strideLength: 3.2, // 4 вокс/с × 0.8 с цикла — шаг без скольжения стоп
});
const heroCloud = renderer.addVoxelCloud(hero.visual());

// ---- NPC: страж у дома (Character на скорости 0 — стоит в позе покоя) ----
const npc = new Character(MAN_RIG, MAN, WALK, [42, 47], { speed: 0 });
renderer.addVoxelCloud(npc.visual());

// ---- Взаимодействие и диалоги: граф — данные, рантайм — движок ----
const hintEl = document.getElementById('hint') as HTMLDivElement;
const dialogEl = document.getElementById('dialog') as HTMLDivElement;

/** Диалоговые графы демки: узлы с выборами, условиями и эффектами (JSON-путь). */
const GRAPHS: Record<string, DialogueGraph> = {
    guard: {
        start: 'greet',
        nodes: {
            greet: {
                speaker: 'Страж',
                text: 'Стой! Это ты рылся в золе у дома?',
                choices: [
                    { text: 'Да, я копал', next: 'dig', setFlags: ['admitted'] },
                    { text: 'Кто ты такой?', next: 'who', setVars: { asked: 1 } },
                    { text: 'Просто мимо иду', next: 'meh' },
                ],
            },
            who: {
                speaker: 'Страж',
                text: 'Я страж пепла. Слежу, чтобы зола не проснулась.',
                next: 'ask2',
            },
            ask2: {
                speaker: 'Страж',
                text: 'Так что будешь делать?',
                whenVar: { key: 'asked', op: 'eq', value: 1 },
                choices: [
                    { text: 'Помогу с золой', next: 'dig', setFlags: ['admitted', 'helped'] },
                    { text: 'Мне пора', next: 'meh' },
                ],
            },
            dig: {
                speaker: 'Страж',
                text: 'Держи цветок. Пепел не любит пустых рук.',
                do: [{ kind: 'toast', text: 'Получено: цветок пепла' }],
                setFlags: ['flowerGiven'],
                next: 'bye',
            },
            meh: { speaker: 'Страж', text: 'Проходи. Но я всё вижу.', setFlags: ['suspicious'], next: 'bye' },
            bye: { speaker: 'Страж', text: 'Счастливо.', end: true },
        },
    },
    sign: {
        start: 'read',
        nodes: {
            read: {
                text: '«Пепельные луга. Глубже — только зола.»',
                do: [{ kind: 'toast', text: 'Табличка прочитана' }],
                end: true,
            },
        },
    },
};
// графы — данные, валидатор ловит ошибки агентом до запуска
const graphErrors = Object.entries(GRAPHS).flatMap(([id, g]) => validateDialogue(g).map((e) => `${id}: ${e}`));

/** Точки взаимодействия: у кого доступно «E» (радиус в вокселях). */
const INTERACTABLES: Interactable[] = [
    { id: 'guard', pos: [42, 2, 47], radius: 4, label: 'Поговорить', dialogueId: 'guard' },
    { id: 'sign', pos: [30, 2, 71], radius: 3, label: 'Прочитать', dialogueId: 'sign' },
];

const flags = new Set<string>();
const vars = new Map<string, number | string | boolean>();
const dlgState: DialogueState = {
    hasFlag: (f) => flags.has(f),
    setFlag: (f) => flags.add(f),
    clearFlag: (f) => flags.delete(f),
    getVar: (k) => vars.get(k),
    setVar: (k, v) => vars.set(k, v),
};
const toasts: string[] = [];

/** View поверх DOM-панели: реплика + кнопки вариантов. */
function makeDialogueView(): DialogueView {
    return {
        show({ speaker, text, choices }) {
            const sp = document.createElement('div');
            sp.className = 'speaker';
            sp.textContent = speaker ? `${speaker}:` : '';
            const tx = document.createElement('div');
            tx.textContent = text;
            const box = document.createElement('div');
            box.className = 'choices';
            for (const c of choices) {
                const btn = document.createElement('button');
                btn.textContent = c.text;
                btn.addEventListener('click', () => {
                    runner.pick(c.index);
                    updateHint(); // выбор меняет state диалога вне тика
                });
                box.append(btn);
            }
            const cont = document.createElement('div');
            cont.className = 'continue';
            cont.textContent = choices.length ? '' : 'E — дальше';
            dialogEl.replaceChildren(sp, tx, box, cont);
            dialogEl.hidden = false;
        },
        hide() {
            dialogEl.hidden = true;
        },
    };
}

const runner = new DialogueRunner(dlgState, makeDialogueView());
runner.onEffect = (op) => {
    if (op.kind === 'toast' && op.text) toasts.push(op.text);
    if (op.kind === 'giveItem' && op.id) toasts.push(`Получено: ${op.id}`);
};

/** Ближайшая доступная точка (считается в тике, читается «E»/мостом). */
let currentNear: Interactable | null = null;
let hintText: string | null = null;
function updateHint(): void {
    const hint = runner.active || !currentNear ? null : `E — ${currentNear.label}`;
    if (hint === hintText) return;
    hintText = hint;
    hintEl.hidden = hint === null;
    if (hint !== null) hintEl.textContent = hint;
}

/** «E»: открыть диалог ближайшей точки; в диалоге — «дальше». */
function interactNearest(): void {
    if (runner.active) {
        if (!runner.waitingForChoice) runner.advance();
        updateHint();
        return;
    }
    const id = currentNear?.dialogueId;
    if (currentNear && id && GRAPHS[id]) {
        runner.start(GRAPHS[id]);
        updateHint(); // подсказка скрыта, пока диалог активен
    }
}

// Клавиатура: экранные оси (урок №1: проекцию делает screenMoveToWorld на границе)
const heldKeys = new Set<string>();
const KEYMAP: Record<string, readonly [number, number]> = {
    KeyW: [0, -1], KeyS: [0, 1], KeyA: [-1, 0], KeyD: [1, 0],
    ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0],
};
window.addEventListener('keydown', (e) => {
    if (KEYMAP[e.code]) {
        heldKeys.add(e.code);
        e.preventDefault();
    }
    if (e.code === 'KeyE') {
        e.preventDefault();
        interactNearest();
    }
});
window.addEventListener('keyup', (e) => heldKeys.delete(e.code));
function keyboardMove(): [number, number] {
    let x = 0, y = 0;
    for (const code of heldKeys) {
        const m = KEYMAP[code];
        x += m[0];
        y += m[1];
    }
    return [x, y];
}

/** Виртуальный стик агента (мост); null — рулит клавиатура. */
let agentInput: readonly [number, number] | null = null;
/** Слежение камеры за героем (мост отключает для фиксированного кадра). */
let followHero = true;

/** Один шаг логики: ввод → герой → облако → камера следит (шаг пиксельной сетки). */
function tick(dt: number): void {
    const move = agentInput ?? keyboardMove();
    hero.setMove(screenMoveToWorld(move, ISO_YAW));
    hero.step(world, dt);
    heroCloud.update(hero.visual());
    if (followHero) {
        const b = hero.body.pos;
        renderer.setTarget([Math.round(b[0]), 2, Math.round(b[2])]);
    }
    currentNear = nearestInteractable(INTERACTABLES, hero.body.pos);
    updateHint();
}

const loop = new GameLoop(60, { update: tick, render: () => renderer.render() });
loop.start();

// Агентный мост (DEV): солнце, статистика, правка мира атомарным ремешингом
let sunK = 0.5;
function stats(): { vertices: number; triangles: number; sun: number } {
    let vertices = 0, triangles = 0;
    for (const m of chunkMeshes.values()) {
        vertices += m.geometry.getAttribute('position').count;
        triangles += (m.geometry.getIndex()?.count ?? 0) / 3;
    }
    return { vertices, triangles, sun: sunK };
}
function chunkStats(): { chunks: number; meshes: number } {
    return { chunks: world.chunkCount, meshes: chunkMeshes.size };
}
/** Статистика генераторных моделей в сцене. */
function modelStats(): { trees: number; boulders: number; treeVariants: number[] } {
    return { ...sceneInfo };
}
/** Пробная штамповка дерева генератором (для гейта: детерминизм, варианты). */
function stampTree(v: number, x: number, z: number, seed: number): number {
    const placed = stampModel(world, treeModel(v as Variant, seed), x, 2, z);
    remeshDirty();
    return placed;
}
/** Штамповка .vox-модели (base64): парсинг → палитра сцены → штамп + ремешинг. */
function stampVoxB64(b64: string, x: number, z: number): number {
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    const model = remapSlots(parseVox(bytes), PALETTE);
    const placed = stampModel(world, model, x, 2, z);
    remeshDirty();
    return placed;
}
/** Заспавнить тело-манекен в воздухе (y=6) с заданной скоростью — гейт физики. */
function bodySpawn(x: number, z: number, vx: number, vz: number) {
    bodyRemove();
    bodyChar = new Character(MAN_RIG, MAN, WALK, [x, z], { size: BODY_SIZE, spawnY: 6 });
    bodyChar.body.vel = [vx, 0, vz];
    bodyCloud = renderer.addVoxelCloud(bodyChar.visual());
    return bodyInfo();
}
/** Прогнать n шагов физики фиксированным dt; фаза walk-цикла растёт в движении. */
function bodyStep(n: number) {
    if (!bodyChar || !bodyCloud) return null;
    for (let i = 0; i < n; i++) bodyChar.step(world, DT);
    bodyCloud.update(bodyChar.visual());
    return bodyInfo();
}
/** Статистика тела для гейта (или null, если тела нет). */
function bodyInfo() {
    if (!bodyChar) return null;
    const b = bodyChar.body;
    return {
        pos: [...b.pos], vel: [...b.vel], grounded: b.grounded,
        size: BODY_SIZE, walkT: bodyChar.phase,
    };
}
/** Убрать тело и его облако (референсный кадр гейта «манекен рисуется»). */
function bodyRemove(): void {
    bodyCloud?.dispose();
    bodyCloud = null;
    bodyChar = null;
}
const agent = {
    setSun(k: number): void { sunK = k; renderer.setSun(k); },
    setVoxel(x: number, y: number, z: number, color: number): void {
        world.set(x, y, z, color);
        remeshDirty();
    },
    stats,
    chunkStats,
    modelStats,
    stampTree,
    stampVoxB64,
    bodySpawn,
    bodyStep,
    bodyInfo,
    bodyRemove,
    /** Виртуальный стик агента (экранные оси −1..1); null — снова клавиатура. */
    setInput(x: number | null, z: number | null): void {
        agentInput = x === null || z === null ? null : [x, z];
    },
    /** Снапшот героя для гейта (плюс текущая цель камеры). */
    heroInfo() {
        return {
            pos: [...hero.body.pos], vel: [...hero.body.vel], grounded: hero.body.grounded,
            phase: hero.phase, yaw: hero.yaw,
            camTarget: renderer.currentTarget(),
        };
    },
    /** Телепорт героя (проверка слежения камеры). */
    heroTeleport(x: number, z: number): void {
        hero.body.pos = [x, 2, z];
        hero.body.vel = [0, 0, 0];
        heroCloud.update(hero.visual());
        renderer.setTarget([Math.round(x), 2, Math.round(z)]);
    },
    /** Ручные шаги логики + рендер (детерминизм гейта). */
    stepTicks(n: number): number {
        return loop.stepTick(n, true);
    },
    /** Ручной/автоматический режим цикла. */
    setManual(on: boolean): void {
        loop.setManual(on);
    },
    /** Слежение камеры за героем (off — камера в центре сцены, кадр гейта физики). */
    setFollow(on: boolean): void {
        followHero = on;
        if (!on) renderer.setTarget([S / 2, 2, S / 2]);
    },
    /** Нажать «E» агентом: открыть диалог ближайшей точки / «дальше». */
    interact(): void {
        interactNearest();
    },
    /** Снапшот взаимодействия: ближайшая точка и текущая подсказка. */
    interactInfo() {
        return { near: currentNear?.id ?? null, label: currentNear?.label ?? null, hint: hintText };
    },
    /** Открыть диалог по id напрямую (тестирование графов без ходьбы). */
    dialogueOpen(id: string): void {
        const g = GRAPHS[id];
        if (g) {
            runner.start(g);
            updateHint();
        }
    },
    /** Снапшот диалога: узел, выборы, путь, флаги/переменные, эффекты. */
    dialogueInfo() {
        return {
            active: runner.active,
            nodeId: runner.nodeId,
            text: runner.node?.text ?? null,
            speaker: runner.node?.speaker ?? null,
            choices: runner.choices,
            waitingForChoice: runner.waitingForChoice,
            path: runner.path,
            flags: [...flags],
            vars: Object.fromEntries(vars),
            toasts: [...toasts],
            result: runner.result,
            graphErrors,
        };
    },
    /** Выбрать вариант (index в показанном списке). */
    dialoguePick(index: number): void {
        runner.pick(index);
        updateHint();
    },
    /** «Дальше» без клавиши (в выбор игнорируется). */
    dialogueAdvance(): void {
        if (!runner.waitingForChoice) runner.advance();
        updateHint();
    },
    perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } {
        return { ...chunkStats(), buildMs, ...stats() };
    },
};
(window as unknown as Record<string, unknown>).__agent = agent;

(window as unknown as Record<string, unknown>).__beacon = true;