/**
* Пейзажная фауна (перенос v1 FaunaSystem, воксельная версия): безгласные
* олени. Стоят, медленно бродят по соседним тайлам; на громкий звон рядом
* подходят к источнику и стоят у него. В v1 звон — боевой удар героя
* (колокольчик); боёвки в игре v2 пока нет — звон подаёт startleAt
* (гейт зовёт мостом startleFauna, при переносе боя проводим от удара).
*/
import { Rng, type VoxelCloud } from '@rpg/engine';
import { UNIT, tileCenter } from './areas';
import { deerVoxels } from './deer';
/** Радиус слуха на звон (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;
}
export class FaunaSystem {
private deer: Deer[] = [];
/** Детерминированный ГПСЧ — брожение одинаково между прогонами (v1). */
private rng = new Rng(20260908);
constructor(private deps: FaunaDeps) {
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;
}
}