import {
Container,
Sprite,
StateMachine,
Texture,
moveTowardsW,
worldDist,
worldToScreen,
type EventBus,
type IsoDepthLayer,
type TileMapData,
type Vec2
} from '@rpg/engine';
/**
* Пейзажная фауна (витрина StateMachine вне боя): безгласный олень.
* Стоит, медленно бродит по соседним тайлам; на громкий звон рядом
* подходит к источнику и стоит у колокольчиков. Не боевой.
*/
/** Радиус слуха на звон (мировые юниты). */
const HEAR_RADIUS = 7;
/** Скорость брожения / подхода (юниты/сек). */
const WANDER_SPEED = 0.35;
const APPROACH_SPEED = 0.7;
interface FaunaEntity {
/** Позиция (ноги) в мировых юнитах. */
pos: Vec2;
machine: StateMachine;
/** Куда идём (если идём). */
dest: Vec2 | null;
view: Container;
sprite: Sprite;
frames: [Texture, Texture];
animT: number;
animI: number;
/** Пауза перед следующим шагом (idle). */
pause: number;
}
export class FaunaSystem {
private entities: FaunaEntity[] = [];
private rng: () => number;
private off: () => void;
constructor(
private actors: IsoDepthLayer,
events: EventBus,
private data: TileMapData,
spawns: Vec2[],
seed = 20260908
) {
// Детерминированный ГПСЧ — брожение одинаково между запусками.
let s = seed;
this.rng = () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
for (const p of spawns) this.spawn(p);
// Громкий звон рядом — олень подходит к источнику и стоит у колокольчиков.
this.off = events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => {
for (const e of this.entities) {
if (worldDist(e.pos, origin) <= HEAR_RADIUS) {
e.dest = { ...origin };
e.machine.change('approach');
}
}
});
}
/** Привязать кадры (вызывается сценой после конструктора). */
setFrames(frames: [Texture, Texture]): void {
for (const e of this.entities) {
e.frames = frames;
e.sprite.texture = frames[0];
}
}
/** Заспавнить оленя в точке (юниты). */
private spawn(pos: Vec2): void {
const view = new Container();
const sprite = new Sprite();
sprite.anchor.set(0.5, 1);
view.addChild(sprite);
const sp = worldToScreen(pos.x, pos.y);
view.position.set(sp.x, sp.y);
this.actors.add(view, Math.floor(pos.x), Math.floor(pos.y));
const e: FaunaEntity = {
pos: { ...pos },
machine: new StateMachine(),
dest: null,
view,
sprite,
frames: [sprite.texture, sprite.texture],
animT: 0,
animI: 0,
pause: 0
};
// Стоит: через паузу делает шаг к соседнему тайлу.
e.machine.add('idle', {
update: () => {
if (e.machine.time >= e.pause) this.startWander(e);
}
});
// Бродит: медленно к случайной соседней точке.
e.machine.add('wander', {
update: (dt) => this.stepTo(e, dt, WANDER_SPEED, 'idle')
});
// Подходит на звон: быстрее, к источнику звука.
e.machine.add('approach', {
update: (dt) => this.stepTo(e, dt, APPROACH_SPEED, 'idle')
});
e.machine.change('idle');
e.pause = 1.5 + this.rng() * 3;
this.entities.push(e);
}
/** Начать шаг к случайной соседней проходимой точке. */
private startWander(e: FaunaEntity): void {
const dirs = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1]
];
const options = dirs
.map(([dx, dy]) => ({ x: e.pos.x + dx, y: e.pos.y + dy }))
.filter((p) => this.walkable(p));
if (options.length === 0) {
e.pause = 2;
return;
}
e.dest = options[Math.floor(this.rng() * options.length)]!;
e.pause = 1.5 + this.rng() * 3;
e.machine.change('wander');
}
/** Движение к dest; по приходе — переход в idle. */
private stepTo(e: FaunaEntity, dt: number, speed: number, back: string): void {
if (!e.dest) {
e.machine.change(back);
return;
}
e.pos = moveTowardsW(e.pos, e.dest, speed * dt);
if (e.pos.x === e.dest.x && e.pos.y === e.dest.y) {
e.dest = null;
e.machine.change(back);
}
}
private walkable(p: Vec2): boolean {
const tx = Math.floor(p.x);
const ty = Math.floor(p.y);
if (tx < 0 || ty < 0 || tx >= this.data.width || ty >= this.data.height) return false;
return !this.blocked.includes(this.data.tiles[ty * this.data.width + tx]!);
}
private blocked: number[] = [];
/** Задать непроходимые id тайлов (сцена синхронизирует со своей картой). */
setBlocked(ids: number[]): void {
this.blocked = ids;
}
update(dt: number): void {
for (const e of this.entities) {
e.machine.update(dt);
// Покадровая анимация: 2 кадра, 3 Гц, только на ходу.
if (e.dest) {
e.animT += dt;
if (e.animT >= 0.33) {
e.animT -= 0.33;
e.animI = 1 - e.animI;
e.sprite.texture = e.frames[e.animI]!;
}
} else {
e.animI = 0;
e.sprite.texture = e.frames[0]!;
}
const sp = worldToScreen(e.pos.x, e.pos.y);
e.view.position.set(sp.x, sp.y);
this.actors.setDepth(e.view, Math.floor(e.pos.x), Math.floor(e.pos.y));
}
}
exit(): void {
this.off?.();
for (const e of this.entities) {
this.actors.removeChild(e.view);
e.view.destroy({ children: true });
}
this.entities = [];
}
}