import {
EventBus,
AudioManager,
IsometricTileMap,
World,
screenToIsoExact,
inCircle,
inCone,
type Entity,
type System,
type Vec2
} from '@rpg/engine';
import { DEFAULT_ISO } from '@rpg/engine';
import { ENEMY_KINDS, type EnemyKindDef, type EnemyKindId } from '../../data/enemies';
import { EnemyBrain, type EnemyIntent } from './EnemyBrain';
import { PLAYER_COMBAT } from './stats';
/**
* Боевой мир: сущности (враги, снаряды) в движковом ECS, поведение — в системах.
* Пиксельные вьюхи держатся отдельно (CombatViews) — в ECS только данные.
*/
export interface EnemyComp {
kind: EnemyKindDef;
brain: EnemyBrain;
pos: Vec2;
hp: number;
/** Секунды до исчезновения трупа (после смерти). */
corpseTimer: number;
}
export interface ProjectileComp {
pos: Vec2;
vel: Vec2;
damage: number;
life: number;
}
export interface CombatDeps {
map: IsometricTileMap;
events: EventBus;
audio: AudioManager;
/** Позиция героя (ноги), виртуальные пиксели. */
getPlayerPos: () => Vec2;
/** Герой получил урон (сцена отбросит/шейкнет/проиграет звук). */
damagePlayer: (damage: number, from: Vec2) => void;
}
/** Движение врага: шаг в экранных координатах с отбраковкой по проходимости. */
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 = screenToIsoExact(nx, ny, map.data.width, map.data.height, DEFAULT_ISO);
if (tile && map.isWalkable(tile.x, tile.y)) {
pos.x = nx;
pos.y = ny;
}
}
// ---------- системы ECS ----------
/** ИИ и движение сгустков. */
class EnemySystem implements System {
constructor(private combat: CombatWorld) {}
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;
}
const dx = playerPos.x - en.pos.x;
const dy = playerPos.y - en.pos.y;
const dist = Math.hypot(dx, dy) || 1;
const intent: EnemyIntent = en.brain.update(dt, {
dist,
dirToPlayer: { x: dx / dist, y: dy / dist }
});
switch (intent.type) {
case 'move':
stepWithCollision(this.combat.deps.map, en.pos, intent.dir, en.kind.speed, dt);
break;
case 'strike':
// Удар доходит, если герой в радиусе тела + замах
if (dist <= en.kind.attackRange + 8) {
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);
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 (inCircle(playerPos, 6, pr.pos)) {
this.combat.deps.damagePlayer(pr.damage, pr.pos);
this.combat.killProjectile(e);
continue;
}
// Вне карты или срок истёк
const tile = screenToIsoExact(pr.pos.x, pr.pos.y, this.combat.deps.map.data.width, this.combat.deps.map.data.height, DEFAULT_ISO);
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>();
constructor(
readonly deps: CombatDeps,
private onEnemyKilled?: (kind: EnemyKindDef) => void
) {
this.world.addSystem(new EnemySystem(this));
this.world.addSystem(new ProjectileSystem(this));
}
/** Заспавнить сгусток (спит в пепле, пока не разбудит громкий звук). */
spawnEnemy(kindId: EnemyKindId, pos: Vec2): Entity {
const kind = ENEMY_KINDS[kindId];
const e = this.world.createEntity();
this.enemies.set(e, {
kind,
brain: new EnemyBrain(kind),
pos: { ...pos },
hp: kind.hp,
corpseTimer: 0.5
});
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 * 10, y: from.y + dir.y * 10 },
vel: { x: dir.x * 70, y: dir.y * 70 },
damage,
life: 1.5
});
return e;
}
killProjectile(e: Entity): void {
if (!this.world.isAlive(e)) return;
this.world.destroyEntity(e);
this.projectiles.delete(e);
}
/**
* Короткий удар конусом. Возвращает число поражённых сгустков.
* Побочный эффект: гулкий звук будит спящих в wakeRadius.
*/
playerConeAttack(from: Vec2, dir: Vec2): number {
void this.deps.audio.play('sfx/bell_hit', 0.8);
this.loudSound(from, PLAYER_COMBAT.wakeRadius);
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 (!inCone(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 (inCircle(from, PLAYER_COMBAT.resonanceRange, en.pos)) {
en.brain.putToSleep(PLAYER_COMBAT.resonanceSleep);
slept++;
}
}
return slept;
}
/** Гулкий звук в точке: будит спящих сгустков в радиусе. */
loudSound(pos: Vec2, radius: number): void {
for (const [, en] of this.enemies) {
if (inCircle(pos, radius, 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 });
}
}
}
/** Урон сгустку; смерть — дроп и событие. */
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.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) * 2;
en.pos.y += Math.sign(en.pos.y - from.y) * 2;
}
}
}
update(dt: number): void {
this.world.update(dt);
}
}