/**
* Демо-полигон v2: воксельный мир 6×6 чанков (пепельный луг) под скриншот-гейтом.
* Мир живёт в воксельных единицах, проекция — только в VoxelRenderer.
* Деревья/валуны — из процедурных генераторов моделей (штамповка с клиппингом);
* дом — пример ручного пути. Ремешинг по dirty-чанкам: правки моста
* меняют ровно затронутые чанки. Палитра — слоты арт-библии v1.
*/
import {
VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, ISO_YAW,
treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant,
mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
DialogueRunner, validateDialogue, nearestInteractable, Inventory, SaveManager,
SceneManager,
PlayerCombat, CombatWorld, moveCircleHor, PLAYER_COMBAT,
} from '@rpg/engine';
import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine';
import type { InventoryData, Scene, FadeOverlay, EnemyKindId, EnemyComp, Entity, Hor } from '@rpg/engine';
import * as THREE from 'three';
// Слоты палитры арт-библии: индексы сцены → hex
const PALETTE = {
colors: {
1: '#4a5340', // G1 — базовый луг
2: '#343b2c', // G0 — тень травы (нижний слой)
3: '#626c4c', // G2 — трава, свет (пятна)
4: '#5c3b2e', // R1 — стволы/крыша тень
5: '#7d8662', // G2H — крона, свет
6: '#5b5b66', // P4 — камень, стены
7: '#94949e', // P6 — свет камня
8: '#82503a', // R2 — светлая ржавчина (крыша)
9: '#d99a32', // B1 — цветок (жизнь!)
10: '#44454e', // M1 — металл
},
};
const CHUNKS = 6; // мир 6×6 чанков (96×96) — демо масштаба движка
const S = CHUNKS * CHUNK_SX; // 96 — сторона площадки
/** Сценка: земля, генераторные деревья/валуны, дом, цветы. */
function buildScene(world: VoxelWorld): { trees: number; boulders: number; treeVariants: number[] } {
for (let x = 0; x < S; x++)
for (let z = 0; z < S; z++) {
world.set(x, 0, z, 2);
const patch = (x * 7 + z * 13) % 11 < 3; // детерминированные пятна
world.set(x, 1, z, patch ? 3 : 1);
}
// деревья из генератора: [x, z, вариант, сид]; (16,20) — на шве x=CHUNK_SX
const treeSpots: ReadonlyArray<readonly [number, number, Variant, number]> = [
[6, 8, 0, 11], [24, 12, 1, 12], [10, 25, 2, 13], [CHUNK_SX, 20, 0, 14],
[52, 30, 1, 15], [62, 54, 2, 16], [40, 70, 0, 17], [30, 60, 1, 18],
[70, 40, 2, 19], [86, 22, 0, 20], [80, 80, 1, 21], [50, 88, 2, 22],
];
const treeVariants = [0, 0, 0];
for (const [tx, tz, v, seed] of treeSpots) {
stampModel(world, treeModel(v, seed), tx, 2, tz);
treeVariants[v]++;
}
house(world, 44, 44);
// валуны из генератора: [x, z, вариант, сид]
const boulderSpots: ReadonlyArray<readonly [number, number, Variant, number]> = [
[52, 52, 1, 31], [70, 72, 0, 32], [20, 76, 2, 33],
];
for (const [bx, bz, v, seed] of boulderSpots) {
stampModel(world, boulderModel(v, seed), bx, 2, bz);
}
for (const [fx, fz] of [[38, 50], [46, 56], [56, 48], [36, 38]] as const) {
world.set(fx, 2, fz, 9); // цветок — единственный тёплый цвет
}
// табличка (ручной путь, как дом): столбик + доска
world.set(30, 2, 70, 4);
for (let x = 29; x <= 31; x++) world.set(x, 3, 70, 8);
return { trees: treeSpots.length, boulders: boulderSpots.length, treeVariants };
}
/** Дом: стены P4 со светлыми пилястрами, ступенчатая крыша R2. */
function house(world: VoxelWorld, x: number, z: number): void {
const w = 6, d = 5, hWall = 3;
for (let i = 0; i < w; i++)
for (let j = 0; j < d; j++) {
const wall = i === 0 || j === 0 || i === w - 1 || j === d - 1;
for (let y = 2; y < 2 + hWall; y++) {
if (wall) world.set(x + i, y, z + j, (i + j) % 3 === 0 ? 7 : 6);
else world.set(x + i, y, z + j, 10); // интерьер темнее
}
}
// дверной проём с юга
for (let y = 2; y < 4; y++) world.set(x + 3, y, z, 0);
// крыша: три сужающиеся ступени (скат вверх к коньку)
for (let t = 0; t < 3; t++) {
const j0 = z + t, j1 = z + d - 1 - t;
for (let i = 0; i < w; i++)
for (let j = j0; j <= j1; j++) world.set(x + i, 2 + hWall + t, j, t === 2 ? 4 : 8);
}
}
/** Валун — из генератора boulderModel (ручной вариант удалён: урок №4). */
// ---- Манекен: тело физики + walk-анимация через Character ----
// AABB — узкий силуэт манекена; махи ног визуально выходят за AABB, это норма
// (деформация поз не участвует в коллизиях).
const { model: MAN, rig: MAN_RIG } = mannequin();
const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 });
const BODY_SIZE: [number, number, number] = [3, 10, 3];
const DT = 1 / 60; // фиксированный шаг физики (детерминизм гейта)
/** Тело гейта физики: отдельный Character со спавном в воздухе. */
let bodyChar: Character | null = null;
let bodyCloud: VoxelCloud | null = null;
const world = new VoxelWorld(24);
const buildStart = performance.now();
const sceneInfo = buildScene(world);
const canvas = document.getElementById('scene') as HTMLCanvasElement;
const renderer = new VoxelRenderer(canvas, { target: [S / 2, 2, S / 2], viewSize: 17 });
// Меш на чанк; ремешинг только dirty-чанков
const chunkMeshes = new Map<string, THREE.Mesh>();
function remeshDirty(): void {
for (const key of world.takeDirty()) {
const [cx, cz] = key.split(',').map(Number);
const old = chunkMeshes.get(key);
if (old) {
renderer.scene.remove(old);
old.geometry.dispose();
}
const mesh = meshChunk(world, cx, cz, PALETTE);
if (mesh.triangleCount > 0) chunkMeshes.set(key, renderer.addMesh(mesh));
else chunkMeshes.delete(key);
}
}
remeshDirty();
const buildMs = performance.now() - buildStart;
// ---- Герой: Character + игровой цикл + клавиатура (агент — виртуальным стиком) ----
const hero = new Character(MAN_RIG, MAN, WALK, [40, 60], {
size: BODY_SIZE,
speed: 4,
strideLength: 3.2, // 4 вокс/с × 0.8 с цикла — шаг без скольжения стоп
});
const heroCloud = renderer.addVoxelCloud(hero.visual());
// ---- NPC: страж у дома (Character на скорости 0 — стоит в позе покоя) ----
const npc = new Character(MAN_RIG, MAN, WALK, [42, 47], { speed: 0 });
renderer.addVoxelCloud(npc.visual());
// ---- Бой: CombatWorld (в юнитах v1) + сгустки-вьюхи (облака) ----
// Боевые числа v1 — в юнитах (герой ≈ 1 юнит); в демке тело героя 3 вокселя,
// поэтому 1 боевой юнит = U вокселей. Конвертация — только здесь, на границе.
const U = 3;
/** Кучка золы: цвет — серые слоты арт-библии (пепел холодный, тёплый — жизнь). */
const ASH = { dark: '#44454e', mid: '#5b5b66', light: '#94949e', deep: '#343b2c' } as const;
/** Цвет вспышки урона (кадр «получил урон»). */
const ASH_FLASH = '#b2b2bb'; // P5H — выгоревший свет
interface ClumpModel {
size: readonly [number, number, number];
voxels: readonly { x: number; y: number; z: number; hex: string }[];
}
/** Генератор «пепельного сгустка»: приплюснутый эллипсоид с рваным краем. */
function clumpModel(kind: EnemyKindId): ClumpModel {
const [sx, sy, sz] = kind === 'heavy' ? [6, 3, 6] : kind === 'spitter' ? [4, 3, 4] : [4, 2, 4];
const seed = kind === 'heavy' ? 5 : kind === 'spitter' ? 11 : 3;
const voxels: { x: number; y: number; z: number; hex: string }[] = [];
for (let x = 0; x < sx; x++)
for (let y = 0; y < sy; y++)
for (let z = 0; z < sz; z++) {
const dx = (x + 0.5) / sx - 0.5, dy = (y + 0.5) / sy - 0.5, dz = (z + 0.5) / sz - 0.5;
const bump = ((x * 31 + y * 17 + z * 7 + seed) % 7) * 0.015;
if ((dx * dx + dy * dy * 2.2 + dz * dz) * 4.4 > 1 - bump) continue;
// пасть плевуна: тёмная яма в центре верхушки
const maw = kind === 'spitter' && y === sy - 1
&& Math.abs(x + 0.5 - sx / 2) < 0.8 && Math.abs(z + 0.5 - sz / 2) < 0.8;
const hex = maw ? ASH.deep : y === sy - 1 ? ASH.light : y === 0 ? ASH.dark : ASH.mid;
voxels.push({ x, y, z, hex });
}
return { size: [sx, sy, sz], voxels };
}
/** Колонка юнита (3×3 вокселя) блокирует движение: стены, дом, деревья (цветок проходим). */
function solidU(cx: number, cz: number): boolean {
for (let vx = cx * U; vx < cx * U + U; vx++)
for (let vz = cz * U; vz < cz * U + U; vz++)
for (let y = 2; y < 8; y++) {
const s = world.get(vx, y, vz);
if (s !== 0 && s !== 9) return true;
}
return false;
}
const playerCombat = new PlayerCombat();
const heroPosU = (): Hor => ({ x: hero.body.pos[0] / U, z: hero.body.pos[2] / U });
/** Игровое время (сек): таймеры вьюх — вспышки урона. */
let gameT = 0;
const combat = new CombatWorld({
getPlayerPos: heroPosU,
/** Урон герою: неуязвимость/кламп — в PlayerCombat; отброс и отклик — здесь. */
damagePlayer: (n, from) => {
if (playerCombat.takeDamage(n) === 0) return;
const dx = hero.body.pos[0] / U - from.x, dz = hero.body.pos[2] / U - from.z;
const d = Math.hypot(dx, dz) || 1;
const p = heroPosU();
moveCircleHor(p, { x: (dx / d) * PLAYER_COMBAT.knockback, z: (dz / d) * PLAYER_COMBAT.knockback }, 0.5, solidU);
hero.body.pos[0] = p.x * U;
hero.body.pos[2] = p.z * U;
hero.body.vel = [0, 0, 0];
renderHp();
},
solid: solidU,
opaque: solidU, // высокие колонки блокируют и взгляд (звон не огибает дом)
hooks: {
onEnemyHurt: (e) => {
const v = enemyViews.get(e);
if (v) v.flashUntil = gameT + 0.12;
},
onEnemyKilled: () => toasts.push('Сгусток развеялся'),
},
});
/** Вьюха сгустка: облако + модель + таймер вспышки урона. */
interface EnemyView { cloud: VoxelCloud; model: ClumpModel; flashUntil: number }
const enemyViews = new Map<Entity, EnemyView>();
/** Воксели сгустка в мире: центр по XZ на позиции, подушка — спящий/труп. */
function clumpVoxels(m: ClumpModel, en: EnemyComp, flash: boolean, maxRow: number) {
const wx = en.pos.x * U, wz = en.pos.z * U;
return m.voxels
.filter((v) => v.y <= maxRow)
.map((v) => ({
pos: [wx + v.x - m.size[0] / 2, 2 + v.y, wz + v.z - m.size[2] / 2] as [number, number, number],
color: flash ? ASH_FLASH : v.hex,
}));
}
/** Синхронизация вьюх с ECS-миром: спящий/труп — нижний ряд, живой — во весь рост. */
function syncEnemies(): void {
for (const [e, view] of enemyViews)
if (!combat.enemies.has(e)) {
view.cloud.dispose();
enemyViews.delete(e);
}
for (const [e, en] of combat.enemies) {
let view = enemyViews.get(e);
if (!view) {
view = { cloud: renderer.addVoxelCloud([]), model: clumpModel(en.kind.id), flashUntil: 0 };
enemyViews.set(e, view);
}
const maxRow = en.brain.asleep || en.brain.dead ? 0 : view.model.size[1] - 1;
view.cloud.update(clumpVoxels(view.model, en, gameT < view.flashUntil, maxRow));
}
}
// Население луга: патрульный ползун, дремлющий плевун и тяжёлый сгусток
combat.spawnEnemy('crawler', { x: 8, z: 10 }, { patrol: { points: [{ x: 8, z: 10 }, { x: 10, z: 12 }], pauseSec: 1.2 } });
combat.spawnEnemy('spitter', { x: 19, z: 20 });
combat.spawnEnemy('heavy', { x: 22, z: 13 });
syncEnemies();
/** Отпустить атаку: конус по направлению взгляда или резонанс под ногами. */
function releaseAttack(): void {
const act = playerCombat.release();
if (!act) return;
if (act === 'attack') {
combat.playerConeAttack(heroPosU(), { x: Math.sin(hero.yaw), z: Math.cos(hero.yaw) });
} else {
combat.resonancePulse(heroPosU());
}
}
// ---- HUD сердец: ♥ заполненные, ♡ потерянные (тёплый — жизнь) ----
const hpEl = document.getElementById('hp') as HTMLDivElement;
let lastHp = -1;
function renderHp(): void {
if (playerCombat.hp === lastHp) return;
lastHp = playerCombat.hp;
hpEl.textContent = '♥'.repeat(playerCombat.hp) + '♡'.repeat(PLAYER_COMBAT.maxHp - playerCombat.hp);
}
/** Сердца видны только в игре (фейд/меню — чистый кадр, гейту важен чёрный). */
function showHp(on: boolean): void {
hpEl.hidden = !on;
if (on) {
lastHp = -1;
renderHp();
}
}
// ---- Взаимодействие и диалоги: граф — данные, рантайм — движок ----
const hintEl = document.getElementById('hint') as HTMLDivElement;
const dialogEl = document.getElementById('dialog') as HTMLDivElement;
/** Диалоговые графы демки: узлы с выборами, условиями и эффектами (JSON-путь). */
const GRAPHS: Record<string, DialogueGraph> = {
guard: {
start: 'greet',
nodes: {
greet: {
speaker: 'Страж',
text: 'Стой! Это ты рылся в золе у дома?',
choices: [
{ text: 'Да, я копал', next: 'dig', setFlags: ['admitted'] },
{ text: 'Кто ты такой?', next: 'who', setVars: { asked: 1 } },
{ text: 'Просто мимо иду', next: 'meh' },
// виден только с цветком в сумке (DialogueWorld.hasItem)
{ text: 'Отдать цветок', next: 'away', hasItem: ['flower'] },
],
},
who: {
speaker: 'Страж',
text: 'Я страж пепла. Слежу, чтобы зола не проснулась.',
next: 'ask2',
},
ask2: {
speaker: 'Страж',
text: 'Так что будешь делать?',
whenVar: { key: 'asked', op: 'eq', value: 1 },
choices: [
{ text: 'Помогу с золой', next: 'dig', setFlags: ['admitted', 'helped'] },
{ text: 'Мне пора', next: 'meh' },
],
},
dig: {
speaker: 'Страж',
text: 'Держи цветок. Пепел не любит пустых рук.',
do: [{ kind: 'giveItem', id: 'flower', count: 1 }],
setFlags: ['flowerGiven'],
next: 'bye',
},
away: {
speaker: 'Страж',
text: 'Страж берёт цветок и кивает. Пепел доволен.',
do: [{ kind: 'takeItem', id: 'flower', count: 1 }],
setFlags: ['flowerGivenAway'],
next: 'bye',
},
meh: { speaker: 'Страж', text: 'Проходи. Но я всё вижу.', setFlags: ['suspicious'], next: 'bye' },
bye: { speaker: 'Страж', text: 'Счастливо.', end: true },
},
},
sign: {
start: 'read',
nodes: {
read: {
text: '«Пепельные луга. Глубже — только зола.»',
do: [{ kind: 'toast', text: 'Табличка прочитана' }],
end: true,
},
},
},
};
// графы — данные, валидатор ловит ошибки агентом до запуска
const graphErrors = Object.entries(GRAPHS).flatMap(([id, g]) => validateDialogue(g).map((e) => `${id}: ${e}`));
/** Точки взаимодействия: у кого доступно «E» (радиус в вокселях). */
const INTERACTABLES: Interactable[] = [
{ id: 'guard', pos: [42, 2, 47], radius: 4, label: 'Поговорить', dialogueId: 'guard' },
{ id: 'sign', pos: [30, 2, 71], radius: 3, label: 'Прочитать', dialogueId: 'sign' },
];
const flags = new Set<string>();
const vars = new Map<string, number | string | boolean>();
const dlgState: DialogueState = {
hasFlag: (f) => flags.has(f),
setFlag: (f) => flags.add(f),
clearFlag: (f) => flags.delete(f),
getVar: (k) => vars.get(k),
setVar: (k, v) => vars.set(k, v),
};
/** Сумка героя; DialogueWorld предикатов диалогов смотрит в неё. */
const inv = new Inventory({ maxSlots: 8 });
const invWorld = { hasItem: (id: string) => inv.has(id) };
/** Отображаемые имена предметов (HUD). */
const ITEM_NAMES: Record<string, string> = { flower: 'Цветок пепла' };
const toasts: string[] = [];
/** View поверх DOM-панели: реплика + кнопки вариантов. */
function makeDialogueView(): DialogueView {
return {
show({ speaker, text, choices }) {
const sp = document.createElement('div');
sp.className = 'speaker';
sp.textContent = speaker ? `${speaker}:` : '';
const tx = document.createElement('div');
tx.textContent = text;
const box = document.createElement('div');
box.className = 'choices';
for (const c of choices) {
const btn = document.createElement('button');
btn.textContent = c.text;
btn.addEventListener('click', () => {
runner.pick(c.index);
updateHint(); // выбор меняет state диалога вне тика
});
box.append(btn);
}
const cont = document.createElement('div');
cont.className = 'continue';
cont.textContent = choices.length ? '' : 'E — дальше';
dialogEl.replaceChildren(sp, tx, box, cont);
dialogEl.hidden = false;
},
hide() {
dialogEl.hidden = true;
},
};
}
const runner = new DialogueRunner(dlgState, makeDialogueView(), invWorld);
runner.onEffect = (op) => {
if (op.kind === 'toast' && op.text) toasts.push(op.text);
// 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);
};
// ---- HUD сумки: перерисовка по событию контейнера (вне тика — надёжно) ----
const invEl = document.getElementById('inv') as HTMLDivElement;
function renderInv(): void {
if (inv.empty) {
invEl.hidden = true;
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.className = 'item';
el.textContent = `${ITEM_NAMES[slot.id] ?? slot.id} ×${slot.count}`;
invEl.append(el);
}
invEl.hidden = false;
}
inv.onChange(renderInv);
renderInv();
/** Ближайшая доступная точка (считается в тике, читается «E»/мостом). */
let currentNear: Interactable | null = null;
let hintText: string | null = null;
function updateHint(): void {
const hint = runner.active || !currentNear ? null : `E — ${currentNear.label}`;
if (hint === hintText) return;
hintText = hint;
hintEl.hidden = hint === null;
if (hint !== null) hintEl.textContent = hint;
}
/** «E»: открыть диалог ближайшей точки; в диалоге — «дальше». */
function interactNearest(): void {
if (runner.active) {
if (!runner.waitingForChoice) runner.advance();
updateHint();
return;
}
const id = currentNear?.dialogueId;
if (currentNear && id && GRAPHS[id]) {
runner.start(GRAPHS[id]);
updateHint(); // подсказка скрыта, пока диалог активен
}
}
// Клавиатура: экранные оси (урок №1: проекцию делает screenMoveToWorld на границе)
const heldKeys = new Set<string>();
const KEYMAP: Record<string, readonly [number, number]> = {
KeyW: [0, -1], KeyS: [0, 1], KeyA: [-1, 0], KeyD: [1, 0],
ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0],
};
window.addEventListener('keydown', (e) => {
if (KEYMAP[e.code]) {
heldKeys.add(e.code);
e.preventDefault();
}
if (e.code === 'KeyE') {
e.preventDefault();
interactNearest();
}
// Space: тап — короткий удар, удержание — заряд низкого резонанса
if (e.code === 'Space' && sm.current === gameScene && !runner.active) {
e.preventDefault();
if (!e.repeat) playerCombat.startCharge();
}
if (e.code === 'Escape') {
e.preventDefault();
gotoMenu(); // hoisted-функция (объявлена ниже, в секции сцен)
}
});
window.addEventListener('keyup', (e) => {
heldKeys.delete(e.code);
if (e.code === 'Space' && playerCombat.isCharging() && sm.current === gameScene) releaseAttack();
});
function keyboardMove(): [number, number] {
let x = 0, y = 0;
for (const code of heldKeys) {
const m = KEYMAP[code];
x += m[0];
y += m[1];
}
return [x, y];
}
/** Виртуальный стик агента (мост); null — рулит клавиатура. */
let agentInput: readonly [number, number] | null = null;
/** Слежение камеры за героем (мост отключает для фиксированного кадра). */
let followHero = true;
/** Один шаг логики: ввод → герой → бой → облака → камера (шаг пиксельной сетки). */
function tick(dt: number): void {
gameT += dt;
const move = agentInput ?? keyboardMove();
hero.setMove(screenMoveToWorld(move, ISO_YAW));
hero.step(world, dt);
heroCloud.update(hero.visual());
playerCombat.update(dt);
// шаги героя — тихий шум (0.35): бодрые сгустки в hearRadius настораживаются
if (Math.hypot(hero.body.vel[0], hero.body.vel[2]) > 0.1) combat.noise(heroPosU(), 0.35);
combat.update(dt);
syncEnemies();
if (playerCombat.dead) {
// респаун: полный hp на точке старта (труп героя не рисуем)
toasts.push('Герой пал в пепле… но луга милосердны');
playerCombat.revive();
hero.body.pos = [40, 2, 60];
hero.body.vel = [0, 0, 0];
heroCloud.update(hero.visual());
if (followHero) renderer.setTarget([40, 2, 60]);
renderHp();
}
if (followHero) {
const b = hero.body.pos;
renderer.setTarget([Math.round(b[0]), 2, Math.round(b[2])]);
}
currentNear = nearestInteractable(INTERACTABLES, hero.body.pos);
updateHint();
}
// ---- Сохранения: слоты JSON с метой; сериализация состояния демки ----
/** Сериализуемое состояние демки (формат стабильный — старые сейвы читаемы). */
interface DemoSave {
version: 1;
heroPos: [number, number]; // x, z (ноги)
heroYaw: number;
heroPhase: number;
flags: string[];
vars: Record<string, number | string | boolean>;
inv: InventoryData;
}
const saves = new SaveManager(window.localStorage);
const SAVE_VERSION = 1;
function serializeSave(): DemoSave {
return {
version: SAVE_VERSION,
heroPos: [hero.body.pos[0], hero.body.pos[2]],
heroYaw: hero.yaw,
heroPhase: hero.phase,
flags: [...flags],
vars: Object.fromEntries(vars),
inv: inv.serialize(),
};
}
/** Применить сейв: герой, флаги/переменные, сумка; HUD обновить сразу. */
function applySave(data: DemoSave): void {
if (data.version !== SAVE_VERSION) throw new Error(`Сейв версии ${data.version} не поддерживается`);
if (runner.active) runner.abort();
hero.body.pos = [data.heroPos[0], 2, data.heroPos[1]];
hero.body.vel = [0, 0, 0];
hero.yaw = data.heroYaw;
hero.phase = data.heroPhase;
heroCloud.update(hero.visual());
renderer.setTarget([Math.round(hero.body.pos[0]), 2, Math.round(hero.body.pos[2])]);
flags.clear();
for (const f of data.flags) flags.add(f);
vars.clear();
for (const [k, v] of Object.entries(data.vars)) vars.set(k, v);
inv.load(data.inv);
updateHint(); // состояние изменено вне тика — HUD-подсказку обновить сразу
}
/** Записать слот (мета с заголовком и временем); возвращает список слотов. */
function saveSlot(slot: string, title?: string) {
const meta = { title: title ?? 'Демка v2', savedAt: Date.now(), version: SAVE_VERSION };
saves.save(slot, serializeSave(), meta);
return { slot, meta, list: saves.list() };
}
/** Загрузить и применить слот (false — слота нет/битый). */
function loadSlot(slot: string): boolean {
const data = saves.load<DemoSave>(slot);
if (data) applySave(data);
return data !== null;
}
// ---- Сцены: игра + меню (DOM); стек SceneManager, фейд — DOM-слой ----
const fadeEl = document.getElementById('fade') as HTMLDivElement;
const menuEl = document.getElementById('menu') as HTMLDivElement;
const menuStartBtn = document.getElementById('menu-start') as HTMLButtonElement;
const menuContBtn = document.getElementById('menu-continue') as HTMLButtonElement;
const fadeOverlay: FadeOverlay = {
setAlpha(alpha: number): void {
fadeEl.style.opacity = String(alpha);
},
};
const menuScene: Scene = {
name: 'menu',
enter(): void {
menuContBtn.disabled = !saves.has('auto'); // «Продолжить» есть только с автосейвом
menuEl.hidden = false;
showHp(false); // сердца — только в игре
},
exit(): void {
menuEl.hidden = true;
},
update(): void {}, // DOM-меню не тикает
render(): void {}, // мир под меню рисует игровая сцена
};
const gameScene: Scene = {
name: 'game',
enter(): void {
showHp(true);
},
exit(): void {},
update(dt: number): void {
tick(dt);
},
render(): void {
renderer.render();
},
};
const sm = new SceneManager(fadeOverlay);
sm.push(gameScene); // мир живёт под меню сразу
sm.push(menuScene);
/** Esc/мост: автосохранение и меню поверх игры; в меню — возврат в игру. */
function gotoMenu(): void {
if (sm.transitioning) return;
if (sm.current === gameScene) {
saveSlot('auto', 'Автосохранение');
sm.push(menuScene, { duration: 0.4 });
} else if (sm.current === menuScene) {
sm.pop({ duration: 0.4 });
}
}
/** Действие меню: «Войти в мир» / «Продолжить» (load=true — грузит auto). */
function menuAction(load: boolean): void {
if (sm.transitioning || sm.current !== menuScene) return;
if (load && saves.has('auto')) loadSlot('auto');
sm.pop({ duration: 0.4 });
}
menuStartBtn.addEventListener('click', () => menuAction(false));
menuContBtn.addEventListener('click', () => menuAction(true));
const loop = new GameLoop(60, { update: (dt) => sm.update(dt), render: () => sm.render() });
loop.start();
// Агентный мост (DEV): солнце, статистика, правка мира атомарным ремешингом
let sunK = 0.5;
function stats(): { vertices: number; triangles: number; sun: number } {
let vertices = 0, triangles = 0;
for (const m of chunkMeshes.values()) {
vertices += m.geometry.getAttribute('position').count;
triangles += (m.geometry.getIndex()?.count ?? 0) / 3;
}
return { vertices, triangles, sun: sunK };
}
function chunkStats(): { chunks: number; meshes: number } {
return { chunks: world.chunkCount, meshes: chunkMeshes.size };
}
/** Статистика генераторных моделей в сцене. */
function modelStats(): { trees: number; boulders: number; treeVariants: number[] } {
return { ...sceneInfo };
}
/** Пробная штамповка дерева генератором (для гейта: детерминизм, варианты). */
function stampTree(v: number, x: number, z: number, seed: number): number {
const placed = stampModel(world, treeModel(v as Variant, seed), x, 2, z);
remeshDirty();
return placed;
}
/** Штамповка .vox-модели (base64): парсинг → палитра сцены → штамп + ремешинг. */
function stampVoxB64(b64: string, x: number, z: number): number {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const model = remapSlots(parseVox(bytes), PALETTE);
const placed = stampModel(world, model, x, 2, z);
remeshDirty();
return placed;
}
/** Заспавнить тело-манекен в воздухе (y=6) с заданной скоростью — гейт физики. */
function bodySpawn(x: number, z: number, vx: number, vz: number) {
bodyRemove();
bodyChar = new Character(MAN_RIG, MAN, WALK, [x, z], { size: BODY_SIZE, spawnY: 6 });
bodyChar.body.vel = [vx, 0, vz];
bodyCloud = renderer.addVoxelCloud(bodyChar.visual());
return bodyInfo();
}
/** Прогнать n шагов физики фиксированным dt; фаза walk-цикла растёт в движении. */
function bodyStep(n: number) {
if (!bodyChar || !bodyCloud) return null;
for (let i = 0; i < n; i++) bodyChar.step(world, DT);
bodyCloud.update(bodyChar.visual());
return bodyInfo();
}
/** Статистика тела для гейта (или null, если тела нет). */
function bodyInfo() {
if (!bodyChar) return null;
const b = bodyChar.body;
return {
pos: [...b.pos], vel: [...b.vel], grounded: b.grounded,
size: BODY_SIZE, walkT: bodyChar.phase,
};
}
/** Убрать тело и его облако (референсный кадр гейта «манекен рисуется»). */
function bodyRemove(): void {
bodyCloud?.dispose();
bodyCloud = null;
bodyChar = null;
}
const agent = {
setSun(k: number): void { sunK = k; renderer.setSun(k); },
setVoxel(x: number, y: number, z: number, color: number): void {
world.set(x, y, z, color);
remeshDirty();
},
stats,
chunkStats,
modelStats,
stampTree,
stampVoxB64,
bodySpawn,
bodyStep,
bodyInfo,
bodyRemove,
/** Виртуальный стик агента (экранные оси −1..1); null — снова клавиатура. */
setInput(x: number | null, z: number | null): void {
agentInput = x === null || z === null ? null : [x, z];
},
/** Снапшот героя для гейта (плюс текущая цель камеры). */
heroInfo() {
return {
pos: [...hero.body.pos], vel: [...hero.body.vel], grounded: hero.body.grounded,
phase: hero.phase, yaw: hero.yaw,
camTarget: renderer.currentTarget(),
};
},
/** Телепорт героя (проверка слежения камеры). */
heroTeleport(x: number, z: number): void {
hero.body.pos = [x, 2, z];
hero.body.vel = [0, 0, 0];
heroCloud.update(hero.visual());
renderer.setTarget([Math.round(x), 2, Math.round(z)]);
},
/** Ручные шаги логики + рендер (детерминизм гейта). */
stepTicks(n: number): number {
return loop.stepTick(n, true);
},
/** Ручной/автоматический режим цикла. */
setManual(on: boolean): void {
loop.setManual(on);
},
/** Слежение камеры за героем (off — камера в центре сцены, кадр гейта физики). */
setFollow(on: boolean): void {
followHero = on;
if (!on) renderer.setTarget([S / 2, 2, S / 2]);
},
/** Нажать «E» агентом: открыть диалог ближайшей точки / «дальше». */
interact(): void {
interactNearest();
},
/** Снапшот взаимодействия: ближайшая точка и текущая подсказка. */
interactInfo() {
return { near: currentNear?.id ?? null, label: currentNear?.label ?? null, hint: hintText };
},
/** Открыть диалог по id напрямую (тестирование графов без ходьбы). */
dialogueOpen(id: string): void {
const g = GRAPHS[id];
if (g) {
runner.start(g);
updateHint();
}
},
/** Снапшот диалога: узел, выборы, путь, флаги/переменные, эффекты. */
dialogueInfo() {
return {
active: runner.active,
nodeId: runner.nodeId,
text: runner.node?.text ?? null,
speaker: runner.node?.speaker ?? null,
choices: runner.choices,
waitingForChoice: runner.waitingForChoice,
path: runner.path,
flags: [...flags],
vars: Object.fromEntries(vars),
toasts: [...toasts],
result: runner.result,
graphErrors,
};
},
/** Выбрать вариант (index в показанном списке). */
dialoguePick(index: number): void {
runner.pick(index);
updateHint();
},
/** «Дальше» без клавиши (в выбор игнорируется). */
dialogueAdvance(): void {
if (!runner.waitingForChoice) runner.advance();
updateHint();
},
/** Снапшот сумки: слоты и лимиты. */
invInfo() {
return { all: inv.all, size: inv.size, maxSlots: inv.slotLimit, empty: inv.empty };
},
/** Сохранить состояние в слот (мета с заголовком и временем). */
saveTo(slot: string, title?: string) {
return saveSlot(slot, title);
},
/** Загрузить слот и применить (null — слота нет). */
loadFrom(slot: string) {
const loaded = loadSlot(slot);
return { loaded, heroPos: [hero.body.pos[0], hero.body.pos[2]] as const };
},
/** Список слотов с метой. */
saveList() {
return saves.list();
},
/** Удалить слот. */
saveDelete(slot: string): void {
saves.delete(slot);
},
/** Снапшот сцен: текущая, идёт ли переход, есть ли автосейв. */
sceneInfo() {
return {
current: sm.current?.name ?? null,
transitioning: sm.transitioning,
autoSaved: saves.has('auto'),
};
},
/** Esc агентом: игра → меню (с автосохранением), меню → игра. */
gotoMenu(): void {
gotoMenu();
},
/** Действие меню агентом: startGame(false) — «Войти», (true) — «Продолжить». */
startGame(load: boolean): void {
menuAction(load);
},
/** Заспавнить сгусток: kind, позиция в юнитах, опц. патруль (точки в юнитах). */
combatSpawn(
kind: string,
x: number,
z: number,
patrol?: { points: [number, number][]; pauseSec: number },
): number {
const e = combat.spawnEnemy(kind as EnemyKindId, { x, z }, patrol
? { patrol: { points: patrol.points.map(([px, pz]) => ({ x: px, z: pz })), pauseSec: patrol.pauseSec } }
: undefined);
syncEnemies();
return e;
},
/** Убрать всех сгустков и снаряды (чистый кадр для проверок). */
combatClear(): void {
for (const [e] of combat.enemies) combat.world.destroyEntity(e);
combat.enemies.clear();
for (const [e] of combat.projectiles) combat.world.destroyEntity(e);
combat.projectiles.clear();
syncEnemies();
},
/** Снапшот боя: сгустки, снаряды, герой. */
combatInfo() {
return {
enemies: [...combat.enemies].map(([e, en]) => ({
id: e, kind: en.kind.id, state: en.brain.state,
pos: { x: en.pos.x, z: en.pos.z }, hp: en.hp,
asleep: en.brain.asleep, dead: en.brain.dead,
})),
projectiles: combat.projectiles.size,
hp: playerCombat.hp,
maxHp: PLAYER_COMBAT.maxHp,
charging: playerCombat.isCharging(),
chargeTime: playerCombat.chargeTime,
};
},
/** Счётчики боя для гейта: всего/спит/трупы/снаряды. */
combatStats() {
let asleep = 0, dead = 0;
for (const [, en] of combat.enemies) {
if (en.brain.asleep) asleep++;
if (en.brain.dead) dead++;
}
return { enemies: combat.enemies.size, asleep, dead, projectiles: combat.projectiles.size };
},
/** Space агентом: true — нажать (заряд), false — отпустить (удар/резонанс). */
attackInput(down: boolean): void {
if (down) playerCombat.startCharge();
else if (playerCombat.isCharging()) releaseAttack();
},
perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } {
return { ...chunkStats(), buildMs, ...stats() };
},
};
(window as unknown as Record<string, unknown>).__agent = agent;
(window as unknown as Record<string, unknown>).__beacon = true;