import {
EventBus,
AudioManager,
IsometricTileMap,
World,
worldToTile,
worldDist,
worldNorm,
inCircleW,
inConeW,
tileToWorld,
type Entity,
type System,
type Vec2,
type Invariant
} from '@rpg/engine';
import {
ENEMY_KINDS,
withDefaults,
type EnemyKindDef,
type EnemyKindId,
type EnemyPatrolDef
} from '../../data/enemies';
import { EnemyBrain, ENEMY_STATES, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemyIntent } from './EnemyBrain';
import { hasLineOfSight, tileOpaque } from './los';
import { PLAYER_COMBAT } from './stats';
/**
* Боевой мир: сущности (враги, снаряды) в движковом ECS, поведение — в системах.
* Пиксельные вьюхи держатся отдельно (CombatViews) — в ECS только данные.
*/
export interface EnemyComp {
kind: EnemyKindDef;
brain: EnemyBrain;
pos: Vec2;
hp: number;
/** Точка спавна — «дом» для возврата после погони. */
home: Vec2;
/** Точки патруля, мировые юниты (пусто — враг спит в пепле). */
patrolPoints: Vec2[];
/** Секунды до исчезновения трупа (после смерти). */
corpseTimer: number;
/** Кэш прямой видимости героя: true/false и сек до пересчёта. */
los: boolean;
losTimer: number;
}
export interface ProjectileComp {
pos: Vec2;
vel: Vec2;
damage: number;
life: number;
}
export interface CombatDeps {
map: IsometricTileMap;
events: EventBus;
audio: AudioManager;
/** Позиция героя (ноги), мировые юниты (1 юнит = 1 тайл). */
getPlayerPos: () => Vec2;
/** Герой получил урон (сцена отбросит/шейкнет/проиграет звук). */
damagePlayer: (damage: number, from: Vec2) => void;
}
/** Окно слышимости шума: событие живёт столько секунд для бодрых ушей. */
const NOISE_WINDOW = 0.5;
/** Пересчёт LOS раз в 3 тика (60 Гц) — видимость меняется медленнее. */
const LOS_INTERVAL = 0.05;
/** Бег (отступление) быстрее шага. */
const RUN_MULT = 1.3;
/** Движение врага: шаг в мировых юнитах с отбраковкой по проходимости. */
function stepWithCollision(
map: IsometricTileMap,
pos: Vec2,
dir: Vec2,
speed: number,
dt: number
): void {
const nx = pos.x + dir.x * speed * dt;
const ny = pos.y + dir.y * speed * dt;
const tile = worldToTile(nx, ny, map.data.width, map.data.height);
if (tile && map.isWalkable(tile.x, tile.y)) {
pos.x = nx;
pos.y = ny;
}
}
// ---------- системы ECS ----------
/** ИИ и движение сгустков. */
class EnemySystem implements System {
/** Колбэк непрозрачности тайлов (карта не меняется за жизнь мира). */
private opaque: (tx: number, ty: number) => boolean;
constructor(private combat: CombatWorld) {
this.opaque = tileOpaque({ data: combat.deps.map.data });
}
update(_world: World, dt: number): void {
const playerPos = this.combat.deps.getPlayerPos();
for (const [e, en] of this.combat.enemies) {
const world = this.combat.world;
if (en.brain.dead) {
en.corpseTimer -= dt;
if (en.corpseTimer <= 0) {
world.destroyEntity(e);
this.combat.enemies.delete(e);
}
continue;
}
// LOS с кэшем: пересчёт раз в LOS_INTERVAL.
en.losTimer -= dt;
if (en.losTimer <= 0) {
en.los = hasLineOfSight(en.pos, playerPos, this.opaque);
en.losTimer = LOS_INTERVAL;
}
const dx = playerPos.x - en.pos.x;
const dy = playerPos.y - en.pos.y;
const dist = worldDist(en.pos, playerPos) || 1e-4;
const noise = this.combat.noiseFor(en);
const intent: EnemyIntent = en.brain.update(dt, {
dist,
dirToPlayer: worldNorm(dx, dy),
pos: en.pos,
canSee: en.los,
noise,
patrolPoints: en.patrolPoints,
home: en.home,
hpFraction: en.hp / en.kind.hp
});
switch (intent.type) {
case 'move':
stepWithCollision(
this.combat.deps.map,
en.pos,
intent.dir,
en.kind.speed * (intent.run ? RUN_MULT : 1),
dt
);
break;
case 'strike':
// Удар доходит, если герой в радиусе тела + замах
if (dist <= en.kind.attackRange + 0.25) {
this.combat.deps.damagePlayer(en.kind.attackDamage, en.pos);
}
break;
case 'shoot':
this.combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage);
void this.combat.deps.audio.play('sfx/spit', 0.6);
// Плевок слышно, но спящих не будит (< 0.7)
this.combat.noise(en.pos, 0.6);
break;
default:
break;
}
}
}
}
/** Снаряды: полёт, попадание в героя, срок жизни. */
class ProjectileSystem implements System {
constructor(private combat: CombatWorld) {}
update(_world: World, dt: number): void {
const playerPos = this.combat.deps.getPlayerPos();
for (const [e, pr] of this.combat.projectiles) {
pr.life -= dt;
pr.pos.x += pr.vel.x * dt;
pr.pos.y += pr.vel.y * dt;
// Столкновение с героем
if (inCircleW(playerPos, 0.2, pr.pos)) {
this.combat.deps.damagePlayer(pr.damage, pr.pos);
this.combat.killProjectile(e);
continue;
}
// Вне карты или срок истёк
const tile = worldToTile(pr.pos.x, pr.pos.y, this.combat.deps.map.data.width, this.combat.deps.map.data.height);
if (pr.life <= 0 || !tile) {
this.combat.killProjectile(e);
}
}
}
}
// ---------- фасад ----------
export class CombatWorld {
readonly world = new World();
readonly enemies = new Map<Entity, EnemyComp>();
readonly projectiles = new Map<Entity, ProjectileComp>();
/** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */
private lastNoise: { origin: Vec2; level: number; age: number } | null = null;
constructor(
readonly deps: CombatDeps,
private onEnemyKilled?: (kind: EnemyKindDef) => void
) {
this.world.addSystem(new EnemySystem(this));
this.world.addSystem(new ProjectileSystem(this));
}
/**
* Заспавнить сгусток. Без patrol спит в пепле (dormant), пока не разбудит
* гулкий звук; с патрулём сразу бродит по маршруту.
*/
spawnEnemy(kindId: EnemyKindId, pos: Vec2, opts?: { patrol?: EnemyPatrolDef }): Entity {
const kind = withDefaults(ENEMY_KINDS[kindId]);
const e = this.world.createEntity();
this.enemies.set(e, {
kind,
brain: new EnemyBrain(
kind,
opts?.patrol
? { points: opts.patrol.points.map((p) => tileToWorld(p.x, p.y)), pauseSec: opts.patrol.pauseSec }
: undefined
),
pos: { ...pos },
hp: kind.hp,
home: { ...pos },
patrolPoints: opts?.patrol ? opts.patrol.points.map((p) => tileToWorld(p.x, p.y)) : [],
corpseTimer: 0.5,
los: false,
losTimer: 0
});
return e;
}
spawnProjectile(from: Vec2, dir: Vec2, damage: number): Entity {
const e = this.world.createEntity();
this.projectiles.set(e, {
pos: { x: from.x + dir.x * 0.3, y: from.y + dir.y * 0.3 },
vel: { x: dir.x * 2.2, y: dir.y * 2.2 },
damage,
life: 1.5
});
return e;
}
killProjectile(e: Entity): void {
if (!this.world.isAlive(e)) return;
this.world.destroyEntity(e);
this.projectiles.delete(e);
}
/**
* Короткий удар конусом. Возвращает число поражённых сгустков.
* Побочный эффект: гулкий звон (уровень 1.0) будит спящих в wakeRadius.
*/
playerConeAttack(from: Vec2, dir: Vec2): number {
void this.deps.audio.play('sfx/bell_hit', 0.8);
this.noise(from, 1.0);
this.deps.events.emit('combat:attack', { mode: 'cone', origin: { ...from } });
let hits = 0;
for (const [e, en] of this.enemies) {
if (en.brain.dead) continue;
if (!inConeW(from, dir, PLAYER_COMBAT.coneRange, PLAYER_COMBAT.coneHalfAngle, en.pos)) continue;
this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from);
hits++;
}
return hits;
}
/** Низкий резонанс: усыпляет сгустков в радиусе. Возвращает число усыпленных. */
resonancePulse(from: Vec2): number {
void this.deps.audio.play('sfx/bell_low', 0.9);
this.deps.events.emit('combat:attack', { mode: 'resonance', origin: { ...from } });
let slept = 0;
for (const [, en] of this.enemies) {
if (en.brain.dead) continue;
if (inCircleW(from, PLAYER_COMBAT.resonanceRange, en.pos)) {
en.brain.putToSleep(PLAYER_COMBAT.resonanceSleep);
slept++;
}
}
return slept;
}
/**
* Шум в точке мира. level 0.35 — слышат бодрые в hearRadius (патруль
* идёт проверять); 0.7+ — гулкий удар, будит и спящих в wakeRadius;
* 0.6 — плевок/смерть сгустка (слышат, но не просыпаются).
*/
noise(pos: Vec2, level: number): void {
this.lastNoise = { origin: { ...pos }, level, age: 0 };
if (level >= NOISE_WAKE_LEVEL) {
for (const [, en] of this.enemies) {
if (inCircleW(pos, PLAYER_COMBAT.wakeRadius, en.pos) && en.brain.asleep) {
en.brain.hearLoud();
void this.deps.audio.play('sfx/ash_hiss', 0.5);
this.deps.events.emit('combat:awake', { kind: en.kind.id });
}
}
}
}
/** Шум, слышимый конкретному врагу (фильтр по уровню, радиусу слуха и окну). */
noiseFor(en: EnemyComp): { origin: Vec2; level: number } | null {
const n = this.lastNoise;
if (!n || n.age > NOISE_WINDOW || n.level < NOISE_HEAR_LEVEL) return null;
if (en.brain.asleep) return null; // спящего будит только noise() от 0.7
if (worldDist(en.pos, n.origin) > (en.kind.hearRadius ?? 5)) return null;
return { origin: { ...n.origin }, level: n.level };
}
/** Урон сгустку; смерть — дроп, событие и приглушённый шум. */
damageEnemy(e: Entity, en: EnemyComp, damage: number, from: Vec2): void {
en.hp -= damage;
en.brain.hurt();
this.deps.events.emit('combat:hurt', { entity: e, died: en.hp <= 0 });
if (en.hp <= 0) {
if (en.brain.die()) {
void this.deps.audio.play('sfx/ash_die', 0.8);
this.noise(en.pos, 0.6);
this.deps.events.emit('combat:kill', { kind: en.kind.id, tile: { ...en.pos } });
this.onEnemyKilled?.(en.kind);
// Отброс трупа от героя — лёгкий визуальный эффект делает CombatViews.
en.pos.x += Math.sign(en.pos.x - from.x) * 0.0625;
en.pos.y += Math.sign(en.pos.y - from.y) * 0.0625;
}
}
}
/** Инварианты боевого мира: легальность состояний и позиций врагов. */
agentInvariants(): Invariant[] {
const where = 'systems/combat';
const { width, height } = this.deps.map.data;
const out: Invariant[] = [];
for (const [, en] of this.enemies) {
if (!ENEMY_STATES.includes(en.brain.state as (typeof ENEMY_STATES)[number])) {
out.push({
id: 'enemy-state-legal',
severity: 'error',
message: `${en.kind.id}: неизвестное состояние "${en.brain.state}"`,
where
});
}
const tx = Math.floor(en.pos.x);
const ty = Math.floor(en.pos.y);
if (tx < 0 || ty < 0 || tx >= width || ty >= height) {
out.push({
id: 'enemy-in-bounds',
severity: 'error',
message: `${en.kind.id} вне карты (${en.pos.x.toFixed(2)},${en.pos.y.toFixed(2)})`,
where
});
}
}
return out;
}
update(dt: number): void {
if (this.lastNoise) {
this.lastNoise.age += dt;
if (this.lastNoise.age > NOISE_WINDOW) this.lastNoise = null;
}
this.world.update(dt);
}
}