/**
* «Пепельные луга» на v2 (трек 16 «NPC и квестовый каркас»): мир областей из
* раскладки v1 (maps.json → buildAreaWorld), герой-манекен, переходы
* step/click с фейдом и стек возврата, NPC с диалогами по квест-стадиям,
* сумка, журнал квестов, агентный мост для гейтов. Позиции v1 (спавны/
* переходы/NPC, в тайлах) конвертируются на границе: tileCenter
* (tile*UNIT + 5). Референс — демка v1.
*/
import {
VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW,
mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
DialogueRunner, Inventory, CutsceneRunner, validateCutscenes,
resolveTiles, collides, worldTileAtlas,
} from '@rpg/engine';
import type {
VoxelCloud, DialogueGraph, DialogueView,
DialogueEffectOp, Interactable, WorldTileAtlas,
} from '@rpg/engine';
import * as THREE from 'three';
import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode, type AreaLayout } 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 { worldTiles as registryTiles } from './worldTex';
import { resolveCutscenes, type CutsceneAction } from './data/cutscenes';
import { FlowerQuest } from './flowers';
import { GameClock } from './clock';
import { LIGHTING, ATMOSPHERE, type Atmosphere, type LightingDef } from './data/lighting';
import { GameLighting, type HeroLampDef } from './lighting';
import { AshParticles } from './ash';
import { AmbientParticles, type AmbientKind } from './ambientParticles';
import { WaterShimmer, type WaterCell } from './waterShimmer';
import { createSaveManager, type GameSave } from './save';
import { GameAudio } from './audio';
import { FaunaSystem } from './fauna';
import { ScreenFx } from './screenFx';
import { DevPanel, devEntries } from './devview';
// Слоты палитры сцены: арт-библия 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 — рельсы
18: '#47474f', // P5 — тёмный камень (цоколь, трещины, проёмы)
19: '#6e4a30', // R3 — дверное полотно (тёплое дерево)
20: '#74553e', // R4 — доска стен / торцы брёвен
21: '#56575f', // M3 — светлый металл (заклёпки, блики)
22: '#3a4550', // W0 — тёмная вода (шахта колодца)
23: '#5e6b4a', // G3 — мох на камнях (жизнь!)
},
};
// ---- Рендер: мир области (пересоздаётся на переходе), меш на чанк ----
const canvas = document.getElementById('scene') as HTMLCanvasElement;
const renderer = new VoxelRenderer(canvas, { target: [80, 2, 120], width: 960, height: 540, viewSize: 80 });
// ---- Катсцены: данные + движковый рантайм (камера — инъекция рендера) ----
const cutsceneActions: Record<string, CutsceneAction> = {
plant_flowers: () => flowers.plantFlowers(),
raise_tower_green: () => flowers.raiseTowerGreen(),
fauna_startle: (p) => { if (p) fauna?.startleAt(p.x, p.z); }, // звон разлетается
toast: (_p, text) => showToast(text ?? ''),
};
const CUTSCENE_STEPS = resolveCutscenes(cutsceneActions);
const cutsceneErrs = validateCutscenes(CUTSCENE_STEPS);
if (cutsceneErrs.length) throw new Error(`Катсцены: ${cutsceneErrs.join('; ')}`); // битый каталог падает сразу
const cutscenes = new CutsceneRunner({
get: () => renderer.currentTarget(),
set: (p) => renderer.setTarget(p),
});
let world = new VoxelWorld(20);
const chunkMeshes = new Map<string, THREE.Mesh>();
// ---- Слой тайлей мира: общий атлас реестра worldTex ----
// Слоты тайлей моделей локальны; при штамповке opts.texRemap (areas.ts)
// переводит их в глобальные id реестра. Материалы чанков сэмплируют ОДИН
// общий атлас (перестраивается после регистраций в модуле мира), его
// texture живёт вне материалов — диспозится только здесь, при пересборке.
const worldTiles = registryTiles;
let worldAtlas: WorldTileAtlas | null = null;
/** Актуальный атлас реестра (null, пока тайлей нет — материалы плоские). */
function currentWorldAtlas(): WorldTileAtlas | null {
if (!worldAtlas) worldAtlas = worldTileAtlas(worldTiles);
return worldAtlas;
}
function remeshDirty(): void {
const atlas = currentWorldAtlas();
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();
(old.material as THREE.MeshLambertMaterial).dispose(); // атлас общий — не диспозим
}
const mesh = meshChunk(world, cx, cz, PALETTE);
if (mesh.triangleCount > 0) {
const obj = renderer.addMesh(mesh);
atlas?.applyTo(obj.material as THREE.MeshLambertMaterial);
chunkMeshes.set(key, obj);
} else chunkMeshes.delete(key);
}
}
/** Снять меши старой области (при переходе мир пересоздаётся целиком). */
function disposeMeshes(): void {
for (const m of chunkMeshes.values()) {
renderer.scene.remove(m);
m.geometry.dispose();
(m.material as THREE.MeshLambertMaterial).dispose();
}
chunkMeshes.clear();
}
// ---- Герой: манекен + walk-цикл (как в демке) ----
// Палитра героя: светлое лицо, тёмный капюшон, ржаво-красный плащ путника,
// тёмные штаны/рукава, сапоги и ремень
const { model: MAN, rig: MAN_RIG } = mannequin({
skin: '#c9a882', hair: '#6b4d36', torso: '#7a5a40', legs: '#4a4440',
arms: '#5f4a38', boots: '#3a3532', belt: '#8a6a3a',
});
const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.4, sway: 0.25 });
const BODY_SIZE: [number, number, number] = [5, 16, 5];
const hero = new Character(MAN_RIG, MAN, WALK, [80, 120], {
size: BODY_SIZE,
speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с
strideLength: 9.6, // темп v1 при ногах 5 вокс: 2.19 цикла/с (трек 24)
});
const heroCloud = renderer.addVoxelCloud(hero.visual(), resolveTiles(MAN));
// ---- Состояние сюжета, сумка: диалоги и квест-стадии пишут прямо сюда ----
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; // играет ли пепел в текущей области
/** Мотыльки лугов / дымка прудов (v1 Atmosphere): частицы области. */
let ambient: AmbientParticles | null = null;
/** Шиммер воды (блики на поверхности прудов). */
let shimmer: WaterShimmer | null = null;
const audio = new GameAudio();
/** Профиль частиц по атмосфере области: луга — мотыльки, пруды — дымка. */
function ambientOf(atm: Atmosphere): AmbientKind | null {
if (atm === 'ash') return 'moths';
if (atm === 'fog') return 'fog';
return null;
}
/** Позиции тайлов раскладки по 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;
}
/** Воксели поверхности воды области (тайл T_WATER=2 v1): центры кубов слоя. */
function waterSurfaceCells(l: AreaLayout): WaterCell[] {
const tiles = rleDecode(l.tiles, l.width * l.height);
const out: WaterCell[] = [];
for (let ty = 0; ty < l.height; ty++)
for (let tx = 0; tx < l.width; tx++) {
if (tiles[ty * l.width + tx] !== 2) continue; // T_WATER раскладки v1
for (let dz = 0; dz < UNIT; dz++)
for (let dx = 0; dx < UNIT; dx++) {
out.push({ x: tx * UNIT + dx + 0.5, z: ty * UNIT + dz + 0.5 });
}
}
return out;
}
/** Живой слой тайлов света текущей области (правки цветков двигают тайлы). */
let layerPos = new Map<number, { x: number; y: number }[]>();
/** Тайл колокольчика (T_BELLFLOWER раскладки — источник света `bells`). */
const T_LIGHT_FLOWER = 5;
/** Правка цветка → слой тайлов света: собранный гаснет, посаженный загорается. */
function trackFlowerTile(
pos: Map<number, { x: number; y: number }[]>,
tx: number,
ty: number,
present: boolean,
): void {
const from = pos.get(T_LIGHT_FLOWER);
if (from) {
const i = from.findIndex((p) => p.x === tx && p.y === ty);
if (i >= 0) {
from.splice(i, 1);
if (from.length === 0) pos.delete(T_LIGHT_FLOWER);
}
}
if (present) {
const list = pos.get(T_LIGHT_FLOWER) ?? [];
if (!list.some((p) => p.x === tx && p.y === ty)) list.push({ x: tx, y: ty });
pos.set(T_LIGHT_FLOWER, list);
}
}
// ---- NPC: статичные по области + размещения по флагам (Клинт) ----
const NPC_RADIUS = 15; // радиус подхода (v1: 1.5 юнита; расчёты расстановки NPC v1 держат этот радиус)
interface NpcEntity {
def: NpcDef;
/** Точка взаимодействия (движковый формат; выбор цели — pickInteract). */
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 { model, rig } = mannequin(def.colors); // у каждого NPC свой дизайн
const char = new Character(rig, model, WALK, [c.x, c.z], {
size: BODY_SIZE, speed: 0, strideLength: 9.6,
});
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(), resolveTiles(char.model)),
};
}
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,
(id) => inv.add(id), // собранный цветок — в сумку (v1: inventory.add)
(tx, ty, present) => trackFlowerTile(layerPos, tx, ty, present),
);
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;
/** Глобальный вкл/выкл фауны (мост: гейт кадр-равенства — олени живые). */
let faunaEnabled = true;
/** Непроходимый тайл для фауны: колонка над землёй занята (вода/пропы). */
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') cutscenes.start(CUTSCENE_STEPS.machine_lull!); // финал акта 2
};
/** Смена области: новый мир + мешы, герой на входном тайле, NPC, свет, музыка. */
/**
* Точка входа в область без застревания: дома/башня штампуются шире своего
* тайла (дом 24×18 ≈ 2.4×1.8 тайла), тайл входа может оказаться внутри
* стен — герой рождался зажатым в доме. Если позиция пересекается с миром,
* ищем ближайшую свободную спиралью с шагом 2 вокс (до 3 тайлов).
*/
function safeEntry(c: { x: number; z: number }): [number, number] {
if (!collides(world, [c.x, GROUND_Y, c.z], BODY_SIZE)) return [c.x, c.z];
for (let r = 2; r <= UNIT * 3; r += 2) {
for (let dx = -r; dx <= r; dx += 2) {
for (const dz of [r, -r]) {
if (!collides(world, [c.x + dx, GROUND_Y, c.z + dz], BODY_SIZE)) return [c.x + dx, c.z + dz];
if (!collides(world, [c.x + dz, GROUND_Y, c.z + dx], BODY_SIZE)) return [c.x + dz, c.z + dx];
}
}
}
return [c.x, c.z]; // не нашли (не должно случиться) — как было
}
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);
const [ex, ez] = safeEntry(c);
hero.body.pos = [ex, GROUND_Y, ez];
hero.body.vel = [0, 0, 0];
heroCloud.update(hero.visual());
prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера)
nearPick = null; // подсказка старой области не должна пережить переход
inHazard = null;
screenFx.reset(); // старый накат не переезжает в новую область
showHint('');
// свет области заново (источники/тон), пепел по атмосфере, тема с кроссфейдом
lighting?.destroy();
layerPos = layerTilePos();
// правки цветков области (сбор/посадка) — в слой света сразу
const flowerEdits = flowers.edits.get(currentArea.id);
if (flowerEdits) {
for (const [idx, present] of flowerEdits) {
trackFlowerTile(layerPos, idx % layout.width, Math.floor(idx / layout.width), present);
}
}
lighting = new GameLighting({
renderer,
lighting: LIGHTING[currentArea.id] as LightingDef | undefined,
layerTilePos: layerPos,
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(); // интерьер/туман — облако пепла не рисуется
// Мотыльки лугов / дымка прудов (v1 Atmosphere): облако на профиль области.
ambient?.dispose();
const amb = ashEnabled ? ambientOf(ATMOSPHERE[currentArea.id]) : null;
const waterCells = ashEnabled ? waterSurfaceCells(layout) : []; // якоря дымки/блики
ambient = amb ? new AmbientParticles(renderer, amb, waterCells) : null;
// Шиммер воды: блики на поверхности прудов (слой y=2 — вода)
shimmer?.dispose();
shimmer = ashEnabled ? new WaterShimmer(renderer, waterCells) : null;
fauna?.dispose(); // фауна области заново (олени по спавнам v1)
fauna = faunaEnabled ? new FaunaSystem({
spawns: currentArea.fauna ?? [],
blocked: tileBlocked,
addCloud: (v) => renderer.addVoxelCloud(v),
}) : null;
void audio.playTheme(currentArea.id);
// float-цель: следящая камера продолжит с той же точки без микро-скачка
renderer.setTarget([c.x, GROUND_Y, 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 = '';
/** Кандидат кнопки действия (v1 InteractCandidate): то, с чем можно «E» в радиусе. */
interface PickCandidate {
kind: 'npc' | 'transition' | 'interact' | 'flower';
/** Дистанция от героя до центра тайла цели (воксели) — решает выбор. */
dist: number;
/** Человекочитаемое имя для подписи «E — …». */
label: string;
npc?: NpcEntity;
trans?: TransitionDef;
inter?: InteractableDef;
flower?: { x: number; y: number };
}
/** Приоритет при ничьей по дистанции: NPC важнее перехода, тот — объекта, объект — цветка. */
const PICK_PRIORITY: Record<PickCandidate['kind'], number> = {
npc: 0,
transition: 1,
interact: 2,
flower: 3,
};
/** Эпсилон ничьей (воксели; v1: 0.05 юнита × UNIT). */
const PICK_EPS = 0.5;
/** Выбор цели «E» (v1 pickInteract): ближайший кандидат; при ничьей — приоритет kind. */
function pickInteract(cands: PickCandidate[]): PickCandidate | null {
let best: PickCandidate | null = null;
for (const c of cands) {
if (c.dist > INTERACT_RANGE) continue;
if (
best === null ||
c.dist < best.dist - PICK_EPS ||
(c.dist <= best.dist + PICK_EPS && PICK_PRIORITY[c.kind] < PICK_PRIORITY[best.kind])
) {
best = c;
}
}
return best;
}
/** Выбранная цель кнопки действия этого тика. */
let nearPick: PickCandidate | null = null;
/** Зона наката под ногами в прошлом тике (тост при входе — один раз). */
let inHazard: string | null = null;
/** Экранные слои атмосферы: виньетка опасности + пелена наката (v1). */
const screenFx = new ScreenFx(
document.getElementById('vignette') as HTMLElement,
document.getElementById('haze') as HTMLElement,
);
/** Тайл героя с прошлого тика (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;
cutscenes.start(CUTSCENE_STEPS.hand_in!); // сдача цветов (акт 1)
}
if (pendingTune) {
pendingTune = false;
cutscenes.start(CUTSCENE_STEPS.tune!); // настройка резонатора (акт 2)
}
};
/** Какой граф у NPC сейчас (v1): незнакомый — всегда first; знакомый — квест-стадия → repeat. */
function dialogueIdFor(npc: NpcDef): string {
if (!state.hasFlag(npc.flagKey)) return npc.dialogueFirst; // знакомство не перекрывается квестом
return questDialogueFor(state, npc.id, invWorld) ?? npc.dialogueRepeat;
}
/** Начать разговор с 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 (bagOpen) return; // «E» под сумкой не действует (мир замер)
if (runner.active) {
if (!runner.waitingForChoice) runner.advance();
updateHud();
return;
}
// катсцена/переход: цели «E» не сканируются (мир замер) — нажатия глотаем
if (cutscenes.active || fade.active) return;
const pick = nearPick;
if (!pick) return;
if (pick.kind === 'npc') {
talkTo(pick.npc!.def.id);
return;
}
if (pick.kind === 'transition') {
startTransition(pick.trans!, heroTile());
return;
}
if (pick.kind === 'interact') {
const res = interactables.tryInteract(pick.inter!);
if (res && !res.ok) showToast(res.lockedText); // заперто: сундук/машина/тон
updateHud();
return;
}
if (pick.flower) {
flowers.collect(pick.flower.x, pick.flower.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();
// ---- Панель сумки (клавиша I): предметы с названиями, мир стоит ----
const bagEl = document.getElementById('bag') as HTMLDivElement;
let bagOpen = false;
/** Показ/скрытие панели сумки (содержимое — на момент открытия). */
function renderBag(): void {
if (!bagOpen) {
bagEl.hidden = true;
return;
}
bagEl.replaceChildren();
const title = document.createElement('div');
title.className = 'title';
title.textContent = 'СУМКА';
bagEl.append(title);
if (inv.empty) {
const empty = document.createElement('div');
empty.textContent = 'Пусто.';
bagEl.append(empty);
}
for (const slot of inv.all) {
const row = document.createElement('div');
row.className = 'item';
row.textContent = `${itemName(slot.id)} — ${slot.count}`;
bagEl.append(row);
}
const foot = document.createElement('div');
foot.className = 'foot';
foot.textContent = 'I — закрыть';
bagEl.append(foot);
bagEl.hidden = false;
}
// ---- Дев-панель (F3): список объектов + вьювер движка ----
const dev = new DevPanel((open) => {
if (open) showHint('');
updateHud();
});
/** Тост: всплывашка эффекта, уходит сама (реальное время). */
function showToast(text: string): void {
const el = document.createElement('div');
el.textContent = text;
toastsEl.append(el);
setTimeout(() => el.remove(), 2600);
}
// ---- Кат-сцены: данные (data/cutscenes.ts) + движковый CutsceneRunner ----
// мир замер, герой стоит, камера глайдит по шагам; по окончании камера
// возвращается к герою (политика игры — в tick).
/** Снапшот диалога для моста: узел/выборы/путь + флаги/вары + журнал. */
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();
// пустой стек (вход мостом/сейвом): v1 фолбэчится на спавн —
// иначе герой остаётся на триггере и фейд зацикливается
setArea(back ? back.area : fromArea, back ? back.entry : currentArea.spawn);
} else {
// origin — откуда вошли (шаг на триггер = прошлый тайл, дверь = стояли)
returnStack.push({ area: fromArea, entry: origin });
setArea(tr.target.area, tr.target.entry);
}
};
showHint('');
}
// ---- Меню и сейвы: Esc — пауза с автосейвом; «Продолжить» после перезагрузки ----
const menuEl = document.getElementById('menu') as HTMLDivElement;
const menuContinue = document.getElementById('menu-continue') as HTMLButtonElement;
const menuNew = document.getElementById('menu-new') as HTMLButtonElement;
const saves = createSaveManager(window.localStorage);
let menuOpen = true; // страница стартует в меню (мир виден под панелью)
/** Снапшот текущего мира в пейлоад сейва. */
function saveSnapshot(): GameSave {
const flowersData: [string, [number, boolean][]][] = [];
for (const [area, per] of flowers.edits) flowersData.push([area, [...per]]);
return {
version: 1,
area: currentArea.id,
tile: heroTile(),
state: state.toData(),
inv: inv.serialize(),
minutes: clock.minutes,
flowers: flowersData,
};
}
/** Применить сейв: правки цветков ДО setArea (мир и слой света читают журнал). */
function applySave(s: GameSave): void {
state.fromData(s.state);
inv.load(s.inv);
clock.set(s.minutes);
flowers.edits.clear();
for (const [area, list] of s.flowers) flowers.edits.set(area, new Map(list));
setArea(s.area, s.tile);
updateHud(); // мутации вне тика обязаны обновить HUD сами (грабля)
}
/** Открыть меню: мир на паузе, автосейв текущего состояния в слот auto. */
function openMenu(): void {
menuOpen = true;
menuEl.hidden = false;
saves.save('auto', saveSnapshot(), {
title: `Автосейв — ${currentArea.id}`,
savedAt: Date.now(),
version: 1,
extras: { area: currentArea.id },
});
menuContinue.disabled = !saves.has('auto');
renderSlots();
}
// ---- Слоты сейвов: auto + 3 ручных, сохранить/загрузить/удалить ----
const slotsEl = document.getElementById('slots') as HTMLDivElement;
const MANUAL_SLOTS = ['s1', 's2', 's3'];
const SLOT_LABELS: Record<string, string> = { auto: 'Автосейв', s1: 'Слот 1', s2: 'Слот 2', s3: 'Слот 3' };
/** Строка времени сейва (локаль браузера; нет меты — пусто). */
function slotWhen(meta: { savedAt?: number; title?: string } | null): string {
if (!meta?.savedAt) return '';
const when = new Date(meta.savedAt).toLocaleString('ru-RU');
return meta.title ? `${meta.title} · ${when}` : when;
}
/** Кнопка строки слота (disabled без действия). */
function slotButton(text: string, on: () => void, enabled: boolean): HTMLButtonElement {
const btn = document.createElement('button');
btn.textContent = text;
btn.disabled = !enabled;
btn.addEventListener('click', () => { on(); });
return btn;
}
/** Перерисовать список слотов (открытие меню и после сохранения/удаления). */
function renderSlots(): void {
slotsEl.innerHTML = '<h2>СОХРАНЕНИЯ</h2>';
for (const slot of ['auto', ...MANUAL_SLOTS]) {
const row = document.createElement('div');
row.className = 'slot';
const name = document.createElement('span');
name.className = 'name';
const meta = saves.slotMeta(slot);
name.textContent = SLOT_LABELS[slot];
if (meta) {
const when = document.createElement('span');
when.className = 'when';
when.textContent = slotWhen(meta);
name.appendChild(when);
}
const save = () => {
saves.save(slot, saveSnapshot(), {
title: currentArea.id, savedAt: Date.now(), version: 1, extras: { area: currentArea.id },
});
renderSlots();
};
const load = () => {
const s = saves.load<GameSave>(slot);
if (s) { applySave(s); closeMenu(); }
};
const del = () => { saves.delete(slot); renderSlots(); };
const has = saves.has(slot);
row.appendChild(name);
if (slot !== 'auto') row.appendChild(slotButton('Сохранить', save, true));
row.appendChild(slotButton('Загрузить', load, has));
row.appendChild(slotButton('Удалить', del, has));
slotsEl.appendChild(row);
}
}
/** Закрыть меню без загрузки (Esc в меню; «Войти в мир» — мир уже стоит). */
function closeMenu(): void {
menuOpen = false;
menuEl.hidden = true;
}
/** Кнопки меню: продолжить с автосейва (load) или начать заново. */
function startGame(load: boolean): void {
if (load) {
const s = saves.load<GameSave>('auto');
if (s) applySave(s);
}
closeMenu();
}
menuContinue.addEventListener('click', () => startGame(true));
menuNew.addEventListener('click', () => startGame(false));
menuContinue.disabled = !saves.has('auto'); // сейвы прошлой сессии готовы к загрузке
// ---- Ввод: клавиатура (экранные оси) + виртуальный стик агента ----
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) => {
// Esc: меню-пауза с автосейвом; открытый диалог прерывается (сейвы без диалогов)
if (e.code === 'Escape') {
e.preventDefault();
if (bagOpen) { bagOpen = false; renderBag(); return; } // сумка закрывается первой
if (menuOpen) closeMenu();
else {
if (runner.active) runner.abort();
openMenu();
}
updateHud();
return;
}
if (KEYMAP[e.code]) {
heldKeys.add(e.code);
e.preventDefault();
}
if (e.code === 'KeyE') {
e.preventDefault();
interact();
}
// I: панель сумки (мир стоит, Esc/меню закрывают)
if (e.code === 'KeyI') {
e.preventDefault();
bagOpen = !bagOpen;
if (bagOpen && menuOpen) closeMenu();
renderBag();
}
// F3 / «: дев-панель (список объектов и вьювер моделей)
if (e.code === 'F3' || e.code === 'Backquote') {
e.preventDefault();
if (dev.open) dev.close();
else dev.openPanel();
}
// выбор варианта ответа — цифрами (позиция в показанном списке)
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 = 15 вокселей. */
const INTERACT_RANGE = 15;
/** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */
function tick(dt: number): void {
if (menuOpen) return; // меню-пауза: мир стоит (автосейв снимается при открытии)
if (dev.open) return; // дев-панель/вьювер: мир стоит (просмотр моделей)
// время суток и свет идут всегда (в фейде/катсцене — тоже, как в v1)
clock.advance(dt);
elapsed += dt;
lighting?.update();
if (areaAsh) ash.update(elapsed, renderer.currentTarget());
ambient?.update(elapsed, renderer.currentTarget()); // мотыльки/дымка области
shimmer?.update(elapsed); // блики на воде прудов
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 (cutscenes.active) {
// катсцена: герой стоит, камера глайдит по шагам
if (!cutscenes.update(dt)) {
const b = hero.body.pos; // конец: камера возвращается к герою
renderer.setTarget([b[0], GROUND_Y, b[2]]);
}
heroCloud.update(hero.visual());
showHint('');
return;
}
const talking = runner.active; // мир стоит и при открытой сумке
if (bagOpen) {
showHint('');
return;
}
// в диалоге герой стоит (как в 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;
// виньетка (накат без полотна) и пелена низины — с фейдом (v1)
screenFx.update(
dt,
hazard !== null && !masked ? 0.35 : 0,
hazard === null ? 0 : masked ? 0.12 : 0.28,
);
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([b[0], GROUND_Y, b[2]]); // без округления — камера не дёргается
}
// цели «E» (v1 InteractRouting): кандидаты в радиусе → ближайший,
// приоритет kind только при ничьей
nearPick = null;
if (!talking) {
const b = hero.body.pos;
const cands: PickCandidate[] = [];
for (const e of npcEntities) {
const c = tileCenter(e.def.tile.x, e.def.tile.y);
cands.push({ kind: 'npc', dist: Math.hypot(c.x - b[0], c.z - b[2]), label: 'Поговорить', npc: e });
}
for (const def of currentArea.interactables ?? []) {
if (def.once && interactables.isUsed(def.id)) continue; // поднято — мимо
const c = tileCenter(def.tile.x, def.tile.y);
cands.push({
kind: 'interact',
dist: Math.hypot(c.x - b[0], c.z - b[2]),
label: def.label ?? def.id,
inter: def,
});
}
// цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области
if (currentArea.harvestTile !== undefined) {
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);
cands.push({
kind: 'flower',
dist: Math.hypot(c.x - b[0], c.z - b[2]),
label: 'Сорвать лунный колокольчик',
flower: { x: tx, y: ty },
});
}
}
}
for (const t of currentArea.transitions) {
if (t.trigger !== 'click') continue; // step-переходы — входом на тайл
const c = tileCenter(t.tile.x, t.tile.y);
cands.push({ kind: 'transition', dist: Math.hypot(c.x - b[0], c.z - b[2]), label: t.label ?? 'перейти', trans: t });
}
nearPick = pickInteract(cands);
}
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;
// подсказка — выбранная цель (ближайшая, приоритет при ничьей — v1)
showHint(nearPick ? `E — ${nearPick.label}` : '');
}
// ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ----
const loadingEl = document.getElementById('loading') as HTMLDivElement;
const loop = new GameLoop(60, {
update: (dt) => tick(dt),
render: () => {
remeshDirty();
renderer.render();
if (!loadingEl.hidden) loadingEl.hidden = true; // лоад-скрин: до первого кадра
},
});
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([x, GROUND_Y, 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) {
// float-цель спавна: кадр детерминирован (спавн фиксирован) и
// совпадает с кадром после gotoArea (там тоже float-цель)
const c = tileCenter(currentArea.spawn.x, currentArea.spawn.y);
renderer.setTarget([c.x, GROUND_Y, 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: [] };
},
/** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды (гейт). */
ambientInfo() {
return {
ash: areaAsh,
ambient: ambient?.info ?? null,
shimmer: shimmer?.info ?? null,
};
},
/** Снапшот аудио: тема области + кольцевой буфер фактических запусков. */
audioInfo() {
return {
theme: audio.theme,
played: audio.log,
};
},
/** Пепел вкл/выкл (гейт: кадр-равенство сборки — мир без атмосферы). */
setAsh(on: boolean): void {
ashEnabled = on;
areaAsh = on && ATMOSPHERE[currentArea.id] === 'ash';
if (!areaAsh) ash.hide();
// частицы области гасятся тем же мостом (кадр-равенства)
ambient?.hide();
ambient = null;
shimmer?.hide();
shimmer = null;
},
/** Фауна вкл/выкл (гейт: кадр-равенство — олени бродят в реальном времени). */
setFauna(on: boolean): void {
faunaEnabled = on;
if (!on) {
fauna?.dispose();
fauna = null;
}
},
/** Снапшот области: раскладка, переходы, фейд, стек возврата, меши. */
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 p = nearPick;
return {
label: p?.kind === 'transition' ? p.trans?.label ?? null : null,
hint: hintText,
kind: p ? p.kind : null,
near:
p?.kind === 'npc' ? p.npc!.def.id
: p?.kind === 'interact' ? p.inter!.id
: p?.kind === 'flower' ? 'flower'
: null,
target:
p?.kind === 'interact'
? { id: p.inter!.id, kind: p.inter!.kind, tile: p.inter!.tile }
: null,
flower: p?.kind === 'flower' ? p.flower! : null,
};
},
/** Слот вокселя мира (гейт: пропы, цветы, вода). */
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: nearPick?.kind === 'npc' ? nearPick.npc!.def.id : null,
};
},
/** Начать разговор (id NPC; без аргумента — ближайший). */
talkTo(id?: string): ReturnType<typeof dialogueSnapshot> {
const target = id ?? (nearPick?.kind === 'npc' ? nearPick.npc!.def.id : undefined);
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([x, GROUND_Y, z]);
},
/** Снапшот катсцены: играет ли, шаг, таймер, каталог сцен. */
cutsceneInfo() {
return { ...cutscenes.info(), catalog: Object.keys(CUTSCENE_STEPS) };
},
/** Играть катсцену по id (агентный dry-run: гейт доигрывает до конца). */
playCutscene(id: string) {
cutscenes.start(CUTSCENE_STEPS[id] ?? []);
return cutscenes.info();
},
/** Снапшот состояния сюжета (флаги/вары) и сумки. */
stateInfo() {
return { ...state.toData(), inv: inv.all.map((s) => ({ id: s.id, count: s.count })) };
},
/** Панель сумки: открыта ли, содержимое с названиями (гейт I). */
bagInfo() {
return {
open: bagOpen,
items: inv.all.map((s) => ({ id: s.id, name: itemName(s.id), count: s.count })),
};
},
/** Панель сумки агентом (клавиша I). */
toggleBag(): void {
bagOpen = !bagOpen;
if (bagOpen && menuOpen) closeMenu();
renderBag();
},
/** Дев-панель: каталог объектов (гейт). */
devInfo() {
return { open: dev.open, entries: devEntries() };
},
/** Дев-панель агентом: открыть список / вьювер / закрыть. */
devOpen(): void {
if (!dev.open) dev.openPanel();
},
devOpenEntry(id: string): boolean {
if (!dev.open) dev.openPanel();
dev.openViewer(id);
return !document.getElementById('devview')!.hidden;
},
devClose(): void {
dev.close();
},
/** Статы вьювера (числовой снапшот: воксели/кости/тайли). */
devStats() {
return dev.stats();
},
/** Снапшот меню: открыто ли, есть ли автосейв, строки слотов (гейт). */
menuInfo() {
return {
open: menuOpen,
hasAuto: saves.has('auto'),
slots: ['auto', ...MANUAL_SLOTS].map((slot) => ({
slot,
has: saves.has(slot),
meta: saves.slotMeta(slot),
})),
};
},
/** Слоты: сохранить/загрузить/удалить агентом (как кнопки меню). */
saveTo(slot: string): void {
saves.save(slot, saveSnapshot(), {
title: currentArea.id, savedAt: Date.now(), version: 1, extras: { area: currentArea.id },
});
if (menuOpen) renderSlots();
},
loadFrom(slot: string): boolean {
const s = saves.load<GameSave>(slot);
if (!s) return false;
applySave(s);
if (menuOpen) closeMenu();
updateHud();
return true;
},
saveDelete(slot: string): void {
saves.delete(slot);
if (menuOpen) renderSlots();
},
/** Меню: открытие с автосейвом / закрытие / вход в игру (гейт). */
gotoMenu(): void {
if (menuOpen) return;
if (runner.active) runner.abort();
openMenu();
updateHud();
},
startGame(load: boolean): void {
startGame(load);
updateHud();
},
};
(window as unknown as Record<string, unknown>).__agent = agent;
(window as unknown as Record<string, unknown>).__beacon = true;