Files
pixelheros/assets/script/game/map/MissionMonComp.ts
pan a905db8e48 feat: 大幅优化战斗体验与编队系统
1. 扩展英雄/怪物网格站位到10个槽位
2. 重构默认攻击距离计算逻辑,按职业动态适配
3. 重做英雄站位与移动系统,新增同阵营间距约束
4. 调整rogue模式怪物数量与强度配置,提升战斗爽感
5. 重构刷怪逻辑为匀速曲线+欠账补刷,优化刷怪节奏
2026-08-12 17:56:59 +08:00

354 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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>(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() { }
}