/**
* «Пепельные луга» на v2 (трек 15 «карты и области»): мир областей из
* раскладки v1 (maps.json → buildAreaWorld), герой-манекен, переходы
* step/click с фейдом и стек возврата, агентный мост для гейтов.
* Позиции v1 (спавны/переходы, в тайлах) конвертируются на границе:
* tileCenter (tile*UNIT + 1.5). Референс — демка v1.
*/
import {
VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW,
mannequin, walkClip, Character, GameLoop, screenMoveToWorld,
} from '@rpg/engine';
import * as THREE from 'three';
import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter } from './areas';
import { areaOf, type AreaDef, type TransitionDef, type Tile } from './data/areas';
// Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17)
const PALETTE = {
colors: {
1: '#4a5340', // G1 — базовый луг
2: '#343b2c', // G0 — тень травы (нижний слой / проплешина)
3: '#626c4c', // G2 — трава, свет (пятна)
4: '#5c3b2e', // R1 — стволы/крыша тень
5: '#7d8662', // G2H — крона, свет
6: '#5b5b66', // P4 — камень, стены
7: '#94949e', // P6 — свет камня
8: '#82503a', // R2 — светлая ржавчина (крыша)
9: '#d99a32', // B1 — цветок (жизнь!)
10: '#44454e', // M1 — металл
11: '#2f2f38', // P2 — пепел
12: '#3e3e48', // P3 — пепел, светлые пятна
13: '#3b5e6b', // W1 — вода
14: '#568590', // W2 — вода, блики
15: '#5a4a3c', // T0 — пол интерьеров
16: '#77634f', // T1 — тропа
17: '#61626c', // M2 — рельсы
},
};
// ---- Рендер: мир области (пересоздаётся на переходе), меш на чанк ----
const canvas = document.getElementById('scene') as HTMLCanvasElement;
const renderer = new VoxelRenderer(canvas, { target: [40, 2, 60], viewSize: 17 });
let world = new VoxelWorld(16);
const chunkMeshes = new Map<string, THREE.Mesh>();
function remeshDirty(): void {
for (const key of world.takeDirty()) {
const [cx, cz] = key.split(',').map(Number);
const old = chunkMeshes.get(key);
if (old) {
renderer.scene.remove(old);
old.geometry.dispose();
}
const mesh = meshChunk(world, cx, cz, PALETTE);
if (mesh.triangleCount > 0) chunkMeshes.set(key, renderer.addMesh(mesh));
else chunkMeshes.delete(key);
}
}
/** Снять меши старой области (при переходе мир пересоздаётся целиком). */
function disposeMeshes(): void {
for (const m of chunkMeshes.values()) {
renderer.scene.remove(m);
m.geometry.dispose();
}
chunkMeshes.clear();
}
// ---- Герой: манекен + walk-цикл (как в демке) ----
const { model: MAN, rig: MAN_RIG } = mannequin();
const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 });
const BODY_SIZE: [number, number, number] = [3, 10, 3];
const hero = new Character(MAN_RIG, MAN, WALK, [40, 60], {
size: BODY_SIZE,
speed: 4,
strideLength: 3.2,
});
const heroCloud = renderer.addVoxelCloud(hero.visual());
// ---- Области: текущая, стек возврата, смена области ----
let currentArea: AreaDef = areaOf('meadows');
let layout = AREA_LAYOUTS.meadows!;
const returnStack: { area: string; entry: Tile }[] = [];
/** Смена области: новый мир + мешы, герой на входном тайле. */
function setArea(id: string, entry: Tile): void {
currentArea = areaOf(id);
layout = AREA_LAYOUTS[currentArea.id]!;
disposeMeshes();
world = buildAreaWorld(layout);
remeshDirty();
const c = tileCenter(entry.x, entry.y);
hero.body.pos = [c.x, GROUND_Y, c.z];
hero.body.vel = [0, 0, 0];
heroCloud.update(hero.visual());
prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера)
nearClick = null; // подсказка старой области не должна пережить переход
showHint('');
renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]);
}
// ---- Фейд и переходы: out → смена области → in (полный = 2×duration) ----
const fadeEl = document.getElementById('fade') as HTMLDivElement;
const hintEl = document.getElementById('hint') as HTMLDivElement;
interface FadeState {
active: boolean;
t: number;
dur: number;
phase: 'out' | 'in';
/** Смена области — в середине перехода (за чёрным кадром). */
apply: (() => void) | null;
}
const fade: FadeState = { active: false, t: 0, dur: 0.4, phase: 'out', apply: null };
let hintText = '';
/** Ближайший click-переход (для «E» и подсказки). */
let nearClick: TransitionDef | null = null;
/** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */
let prevTile: Tile | null = null;
function showHint(text: string): void {
hintText = text;
hintEl.hidden = text === '';
hintEl.textContent = text;
}
/** Начать переход (фейд out, середина — смена области). */
function startTransition(tr: TransitionDef, origin: Tile): void {
if (fade.active) return; // параллельные переходы глотаются (как в v1)
fade.active = true;
fade.t = 0;
fade.dur = tr.duration ?? 0.4;
fade.phase = 'out';
const fromArea = currentArea.id;
fade.apply = () => {
if (tr.target === 'return') {
const back = returnStack.pop();
if (back) setArea(back.area, back.entry);
} else {
// origin — откуда вошли (шаг на триггер = прошлый тайл, дверь = стояли)
returnStack.push({ area: fromArea, entry: origin });
setArea(tr.target.area, tr.target.entry);
}
};
showHint('');
}
// ---- Ввод: клавиатура (экранные оси) + виртуальный стик агента ----
const heldKeys = new Set<string>();
const KEYMAP: Record<string, readonly [number, number]> = {
KeyW: [0, -1], KeyS: [0, 1], KeyA: [-1, 0], KeyD: [1, 0],
ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0],
};
let agentInput: readonly [number, number] | null = null;
let followHero = true;
window.addEventListener('keydown', (e) => {
if (KEYMAP[e.code]) {
heldKeys.add(e.code);
e.preventDefault();
}
if (e.code === 'KeyE' && nearClick) {
e.preventDefault();
startTransition(nearClick, heroTile());
}
});
window.addEventListener('keyup', (e) => {
heldKeys.delete(e.code);
});
function keyboardMove(): [number, number] {
let x = 0, y = 0;
for (const code of heldKeys) {
const m = KEYMAP[code]!;
x += m[0];
y += m[1];
}
return [x, y];
}
/** Тайл героя (в тайлах v1) — триггеры и снапшоты моста. */
function heroTile(): Tile {
const b = hero.body.pos;
return { x: Math.floor(b[0] / UNIT), y: Math.floor(b[2] / UNIT) };
}
/** Один шаг логики: фейд → ввод → герой → камера → переходы → подсказка. */
function tick(dt: number): void {
if (fade.active) {
fade.t += dt;
if (fade.phase === 'out') {
fadeEl.style.opacity = String(Math.min(1, fade.t / fade.dur));
if (fade.t >= fade.dur) {
fade.apply?.();
fade.phase = 'in';
fade.t = 0;
}
} else {
fadeEl.style.opacity = String(Math.max(0, 1 - fade.t / fade.dur));
if (fade.t >= fade.dur) {
fade.active = false;
fadeEl.style.opacity = '0';
}
}
return; // во время перехода мир замер
}
const move = agentInput ?? keyboardMove();
hero.setMove(screenMoveToWorld(move, ISO_YAW));
hero.step(world, dt);
heroCloud.update(hero.visual());
if (followHero) {
const b = hero.body.pos;
renderer.setTarget([Math.round(b[0]), GROUND_Y, Math.round(b[2])]);
}
// step-переход: только что вошёл на тайл-триггер
const tile = heroTile();
const justEntered = prevTile !== null && (tile.x !== prevTile.x || tile.y !== prevTile.y);
if (justEntered) {
const tr = currentArea.transitions.find(
(t) => (t.trigger ?? 'step') === 'step' && t.tile.x === tile.x && t.tile.y === tile.y,
);
if (tr) {
startTransition(tr, prevTile!);
return;
}
}
prevTile = tile;
// click-переход рядом: подсказка «E — …»
nearClick =
currentArea.transitions.find(
(t) =>
t.trigger === 'click' &&
Math.max(Math.abs(t.tile.x - tile.x), Math.abs(t.tile.y - tile.y)) <= 1,
) ?? null;
showHint(nearClick ? `E — ${nearClick.label ?? 'перейти'}` : '');
}
// ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ----
const loop = new GameLoop(60, {
update: (dt) => tick(dt),
render: () => {
remeshDirty();
renderer.render();
},
});
setArea(currentArea.id, currentArea.spawn); // старт — спавн лугов
loop.start();
// ---- Агентный мост (DEV): гейты областей ----
const agent = {
/** Ручной/автоматический режим цикла. */
setManual(on: boolean): void {
loop.setManual(on);
},
/** Ручные шаги логики + рендер (детерминизм гейта). */
stepTicks(n: number): number {
return loop.stepTick(n, true);
},
/** Снапшот героя для гейта. */
heroInfo() {
const b = hero.body.pos;
return {
area: currentArea.id,
pos: [...b], vel: [...hero.body.vel], grounded: hero.body.grounded,
phase: hero.phase, yaw: hero.yaw,
tile: heroTile(),
camTarget: renderer.currentTarget(),
};
},
/** Телепорт героя (гейт: слежение камеры, непроходимость). */
heroTeleport(x: number, z: number): void {
hero.body.pos = [x, GROUND_Y, z];
hero.body.vel = [0, 0, 0];
heroCloud.update(hero.visual());
prevTile = heroTile(); // телепорт — не «вход на тайл»
renderer.setTarget([Math.round(x), GROUND_Y, Math.round(z)]);
},
/** Виртуальный стик агента (экранные оси −1..1); null — клавиатура. */
setInput(x: number | null, z: number | null): void {
agentInput = x === null || z === null ? null : [x, z];
},
/** Слежение камеры за героем (off — кадр гейта фиксируется). */
setFollow(on: boolean): void {
followHero = on;
if (!on) {
const c = tileCenter(currentArea.spawn.x, currentArea.spawn.y);
renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]);
}
},
/** Яркость солнца (утро/день/вечер — как в демке). */
setSun(k: number): void {
renderer.setSun(k);
},
/** Снапшот области: раскладка, переходы, фейд, стек возврата, меши. */
areaInfo() {
return {
id: currentArea.id,
name: currentArea.name,
width: layout.width,
height: layout.height,
spawn: currentArea.spawn,
heroTile: heroTile(),
transitions: currentArea.transitions.map((t) => ({
tile: t.tile,
label: t.label ?? null,
trigger: t.trigger ?? 'step',
target: t.target === 'return' ? 'return' : t.target.area,
duration: t.duration ?? 0.4,
})),
fade: { active: fade.active, phase: fade.phase },
returns: returnStack.length,
chunks: world.chunkCount,
meshes: chunkMeshes.size,
};
},
/** Прямой переход области (гейт: скриншоты и переходы без ходьбы). */
gotoArea(id: string, entry?: Tile): void {
returnStack.length = 0;
fade.active = false;
fadeEl.style.opacity = '0';
const def = areaOf(id);
setArea(id, entry ?? def.spawn);
},
/** Сплошная ли колонка НАД землёй (вода/объект) — гейт blocked. */
solidAt(x: number, z: number): boolean {
const fx = Math.floor(x), fz = Math.floor(z);
for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) {
if (world.get(fx, y, fz) !== 0) return true;
}
return false;
},
/** Нажать «E» агентом: активировать click-переход. */
interact(): void {
if (nearClick && !fade.active) startTransition(nearClick, heroTile());
},
/** Снапшот взаимодействия: ближайшая дверь и подсказка. */
interactInfo() {
return { label: nearClick?.label ?? null, hint: hintText };
},
};
(window as unknown as Record<string, unknown>).__agent = agent;
(window as unknown as Record<string, unknown>).__beacon = true;