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.
617 lines
22 KiB
617 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 '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);
|
|
if (wave.ports.length >= 2 && this._portStates.length < 2) {
|
|
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 : defaultPlayerId);
|
|
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;
|
|
if (move && via) {
|
|
this._movement.assignPathVia(entity, via);
|
|
}
|
|
// 立即派发:Presenter 在解锁 presentLocked 前须已有路径(营跳读 path[0])
|
|
this._events.emit(BattleEventFactory.unitSpawned(entity, unitDefId, visualPort, maxHp), true);
|
|
if (move && !startCell && !via) {
|
|
this._movement.assignPath(entity);
|
|
}
|
|
return entity;
|
|
}
|
|
}
|
|
|