import type { World, EntityId } from 'db://assets/mx/module/ecs'; import type { ISystem } from 'db://assets/mx/module/ecs'; import type { Cell } from 'db://assets/mx/module/battle/battlefield/Cell'; import type { UnitDefId, WavePortPlan, WaveSpawnEntry, WaveSpawnSegment } from 'db://assets/mx/module/battle/config/BattleConfigTypes'; import { BattlePhase } from 'db://assets/mx/module/battle/core/BattlePhase'; import { BattleTeamId } from 'db://assets/mx/module/battle/ecs/BattleTeamId'; import { EntityTag } from 'db://assets/mx/module/battle/ecs/EntityTag'; import { BattleEventFactory } from 'db://assets/mx/module/battle/events/BattleEvents'; import type { BattleEventBus } from 'db://assets/mx/module/battle/events/BattleEventBus'; import { UnitFactory } from 'db://assets/mx/module/battle/unit/UnitFactory'; import { finitePositiveHp } from '../../../tools/FiniteHp'; import { traceCombatDamage } from '../../../tools/CombatDamageTrace'; import { TeamComp, TagComp, HealthComp, MovementComp, UnitMetaComp } from 'db://assets/mx/module/battle/ecs/Components'; import type { IConfigProvider } from '../config/IConfigProvider'; import type { GridMap } from '../battlefield/GridMap'; import type { MapTopology } from '../battlefield/MapTopology'; import type { MovementSystem } from '../combat/MovementSystem'; import type { BattleServices } from '../combat/BattleServices'; /** 示例关卡扁平出怪任务(wave.spawns,单出怪口模式) */ interface FlatSpawnTask { /** 怪物单位 ID */ unitDefId: UnitDefId; /** 剩余出怪次数 */ remain: number; /** 出怪间隔(秒) */ interval: number; /** 距下次出怪剩余时间 */ timer: number; /** 出生列 */ spawnCol: number; /** 目标玩家 ID */ playerId: number; } /** 单出口出怪状态(wave.ports,段内顺序、段间顺序) */ interface PortSpawnState { /** 出口编号 */ port: number; /** 出怪段列表 */ segments: WaveSpawnSegment[]; /** 当前段索引 */ segmentIndex: number; /** 当前段剩余出怪数 */ remain: number; /** 距下次出怪剩余时间 */ timer: number; /** 本出口是否已全部出完 */ done: boolean; /** 目标玩家 ID */ playerId: number; } /** * 波次出怪系统(塔防波次制) * * 职责: * - 仅在 COMBAT 阶段 tick,由 BattleSession.startCombat() 显式开波 * - 支持多出怪口并行(wave.ports)与示例扁平列表(wave.spawns) * - 口内按 WaveSpawnSegment 顺序出怪,段间 interval 来自配表 / 配置常量 * - 本波出怪队列耗尽后结波;最后一波须清场;中间波 waveClearRequiresKillAll 时还须清场 */ export class WaveSpawner implements ISystem { readonly priority = 60; enabled = true; private _waveIndex = -1; private _flatTasks: FlatSpawnTask[] = []; private _portStates: PortSpawnState[] = []; private _waveCombatActive = false; /** 唐僧逃跑等演出挂起出怪(计时不推进) */ private _spawnSuspended = false; private _usePortMode = false; private _onWaveCleared: (() => void) | null = null; /** 复活回收后优先出怪队列(逐个重新出生) */ private _requeueTasks: FlatSpawnTask[] = []; /** 清场扫描:被进攻的玩家 ID */ private _aliveScanOwnerId = 0; /** 扫描是否已找到进攻指定玩家的存活敌怪 */ private _aliveScanFound = false; constructor( private readonly _world: World, private readonly _config: IConfigProvider, private readonly _events: BattleEventBus, private readonly _movement: MovementSystem, private readonly _grid: GridMap, private readonly _topology: MapTopology, private readonly _services: BattleServices, ) {} /** 设置本波清场回调(BattleSession 构造后注入,避免反向持有 session) */ setOnWaveCleared(fn: () => void): void { this._onWaveCleared = fn; } /** 当前波次索引(0-based,-1 表示尚未开战) */ get waveIndex(): number { return this._waveIndex; } /** 是否还有未开始的波次 */ hasMoreWaves(): boolean { return !!this._config.getWave(this._services.levelId, this._waveIndex + 1); } /** 当前波次是否处于战斗出怪中(未清场) */ isWaveCombatActive(): boolean { return this._waveCombatActive; } /** * 快照恢复:当前波次索引已推进但出怪未激活/队列被清空时,按配表重建本波出怪任务(不递增波次)。 */ resumeCurrentWaveIfNeeded(): boolean { if (this._waveCombatActive || this._waveIndex < 0) return false; const wave = this._config.getWave(this._services.levelId, this._waveIndex); if (!wave) return false; this._flatTasks = []; this._portStates = []; this._requeueTasks = []; this._waveCombatActive = true; if (wave.ports?.length) { this._usePortMode = true; this._initPortMode(wave.ports, wave.playerId ?? 0); } else { this._usePortMode = false; this._initFlatMode(wave.spawns ?? [], wave.playerId ?? 0); } return true; } /** 挂起/恢复出怪计时(逃跑演出等) */ suspendSpawn(suspended: boolean): void { this._spawnSuspended = suspended; } /** * 指定出怪口当前波次的出怪间隔(秒)。 * 端口模式优先读该口当前 segment;否则回退配表首段。 */ resolveSpawnIntervalForPort(port: number): number { if (this._usePortMode) { const state = this._portStates.find(s => s.port === port); if (state?.segments.length) { const idx = Math.min(state.segmentIndex, state.segments.length - 1); const iv = state.segments[idx]?.interval; if (typeof iv === 'number' && iv > 0) return iv; } } const wave = this._config.getWave(this._services.levelId, this._waveIndex); if (wave?.ports) { for (const portPlan of wave.ports) { if (portPlan.port !== port) continue; for (const seg of portPlan.segments) { if (seg.interval > 0) return seg.interval; } } } if (wave?.spawns?.length) { for (const entry of wave.spawns) { if (entry.interval > 0) return entry.interval; } } return 1.5; } /** 开始下一波(由 BattleSession.startCombat 调用) */ startNextWave(): void { const nextIndex = this._waveIndex + 1; const wave = this._config.getWave(this._services.levelId, nextIndex); if (!wave) return; this._waveIndex = nextIndex; this._flatTasks = []; this._portStates = []; this._requeueTasks = []; this._waveCombatActive = true; if (wave.ports?.length) { this._usePortMode = true; this._initPortMode(wave.ports, wave.playerId ?? 0); let portsWithSegments = 0; for (let i = 0; i < wave.ports.length; i++) { if (wave.ports[i].segments.length > 0) portsWithSegments++; } if (portsWithSegments >= 2 && this._portStates.length < portsWithSegments) { console.error( '[WaveSpawner] 双口出怪配置未完全生效', 'wavePorts=', wave.ports.length, 'activePortStates=', this._portStates.length, ); } } else { this._initFlatMode(wave.spawns ?? [], wave.playerId ?? 0); } this._events.emit(BattleEventFactory.waveStart(nextIndex, wave.playerId)); } /** COMBAT 阶段每帧驱动出怪计时 */ tick(dt: number): void { if (this._services.phase !== BattlePhase.COMBAT || !this._waveCombatActive || this._spawnSuspended) return; this._tickRequeue(dt); if (this._usePortMode) { this._tickPorts(dt); } else { this._tickFlat(dt); } } /** * 复活:清掉场上存活敌人,按原出生口重新排入出怪队列(逐个再出)。 * 不走击杀结算。 */ requeueAliveEnemies(intervalSec: number): void { const interval = Math.max(0.05, intervalSec); const collected: FlatSpawnTask[] = []; const world = this._world; const toRemove: EntityId[] = []; world.forEachAlive(id => { const team = world.getComponent(id, TeamComp); const tags = world.getComponent(id, TagComp); const isEnemy = (team && BattleTeamId.isEnemyTeam(team.team)) || !!tags?.tags.includes(EntityTag.Enemy); if (!isEnemy) return; const meta = world.getComponent(id, UnitMetaComp); if (!meta?.unitDefId) return; collected.push({ unitDefId: meta.unitDefId, remain: 1, interval, timer: collected.length * interval, spawnCol: meta.spawnPoint > 0 ? meta.spawnPoint : 1, playerId: team?.ownerId ?? 0, }); toRemove.push(id); }); for (const id of toRemove) { this._services.lifecycle.despawn(id); } // 先出回收怪,再继续本波剩余队列 this._requeueTasks = collected.concat(this._requeueTasks); if (collected.length > 0) { this._waveCombatActive = true; } } private _tickRequeue(dt: number): void { if (this._requeueTasks.length === 0) return; const completed: number[] = []; for (let i = 0; i < this._requeueTasks.length; i++) { const task = this._requeueTasks[i]; task.timer -= dt; if (task.timer > 0) continue; if (!this._canSpawnMore()) { task.timer = 0.1; continue; } if (!this._spawnEnemy(task.unitDefId, task.spawnCol, task.playerId)) { task.timer = 0.1; continue; } task.remain--; task.timer = task.interval; if (task.remain <= 0) completed.push(i); } for (let i = completed.length - 1; i >= 0; i--) { this._requeueTasks.splice(completed[i], 1); } } private _initPortMode(ports: WavePortPlan[], defaultPlayerId: number): void { this._usePortMode = true; for (const portPlan of ports) { if (!portPlan.segments.length) continue; const first = portPlan.segments[0]; const playerId = portPlan.playerId ?? (portPlan.port === 2 ? 1 : 0); this._portStates.push({ port: portPlan.port, segments: portPlan.segments, segmentIndex: 0, remain: first.count, timer: 0, done: false, playerId, }); } } private _initFlatMode(spawns: WaveSpawnEntry[], playerId: number): void { this._usePortMode = false; let colOffset = 0; for (const entry of spawns) { for (let i = 0; i < entry.count; i++) { this._flatTasks.push({ unitDefId: entry.unitDefId, remain: 1, interval: entry.interval, timer: colOffset * 0.2, spawnCol: (entry.spawnPoint ?? colOffset) % this._grid.cols, playerId, }); colOffset++; } } } private _tickPorts(dt: number): void { for (const state of this._portStates) { if (state.done) continue; state.timer -= dt; } const due: PortSpawnState[] = []; for (const state of this._portStates) { if (state.done || state.timer > 0) continue; due.push(state); } if (due.length > 0) { const cap = this._services.config.getLevel(this._services.levelId)?.maxConcurrent ?? 0; if (cap > 0 && this.countAliveEnemies() + due.length > cap) { for (const state of due) state.timer = 0.1; } else { for (const state of due) this._advanceDuePort(state); } } const allPortsDone = this._portStates.length > 0 && this._portStates.every(s => s.done); if (allPortsDone && this._requeueTasks.length === 0 && this._canFinishWave()) { this._finishWave(); } } /** 到期口出一只并进入下一次间隔;失败则短推迟,不单独饿死另一口的同拍 */ private _advanceDuePort(state: PortSpawnState): void { const segment = state.segments[state.segmentIndex]; if (!this._spawnEnemy(segment.unitDefId, state.port, state.playerId)) { state.timer = 0.1; return; } state.remain--; state.timer = Math.max(0.05, segment.interval || 0); if (state.remain > 0) return; state.segmentIndex++; if (state.segmentIndex >= state.segments.length) { state.done = true; return; } const nextSegment = state.segments[state.segmentIndex]; state.remain = nextSegment.count; if (!(state.timer > 0)) { state.timer = Math.max(0.05, nextSegment.interval || 0); } } private _tickFlat(dt: number): void { const completed: number[] = []; for (let i = 0; i < this._flatTasks.length; i++) { const task = this._flatTasks[i]; task.timer -= dt; if (task.timer > 0) continue; // 同屏怪物预算:超限则推迟本帧出怪(不消耗 remain,下帧再试) if (!this._canSpawnMore()) { task.timer = 0.1; continue; } if (!this._spawnEnemy(task.unitDefId, task.spawnCol, task.playerId)) { task.timer = 0.1; continue; } task.remain--; task.timer = task.interval; if (task.remain <= 0) completed.push(i); } for (let i = completed.length - 1; i >= 0; i--) { this._flatTasks.splice(completed[i], 1); } if (this._flatTasks.length === 0 && this._requeueTasks.length === 0 && this._canFinishWave()) { this._finishWave(); } } /** 本波是否可结波:最后一波须清掉进攻本机的怪;中间波看 waveClearRequiresKillAll */ private _canFinishWave(): boolean { const isLastWave = !this._config.getWave(this._services.levelId, this._waveIndex + 1); if (isLastWave) { const localId = this._services.players[0]?.id; if (localId == null) { console.log('[WaveSpawner] 最后一波清场无本地玩家槽'); return false; } return !this._hasAliveAttackers(localId); } if (this._services.waveClearRequiresKillAll) { return !this._hasAliveEnemies(); } return true; } /** * 是否仍有进攻指定玩家的存活敌怪。 * @param ownerId 被进攻的玩家 ID */ hasAliveAttackers(ownerId: number): boolean { return this._hasAliveAttackers(ownerId); } /** * @param ownerId 被进攻的玩家 ID */ private _hasAliveAttackers(ownerId: number): boolean { this._aliveScanOwnerId = ownerId; this._aliveScanFound = false; this._world.forEachAlive(this._markAliveAttacker); return this._aliveScanFound; } /** * 标记进攻扫描目标玩家的存活敌怪。 * @param id 实体 */ private readonly _markAliveAttacker = (id: EntityId): void => { if (this._aliveScanFound) return; const team = this._world.getComponent(id, TeamComp); if (!team || !BattleTeamId.isEnemyTeam(team.team)) return; if (team.ownerId !== this._aliveScanOwnerId) return; this._aliveScanFound = true; }; private _finishWave(): void { this._waveCombatActive = false; this._onWaveCleared?.(); } private _hasAliveEnemies(): boolean { const world = this._world; let found = false; world.forEachAlive(id => { if (found) return; const tags = world.getComponent(id, TagComp); if (tags?.tags.includes(EntityTag.Enemy)) { found = true; return; } const team = world.getComponent(id, TeamComp); if (team && BattleTeamId.isEnemyTeam(team.team)) { found = true; } }); return found; } /** 当前同屏敌方怪物数量 */ countAliveEnemies(): number { const world = this._world; let n = 0; world.forEachAlive(id => { const team = world.getComponent(id, TeamComp); if (team && BattleTeamId.isEnemyTeam(team.team)) { n++; return; } const tags = world.getComponent(id, TagComp); if (tags?.tags.includes(EntityTag.Enemy)) n++; }); return n; } /** * 循环圈「场内」敌方数量:不含 spawnProtected(出怪口隐藏 / 复活回收待机)的怪。 */ countPerimeterFieldEnemies(): number { const world = this._world; let n = 0; world.forEachAlive(id => { const move = world.getComponent(id, MovementComp); if (move?.spawnProtected) return; const team = world.getComponent(id, TeamComp); if (team && BattleTeamId.isEnemyTeam(team.team)) { n++; return; } const tags = world.getComponent(id, TagComp); if (tags?.tags.includes(EntityTag.Enemy)) n++; }); return n; } /** 是否还允许出怪(受 levelDef.maxConcurrent 限制) */ private _canSpawnMore(): boolean { const cap = this._services.config.getLevel(this._services.levelId)?.maxConcurrent ?? 0; if (cap <= 0) return true; return this.countAliveEnemies() < cap; } /** * 波次队列外额外出怪(技能召唤等)。 * @param spineSkinOverride 非空时写入 UnitMetaComp,供表现层优先套皮 * @returns 实体 id;达并发上限或失败时 null */ spawnExtraEnemy( unitDefId: UnitDefId, spawnPoint: number, targetPlayerId: number, spineSkinOverride?: string, ): EntityId | null { if (!this._canSpawnMore()) { console.log('[WaveSpawner] spawnExtraEnemy 达并发上限', unitDefId, spawnPoint); return null; } return this._spawnEnemyEntity(unitDefId, spawnPoint, targetPlayerId, spineSkinOverride, undefined, undefined, true); } /** * 在指定格出生;meta.spawnPoint 仍记逻辑口,unitSpawned.spawnPort=0。 * via 在派发前写入 spawnJumpToPath 并 assignPathVia,供表现层抛物线跳到进路。 */ spawnExtraEnemyAtCell( unitDefId: UnitDefId, spawnPoint: number, targetPlayerId: number, cell: Cell, spineSkinOverride?: string, via?: Cell, ): EntityId | null { if (!this._canSpawnMore()) { console.log('[WaveSpawner] spawnExtraEnemyAtCell 达并发上限', unitDefId, spawnPoint); return null; } if (!this._grid.inBounds(cell)) { console.error('[WaveSpawner] spawnExtraEnemyAtCell 格越界', cell); return null; } return this._spawnEnemyEntity(unitDefId, spawnPoint, targetPlayerId, spineSkinOverride, cell, via, true); } private _spawnEnemy(unitDefId: UnitDefId, spawnPoint: number, targetPlayerId: number): boolean { return this._spawnEnemyEntity(unitDefId, spawnPoint, targetPlayerId) != null; } /** * 创建敌方单位并发 unitSpawned;失败返回 null。 * @param startCell 非空时在此格出生且 unitSpawned.spawnPort=0 * @param via 非空时派发前 assignPathVia,并标记 spawnJumpToPath * @param glowShadow 非基地波次召唤(技能等)用发光阴影 */ private _spawnEnemyEntity( unitDefId: UnitDefId, spawnPoint: number, targetPlayerId: number, spineSkinOverride?: string, startCell?: Cell, via?: Cell, glowShadow = false, ): EntityId | null { const def = this._config.getUnit(unitDefId); if (!def) { console.error('[WaveSpawner] unit def missing', unitDefId); return null; } const anchor = startCell ?? this._topology.getSpawnCell(spawnPoint); if (!anchor) { console.error('[WaveSpawner] 出怪口未在地图拓扑中配置', spawnPoint, 'unitDefId=', unitDefId); return null; } const entity = UnitFactory.create(this._services, def, targetPlayerId, anchor, [anchor]); const world = this._world; if (!world.isAlive(entity)) return null; // 覆盖为敌方阵营 world.addComponent(entity, new TeamComp(BattleTeamId.enemyForPlayer(targetPlayerId), targetPlayerId)); const meta = world.getComponent(entity, UnitMetaComp); if (meta) { meta.spawnPoint = spawnPoint; if (spineSkinOverride) meta.spineSkinOverride = spineSkinOverride; if (via) meta.spawnJumpToPath = true; meta.glowShadow = glowShadow; } const healthComp = world.getComponent(entity, HealthComp); const maxHp = finitePositiveHp( healthComp?.maxHp, finitePositiveHp(def.components.health?.maxHp, 1), ); traceCombatDamage('waveSpawnEnemy', { entity, unitDefId, ecsMaxHp: healthComp?.maxHp, defMaxHp: def.components.health?.maxHp, eventHp: maxHp, }); // 逻辑层出生即保护:避免表现层激光等绕过 TargetResolver 点名 const move = world.getComponent(entity, MovementComp); if (move) { move.presentLocked = true; move.spawnProtected = true; } const visualPort = startCell ? 0 : spawnPoint; // 先寻路再派发:Presenter 跳出读 path[0];禁止空 path 时表现先落到错误坐标 if (move && via) { this._movement.assignPathVia(entity, via); } else if (move && !startCell) { this._movement.assignPath(entity); } this._events.emit(BattleEventFactory.unitSpawned(entity, unitDefId, visualPort, maxHp), true); return entity; } }