/**
* Пейзажная фауна (перенос v1 FaunaSystem, воксельная версия): безгласные
* олени. Стоят, медленно бродят по соседним тайлам; на громкий звон рядом
* подходят к источнику и стоят у него. В v1 звон — боевой удар героя
* (колокольчик); боёвки в игре v2 пока нет — звон подаёт startleAt
* (гейт зовёт мостом startleFauna, при переносе боя проводим от удара).
*/
import { Rng, encodeModel, VoxelGrid, type VoxelCloud, type VoxelModel } from '@rpg/engine';
import { UNIT, tileCenter } from './areas';
import { deerVoxels } from './deer';
import { registerObject } from './registry';
/** Радиус слуха на звон (v1: 7 юнитов = 7 тайлов → воксели). */
const HEAR_RADIUS = 7 * UNIT;
/** Скорость брожения / подхода (v1: тайлов/сек × UNIT + фидбек «живее»). */
const WANDER_SPEED = 0.45 * UNIT;
const APPROACH_SPEED = 1.0 * UNIT;
/** Пауза между шагами брожения (сек), как в v1: 1.5 + rng*3. */
const PAUSE_BASE = 1.5;
const PAUSE_RANGE = 3;
/** Состояние автомата оленя (снапшот моста). */
export type DeerState = 'idle' | 'wander' | 'approach';
export interface DeerInfo {
/** Позиция ног (мир, воксели). */
x: number;
z: number;
/** Тайл, на котором стоит (в тайлах v1). */
tile: { x: number; y: number };
state: DeerState;
}
interface Deer {
pos: { x: number; z: number };
dest: { x: number; z: number } | null;
state: DeerState;
/** Куда смотрит (четверти оборота от +X: 1 — +Z, 2 — −X, 3 — −Z). */
dir: number;
/** Пауза до следующего шага брожения (сек). */
pause: number;
/** Ключ последнего отрисованного вида (поза/позиция) — без лишних апдейтов. */
drawn: string;
cloud: VoxelCloud;
}
export interface FaunaDeps {
/** Спавны в тайлах v1 (олень стоит в центре тайла). */
spawns: readonly { x: number; y: number }[];
/** Непроходимость тайла (вода/пропы) — туда олень не идёт. */
blocked: (tx: number, ty: number) => boolean;
/** Фабрика облаков вокселей (renderer.addVoxelCloud). */
addCloud: (voxels: ReturnType<typeof deerVoxels>) => VoxelCloud;
}
/** Движение к точке с шагом не больше step; true — дошли (снап к точке). */
function stepTowards(
pos: { x: number; z: number },
to: { x: number; z: number },
step: number,
): boolean {
const dx = to.x - pos.x;
const dz = to.z - pos.z;
const d = Math.hypot(dx, dz);
if (d <= step) {
pos.x = to.x;
pos.z = to.z;
return true;
}
pos.x += (dx / d) * step;
pos.z += (dz / d) * step;
return false;
}
/** Модель из списка вокселей [x,y,z,цвет] (палитра — по уникальным цветам). */
function modelFromVoxels(list: Array<[number, number, number, string]>): VoxelModel {
let max = [0, 0, 0] as [number, number, number];
for (const [x, y, z] of list) max = [Math.max(max[0], x), Math.max(max[1], y), Math.max(max[2], z)];
const g = new VoxelGrid(max[0] + 1, max[1] + 1, max[2] + 1);
const palette: Record<number, string> = {};
let next = 1;
for (const [x, y, z, color] of list) {
let slot = Object.entries(palette).find(([, c]) => c === color)?.[0];
if (slot === undefined) {
palette[next] = color;
slot = String(next);
next++;
}
g.set(x, y, z, Number(slot));
}
return encodeModel(g, palette);
}
/** Модель оленя для каталога: поза покоя у начала координат, сетка от
* минимальных границ (мировые координаты → локальные). */
function deerModel(): VoxelModel {
const list = deerVoxels(0, 0, false).map((v) =>
[Math.round(v.pos[0]), Math.round(v.pos[1]), Math.round(v.pos[2]), v.color] as [number, number, number, string]);
const xs = list.map((v) => v[0]), ys = list.map((v) => v[1]), zs = list.map((v) => v[2]);
const shift = [1 - Math.min(...xs), 1 - Math.min(...ys), 1 - Math.min(...zs)];
return modelFromVoxels(list.map(([x, y, z, c]) => [x + shift[0], y + shift[1], z + shift[2], c] as [number, number, number, string]));
}
export class FaunaSystem {
private deer: Deer[] = [];
/** Детерминированный ГПСЧ — брожение одинаково между прогонами (v1). */
private rng = new Rng(20260908);
constructor(private deps: FaunaDeps) {
registerObject({ id: 'deer', label: 'Олень', build: () => ({ model: deerModel() }) });
for (const s of deps.spawns) this.spawn(s);
}
/** Заспавнить оленя в центре тайла (стоя, ноги на земле). */
private spawn(s: { x: number; y: number }): void {
const c = tileCenter(s.x, s.y);
this.deer.push({
pos: { x: c.x, z: c.z },
dest: null,
state: 'idle',
dir: 0,
pause: PAUSE_BASE + this.rng.next() * PAUSE_RANGE,
drawn: '',
cloud: this.deps.addCloud(deerVoxels(c.x, c.z, false)),
});
}
/** Громкий звон рядом: олень подходит к источнику и стоит (v1). */
startleAt(x: number, z: number): void {
for (const d of this.deer) {
if (Math.hypot(d.pos.x - x, d.pos.z - z) > HEAR_RADIUS) continue;
d.dest = { x, z };
d.state = 'approach';
}
}
/** Шаг брожения: случайный соседний проходимый тайл (v1). */
private startWander(d: Deer): void {
const tx = Math.floor(d.pos.x / UNIT);
const ty = Math.floor(d.pos.z / UNIT);
const options = [[1, 0], [-1, 0], [0, 1], [0, -1]]
.map(([dx, dy]) => ({ x: tx + dx, y: ty + dy }))
.filter((t) => !this.deps.blocked(t.x, t.y));
if (options.length === 0) {
d.pause = 2;
return;
}
const t = this.rng.pick(options);
d.dest = tileCenter(t.x, t.y);
d.state = 'wander';
}
/**
* Тик фауны: автомат (idle→wander→idle, approach по звону) + отрисовка.
* step — фаза шага (2 шага/сек на ходу, стоя — стоп-кадр), как в v1.
*/
update(dt: number, t: number): void {
for (const d of this.deer) {
if (d.state === 'idle') {
d.pause -= dt;
if (d.pause <= 0) this.startWander(d);
} else if (d.dest) {
this.faceDest(d); // олень поворачивается мордой по ходу
if (stepTowards(d.pos, d.dest, (d.state === 'approach' ? APPROACH_SPEED : WANDER_SPEED) * dt)) {
d.dest = null;
d.state = 'idle';
d.pause = PAUSE_BASE + this.rng.next() * PAUSE_RANGE;
}
}
this.draw(d, t);
}
}
/** Направить взгляд по вектору движения (доминирующая ось). */
private faceDest(d: Deer): void {
const dx = d.dest!.x - d.pos.x;
const dz = d.dest!.z - d.pos.z;
if (Math.abs(dx) >= Math.abs(dz)) d.dir = dx >= 0 ? 0 : 2;
else d.dir = dz > 0 ? 1 : 3;
}
/** Перерисовать облако, если позиция, фаза шага или взгляд изменились. */
private draw(d: Deer, t: number): void {
const step = d.dest !== null && Math.floor(t * 2) % 2 === 0;
const key = `${d.pos.x.toFixed(2)},${d.pos.z.toFixed(2)},${step ? 1 : 0},${d.dir}`;
if (key === d.drawn) return;
d.drawn = key;
d.cloud.update(deerVoxels(d.pos.x, d.pos.z, step, d.dir));
}
/** Снапшот для моста: олени области. */
info(): DeerInfo[] {
return this.deer.map((d) => ({
x: d.pos.x,
z: d.pos.z,
tile: { x: Math.floor(d.pos.x / UNIT), y: Math.floor(d.pos.z / UNIT) },
state: d.state,
}));
}
dispose(): void {
for (const d of this.deer) d.cloud.dispose();
this.deer.length = 0;
}
}