You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/mx/module/battle/unit/WaveSpawner.ts

620 lines
22 KiB

import type { World, EntityId } from 'db://assets/mx/module/ecs';
import type { ISystem } from 'db://assets/mx/module/ecs';
import type { Cell } from '../battlefield/Cell';
import type { UnitDefId, WavePortPlan, WaveSpawnEntry, WaveSpawnSegment } from '../config/BattleConfigTypes';
import { BattlePhase } from '../core/BattlePhase';
import { BattleTeamId } from '../ecs/BattleTeamId';
import { EntityTag } from '../ecs/EntityTag';
import { BattleEventFactory } from '../events/BattleEvents';
import type { BattleEventBus } from '../events/BattleEventBus';
import { UnitFactory } from './UnitFactory';
import { finitePositiveHp } from '../../../tools/FiniteHp';
import { traceCombatDamage } from '../../../tools/CombatDamageTrace';
import { TeamComp, TagComp, HealthComp, MovementComp, UnitMetaComp } from '../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;
}
}