import { EntityWorld, type Entity } from '../core/ecs';
import { hasLineOfSight } from './los';
import { horDist, horNorm, inCircle, inCone, type Hor } from './shapes';
import { ENEMY_KINDS, withDefaults, type EnemyKindDef, type EnemyKindId, type EnemyPatrolDef } from './enemies';
import { EnemyBrain, ENEMY_STATES, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemyIntent } from './brain';
import { PLAYER_COMBAT } from './stats';
/**
* Боевой мир (перенос v1, адаптация к вокселям): сущности (враги, снаряды)
* в движковом ECS, поведение — в системах; вьюхи держатся отдельно.
*
* Отличия от v1: бой живёт в горизонтальной плоскости x/z; вместо
* тайловой карты — предикаты колонок мира: `solid` (блокирует движение)
* и `opaque` (блокирует взгляд и снаряды); вместо EventBus — хуки
* `hooks` (вьюхи/звук подписываются игрой).
*/
export interface EnemyComp {
kind: EnemyKindDef;
brain: EnemyBrain;
pos: Hor;
hp: number;
/** Точка спавна — «дом» для возврата после погони. */
home: Hor;
/** Точки патруля, юниты (пусто — враг спит в пепле). */
patrolPoints: Hor[];
/** Секунды до исчезновения трупа (после смерти). */
corpseTimer: number;
/** Кэш прямой видимости героя: true/false и сек до пересчёта. */
los: boolean;
losTimer: number;
}
export interface ProjectileComp {
pos: Hor;
vel: Hor;
damage: number;
life: number;
}
/** Хуки боевого мира: реакции вьюх/звука (в v1 — события EventBus). */
export interface CombatHooks {
/** Сгусток получил урон (died — добит). */
onEnemyHurt?(entity: Entity, died: boolean): void;
/** Сгусток умер (дроп пыли, вспышка). */
onEnemyKilled?(kindId: EnemyKindId, pos: Hor): void;
/** Спящий сгусток разбужен гулким звуком. */
onEnemyAwake?(kindId: EnemyKindId, pos: Hor): void;
/** Атака героя (конус или резонанс) — вьюха сектора/волны. */
onAttack?(mode: 'cone' | 'resonance', origin: Hor): void;
/** Плевок сгустка (звук; спящих не будит). */
onSpit?(pos: Hor): void;
}
export interface CombatDeps {
/** Позиция героя (ноги), юниты. */
getPlayerPos: () => Hor;
/** Герой получил урон (сцена отбросит/вспыхнет/проиграет звук). */
damagePlayer: (damage: number, from: Hor) => void;
/** Колонка (cx, cz) блокирует движение (стены, дом). */
solid: (cx: number, cz: number) => boolean;
/** Колонка блокирует взгляд и снаряды (высокие объекты). */
opaque: (cx: number, cz: number) => boolean;
/** Хуки реакций (опция). */
hooks?: CombatHooks;
/** Границы мира для инварианта «враг в пределах» (опция). */
bounds?: { width: number; height: number };
}
/** Окно слышимости шума: событие живёт столько секунд для бодрых ушей. */
const NOISE_WINDOW = 0.5;
/** Пересчёт LOS раз в 3 тика (60 Гц) — видимость меняется медленнее. */
const LOS_INTERVAL = 0.05;
/** Бег (отступление) быстрее шага. */
const RUN_MULT = 1.3;
/** Зазор от грани колонки при зажиме круга. */
const EPS = 0.001;
/** Радиус попадания снаряда в героя, юнитов. */
const PLAYER_HIT_RADIUS = 0.2;
/** Скорость плевка и срок жизни, юнитов/с и сек. */
const PROJECTILE_SPEED = 2.2;
const PROJECTILE_LIFE = 1.5;
// ---------- движение круга по колонкам ----------
/** Пересекает ли круг хоть одну solid-колонку (граница-касание разрешена). */
export function circleHitsSolid(x: number, z: number, r: number, solid: (cx: number, cz: number) => boolean): boolean {
const minX = Math.floor(x - r);
const maxX = Math.floor(x + r);
const minZ = Math.floor(z - r);
const maxZ = Math.floor(z + r);
for (let cx = minX; cx <= maxX; cx++) {
for (let cz = minZ; cz <= maxZ; cz++) {
if (!solid(cx, cz)) continue;
// Ближайшая к центру круга точка ячейки
const px = Math.max(cx, Math.min(x, cx + 1));
const pz = Math.max(cz, Math.min(z, cz + 1));
const dx = x - px;
const dz = z - pz;
if (dx * dx + dz * dz < r * r) return true;
}
}
return false;
}
/**
* Движение круга с ось-раздельным скольжением вдоль стен (X → Z): застряв
* в solid-колонку, зажимаем к её грани — по аналогии с физикой тел v2.
*/
export function moveCircleHor(pos: Hor, delta: Hor, r: number, solid: (cx: number, cz: number) => boolean): void {
if (delta.x !== 0) {
pos.x += delta.x;
if (circleHitsSolid(pos.x, pos.z, r, solid)) {
pos.x = delta.x > 0 ? Math.floor(pos.x + r) - r - EPS : Math.floor(pos.x - r) + 1 + r + EPS;
}
}
if (delta.z !== 0) {
pos.z += delta.z;
if (circleHitsSolid(pos.x, pos.z, r, solid)) {
pos.z = delta.z > 0 ? Math.floor(pos.z + r) - r - EPS : Math.floor(pos.z - r) + 1 + r + EPS;
}
}
}
/** Вытолкнуть self из пересечения с чужим кругом (двигается только self). */
function separate(self: Hor, selfR: number, other: Hor, otherR: number): void {
const dx = self.x - other.x;
const dz = self.z - other.z;
const d = Math.hypot(dx, dz) || 1e-4;
const overlap = selfR + otherR - d;
if (overlap > 0) {
self.x += (dx / d) * overlap;
self.z += (dz / d) * overlap;
}
}
// ---------- системы ECS ----------
/** ИИ и движение сгустков. */
class EnemySystem {
constructor(private combat: CombatWorld) {}
update(_world: EntityWorld, dt: number): void {
const combat = this.combat;
const playerPos = combat.deps.getPlayerPos();
for (const [e, en] of combat.enemies) {
const world = combat.world;
if (en.brain.dead) {
en.corpseTimer -= dt;
if (en.corpseTimer <= 0) {
world.destroyEntity(e);
combat.enemies.delete(e);
}
continue;
}
// LOS с кэшем: пересчёт раз в LOS_INTERVAL.
en.losTimer -= dt;
if (en.losTimer <= 0) {
en.los = hasLineOfSight(en.pos, playerPos, combat.deps.opaque);
en.losTimer = LOS_INTERVAL;
}
const dist = horDist(en.pos, playerPos) || 1e-4;
const dirToPlayer = horNorm(en.pos, playerPos);
const noise = combat.noiseFor(en);
const intent: EnemyIntent = en.brain.update(dt, {
dist,
dirToPlayer,
pos: en.pos,
canSee: en.los,
noise,
patrolPoints: en.patrolPoints,
home: en.home,
hpFraction: en.hp / en.kind.hp,
});
// Позиция до шага и толкучки: если сепарация втолкнет тело в колонку,
// откатываемся сюда.
const beforeCrowd: Hor = { ...en.pos };
switch (intent.type) {
case 'move': {
const speed = en.kind.speed * (intent.run ? RUN_MULT : 1);
moveCircleHor(en.pos, { x: intent.dir.x * speed * dt, z: intent.dir.z * speed * dt }, en.kind.radius, combat.deps.solid);
break;
}
case 'strike':
// Удар доходит, если герой в радиусе тела + замах
if (dist <= en.kind.attackRange + 0.25) {
combat.deps.damagePlayer(en.kind.attackDamage, { ...en.pos });
}
break;
case 'shoot':
combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage);
combat.deps.hooks?.onSpit?.({ ...en.pos });
combat.noise(en.pos, 0.6); // слышно, но спящих не будит
break;
default:
break;
}
// Тела: каждый враг выталкивает себя из героя и других врагов
// (сепарация после шага — толкучка не отменяет намерение ИИ).
separate(en.pos, en.kind.radius, playerPos, PLAYER_COMBAT.radius);
for (const [o, other] of combat.enemies) {
if (o === e || other.brain.dead) continue;
separate(en.pos, en.kind.radius, other.pos, other.kind.radius);
}
// Стена сильнее толкучки: втолкнули в solid-колонку — откат.
if (circleHitsSolid(en.pos.x, en.pos.z, en.kind.radius, combat.deps.solid)) {
en.pos.x = beforeCrowd.x;
en.pos.z = beforeCrowd.z;
}
}
}
}
/** Снаряды: полёт, попадание в героя, срок жизни. */
class ProjectileSystem {
constructor(private combat: CombatWorld) {}
update(_world: EntityWorld, dt: number): void {
const combat = this.combat;
const playerPos = combat.deps.getPlayerPos();
for (const [e, pr] of combat.projectiles) {
pr.life -= dt;
pr.pos.x += pr.vel.x * dt;
pr.pos.z += pr.vel.z * dt;
// Столкновение с героем
if (inCircle(playerPos, PLAYER_HIT_RADIUS, pr.pos)) {
combat.deps.damagePlayer(pr.damage, { ...pr.pos });
combat.killProjectile(e);
continue;
}
// Срок истёк или плевок в стену/дом (правила прозрачности — игра).
if (pr.life <= 0 || combat.deps.opaque(Math.floor(pr.pos.x), Math.floor(pr.pos.z))) {
combat.killProjectile(e);
}
}
}
}
// ---------- фасад ----------
export class CombatWorld {
readonly world = new EntityWorld();
readonly enemies = new Map<Entity, EnemyComp>();
readonly projectiles = new Map<Entity, ProjectileComp>();
/** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */
private lastNoise: { origin: Hor; level: number; age: number } | null = null;
/** Переиспользуемый view шума для noiseFor — без копии origin на врага/тик. */
private readonly noiseView: { origin: Hor; level: number } = { origin: { x: 0, z: 0 }, level: 0 };
constructor(readonly deps: CombatDeps) {
this.world.addSystem(new EnemySystem(this));
this.world.addSystem(new ProjectileSystem(this));
}
/**
* Заспавнить сгусток. Без patrol спит в пепле (dormant), пока не разбудит
* гулкий звук; с патрулём сразу бродит по маршруту.
*/
spawnEnemy(kindId: EnemyKindId, pos: Hor, opts?: { patrol?: EnemyPatrolDef }): Entity {
const kind = withDefaults(ENEMY_KINDS[kindId]);
const points = opts?.patrol ? opts.patrol.points.map((p) => ({ ...p })) : undefined;
const e = this.world.createEntity();
this.enemies.set(e, {
kind,
brain: new EnemyBrain(kind, points ? { points, pauseSec: opts!.patrol!.pauseSec } : undefined),
pos: { ...pos },
hp: kind.hp,
home: { ...pos },
patrolPoints: points ?? [],
corpseTimer: 0.5,
los: false,
losTimer: 0,
});
return e;
}
spawnProjectile(from: Hor, dir: Hor, damage: number): Entity {
const e = this.world.createEntity();
this.projectiles.set(e, {
pos: { x: from.x + dir.x * 0.3, z: from.z + dir.z * 0.3 },
vel: { x: dir.x * PROJECTILE_SPEED, z: dir.z * PROJECTILE_SPEED },
damage,
life: PROJECTILE_LIFE,
});
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: Hor, dir: Hor): number {
this.noise(from, 1.0);
this.deps.hooks?.onAttack?.('cone', { ...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;
// Звон не огибает дома: цель должна быть в прямой видимости.
if (!hasLineOfSight(from, en.pos, this.deps.opaque)) continue;
this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from);
hits++;
}
return hits;
}
/** Низкий резонанс: усыпляет сгустков в радиусе. Возвращает число усыпленных. */
resonancePulse(from: Hor): number {
this.deps.hooks?.onAttack?.('resonance', { ...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;
}
/**
* Шум в точке мира. level 0.35 — слышат бодрые в hearRadius (патруль
* идёт проверять); 0.7+ — гулкий удар, будит и спящих в wakeRadius;
* 0.6 — плевок/смерть сгустка (слышат, но не просыпаются).
*/
noise(pos: Hor, level: number): void {
this.lastNoise = { origin: { ...pos }, level, age: 0 };
if (level >= NOISE_WAKE_LEVEL) {
for (const [, en] of this.enemies) {
if (inCircle(pos, PLAYER_COMBAT.wakeRadius, en.pos) && en.brain.asleep) {
en.brain.hearLoud();
this.deps.hooks?.onEnemyAwake?.(en.kind.id, { ...en.pos });
}
}
}
}
/** Шум, слышимый конкретному врагу (фильтр по уровню, радиусу слуха и окну). */
noiseFor(en: EnemyComp): { origin: Hor; 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 (horDist(en.pos, n.origin) > (en.kind.hearRadius ?? 5)) return null;
// Общий view (потребитель копирует origin перед хранением — см. EnemyBrain).
this.noiseView.origin = n.origin;
this.noiseView.level = n.level;
return this.noiseView;
}
/** Урон сгустку; смерть — дроп, хук и приглушённый шум. */
damageEnemy(e: Entity, en: EnemyComp, damage: number, from: Hor): void {
en.hp -= damage;
en.brain.hurt();
this.deps.hooks?.onEnemyHurt?.(e, en.hp <= 0);
if (en.hp <= 0) {
if (en.brain.die()) {
this.noise(en.pos, 0.6);
this.deps.hooks?.onEnemyKilled?.(en.kind.id, { ...en.pos });
// Отброс трупа от героя — лёгкий визуальный эффект на игре.
en.pos.x += Math.sign(en.pos.x - from.x) * 0.0625;
en.pos.z += Math.sign(en.pos.z - from.z) * 0.0625;
}
}
}
/** Инварианты боевого мира: легальность состояний и позиций врагов. */
agentInvariants(): { id: string; severity: 'error'; message: string }[] {
const out: { id: string; severity: 'error'; message: string }[] = [];
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}"` });
}
if (this.deps.bounds) {
const { width, height } = this.deps.bounds;
const tx = Math.floor(en.pos.x);
const tz = Math.floor(en.pos.z);
if (tx < 0 || tz < 0 || tx >= width || tz >= height) {
out.push({ id: 'enemy-in-bounds', severity: 'error', message: `${en.kind.id} вне мира (${en.pos.x.toFixed(2)},${en.pos.z.toFixed(2)})` });
}
}
}
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);
}
}