/** * @file MissionMonComp.ts * @description 怪物(Monster)回合刷新管理组件(逻辑层) * * 职责: * 1. 管理每一回合怪物的生成计划:根据 RogueConfig 生成怪物。 * 2. 自动推进回合:在准备阶段结束时(PhasePrepareEnd)启动分批释放。 * 3. 持续刷怪:回合开始整波进入队列,30 秒内按速度曲线匀速刷出(铺垫 → 加压 → 高潮)。 * * 关键设计: * - 所有怪物统一从右侧 X=400 出生点刷出,速度曲线由 m.batch 驱动(前慢后快,压轴批自动高潮)。 * - 每帧欠账式补刷(期望数 − 已刷数),自动补偿帧余量,保证 30 秒预算不流失。 * - 场上怪物超阈值(stop_spawn_mon)时暂停推进,恢复后瞬时补齐欠账。 * - 实际阵型与推进由 MonMoveComp 在战斗中向左移动自然形成,不再使用固定网格点。 * - 槽位索引(3行×4列)仅用于 monGrid 寻路与 SCastSystem 索敌定位。 * - 上一回合残留怪在回合结束/开始时统一清理。 */ import { _decorator, v3, Vec3 } from "cc"; import { mLogger } from "../common/Logger"; import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS"; import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; import { Monster } from "../hero/Mon"; import { smc } from "../common/SingletonModuleComp"; import { GameEvent } from "../common/config/GameEvent"; import { BoxSet, FacSet } from "../common/config/GameSet"; import { spawningEngine, GeneratedMonster, TestModeConfig, WAVE_DURATION } from "./RogueConfig"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { MonMoveComp } from "../hero/MonMoveComp"; const { ccclass, property } = _decorator; @ccclass('MissionMonCompComp') @ecs.register('MissionMonComp', false) export class MissionMonCompComp extends CCComp { // ======================== 常量 ======================== /** 怪物出生掉落高度 */ private static readonly MON_DROP_HEIGHT = 0; /** 怪物统一从右侧 X=400 出生点逐个刷出,随后向左推进 */ private static readonly MON_SPAWN_X = 400; /** * 怪物占位点:所有槽位统一以右侧出生点 (X=400) 作为坐标, * 实际阵型由 MonMoveComp 在战斗中向左推进时自然拉开。 * 槽位索引仍保留 3 行 × 4 列结构,供 monGrid 寻路与 SCastSystem 索敌使用。 * 超出 12 个槽位的怪物(放松回合)按 spawnIndex % 12 复用槽位。 */ public static readonly MON_POSITIONS: Vec3[] = [ v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 0: Col1-Top v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 1: Col1-Mid v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 2: Col1-Bot v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 3: Col2-Top v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 4: Col2-Mid v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 5: Col2-Bot v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 6: Col3-Top v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 7: Col3-Mid v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 8: Col3-Bot v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 9: Col4-Top v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 10: Col4-Mid v3(MissionMonCompComp.MON_SPAWN_X, BoxSet.GAME_LINE, 0), // index 11: Col4-Bot ]; // ======================== 编辑器属性 ======================== @property({ tooltip: "是否启用调试日志" }) private debugMode: boolean = false; // ======================== 运行时状态 ======================== /** 全局生成顺序计数器(用于渲染层级排序) */ private globalSpawnOrder: number = 0; /** 当前回合数 */ private currentWave: number = 0; /** 当前回合的目标怪物总数 */ private waveTargetCount: number = 0; /** 当前回合已生成的怪物数量 */ private waveSpawnedCount: number = 0; /** 待刷队列(回合开始时整波进入,按速度曲线匀速刷出) */ private spawnQueue: GeneratedMonster[] = []; /** 本回合已推进的战斗时间(秒,暂停刷怪时冻结) */ private waveTime: number = 0; /** 各批次在队列中的起始下标(用于速度曲线按批变速) */ private batchStarts: number[] = [0, 0, 0]; /** 是否正在刷怪释放中 */ private isReleasing: boolean = false; /** 当前批已刷出的怪物数(清场加速存活比例分母) */ private batchReleasedCount: number = 0; /** 当前批是否已通过清场加速提前推进过(每批只触发一次) */ private batchFastForwarded: boolean = false; /** 清场加速检测节流计时器(秒) */ private aliveCheckTimer: number = 0; /** 本回合因清场加速累计节省的秒数(供 MissionComp 还原 DDA 判定口径,避免双重惩罚) */ public waveEarlySkipTotal: number = 0; /** 清场加速触发阈值:当前批存活比例低于此值时提前推进(0.25 = 清掉 75%) */ private static readonly BATCH_EARLY_RATIO = 0.25; /** 清场加速提前量(秒):等效推进时间快进,保底不被瞬间叠爆 */ private static readonly BATCH_EARLY_SKIP = 2.0; /** 批次时间边界(秒):batch0 → [0, T1),batch1 → [T1, T2),batch2 → [T2, WAVE_DURATION] */ private static readonly BATCH_T1 = WAVE_DURATION * 0.25; private static readonly BATCH_T2 = WAVE_DURATION * 0.60; // ======================== 生命周期 ======================== onLoad() { this.on(GameEvent.FightReady, this.fight_ready, this); this.on("PhasePrepareEnd", this.onPhasePrepareEnd, this); this.on("TimeUpAdvanceWave", this.onTimeUpAdvanceWave, this); } protected update(dt: number): void { // 待刷出统计(回合结束检测与 HUD 进度消费,必须在 return 前执行保证语义不受暂停影响) smc.vmdata.mission_data.pending_mon_num = this.spawnQueue.length; // 场上怪物超阈值时暂停推进:冻结 waveTime,恢复后欠账式瞬时补齐,30 秒预算不流失 if (smc.mission.stop_spawn_mon) return; if (!this.isReleasing) return; this.waveTime += dt; // 清场加速:0.2s 节流检测当前段存活比例,清得快则快进推进时间(压缩垃圾时间) this.aliveCheckTimer += dt; if (this.aliveCheckTimer >= 0.2) { this.aliveCheckTimer = 0; this.checkEarlyAdvance(); } // 欠账式补刷:期望累计刷出数 − 已刷数,自动补偿帧余量与暂停期间欠账 let expected = this.expectedSpawned(this.waveTime); while (this.waveSpawnedCount < expected && this.spawnQueue.length > 0) { const monData = this.spawnQueue.shift()!; const targetPosIndex = this.waveSpawnedCount % MissionMonCompComp.MON_POSITIONS.length; this.addMonsterAtGrid(targetPosIndex, monData, this.currentWave); this.waveSpawnedCount++; } if (this.spawnQueue.length === 0) { this.isReleasing = false; } } start() { } private setupWaveData(monsters: GeneratedMonster[]) { // 按批次排序后整波入队,记录各批起始下标供速度曲线按批变速(batch 越大越靠后压轴) const sorted = monsters.slice().sort((a, b) => a.batch - b.batch); this.spawnQueue = sorted; const starts = [sorted.length, sorted.length, sorted.length]; for (let i = 0; i < sorted.length; i++) { const b = Math.min(sorted[i].batch, 2); if (i < starts[b]) starts[b] = i; } // 空批起始下标前推到下一批起点(防 expectedSpawned 区间错乱) for (let b = 1; b >= 0; b--) { if (starts[b] === sorted.length) starts[b] = starts[b + 1]; } this.batchStarts = starts; this.waveTargetCount = monsters.length; smc.vmdata.mission_data.pending_mon_num = this.waveTargetCount; let hasBoss = monsters.some(m => m.isBoss); mLogger.log(this.debugMode, 'MissionMonComp', `[MissionMonComp] 回合 ${this.currentWave} 生成怪物总数: ${this.waveTargetCount}`); oops.message.dispatchEvent(GameEvent.NewWave, { wave: this.currentWave, total: this.waveTargetCount, bossWave: hasBoss, }); } /** * 战斗准备:重置所有运行时状态并开始第一回合。 */ fight_ready() { smc.vmdata.mission_data.mon_num = 0; smc.mission.stop_spawn_mon = false; this.globalSpawnOrder = 0; this.currentWave = 1; this.waveTargetCount = 0; this.waveSpawnedCount = 0; this.spawnQueue = []; this.batchStarts = [0, 0, 0]; this.waveTime = 0; this.isReleasing = false; this.batchReleasedCount = 0; this.batchFastForwarded = false; this.aliveCheckTimer = 0; this.waveEarlySkipTotal = 0; // 预生成第一回合数据以获取数量和 Boss 信息 const monsters = spawningEngine.generateWave(this.currentWave); this.setupWaveData(monsters); if (TestModeConfig.enable) { mLogger.log(this.debugMode, 'MissionMonComp', "[MissionMonComp] 测试模式已开启"); } mLogger.log(this.debugMode, 'MissionMonComp', "[MissionMonComp] Starting Wave System"); } // ======================== 回合管理 ======================== /** * 开始下一回合:回合数 +1 并预生成数据 */ private onTimeUpAdvanceWave() { this.currentWave += 1; smc.vmdata.mission_data.level = this.currentWave; const monsters = spawningEngine.generateWave(this.currentWave); this.setupWaveData(monsters); } private onPhasePrepareEnd() { // 准备结束:清残留怪与计时,启动持续刷怪(队列已在 setupWaveData 整波入队,不能清) this.resetBattleTimer(); this.isReleasing = true; this.waveTime = 0; } // ======================== 持续刷怪 ======================== /** * 速度曲线:按 waveTime 所在时间段返回"到当前时刻应累计刷出的怪物数"。 * 三段时间对应三批(铺垫 → 加压 → 高潮),批内匀速;m.batch 只影响排序,不再切断节奏。 */ private expectedSpawned(t: number): number { const total = this.waveTargetCount; if (total <= 0) return 0; const [s0, s1] = [this.batchStarts[0], this.batchStarts[1]]; const s2 = this.batchStarts[2]; const T1 = MissionMonCompComp.BATCH_T1; const T2 = MissionMonCompComp.BATCH_T2; if (t < T1) { const segTotal = s1 - s0; const segTime = T1; return s0 + Math.min(segTotal, Math.floor(t / segTime * segTotal)); } else if (t < T2) { const segTotal = s2 - s1; const segTime = T2 - T1; return s1 + Math.min(segTotal, Math.floor((t - T1) / segTime * segTotal)); } else { const segTotal = total - s2; const segTime = WAVE_DURATION - T2; return s2 + Math.min(segTotal, Math.floor((t - T2) / segTime * segTotal)); } } /** * 清场加速检测:当前时间段已刷完且存活比例 ≤ BATCH_EARLY_RATIO 时,快进推进时间。 * 只奖励"清得快"的 build(压缩垃圾时间),弱 build 清不完则不触发、不叠加压力。 */ private checkEarlyAdvance() { if (this.batchFastForwarded) return; if (this.spawnQueue.length === 0) return; // 当前时间段的 quota 是否已刷完:用 expectedSpawned 反推 const expected = this.expectedSpawned(this.waveTime); if (this.waveSpawnedCount < expected) return; // 本时间段还没放完,不判断 if (this.batchReleasedCount <= 0) { this.batchReleasedCount = Math.max(1, expected); } let alive = 0; ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => { const a = e.get(HeroAttrsComp); if (a && a.fac === FacSet.MON && !a.is_dead) alive++; }); // alive 含前几批残留,比例 clamp 防低估 const aliveRatio = Math.min(1, alive / this.batchReleasedCount); if (aliveRatio <= MissionMonCompComp.BATCH_EARLY_RATIO) { this.batchFastForwarded = true; this.waveTime += MissionMonCompComp.BATCH_EARLY_SKIP; this.waveEarlySkipTotal += MissionMonCompComp.BATCH_EARLY_SKIP; smc.vmdata.mission_data.wave_early_skip = this.waveEarlySkipTotal; mLogger.log(this.debugMode, 'MissionMonComp', `[EarlyAdvance] waveTime=${this.waveTime.toFixed(1)} alive=${alive}/${this.batchReleasedCount},快进 ${MissionMonCompComp.BATCH_EARLY_SKIP}s`); } } // ======================== 槽位管理 ======================== /** * 清理上一回合残留怪物,并重置战斗计时与清场加速状态。 * 注意:spawnQueue / batchStarts / waveTargetCount 由 setupWaveData 在 Prepare 阶段写入, * 本函数不能触碰,否则 PhasePrepareEnd 会清空未刷队列导致回合直接判空。 */ private resetBattleTimer() { ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => { const attrs = e.get(HeroAttrsComp); if (attrs && attrs.fac === FacSet.MON && !attrs.is_dead) { e.destroy(); } }); this.waveSpawnedCount = 0; this.waveTime = 0; this.isReleasing = false; this.batchReleasedCount = 0; this.batchFastForwarded = false; this.aliveCheckTimer = 0; this.waveEarlySkipTotal = 0; smc.vmdata.mission_data.wave_early_skip = 0; } // ======================== 怪物生成 ======================== /** * 在指定位置索引处生成一个怪物 */ private addMonsterAtGrid( posIndex: number, monData: GeneratedMonster, monLv: number = 1 ) { let mon = ecs.getEntity(Monster); let scale = -1; const basePos = MissionMonCompComp.MON_POSITIONS[posIndex % MissionMonCompComp.MON_POSITIONS.length]; const spawnX = basePos.x; const landingY = basePos.y + (monData.isBoss ? 6 : 0); const spawnPos: Vec3 = v3(spawnX, landingY + MissionMonCompComp.MON_DROP_HEIGHT, 0); this.globalSpawnOrder = (this.globalSpawnOrder + 1) % 999; // 技能套装注入:通过 _testSkills 通道传递给 Mon.load() if (monData.skills) { (mon as any)._testSkills = monData.skills; } mon.load(spawnPos, scale, monData.uuid, monData.isBoss, landingY, monLv, posIndex); // oops.message 全局事件总线:Boss 登场(震屏/音效由表现层消费) if (monData.isBoss) { oops.message.dispatchEvent(GameEvent.BossSpawn, { pos: spawnPos.clone() }); } const move = mon.get(MonMoveComp); if (move) { move.spawnOrder = this.globalSpawnOrder; } // 应用新引擎计算好的最终属性 const model = mon.get(HeroAttrsComp); if (model) { model.ap = monData.ap; model.hp_max = monData.hp; model.hp = model.hp_max; } } /** ECS 组件移除时触发 */ reset() { } }