/**
* «Пепельные луга» на 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 } 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 } 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';
// Слоты палитры сцены: арт-библия 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());
// ---- Состояние сюжета, сумка: диалоги и квест-стадии пишут прямо сюда ----
const state = new GameState();
const inv = new Inventory({ maxSlots: 8 });
const invWorld = { hasItem: (id: string) => inv.has(id) };
// ---- 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);
}
}
/** Сюжетные custom-эффекты интерактивов (вне диалогов, как в v1). */
onCustomEvent = (name: string): void => {
if (name === 'lull_machine') showToast('Машина дышит тише. Впервые за годы — тише.');
};
/** Смена области: новый мир + мешы, герой на входном тайле, 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('');
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);
/** Эффекты сюжета «custom» из графов: тост сразу, посадка — катсцена. */
const CUSTOM_LINES: Record<string, string> = {
tune_resonator: 'Резонатор Клинта поймал тон.',
};
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 showToast(CUSTOM_LINES[op.id] ?? op.id);
}
};
/** Катсцена сдачи отложена до конца диалога (v1: pendingCustom в onFinish). */
let pendingHandIn = false;
runner.onFinish = () => {
if (pendingHandIn) {
pendingHandIn = false;
startHandInCutscene();
}
};
/** Какой граф у 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('Пепел отступил у тропы. Машина дышит тише... но дышит.') },
]);
}
/** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */
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 {
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);
},
/** Снапшот области: раскладка, переходы, фейд, стек возврата, меши. */
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» агентом: диалог 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();
},
/** Снапшот состояния сюжета (флаги/вары) и сумки. */
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;