Newer
Older
rpg / v2 / apps / game / src / main.ts
/**
 * «Пепельные луга» на v2 (трек 16 «NPC и квестовый каркас»): мир областей из
 * раскладки v1 (maps.json → buildAreaWorld), герой-манекен, переходы
 * step/click с фейдом и стек возврата, NPC с диалогами по квест-стадиям,
 * сумка, журнал квестов, агентный мост для гейтов. Позиции v1 (спавны/
 * переходы/NPC, в тайлах) конвертируются на границе: tileCenter
 * (tile*UNIT + 1.5). Референс — демка v1.
 */
import {
    VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW,
    mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
    DialogueRunner, nearestInteractable, Inventory,
} from '@rpg/engine';
import type {
    VoxelCloud, DialogueGraph, DialogueView,
    DialogueEffectOp, Interactable,
} from '@rpg/engine';
import * as THREE from 'three';
import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode } from './areas';
import { areaOf, type AreaDef, type TransitionDef, type Tile } from './data/areas';
import { GameState } from './state';
import { DIALOGUES } from './data/dialogues';
import { npcsForArea, type NpcDef } from './data/npcs';
import { questDialogueFor, questTracker, QUEST_TONE_TILES } from './data/quests';
import { itemName } from './data/items';
import { Interactables, type InteractSink } from './interactables';
import { type InteractableDef } from './data/interactables';
import { stampProp, clearProp } from './props';
import { FlowerQuest } from './flowers';
import { GameClock } from './clock';
import { LIGHTING, ATMOSPHERE, type LightingDef } from './data/lighting';
import { GameLighting, type HeroLampDef } from './lighting';
import { AshParticles } from './ash';
import { GameAudio } from './audio';
import { FaunaSystem } from './fauna';

// Слоты палитры сцены: арт-библия 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], width: 960, height: 540, viewSize: 24 });

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());

// ---- Состояние сюжета, сумка: диалоги и квест-стадии пишут прямо сюда ----
const state = new GameState();
const inv = new Inventory({ maxSlots: 8 });
const invWorld = { hasItem: (id: string) => inv.has(id) };

// ---- Время суток: 1 сек реального = 1 игровая минута (сутки 24 мин) ----
const clock = new GameClock();
/** Время цикла в секундах (тик/60) — детерминизм мерцания/пепла в гейтах. */
let elapsed = 0;

// ---- Освещение и атмосфера (трек 18): области задают тон и источники ----
const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.0, intensity: 1.0, flicker: 0.12 };
let lighting: GameLighting | null = null;
const ash = new AshParticles(renderer);
/** Глобальный вкл/выкл пепла (мост: гейт кадр-равенства). */
let ashEnabled = true;
let areaAsh = false; // играет ли пепел в текущей области
const audio = new GameAudio();

/** Позиции тайлов раскладки по id (источники `tiles`: колокольчики и пр.). */
function layerTilePos(): Map<number, { x: number; y: number }[]> {
    const l = layout;
    const tiles = rleDecode(l.tiles, l.width * l.height);
    const out = new Map<number, { x: number; y: number }[]>();
    for (let ty = 0; ty < l.height; ty++)
        for (let tx = 0; tx < l.width; tx++) {
            const t = tiles[ty * l.width + tx]!;
            let list = out.get(t);
            if (!list) out.set(t, (list = []));
            list.push({ x: tx, y: ty });
        }
    return out;
}

// ---- NPC: статичные по области + размещения по флагам (Клинт) ----
const NPC_RADIUS = 5; // радиус подхода (v1: 1.5 юнита = 4.5 вокселя, с запасом)
interface NpcEntity {
    def: NpcDef;
    /** Точка взаимодействия для движкового nearestInteractable. */
    spot: Interactable;
    char: Character;
    cloud: VoxelCloud;
}
const npcEntities: NpcEntity[] = [];

/** Поставить NPC-манекен в центр его тайла (поза покоя, без walk-цикла). */
function spawnNpc(def: NpcDef): NpcEntity {
    const c = tileCenter(def.tile.x, def.tile.y);
    const char = new Character(MAN_RIG, MAN, WALK, [c.x, c.z], {
        size: BODY_SIZE, speed: 0, strideLength: 3.2,
    });
    char.body.pos = [c.x, GROUND_Y, c.z];
    return {
        def,
        spot: {
            id: def.id,
            pos: [c.x, GROUND_Y, c.z],
            radius: NPC_RADIUS,
            label: 'Поговорить',
            dialogueId: def.id,
        },
        char,
        cloud: renderer.addVoxelCloud(char.visual()),
    };
}

function disposeNpcs(): void {
    for (const e of npcEntities) e.cloud.dispose();
    npcEntities.length = 0;
}

/** Пересобрать NPC области (вызывается из setArea и при смене флагов). */
function spawnNpcs(): void {
    disposeNpcs();
    for (const def of npcsForArea(currentArea.id, (f) => state.hasFlag(f))) {
        npcEntities.push(spawnNpc(def));
    }
}

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

// ---- Интерактивы области: реакции + воксельные пропсы ----
/** Сюжетные события custom (кат-сцены) — подключаются ниже, в сюжете. */
let onCustomEvent: ((name: string) => void) | null = null;
const interactSink: InteractSink = {
    hasItem: (id) => inv.has(id),
    give: (id, count) => inv.add(id, count),
    showToast,
    onCustom: (name) => onCustomEvent?.(name),
    // одноразовый объект поднят — проп убираем с поля
    onConsumed: (def) => clearProp(world, def),
};
let interactables: Interactables = new Interactables([], state, interactSink);

// ---- Квест «Три цветка»: сбор у прудов, посадка, зелень у башни ----
const flowers = new FlowerQuest(
    state,
    () => world,
    () => layout,
    () => currentArea.id,
    showToast,
);

function stampAreaProps(): void {
    for (const def of currentArea.interactables ?? []) {
        if (def.once && interactables.isUsed(def.id)) continue; // поднято — не возрождается
        stampProp(world, def);
    }
}

// ---- Пейзажная фауна (акт 2): безгласные олени области ----
let fauna: FaunaSystem | null = null;

/** Непроходимый тайл для фауны: колонка над землёй занята (вода/пропы). */
function tileBlocked(tx: number, ty: number): boolean {
    const vx = tx * UNIT + 1;
    const vz = ty * UNIT + 1;
    for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) {
        if (world.get(vx, y, vz) !== 0) return true;
    }
    return false;
}

/** Сюжетные custom-эффекты интерактивов (вне диалогов, как в v1). */
onCustomEvent = (name: string): void => {
    if (name === 'lull_machine') startMachineLullCutscene(); // финал акта 2
};

/** Смена области: новый мир + мешы, герой на входном тайле, NPC, свет, музыка. */
function setArea(id: string, entry: Tile): void {
    currentArea = areaOf(id);
    layout = AREA_LAYOUTS[currentArea.id]!;
    disposeMeshes();
    world = buildAreaWorld(layout);
    remeshDirty();
    interactables = new Interactables(currentArea.interactables ?? [], state, interactSink);
    flowers.restore(); // правки цветков переживают пересборку мира
    stampAreaProps();
    spawnNpcs();
    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; // подсказка старой области не должна пережить переход
    nearNpc = null;
    nearInter = null;
    nearFlower = null;
    inHazard = null;
    showHint('');
    // свет области заново (источники/тон), пепел по атмосфере, тема с кроссфейдом
    lighting?.destroy();
    lighting = new GameLighting({
        renderer,
        lighting: LIGHTING[currentArea.id] as LightingDef | undefined,
        layerTilePos: layerTilePos(),
        heroPos: () => ({ x: hero.body.pos[0], z: hero.body.pos[2] }),
        hasFlag: (f) => state.hasFlag(f),
        timeHours: () => clock.hours,
        timeSeconds: () => elapsed,
        lamp: HERO_LAMP,
    });
    areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash';
    if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется
    fauna?.dispose(); // фауна области заново (олени по спавнам v1)
    fauna = new FaunaSystem({
        spawns: currentArea.fauna ?? [],
        blocked: tileBlocked,
        addCloud: (v) => renderer.addVoxelCloud(v),
    });
    void audio.playTheme(currentArea.id);
    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;
/** Ближайший NPC в радиусе подхода (для «E — Поговорить»). */
let nearNpc: NpcEntity | null = null;
/** Ближайший интерактивный объект (для «E — подпись»). */
let nearInter: InteractableDef | null = null;
/** Ближайший собираемый цветок (тайл). */
let nearFlower: { x: number; y: number } | null = null;
/** Зона наката под ногами в прошлом тике (тост при входе — один раз). */
let inHazard: string | null = null;
/** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */
let prevTile: Tile | null = null;

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

// ---- Диалоги: выбор графа по квест-стадиям → рантайм движка → DOM-view ----
const dialogEl = document.getElementById('dialog') as HTMLDivElement;

/** 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);
                    updateHud();
                });
                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;
        },
    };
}

/** id открытого диалога (runner граф не помнит — для снапшота моста). */
let currentDialogueId: string | null = null;
const runner = new DialogueRunner(state, makeDialogueView(), invWorld);

runner.onEffect = (op: DialogueEffectOp) => {
    // движок только сообщает: исполнение — игра (give/take — сумка)
    if (op.kind === 'giveItem' && op.id) inv.add(op.id, op.count ?? 1);
    if (op.kind === 'takeItem' && op.id) inv.remove(op.id, op.count ?? 1);
    if (op.kind === 'toast' && op.text) showToast(op.text);
    if (op.kind === 'custom' && op.id) {
        // сюжетные катсцены — после реплик (поверх диалога не идут, как в v1)
        if (op.id === 'plant_flowers') pendingHandIn = true;
        else if (op.id === 'tune_resonator') pendingTune = true;
        else showToast(op.id);
    }
};

/** Катсцены отложены до конца диалога (v1: pendingCustom в onFinish). */
let pendingHandIn = false;
let pendingTune = false;
runner.onFinish = () => {
    if (pendingHandIn) {
        pendingHandIn = false;
        startHandInCutscene();
    }
    if (pendingTune) {
        pendingTune = false;
        startTuneCutscene(); // настройка резонатора (акт 2)
    }
};

/** Какой граф у NPC сейчас: квест-стадия → знакомый repeat → первый. */
function dialogueIdFor(npc: NpcDef): string {
    return questDialogueFor(state, npc.id, invWorld)
        ?? (state.hasFlag(npc.flagKey) ? npc.dialogueRepeat : npc.dialogueFirst);
}

/** Начать разговор с NPC (id или ближайший). */
function talkTo(id: string): void {
    const e = npcEntities.find((n) => n.def.id === id);
    if (!e || runner.active) return;
    const graphId = dialogueIdFor(e.def);
    const graph: DialogueGraph | undefined = DIALOGUES[graphId];
    if (!graph) return;
    currentDialogueId = graphId;
    runner.start(graph);
    updateHud();
}

/** Нажатие «E»: диалог → NPC → click-переход → интерактив → цветок (v1). */
function interact(): void {
    if (runner.active) {
        if (!runner.waitingForChoice) runner.advance();
        updateHud();
        return;
    }
    if (nearNpc) {
        talkTo(nearNpc.def.id);
        return;
    }
    if (nearClick && !fade.active) {
        startTransition(nearClick, heroTile());
        return;
    }
    if (nearInter) {
        const pick = interactables.tryInteract(nearInter);
        if (pick && !pick.ok) showToast(pick.lockedText); // заперто: сундук/машина/тон
        updateHud();
        return;
    }
    if (nearFlower) {
        flowers.collect(nearFlower.x, nearFlower.y);
        updateHud();
    }
}

// ---- HUD: журнал квестов (трекер) + сумка + тосты ----
const questEl = document.getElementById('quest') as HTMLDivElement;
const invEl = document.getElementById('inv') as HTMLDivElement;
const toastsEl = document.getElementById('toasts') as HTMLDivElement;

function updateHud(): void {
    // трекер: первая активная стадия (скрыт, когда квестов нет)
    const tr = questTracker(state);
    questEl.hidden = tr === null;
    if (tr !== null) questEl.textContent = tr;
    // сумка: «Имя ×N» по слотам
    if (inv.empty) {
        invEl.hidden = true;
        invEl.replaceChildren();
        return;
    }
    invEl.replaceChildren();
    const title = document.createElement('div');
    title.className = 'title';
    title.textContent = 'Сумка';
    invEl.append(title);
    for (const slot of inv.all) {
        const el = document.createElement('div');
        el.textContent = `${itemName(slot.id)} ×${slot.count}`;
        invEl.append(el);
    }
    invEl.hidden = false;
}
inv.onChange(updateHud);
updateHud();

/** Тост: всплывашка эффекта, уходит сама (реальное время). */
function showToast(text: string): void {
    const el = document.createElement('div');
    el.textContent = text;
    toastsEl.append(el);
    setTimeout(() => el.remove(), 2600);
}

// ---- Кат-сцены: стек шагов {dur, fn?, cam?}, мир замер, камера глайдом ----
interface CutsceneStep {
    /** Длительность шага (сек ручного времени цикла — детерминизм гейта). */
    dur: number;
    /** Разовое действие в начале шага. */
    fn?: () => void;
    /** Цель камеры (мир): глайд от точки старта шага. */
    cam?: [number, number, number];
}
interface CutsceneState {
    steps: CutsceneStep[];
    i: number;
    t: number;
    /** Откуда глайдит камера (позиция на старте шага). */
    from: [number, number, number] | null;
}
let cutscene: CutsceneState | null = null;

/** Начать кат-сцену: первый шаг стартует сразу. */
function playCutscene(steps: CutsceneStep[]): void {
    cutscene = { steps, i: 0, t: 0, from: null };
    steps[0]?.fn?.();
}

/** Тик кат-сцены: таймер шага + глайд камеры; true — ещё играет. */
function tickCutscene(dt: number): boolean {
    if (!cutscene) return false;
    const step = cutscene.steps[cutscene.i]!;
    if (step.cam) {
        if (!cutscene.from) cutscene.from = renderer.currentTarget();
        const f = cutscene.from!;
        const s = step.cam;
        const k = Math.min(1, cutscene.t / step.dur);
        renderer.setTarget([
            Math.round(f[0] + (s[0] - f[0]) * k),
            Math.round(f[1] + (s[1] - f[1]) * k),
            Math.round(f[2] + (s[2] - f[2]) * k),
        ]);
    }
    cutscene.t += dt;
    if (cutscene.t < step.dur) return true;
    cutscene.i += 1;
    cutscene.t = 0;
    cutscene.from = null;
    const next = cutscene.steps[cutscene.i];
    if (!next) {
        cutscene = null;
        return false;
    }
    next.fn?.();
    return true;
}

/** Катсцена сдачи цветов: посадка → камера к башне → зелень → финал акта 1. */
function startHandInCutscene(): void {
    const t = flowers.towerTile;
    const tower: [number, number, number] = [t.x * UNIT + 1.5, GROUND_Y, t.y * UNIT + 1.5];
    playCutscene([
        { dur: 0.8, fn: () => flowers.plantFlowers() },
        { dur: 1.2, cam: tower },
        { dur: 1.2, fn: () => flowers.raiseTowerGreen() },
        { dur: 1.0, fn: () => showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') },
    ]);
}

/** Катсцена настройки резонатора (акт 2): камера к резонатору, звон,
 *  роща отвечает (камера к прозвеневшему дереву), тост-финал квеста. */
function startTuneCutscene(): void {
    const r = tileCenter(24, 16); // резонатор Клинта (clint_resonator)
    const [tx, ty] = QUEST_TONE_TILES[1]!; // дерево-механизм у рощи
    const tree = tileCenter(tx, ty);
    playCutscene([
        { dur: 1.0, cam: [r.x, GROUND_Y, r.z] },
        { dur: 1.0, fn: () => fauna?.startleAt(r.x, r.z) }, // звон разлетается
        { dur: 1.2, cam: [tree.x, GROUND_Y, tree.z] }, // роща отвечает
        { dur: 0.8, fn: () => showToast('Резонатор держит тон. Роща слышит.') },
    ]);
}

/** Финал акта 2 — «правильный звон»: камера спускается к устью Машины,
 *  резонатор держит тон, Машина засыпает (тост — финальный шаг). */
function startMachineLullCutscene(): void {
    const m = tileCenter(13, 1); // устье печи (свет machine_mouth, проп old_machine)
    playCutscene([
        { dur: 1.2, cam: [m.x, GROUND_Y, m.z] },
        { dur: 1.0 }, // правильный звон держит
        { dur: 1.2 }, // дыхание обрывается — угольки гаснут
        { dur: 0.8, fn: () => showToast('Машина дышит тише. Впервые за годы — тише.') },
    ]);
}

/** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */
function dialogueSnapshot() {
    const node = runner.node;
    return {
        active: runner.active,
        graphId: currentDialogueId,
        nodeId: runner.nodeId,
        speaker: node?.speaker ?? null,
        text: node?.text ?? null,
        choices: runner.choices,
        waitingForChoice: runner.waitingForChoice,
        path: runner.path,
        result: runner.result,
        questTracker: questTracker(state),
        toasts: [...toastsEl.children].map((c) => c.textContent),
        ...state.toData(),
        inv: inv.all.map((s) => ({ id: s.id, count: s.count })),
    };
}

/** Начать переход (фейд 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') {
        e.preventDefault();
        interact();
    }
    // выбор варианта ответа — цифрами (позиция в показанном списке)
    if (runner.active && runner.waitingForChoice && /^Digit[1-9]$/.test(e.code)) {
        e.preventDefault();
        runner.pick(Number(e.code.slice(5)) - 1);
        updateHud();
    }
});
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) };
}

/** Радиус взаимодействия с интерактивами/цветками: 1.5 юнита v1 = 4.5 вокселя. */
const INTERACT_RANGE = 4.5;

/** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */
function tick(dt: number): void {
    // время суток и свет идут всегда (в фейде/катсцене — тоже, как в v1)
    clock.advance(dt);
    elapsed += dt;
    lighting?.update();
    if (areaAsh) ash.update(elapsed, renderer.currentTarget());
    fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер)
    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; // во время перехода мир замер
    }
    if (cutscene) {
        // катсцена: герой стоит, камера глайдит по шагам
        if (!tickCutscene(dt)) {
            const b = hero.body.pos; // конец: камера возвращается к герою
            renderer.setTarget([Math.round(b[0]), GROUND_Y, Math.round(b[2])]);
        }
        heroCloud.update(hero.visual());
        showHint('');
        return;
    }
    const talking = runner.active;
    // в диалоге герой стоит (как в v1: мир не тикается под репликой)
    const move: readonly [number, number] = talking ? [0, 0] : (agentInput ?? keyboardMove());
    const wm = screenMoveToWorld(move, ISO_YAW);
    const tile = heroTile();
    if (!talking) {
        // зона наката под ногами: без полотна еле идём, вход — тост (один раз)
        const hazard =
            (currentArea.hazards ?? []).find((h) =>
                h.tiles.some((t) => t.x === tile.x && t.y === tile.y),
            ) ?? null;
        const masked = hazard !== null && inv.has(hazard.requiresItem);
        if (hazard !== null && inHazard === null) {
            showToast(
                masked
                    ? `${hazard.name}: полотно держит — но пепел у самых губ.`
                    : `${hazard.name}! Дышать поверх — потерять голос.`,
            );
        }
        inHazard = hazard?.name ?? null;
        if (hazard !== null && !masked) {
            hero.setMove([wm[0] * 0.5, wm[1] * 0.5]); // ползём по свежему пеплу
        } else {
            hero.setMove(wm);
        }
    } else {
        hero.setMove(wm);
    }
    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])]);
    }
    // NPC рядом: ближайший в радиусе подхода (движковый чистый nearest)
    const hit = talking ? null : nearestInteractable(npcEntities.map((e) => e.spot), hero.body.pos);
    nearNpc = hit ? npcEntities.find((e) => e.spot === hit) ?? null : null;
    nearInter = null;
    nearFlower = null;
    if (!talking) {
        // интерактив рядом: ближайший в радиусе (поднятые once — мимо)
        const b = hero.body.pos;
        let best = Infinity;
        for (const def of currentArea.interactables ?? []) {
            if (def.once && interactables.isUsed(def.id)) continue;
            const c = tileCenter(def.tile.x, def.tile.y);
            const d = Math.hypot(c.x - b[0], c.z - b[2]);
            if (d <= INTERACT_RANGE && d < best) {
                best = d;
                nearInter = def;
            }
        }
        // цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области
        if (currentArea.harvestTile !== undefined) {
            best = Infinity;
            for (let dy = -2; dy <= 2; dy++) {
                for (let dx = -2; dx <= 2; dx++) {
                    const tx = tile.x + dx;
                    const ty = tile.y + dy;
                    if (!flowers.isFlower(tx, ty)) continue;
                    const c = tileCenter(tx, ty);
                    const d = Math.hypot(c.x - b[0], c.z - b[2]);
                    if (d <= INTERACT_RANGE && d < best) {
                        best = d;
                        nearFlower = { x: tx, y: ty };
                    }
                }
            }
        }
    }
    if (talking) {
        showHint('');
        return; // диалог: без переходов и подсказок двери
    }
    // step-переход: только что вошёл на тайл-триггер
    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;
    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;
    // приоритет подсказки: NPC → переход → объект → цветок (как в v1)
    showHint(
        nearNpc ? 'E — Поговорить'
            : nearClick ? `E — ${nearClick.label ?? 'перейти'}`
            : nearInter ? `E — ${nearInter.label ?? nearInter.id}`
            : nearFlower ? 'E — Сорвать лунный колокольчик'
            : '',
    );
}

// ---- Цикл: фиксированный шаг 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);
    },
    /** Время суток: снапшот, перемотка (гейт золотого часа/ночи). */
    clockInfo() {
        return {
            minutes: clock.minutes,
            hours: Math.round(clock.hours * 1000) / 1000,
            label: clock.label,
            nightFactor: lighting?.nightFactor ?? 0,
        };
    },
    setClock(minutes: number): void {
        clock.set(minutes);
        lighting?.update(); // свет пересчитать сразу, не дожидаясь тика
    },
    /** Снапшот освещения: ночь + активные источники (пост-мерцание). */
    lightingInfo(): ReturnType<GameLighting['snapshot']> {
        return lighting?.snapshot() ?? { nightFactor: 0, sources: [] };
    },
    /** Снапшот аудио: тема области + кольцевой буфер фактических запусков. */
    audioInfo() {
        return {
            theme: audio.theme,
            played: audio.log,
        };
    },
    /** Пепел вкл/выкл (гейт: кадр-равенство сборки — мир без атмосферы). */
    setAsh(on: boolean): void {
        ashEnabled = on;
        areaAsh = on && ATMOSPHERE[currentArea.id] === 'ash';
        if (!areaAsh) ash.hide();
    },
    /** Снапшот области: раскладка, переходы, фейд, стек возврата, меши. */
    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;
    },
    /** Центр тайла в мировых вокселях (тулза shot.mjs, гейты). */
    tileCenter(tx: number, ty: number): { x: number; z: number } {
        return tileCenter(tx, ty);
    },
    /** Нажать «E» агентом: диалог NPC / click-переход / «дальше» в диалоге. */
    interact(): void {
        interact();
    },
    /** Снапшот взаимодействия: ближайшая цель (приоритет v1) и подсказка. */
    interactInfo() {
        const kind =
            nearNpc ? 'npc' : nearClick ? 'transition' : nearInter ? 'interact' : nearFlower ? 'flower' : null;
        return {
            label: nearClick?.label ?? null,
            hint: hintText,
            kind,
            near: nearNpc?.def.id ?? nearInter?.id ?? (nearFlower ? 'flower' : null),
            target: nearInter
                ? { id: nearInter.id, kind: nearInter.kind, tile: nearInter.tile }
                : null,
            flower: nearFlower,
        };
    },
    /** Слот вокселя мира (гейт: пропы, цветы, вода). */
    voxAt(x: number, y: number, z: number): number {
        return world.get(Math.floor(x), Math.floor(y), Math.floor(z));
    },
    /** Тайлы области с лунными колокольчиками (гейт квеста цветов). */
    flowerTiles(): { x: number; y: number }[] {
        const out: { x: number; y: number }[] = [];
        for (let y = 0; y < layout.height; y++) {
            for (let x = 0; x < layout.width; x++) {
                if (flowers.isFlower(x, y)) out.push({ x, y });
            }
        }
        return out;
    },
    /** NPC области: кто стоит, тайлы, кто ближайший для «E». */
    npcInfo() {
        return {
            area: currentArea.id,
            npcs: npcEntities.map((e) => ({
                id: e.def.id,
                name: e.def.name,
                tile: e.def.tile,
                pos: [...e.char.body.pos],
                dialogue: dialogueIdFor(e.def),
            })),
            near: nearNpc?.def.id ?? null,
        };
    },
    /** Начать разговор (id NPC; без аргумента — ближайший). */
    talkTo(id?: string): ReturnType<typeof dialogueSnapshot> {
        const target = id ?? nearNpc?.def.id;
        if (target) talkTo(target);
        return dialogueSnapshot();
    },
    /** Снапшот диалога/квестов: узел, выборы, путь, флаги, вары, журнал. */
    dialogueInfo(): ReturnType<typeof dialogueSnapshot> {
        return dialogueSnapshot();
    },
    /** Выбрать вариант ответа (индекс в показанном списке). */
    dialoguePick(index: number): ReturnType<typeof dialogueSnapshot> {
        runner.pick(index);
        updateHud();
        return dialogueSnapshot();
    },
    /** «Дальше» в диалоге. */
    dialogueAdvance(): ReturnType<typeof dialogueSnapshot> {
        if (runner.active && !runner.waitingForChoice) runner.advance();
        updateHud();
        return dialogueSnapshot();
    },
    /** Прервать диалог досрочно (гейт/сейв). */
    dialogueAbort(): void {
        runner.abort();
        updateHud();
    },
    /** Правка состояния агентом (гейт квест-стадий): вары и флаги. */
    setState(patch: { vars?: Record<string, number | string | boolean>; flags?: string[] }): void {
        for (const [k, v] of Object.entries(patch.vars ?? {})) state.setVar(k, v);
        for (const f of patch.flags ?? []) state.setFlag(f);
        updateHud();
    },
    /** Снапшот фауны: олени области (позиция/состояние) — гейт трека 19. */
    faunaInfo() {
        return { area: currentArea.id, deer: fauna?.info() ?? [] };
    },
    /** Громкий звон в точке мира (гейт: олени подходят к источнику). */
    startleFauna(x: number, z: number): void {
        fauna?.startleAt(x, z);
    },
    /** Камера на точку мира без телепорта героя (агентный просмотр участка). */
    setCam(x: number, z: number): void {
        followHero = false;
        renderer.setTarget([Math.round(x), GROUND_Y, Math.round(z)]);
    },
    /** Снапшот катсцены: играет ли, шаг, таймер (числовой дебаг сцены). */
    cutsceneInfo() {
        return cutscene
            ? { active: true, step: cutscene.i, of: cutscene.steps.length, t: cutscene.t }
            : { active: false, step: null, of: null, t: 0 };
    },
    /** Снапшот состояния сюжета (флаги/вары) и сумки. */
    stateInfo() {
        return { ...state.toData(), inv: inv.all.map((s) => ({ id: s.id, count: s.count })) };
    },
};
(window as unknown as Record<string, unknown>).__agent = agent;
(window as unknown as Record<string, unknown>).__beacon = true;