Newer
Older
rpg / v2 / apps / game / src / main.ts
/**
 * «Пепельные луга» на v2 (трек 15 «карты и области»): мир областей из
 * раскладки v1 (maps.json → buildAreaWorld), герой-манекен, переходы
 * step/click с фейдом и стек возврата, агентный мост для гейтов.
 * Позиции v1 (спавны/переходы, в тайлах) конвертируются на границе:
 * tileCenter (tile*UNIT + 1.5). Референс — демка v1.
 */
import {
    VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW,
    mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
} from '@rpg/engine';
import * as THREE from 'three';
import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter } from './areas';
import { areaOf, type AreaDef, type TransitionDef, type Tile } from './data/areas';

// Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17)
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 — металл
        11: '#2f2f38', // P2 — пепел
        12: '#3e3e48', // P3 — пепел, светлые пятна
        13: '#3b5e6b', // W1 — вода
        14: '#568590', // W2 — вода, блики
        15: '#5a4a3c', // T0 — пол интерьеров
        16: '#77634f', // T1 — тропа
        17: '#61626c', // M2 — рельсы
    },
};

// ---- Рендер: мир области (пересоздаётся на переходе), меш на чанк ----
const canvas = document.getElementById('scene') as HTMLCanvasElement;
const renderer = new VoxelRenderer(canvas, { target: [40, 2, 60], viewSize: 17 });

let world = new VoxelWorld(16);
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);
    }
}

/** Снять меши старой области (при переходе мир пересоздаётся целиком). */
function disposeMeshes(): void {
    for (const m of chunkMeshes.values()) {
        renderer.scene.remove(m);
        m.geometry.dispose();
    }
    chunkMeshes.clear();
}

// ---- Герой: манекен + walk-цикл (как в демке) ----
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 hero = new Character(MAN_RIG, MAN, WALK, [40, 60], {
    size: BODY_SIZE,
    speed: 4,
    strideLength: 3.2,
});
const heroCloud = renderer.addVoxelCloud(hero.visual());

// ---- Области: текущая, стек возврата, смена области ----
let currentArea: AreaDef = areaOf('meadows');
let layout = AREA_LAYOUTS.meadows!;
const returnStack: { area: string; entry: Tile }[] = [];

/** Смена области: новый мир + мешы, герой на входном тайле. */
function setArea(id: string, entry: Tile): void {
    currentArea = areaOf(id);
    layout = AREA_LAYOUTS[currentArea.id]!;
    disposeMeshes();
    world = buildAreaWorld(layout);
    remeshDirty();
    const c = tileCenter(entry.x, entry.y);
    hero.body.pos = [c.x, GROUND_Y, c.z];
    hero.body.vel = [0, 0, 0];
    heroCloud.update(hero.visual());
    prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера)
    nearClick = null; // подсказка старой области не должна пережить переход
    showHint('');
    renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]);
}

// ---- Фейд и переходы: out → смена области → in (полный = 2×duration) ----
const fadeEl = document.getElementById('fade') as HTMLDivElement;
const hintEl = document.getElementById('hint') as HTMLDivElement;

interface FadeState {
    active: boolean;
    t: number;
    dur: number;
    phase: 'out' | 'in';
    /** Смена области — в середине перехода (за чёрным кадром). */
    apply: (() => void) | null;
}
const fade: FadeState = { active: false, t: 0, dur: 0.4, phase: 'out', apply: null };

let hintText = '';
/** Ближайший click-переход (для «E» и подсказки). */
let nearClick: TransitionDef | null = null;
/** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */
let prevTile: Tile | null = null;

function showHint(text: string): void {
    hintText = text;
    hintEl.hidden = text === '';
    hintEl.textContent = text;
}

/** Начать переход (фейд out, середина — смена области). */
function startTransition(tr: TransitionDef, origin: Tile): void {
    if (fade.active) return; // параллельные переходы глотаются (как в v1)
    fade.active = true;
    fade.t = 0;
    fade.dur = tr.duration ?? 0.4;
    fade.phase = 'out';
    const fromArea = currentArea.id;
    fade.apply = () => {
        if (tr.target === 'return') {
            const back = returnStack.pop();
            if (back) setArea(back.area, back.entry);
        } else {
            // origin — откуда вошли (шаг на триггер = прошлый тайл, дверь = стояли)
            returnStack.push({ area: fromArea, entry: origin });
            setArea(tr.target.area, tr.target.entry);
        }
    };
    showHint('');
}

// ---- Ввод: клавиатура (экранные оси) + виртуальный стик агента ----
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],
};
let agentInput: readonly [number, number] | null = null;
let followHero = true;

window.addEventListener('keydown', (e) => {
    if (KEYMAP[e.code]) {
        heldKeys.add(e.code);
        e.preventDefault();
    }
    if (e.code === 'KeyE' && nearClick) {
        e.preventDefault();
        startTransition(nearClick, heroTile());
    }
});
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];
}

/** Тайл героя (в тайлах v1) — триггеры и снапшоты моста. */
function heroTile(): Tile {
    const b = hero.body.pos;
    return { x: Math.floor(b[0] / UNIT), y: Math.floor(b[2] / UNIT) };
}

/** Один шаг логики: фейд → ввод → герой → камера → переходы → подсказка. */
function tick(dt: number): void {
    if (fade.active) {
        fade.t += dt;
        if (fade.phase === 'out') {
            fadeEl.style.opacity = String(Math.min(1, fade.t / fade.dur));
            if (fade.t >= fade.dur) {
                fade.apply?.();
                fade.phase = 'in';
                fade.t = 0;
            }
        } else {
            fadeEl.style.opacity = String(Math.max(0, 1 - fade.t / fade.dur));
            if (fade.t >= fade.dur) {
                fade.active = false;
                fadeEl.style.opacity = '0';
            }
        }
        return; // во время перехода мир замер
    }
    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]), GROUND_Y, Math.round(b[2])]);
    }
    // step-переход: только что вошёл на тайл-триггер
    const tile = heroTile();
    const justEntered = prevTile !== null && (tile.x !== prevTile.x || tile.y !== prevTile.y);
    if (justEntered) {
        const tr = currentArea.transitions.find(
            (t) => (t.trigger ?? 'step') === 'step' && t.tile.x === tile.x && t.tile.y === tile.y,
        );
        if (tr) {
            startTransition(tr, prevTile!);
            return;
        }
    }
    prevTile = tile;
    // click-переход рядом: подсказка «E — …»
    nearClick =
        currentArea.transitions.find(
            (t) =>
                t.trigger === 'click' &&
                Math.max(Math.abs(t.tile.x - tile.x), Math.abs(t.tile.y - tile.y)) <= 1,
        ) ?? null;
    showHint(nearClick ? `E — ${nearClick.label ?? 'перейти'}` : '');
}

// ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ----
const loop = new GameLoop(60, {
    update: (dt) => tick(dt),
    render: () => {
        remeshDirty();
        renderer.render();
    },
});
setArea(currentArea.id, currentArea.spawn); // старт — спавн лугов
loop.start();

// ---- Агентный мост (DEV): гейты областей ----
const agent = {
    /** Ручной/автоматический режим цикла. */
    setManual(on: boolean): void {
        loop.setManual(on);
    },
    /** Ручные шаги логики + рендер (детерминизм гейта). */
    stepTicks(n: number): number {
        return loop.stepTick(n, true);
    },
    /** Снапшот героя для гейта. */
    heroInfo() {
        const b = hero.body.pos;
        return {
            area: currentArea.id,
            pos: [...b], vel: [...hero.body.vel], grounded: hero.body.grounded,
            phase: hero.phase, yaw: hero.yaw,
            tile: heroTile(),
            camTarget: renderer.currentTarget(),
        };
    },
    /** Телепорт героя (гейт: слежение камеры, непроходимость). */
    heroTeleport(x: number, z: number): void {
        hero.body.pos = [x, GROUND_Y, z];
        hero.body.vel = [0, 0, 0];
        heroCloud.update(hero.visual());
        prevTile = heroTile(); // телепорт — не «вход на тайл»
        renderer.setTarget([Math.round(x), GROUND_Y, Math.round(z)]);
    },
    /** Виртуальный стик агента (экранные оси −1..1); null — клавиатура. */
    setInput(x: number | null, z: number | null): void {
        agentInput = x === null || z === null ? null : [x, z];
    },
    /** Слежение камеры за героем (off — кадр гейта фиксируется). */
    setFollow(on: boolean): void {
        followHero = on;
        if (!on) {
            const c = tileCenter(currentArea.spawn.x, currentArea.spawn.y);
            renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]);
        }
    },
    /** Яркость солнца (утро/день/вечер — как в демке). */
    setSun(k: number): void {
        renderer.setSun(k);
    },
    /** Снапшот области: раскладка, переходы, фейд, стек возврата, меши. */
    areaInfo() {
        return {
            id: currentArea.id,
            name: currentArea.name,
            width: layout.width,
            height: layout.height,
            spawn: currentArea.spawn,
            heroTile: heroTile(),
            transitions: currentArea.transitions.map((t) => ({
                tile: t.tile,
                label: t.label ?? null,
                trigger: t.trigger ?? 'step',
                target: t.target === 'return' ? 'return' : t.target.area,
                duration: t.duration ?? 0.4,
            })),
            fade: { active: fade.active, phase: fade.phase },
            returns: returnStack.length,
            chunks: world.chunkCount,
            meshes: chunkMeshes.size,
        };
    },
    /** Прямой переход области (гейт: скриншоты и переходы без ходьбы). */
    gotoArea(id: string, entry?: Tile): void {
        returnStack.length = 0;
        fade.active = false;
        fadeEl.style.opacity = '0';
        const def = areaOf(id);
        setArea(id, entry ?? def.spawn);
    },
    /** Сплошная ли колонка НАД землёй (вода/объект) — гейт blocked. */
    solidAt(x: number, z: number): boolean {
        const fx = Math.floor(x), fz = Math.floor(z);
        for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) {
            if (world.get(fx, y, fz) !== 0) return true;
        }
        return false;
    },
    /** Нажать «E» агентом: активировать click-переход. */
    interact(): void {
        if (nearClick && !fade.active) startTransition(nearClick, heroTile());
    },
    /** Снапшот взаимодействия: ближайшая дверь и подсказка. */
    interactInfo() {
        return { label: nearClick?.label ?? null, hint: hintText };
    },
};
(window as unknown as Record<string, unknown>).__agent = agent;
(window as unknown as Record<string, unknown>).__beacon = true;