Files
pixelheros/assets/script/game/map/MissionComp.ts
pan 409113e269 feat: 完成肉鸽回合制游戏核心玩法迭代
新增连杀系统、金币飞行特效、波次HUD、屏幕震动等表现功能,重构怪物刷出逻辑与难度动态调节,调整回合回血比例,优化游戏节奏与体验。

主要变更:
1.  调整回合回血比例从0.5到0.4,优化前期节奏
2.  新增连杀计数与奖励系统,支持5/10/20连杀触发对应表现
3.  实现怪物死亡掉落金币的抛物线飞行特效
4.  增加波次进度HUD,显示剩余怪物与全局回合进度
5.  新增屏幕震动工具与战斗横幅统一展示系统
6.  重构怪物刷出逻辑,支持按回合类型调整刷怪间隔,加入清场加速机制
7.  优化动态难度调节算法,增加滞回与指数平滑,避免难度突变
8.  新增Boss预警、登场事件与回合清屏事件,完善事件总线
9.  调整怪物数量阈值与回合倒计时档位,适配新的节奏设计
2026-08-11 19:00:23 +08:00

1201 lines
50 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 MissionComp.ts
* @description 任务关卡核心控制组件UI + 逻辑层)
*
* 职责:
* 1. 管理单局游戏的 **完整生命周期**:初始化 → 准备阶段 → 战斗阶段 → 结算。
* 2. 在战斗阶段每帧更新战斗计时器、同步怪物数量、检测英雄全灭。
* 3. 管理怪物数量阈值(暂停 / 恢复刷怪的上下限)。
* 4. 处理新一回合事件NewWave进入准备阶段并发放金币奖励。
* 5. 提供战斗结束后的结算弹窗入口VictoryComp
* 6. (可选)内建性能监控面板,显示内存、帧率、实体数量等开发信息。
*
* 关键设计:
* - mission_start() 初始化所有游戏数据 → 进入准备阶段 → 显示 loading。
* - 准备阶段enterPreparePhase停止刷怪显示开始按钮。
* - 战斗阶段to_fight开始刷怪隐藏按钮由 update 驱动。
* - 怪物数量管理采用 max/resume 双阈值:
* * 超过 max → 暂停刷怪stop_spawn_mon=true
* * 降至 resume 以下 → 恢复刷怪
* - cleanComponents() 在任务开始/结束时销毁所有英雄和技能 ECS 实体。
* - clearBattlePools() 回收对象池Monster / Skill / Tooltip
*
* 依赖:
* - smc.mission —— 全局任务运行状态play / pause / in_fight / stop_spawn_mon 等)
* - smc.vmdata.mission_data —— 局内数据(金币 / 回合数 / 怪物数量等)
* - FightSet —— 战斗常量配置
* - CardInitCoins —— 初始金币数
* - UIID.Victory —— 结算弹窗
*/
import { _decorator, Vec3, Animation, instantiate, Prefab, Node, NodeEventType, ProgressBar, Label, CCInteger, tween, v3, Tween, Widget, UIOpacity } from "cc";
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { smc } from "../common/SingletonModuleComp";
import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { GameEvent } from "../common/config/GameEvent";
import { SkillTriggerType } from "../common/config/heroSet";
import { HeroViewComp } from "../hero/HeroViewComp";
import { SkillTriggerHelper } from "../hero/SkillTriggerHelper";
import { UIID } from "../common/config/GameUIConfig";
import { SkillView } from "../skill/SkillView";
import { FacSet, FightSet } from "../common/config/GameSet";
import { HeroInfo } from "../common/config/heroSet";
import { mLogger } from "../common/Logger";
import { Monster } from "../hero/Mon";
import { Skill } from "../skill/Skill";
import { Tooltip } from "../skill/Tooltip";
import { Timer } from "db://oops-framework/core/common/timer/Timer";
import { FieldSkillType } from "../common/config/SkillSet";
import { FieldSkillHelper } from "../hero/FieldSkillHelper";
import { spawningEngine, MAX_WAVE, DynamicTuner, WAVE_TIMEOUT, WAVE_TIMEOUT_BOSS, BATCH_INTERVAL, BATCH_COUNT, WAVE_DURATION } from "./RogueConfig";
const { ccclass, property } = _decorator;
/** 任务(关卡)生命周期阶段 */
export enum MissionPhase {
None = 0, // 未初始化
PrepareStart = 1, // 准备开始阶段 (2s)
Prepare = 2, // 准备阶段 (等待玩家点击开始)
PrepareEnd = 3, // 准备结束阶段 (2s)
BattleStart = 4, // 战斗开始阶段 (2s)
Battle = 5, // 战斗阶段 (刷怪、战斗中)
BattleEnd = 6, // 战斗结束阶段 (2s)
Settle = 7 // 结算阶段
}
//@todo 需要关注 当boss死亡的时候的动画播放完成后需要触发事件通知 MissionComp 进行奖励处理
/**
* MissionComp —— 任务(关卡)核心控制器
*
* 驱动单局游戏的完整流程:准备 → 战斗 → 结算。
* 管理战斗计时、怪物数量控制、英雄全灭检测和金币奖励发放。
*/
@ccclass('MissionComp')
@ecs.register('MissionComp', false)
export class MissionComp extends CCComp {
@property({ tooltip: "是否启用调试日志" })
private debugMode: boolean = true;
@property({ tooltip: "是否显示战斗内存观测面板" })
private showMemoryPanel: boolean = false;
// ======================== 配置参数 ========================
/** 怪物数量上限(超过后暂停刷怪):略高于单回合上限 MAX_MONSTERS(54),只有跨回合堆积才触发泄压 */
private maxMonsterCount: number = 60;
/** 怪物数量恢复阈值(降至此值以下恢复刷怪,需留空间接纳后续批次,单批最大约 18 只) */
private resumeMonsterCount: number = 40;
// ======================== 编辑器绑定节点 ========================
/** 开始战斗按钮 */
@property(Node)
start_btn: Node = null!
/** 时间/回合数显示节点 */
@property(Node)
time_node: Node = null!
@property(Node)
tooltip: Node = null!
/** 阶段名称映射表(用于 UI 显示) */
private static readonly PhaseNameMap: Record<MissionPhase, string> = {
[MissionPhase.None]: "未开始",
[MissionPhase.PrepareStart]: "准备开始",
[MissionPhase.Prepare]: "准备阶段",
[MissionPhase.PrepareEnd]: "准备结束",
[MissionPhase.BattleStart]: "战斗开始",
[MissionPhase.Battle]: "战斗中",
[MissionPhase.BattleEnd]: "战斗结束",
[MissionPhase.Settle]: "结算阶段"
};
// ======================== 运行时状态 ========================
/** 本回合战斗已耗时(秒),正向计时,用于自适应难度评估 */
clearTime: number = 0
/** 剩余复活次数 */
revive_times: number = 1;
/** 掉落奖励列表 */
rewards: any[] = []
/** 累计游戏数据 */
game_data: any = {
exp: 0,
gold: 0,
diamond: 0
}
/**秒计时 */
PhaseTime: Timer = new Timer(1)
/** 回合间倒计时(秒) */
private waveCountdown: number = 0;
/** 倒计时档位:快清场(压缩垃圾时间,奖励强 build */
private static readonly COUNTDOWN_FAST = 2.5;
/** 倒计时档位:普通 */
private static readonly COUNTDOWN_NORMAL = 4.0;
/** 倒计时档位:有英雄死亡(保留满运营窗口:调整阵型/复活/买卡) */
private static readonly COUNTDOWN_FULL = 5.0;
/** 上一次显示的时间字符串(避免重复设置) */
private lastTimeStr: string = "";
/** 上一次显示的秒数(避免重复计算) */
private lastTimeSecond: number = -1;
/** 性能监控面板 Label 引用 */
private memoryLabel: Label | null = null;
/** 性能监控刷新计时器 */
private memoryRefreshTimer: number = 0;
/** 上一次性能文本(避免重复渲染) */
private lastMemoryText: string = "";
/** 帧间隔累加(用于计算平均 FPS */
private perfDtAcc: number = 0;
/** 帧数计数 */
private perfFrameCount: number = 0;
/** 初始堆内存基准值MB */
private heapBaseMB: number = -1;
/** 堆内存峰值MB */
private heapPeakMB: number = 0;
/** 堆内存增长趋势MB/分钟) */
private heapTrendPerMinMB: number = 0;
/** 趋势计算计时器 */
private heapTrendTimer: number = 0;
/** 趋势计算基准MB */
private heapTrendBaseMB: number = -1;
/** 怪物数量同步计时器(降低同步频率) */
private monsterCountSyncTimer: number = 0;
/** 当前回合数 */
private currentWave: number = 0;
/** 是否为Boss回合 */
private isBossWave: boolean = false;
/** 超时回合已在 onBattleTimeout 预扣留存分BattleEnd 需跳过重复累加 */
private skipRemainScoreOnBattleEnd: boolean = false;
/** 上回合英雄死亡数快照BattleEnd 写入,供下一回合倒计时档位判定) */
private lastWaveDeathCount: number = 0;
/** 上回合计时口径清场时间快照(含清场加速还原,供下一回合倒计时档位判定) */
private lastWaveClearTime: number = 0;
/** 当前任务阶段 */
public currentPhase: MissionPhase = MissionPhase.None;
/** 是否处于回合间倒计时状态 */
private isWaveCountdown: boolean = false;
// ======================== ECS 查询匹配器(预缓存) ========================
/** 匹配拥有 HeroViewComp 的实体(英雄/怪物视图) */
private heroViewMatcher: any = null;
/** 匹配拥有 SkillView 的实体(技能视图) */
private skillViewMatcher: any = null;
/** 匹配拥有 HeroAttrsComp 的实体(英雄/怪物属性) */
private heroAttrsMatcher: any = null;
// ======================== 生命周期 ========================
onLoad() {
this.heroViewMatcher = ecs.allOf(HeroViewComp);
this.skillViewMatcher = ecs.allOf(SkillView);
this.heroAttrsMatcher = ecs.allOf(HeroAttrsComp);
this.showMemoryPanel = false
// 注册生命周期事件
this.on(GameEvent.MissionEnd, this.mission_end, this)
this.on(GameEvent.NewWave, this.onNewWave, this)
this.on(GameEvent.DO_AD_BACK, this.do_ad, this)
this.start_btn?.on(NodeEventType.TOUCH_END, this.onStartFightBtnClick, this)
// 第一回合准备阶段,首个英雄召唤后自动开始战斗(无需玩家点击开始按钮)
oops.message.on(GameEvent.MasterCalled, this.onHeroSummoned, this);
this.removeMemoryPanel()
}
onAdded(args: any) {
// 使用 scheduleOnce 将事件推迟到下一帧执行,
// 确保所有关联组件(如 MissionCardComp 等)都已经完成其 onLoad 生命周期。
this.scheduleOnce(() => {
oops.message.dispatchEvent(GameEvent.MissionStart, {});
this.mission_start();
}, 0);
smc.map.MapView.scene.mapLayer.stopAnimations();
// smc.map.MapView.scene.mapLayer.node.getChildByName("fight").getChildByName("fbox").active = true;
}
onDestroy() {
smc.map.MapView.scene.mapLayer.playAnimations()
// smc.map.MapView.scene.mapLayer.node.getChildByName("fight").getChildByName("fbox").active = false;
super.onDestroy();
if (this.start_btn && this.start_btn.isValid) {
this.start_btn.off(NodeEventType.TOUCH_END, this.onStartFightBtnClick, this);
}
oops.message.off(GameEvent.MasterCalled, this.onHeroSummoned, this);
}
/**
* 帧更新:
* - 非播放 / 暂停状态 → 跳过
* - 战斗中 → 同步怪物状态、更新计时器
*/
protected update(dt: number): void {
if (!smc.mission.play) return
// 如果是暂停状态,且不在 BattleEnd 阶段(全灭时需要播放完 fend 技能动画并自动流转),才真正停止 update 逻辑
if (smc.mission.pause && this.currentPhase !== MissionPhase.BattleEnd) return
// 回合间倒计时PrepareStart 复用为倒计时阶段5 秒后自动进入下一回合
if (this.currentPhase === MissionPhase.PrepareStart) {
this.waveCountdown -= dt;
this.updateCountdownUI();
if (this.waveCountdown <= 0) {
this.autoNextPhase();
}
return;
}
// 处理过渡阶段的计时
if (this.currentPhase === MissionPhase.PrepareEnd ||
this.currentPhase === MissionPhase.BattleStart ||
this.currentPhase === MissionPhase.BattleEnd) {
if (this.PhaseTime.update(dt)) {
this.autoNextPhase();
}
}
if (this.currentPhase === MissionPhase.Battle) {
this.syncMonsterSpawnState(dt)
if (smc.mission.stop_mon_action) return
smc.vmdata.mission_data.fight_time += dt
this.clearTime += dt
this.update_time();
// 回合超时兜底:僵局(坦克 vs 高血 Boss 互磨不死时强制收束回合Boss 回合阈值放宽
const timeout = this.isBossWave ? WAVE_TIMEOUT_BOSS : WAVE_TIMEOUT;
if (this.clearTime >= timeout) {
this.onBattleTimeout();
}
}
}
// ======================== 时间显示 ========================
/** 更新时间/回合数显示(仅在秒数变化时更新以减少 Label 操作) */
update_time() {
const remainSecond = Math.floor(smc.vmdata.mission_data.fight_time);
if (remainSecond === this.lastTimeSecond) return;
this.lastTimeSecond = remainSecond;
let m = Math.floor(remainSecond / 60);
let s = remainSecond % 60;
let str = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
if (str != this.lastTimeStr) {
if (this.time_node && this.time_node.isValid) {
const timeChild = this.time_node.getChildByName("time");
if (timeChild) {
const label = timeChild.getComponent(Label);
if (label) label.string = str;
}
}
this.lastTimeStr = str;
}
}
// ======================== 回合倒计时 ========================
/** 进入回合间倒计时:按上回合战况分档(快清场压缩垃圾时间,有死亡保留运营窗口) */
private startWaveCountdown() {
this.waveCountdown = this.computeCountdown();
this.isWaveCountdown = true;
this.updateCountdownUI(true);
}
/** 计算下一回合倒计时档位:读 BattleEnd 写入的快照onNewWave 会清零 clearTime不能现读 */
private computeCountdown(): number {
if (this.lastWaveDeathCount > 0) return MissionComp.COUNTDOWN_FULL;
if (this.lastWaveClearTime > 0 && this.lastWaveClearTime < WAVE_DURATION * 0.65) return MissionComp.COUNTDOWN_FAST;
return MissionComp.COUNTDOWN_NORMAL;
}
/** 更新倒计时 UI显示剩余秒数 */
private updateCountdownUI(force: boolean = false) {
if (!this.isWaveCountdown) return;
const remain = Math.max(0, Math.ceil(this.waveCountdown));
if (!force && remain === this.lastTimeSecond) return;
this.lastTimeSecond = remain;
if (this.time_node && this.time_node.isValid) {
const phaseNode = this.time_node.getChildByPath("Phase/Label");
if (phaseNode) {
const label = phaseNode.getComponent(Label);
if (label) {
label.string = `下一回合 ${remain}s`;
}
}
}
}
/** 结束回合间倒计时 */
private stopWaveCountdown() {
this.isWaveCountdown = false;
this.lastTimeSecond = -1;
}
// ======================== 奖励与广告 ========================
/** 奖励发放(预留) */
do_reward() {
}
/**
* 广告回调处理:
* 成功 → 增加刷新次数;失败 → 分发失败事件。
*/
do_ad() {
if (this.ad_back()) {
oops.message.dispatchEvent(GameEvent.AD_BACK_TRUE)
smc.vmdata.mission_data.refresh_count += FightSet.MORE_RC
} else {
oops.message.dispatchEvent(GameEvent.AD_BACK_FALSE)
}
}
/** 广告观看结果(预留,默认返回 true */
ad_back() {
return true
}
// ======================== 任务生命周期 ========================
/**
* 任务开始:
* 1. 取消上一局延迟回调。
* 2. 清理残留实体。
* 3. 初始化全部局内数据。
* 4. 分发 FightReady 事件。
* 5. 进入准备阶段并显示 loading。
*/
async mission_start() {
this.unscheduleAllCallbacks();
this.cleanComponents();
this.data_init()
oops.message.dispatchEvent(GameEvent.FightReady)
this.changePhase(MissionPhase.Prepare)
let loading = this.node.getChildByName("loading")
if (loading) {
loading.active = true
this.scheduleOnce(() => {
loading.active = false
}, 0.5)
}
// 播放战斗背景音乐,并稍微降低音量
oops.audio.volumeMusic = 0.5;
oops.audio.playerMusicLoop("music/BATTLE");
}
/** 更新开始按钮的状态显示 */
private updateStartBtnState() {
if (!this.start_btn || !this.start_btn.isValid) return;
const nobg = this.start_btn.getChildByName("nobg");
if (nobg) {
// 只有在 Prepare 阶段且未暂停时,按钮才可点击,否则激活 nobg显示不可点击状态
const canClick = this.currentPhase === MissionPhase.Prepare && smc.mission.play && !smc.mission.pause;
nobg.active = !canClick;
}
}
/** 播放阶段提示栏Tooltip动感切换动画 */
private playTooltipAnim(phaseName: string) {
if (!this.tooltip || !this.tooltip.isValid) {
console.warn("MissionComp: tooltip 节点未绑定或已失效,无法播放阶段提示动画!请在编辑器中将对应节点拖入 tooltip 属性中。");
return;
}
// 先激活节点,确保后续的组件刷新和渲染数据更新能正常生效
this.tooltip.active = true;
// 禁用 Widget 组件,防止其在 LateUpdate 中覆盖 tween 的位置修改
const widget = this.tooltip.getComponent(Widget);
if (widget) {
widget.updateAlignment(); // 强制刷新一次布局,防止第一次激活时尺寸未初始化
widget.enabled = false;
}
const labNode = this.tooltip.getChildByName("lab");
if (labNode) {
const label = labNode.getComponent(Label);
if (label) {
label.string = phaseName;
label.updateRenderData(true); // 强制更新渲染数据,避免首次无文本
}
}
Tween.stopAllByTarget(this.tooltip);
// 动感动画设计:右侧进入 -> 屏幕中央(带有轻微的弹跳和滑动) -> 左侧飞出
// 假设屏幕宽度适配下1200是一个足够的屏幕外距离适配横竖屏
const startPos = v3(1200, this.tooltip.position.y, this.tooltip.position.z);
const centerPos = v3(0, this.tooltip.position.y, this.tooltip.position.z);
const driftPos = v3(-50, this.tooltip.position.y, this.tooltip.position.z); // 在中央时的缓慢漂移
const endPos = v3(-1200, this.tooltip.position.y, this.tooltip.position.z);
this.tooltip.setPosition(startPos);
tween(this.tooltip)
// 1. 从右侧快速飞入并带回弹效果 (0.5秒)
.to(0.5, { position: centerPos }, { easing: "backOut" })
// 2. 在屏幕中央缓慢向左漂移,增强动感停留 (1.0秒)
.to(1.0, { position: driftPos }, { easing: "sineInOut" })
// 3. 快速向左飞出并消失 (0.4秒)
.to(0.4, { position: endPos }, { easing: "backIn" })
.call(() => {
this.tooltip.active = false;
})
.start();
}
/**
* 阶段切换核心方法(状态机)
* 处理状态流转时所需的事件触发和全局标志位修改。
* @param targetPhase 目标阶段
*/
private changePhase(targetPhase: MissionPhase) {
if (this.currentPhase === targetPhase) return;
const oldPhase = this.currentPhase;
this.currentPhase = targetPhase;
const phaseName = MissionComp.PhaseNameMap[targetPhase] || "未知";
// 播放状态切换提示栏动效(过滤掉 None、Prepare 准备阶段、Battle 战斗中阶段)
if (targetPhase !== MissionPhase.None &&
targetPhase !== MissionPhase.Prepare &&
targetPhase !== MissionPhase.Battle) {
this.playTooltipAnim(phaseName);
}
// 更新阶段显示 UI
if (this.time_node && this.time_node.isValid) {
const phaseNode = this.time_node.getChildByPath("Phase/Label");
if (phaseNode) {
const label = phaseNode.getComponent(Label);
if (label) {
const wave = Math.max(1, this.currentWave || (smc.vmdata && smc.vmdata.mission_data ? smc.vmdata.mission_data.level : 1) || 1);
label.string = `${wave} 回合`;
}
// 阶段切换动感表现:只在进入战斗阶段跳动一下,让流程充满心流体验
if (targetPhase === MissionPhase.BattleStart) {
Tween.stopAllByTarget(this.time_node);
this.time_node.scale = v3(1, 1, 1);
tween(this.time_node)
.to(0.3, { scale: v3(1.2, 1.2, 1) }, { easing: "backOut" })
.to(0.2, { scale: v3(1, 1, 1) }, { easing: "sineInOut" })
.start();
}
}
}
// 重置状态机的计时器
if (this.PhaseTime) {
this.PhaseTime.reset();
}
switch (targetPhase) {
case MissionPhase.PrepareStart:
smc.mission.in_fight = false;
smc.vmdata.mission_data.in_fight = false;
smc.mission.stop_spawn_mon = true;
// 回合间倒计时隐藏开始按钮nobg 激活表示不可点击状态)
if (this.start_btn && this.start_btn.isValid) {
const nobg = this.start_btn.getChildByName("nobg");
if (nobg) nobg.active = true;
}
// 启动 5 秒倒计时,由 update 驱动自动进入下一回合
this.startWaveCountdown();
oops.message.dispatchEvent("PhasePrepareStart");
break;
case MissionPhase.Prepare:
if (this.start_btn && this.start_btn.isValid) {
this.start_btn.active = true;
const nobg = this.start_btn.getChildByName("nobg");
if (nobg) nobg.active = false;
}
break;
case MissionPhase.PrepareEnd:
// 不隐藏开始按钮
// 开闸刷怪MissionMonComp 在本阶段启动分批释放,需解除暂停标志让第一批按时出场
smc.mission.stop_spawn_mon = false;
oops.message.dispatchEvent("PhasePrepareEnd");
break;
case MissionPhase.BattleStart:
// 触发战斗开始技能fstart
this.triggerHeroBattleSkills(true);
// Boss 回合预警战斗正式开始、Boss 压轴进场(最后一批),提前给 UI 表现窗口
if (this.isBossWave) {
oops.message.dispatchEvent(GameEvent.BossWarning, {
wave: this.currentWave,
eta: BATCH_INTERVAL * (BATCH_COUNT - 1),
});
}
break;
case MissionPhase.Battle:
smc.mission.stop_spawn_mon = false;
smc.mission.in_fight = true;
smc.vmdata.mission_data.in_fight = true;
// 战斗阶段:不隐藏开始按钮,激活 nobg
if (this.start_btn && this.start_btn.isValid) {
const nobg = this.start_btn.getChildByName("nobg");
if (nobg) nobg.active = true;
}
oops.message.dispatchEvent(GameEvent.FightStart);
break;
case MissionPhase.BattleEnd:
smc.mission.in_fight = false;
smc.vmdata.mission_data.in_fight = false;
smc.mission.stop_spawn_mon = true;
if (smc.mission.play && !smc.mission.pause) {
// 【评分系统 - 战绩分】每回合胜利加分
smc.vmdata.scores.wave_win_count++;
// 【评分系统 - 战绩分】记录每回合结束时场上留存的敌人数量(扣分项)
// 超时回合已在 onBattleTimeout 预扣,跳过防重
if (!this.skipRemainScoreOnBattleEnd) {
smc.vmdata.scores.wave_remain_monsters += smc.vmdata.mission_data.mon_num;
}
this.skipRemainScoreOnBattleEnd = false;
let allAlive = true;
let hasHero = false;
let heroDeathCount = 0;
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.HERO) {
hasHero = true;
if (attrs.is_dead) {
allAlive = false;
heroDeathCount++;
}
}
});
// 【动态难度调节】根据本回合战况自动放水 / 加压
// 清场加速节省的时间还原进口径,避免"打得好"被节奏加速+强度加压双重惩罚
const effectiveClearTime = this.clearTime + smc.vmdata.mission_data.wave_early_skip;
const tuned = DynamicTuner.adjust(effectiveClearTime, heroDeathCount);
if (tuned) {
mLogger.log(this.debugMode, 'MissionComp', `[DynamicTuner] wave=${this.currentWave} effClear=${effectiveClearTime.toFixed(1)}s deaths=${heroDeathCount} factor=${DynamicTuner.factor.toFixed(3)}`);
}
// 快照供下一回合倒计时档位判定onNewWave 会清零 clearTime必须提前快照
this.lastWaveDeathCount = heroDeathCount;
this.lastWaveClearTime = effectiveClearTime;
// 【评分系统 - 战绩分】记录全员存活的胜利回合数(额外加分)
if (hasHero && allAlive) {
smc.vmdata.scores.wave_all_alive_count++;
}
// 【评分系统 - 战绩分】判断是否通过最后一关第20回合
if (this.currentWave === MAX_WAVE) {
smc.vmdata.scores.passed_wave_20 = true;
}
}
// 触发战斗结束技能fend
this.triggerHeroBattleSkills(false);
// 战斗结束阶段,按 FightSet.WAVE_HEAL_RATE 恢复所有英雄血量
this.healAllHeroes();
// 【新增】派发每回合战斗结束事件,供卡牌技能监听(区别于整局结束的 MissionEnd
oops.message.dispatchEvent(GameEvent.FightEnd);
break;
case MissionPhase.Settle:
smc.mission.in_fight = false;
smc.vmdata.mission_data.in_fight = false;
smc.mission.stop_spawn_mon = true;
// 不隐藏开始按钮
break;
case MissionPhase.None:
smc.mission.in_fight = false;
smc.vmdata.mission_data.in_fight = false;
smc.mission.stop_spawn_mon = false;
if (this.start_btn && this.start_btn.isValid) {
const nobg = this.start_btn.getChildByName("nobg");
if (nobg) nobg.active = true;
}
break;
}
// 阶段切换后更新按钮状态
this.updateStartBtnState();
}
/** 自动流转到下一阶段(过渡状态结束时调用) */
private autoNextPhase() {
switch (this.currentPhase) {
case MissionPhase.PrepareStart:
// 回合间倒计时结束,停止倒计时状态,直接进入 PrepareEnd不再等待玩家点击
this.stopWaveCountdown();
this.changePhase(MissionPhase.PrepareEnd);
break;
case MissionPhase.PrepareEnd:
this.changePhase(MissionPhase.BattleStart);
break;
case MissionPhase.BattleStart:
this.changePhase(MissionPhase.Battle);
break;
case MissionPhase.BattleEnd:
// BattleEnd 计时结束后,如果是因为全灭或手动调用的 fight_end进入 Settle
// 需要注意的是open_Victory / fight_end 现在只需切换到 BattleEnd 即可Settle 由这里自动接管
// 如果游戏正在运行(回合更迭),则自动进入 PrepareStart 阶段
if (smc.mission.play && !smc.mission.pause) {
this.changePhase(MissionPhase.PrepareStart);
} else {
this.changePhase(MissionPhase.Settle);
// 此时已经经过了 2s可以真正执行结算弹窗或清理逻辑
if (smc.mission.play) {
// 如果游戏还在运行中,说明是通过 open_Victory 进来的
smc.mission.pause = true;
mLogger.log(this.debugMode, 'MissionComp', " autoNextPhase -> open_Victory logic", this.revive_times);
oops.gui.open(UIID.Victory, {
victory: false,
rewards: this.rewards,
game_data: this.game_data,
can_revive: this.revive_times > 0
});
} else {
// 如果 play 已经是 false说明是通过 fight_end 进来的
this.cleanComponents();
this.clearBattlePools();
}
}
break;
}
}
/**
* 进入战斗:
* - 恢复刷怪
* - 标记战斗中
* - 隐藏开始按钮
* - 分发 FightStart 事件
* - 触发英雄战斗开始技能
*/
to_fight() {
this.changePhase(MissionPhase.PrepareEnd);
}
/**
* 进入准备阶段:
* - 标记非战斗
* - 暂停刷怪
* - 显示开始按钮
* - 触发英雄战斗结束技能
*/
private enterPreparePhase() {
this.changePhase(MissionPhase.PrepareStart);
}
/**
* 触发英雄的战斗开始/结束技能
* @param isStart 是否为战斗开始
*/
private triggerHeroBattleSkills(isStart: boolean) {
let triggerCount = 1;
if (isStart) {
triggerCount += FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.StartCount);
} else {
triggerCount += FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.EndCount);
}
triggerCount = Math.max(1, Math.floor(triggerCount));
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
const view = entity.get(HeroViewComp);
if (!attrs || !view || attrs.is_dead || attrs.fac !== FacSet.HERO) return;
// 触发战斗开始/结束技能
for (let i = 0; i < triggerCount; i++) {
SkillTriggerHelper.trigger(isStart ? SkillTriggerType.FStart : SkillTriggerType.FEnd, attrs, view);
}
});
}
/**
* 战斗结束阶段治疗所有英雄(包括墓地英雄),按 FightSet.WAVE_HEAL_RATE 恢复最大生命值比例
*/
private healAllHeroes() {
const healRateBoost = FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.WaveHeal);
const finalHealRate = Math.min(1, FightSet.WAVE_HEAL_RATE + healRateBoost);
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
const view = entity.get(HeroViewComp);
if (!attrs || !view || attrs.fac !== FacSet.HERO) return;
// 计算恢复量:基于配置的百分比(如 70%)的最大生命值
const healAmount = Math.floor(attrs.hp_max * finalHealRate);
// 应用恢复量,不超过最大生命值
attrs.hp = Math.min(attrs.hp_max, attrs.hp + healAmount);
attrs.dirty_hp = true;
// 重置复活次数,使得下回合可以继续复活
attrs.revived_count = 0;
// 触发治疗动画,即使在墓地的英雄也触发(会在其当前位置播放)
view.health(healAmount);
});
}
/** 开始战斗按钮点击回调 */
private onStartFightBtnClick() {
if (!smc.mission.play) return;
if (smc.mission.pause) return;
if (this.currentPhase !== MissionPhase.Prepare) return;
oops.audio.playEffect("music/button");
this.to_fight();
}
/**
* 英雄召唤事件回调:
* 第一回合准备阶段Prepare只有第 1 回合才会进入,
* 此时首个英雄一旦召唤即自动开战,无需玩家点击开始按钮。
* 后续回合走 PrepareStart 倒计时自动流转,不会进入 Prepare。
*/
private onHeroSummoned() {
if (this.currentPhase !== MissionPhase.Prepare) return;
if (!smc.mission.play || smc.mission.pause) return;
this.to_fight();
}
/**
* 打开结算弹窗:
* - 暂停游戏
* - 打开 VictoryComp 弹窗
*
* @param e 事件对象(未使用)
* @param is_hero_dead 是否因英雄全灭触发
*/
open_Victory(e: any, is_hero_dead: boolean = false) {
// 战斗失败或胜利,标记暂停状态以切断回合流转逻辑
smc.mission.pause = true;
// 直接切入 BattleEnd触发 fend 表现
// 倒计时逻辑已在 update 中由 PhaseTime 接管2s 后将触发 autoNextPhase 弹窗结算
this.changePhase(MissionPhase.BattleEnd);
}
/** 战斗结束:延迟清理组件和对象池 */
fight_end() {
// 这里只是强制清理关卡,为了防止重复弹窗,标记 play = false
smc.mission.play = false
this.changePhase(MissionPhase.BattleEnd);
}
/**
* 任务结束(完全退出关卡):
* - 取消所有延迟回调
* - 重置全局标志
* - 清理组件和对象池
* - 隐藏节点
*/
mission_end() {
this.unscheduleAllCallbacks();
smc.mission.play = false
smc.mission.pause = false;
this.changePhase(MissionPhase.None);
this.cleanComponents()
this.clearBattlePools()
oops.gui.remove(UIID.Mission);
}
/**
* 初始化全部局内数据:
* - 全局运行标志
* - 战斗时间 / 怪物数量 / 金币 / 回合数
* - 奖励列表 / 复活次数
* - 性能监控基准值
*/
data_init() {
if (!this.PhaseTime) {
this.PhaseTime = new Timer(1);
}
smc.mission.play = true;
smc.mission.pause = false;
smc.mission.stop_mon_action = false;
smc.mission.stop_spawn_mon = false;
smc.vmdata.mission_data.in_fight = false
smc.vmdata.mission_data.fight_time = 0
this.clearTime = 0
smc.vmdata.mission_data.mon_num = 0
smc.vmdata.mission_data.level = 1
smc.vmdata.mission_data.mon_max = Math.max(1, Math.floor(this.maxMonsterCount))
this.currentPhase = MissionPhase.None;
this.currentWave = 1;
this.isBossWave = false;
this.skipRemainScoreOnBattleEnd = false;
this.lastWaveDeathCount = 0;
this.lastWaveClearTime = 0;
this.rewards = []
this.revive_times = 1;
this.lastTimeStr = "";
this.lastTimeSecond = -1;
this.memoryRefreshTimer = 0;
this.lastMemoryText = "";
this.perfDtAcc = 0;
this.perfFrameCount = 0;
this.heapBaseMB = -1;
this.heapPeakMB = 0;
this.heapTrendPerMinMB = 0;
this.heapTrendTimer = 0;
this.heapTrendBaseMB = -1;
this.monsterCountSyncTimer = 0;
spawningEngine.reset();
// 重置所有的战局得分数据,防止上一局的数据污染
smc.resetScores();
smc.vmdata.mission_data.coin = Math.max(0, Math.floor(FightSet.INIT_COIN));
// 初始化刷新石数量,并派发事件通知 UI 刷新MissionStart 早于 data_init需手动通知
smc.vmdata.mission_data.refresh_stone = Math.max(0, Math.floor(FightSet.INIT_REFRESH_STONE));
oops.message.dispatchEvent(GameEvent.RefreshStone, { delta: FightSet.INIT_REFRESH_STONE });
// 【评分系统 - 效率分】记录初始获得的金币收入
smc.vmdata.scores.gold_earned += smc.vmdata.mission_data.coin;
}
// ======================== 回合管理 ========================
/**
* 新一回合事件回调:
* 1. 进入准备阶段。
* 2. 更新当前回合数。
* 3. 刷新时间显示。
*
* 注意:金币不再按回合固定发放,改为怪物死亡时掉落(见 HeroAtkSystem
*
* @param event 事件名
* @param data { wave: number }
*/
private onNewWave(event: string, data: any) {
const wave = Number(data?.wave ?? 0);
if (wave <= 0) return;
this.isBossWave = !!data?.bossWave;
if (wave > 1) {
// 在新一回合到来时,先进入 BattleEnd触发上一回合的战斗结束技能 (fend)2秒后自动进入下一回合的准备阶段
this.changePhase(MissionPhase.BattleEnd);
} else {
// 第1回合不需要结束上一回合延迟一点播放提示避免被开始游戏的loading遮挡
this.scheduleOnce(() => {
if (this.currentPhase === MissionPhase.Prepare) {
this.playTooltipAnim(`${wave} 回合`);
}
}, 0.5);
}
this.currentWave = wave;
smc.vmdata.mission_data.level = wave;
// 金币改为怪物死亡掉落(见 HeroAtkSystem不再每回合固定发放
this.lastTimeSecond = -1;
this.clearTime = 0;
this.update_time();
}
// ======================== 怪物数量管理 ========================
/**
* 获取怪物数量阈值配置。
* @returns { max: 刷怪上限, resume: 恢复刷怪阈值 }
*/
private getMonsterThresholds(): { max: number; resume: number } {
const max = Math.max(1, Math.floor(this.maxMonsterCount));
const resume = Math.min(max - 1, Math.max(0, Math.floor(this.resumeMonsterCount)));
return { max, resume };
}
/**
* 同步怪物刷新状态(降频执行,每 0.2 秒一次):
* 1. 遍历所有 HeroAttrsComp 实体,统计怪物和英雄数量。
* 2. 检测英雄全灭。
* 3. 根据 max/resume 阈值切换 stop_spawn_mon 状态。
*
* @param dt 帧间隔
*/
private syncMonsterSpawnState(dt: number) {
this.monsterCountSyncTimer += dt;
if (dt > 0 && this.monsterCountSyncTimer < 0.2) return;
this.monsterCountSyncTimer = 0;
let monsterCount = 0;
let heroCount = 0;
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
if (!attrs || attrs.is_dead) return;
if (attrs.fac === FacSet.MON) {
monsterCount += 1;
return;
}
if (attrs.fac === FacSet.HERO) {
heroCount += 1;
}
});
this.handleHeroWipe(heroCount);
// 怪物全灭检测:如果战斗阶段场上没有任何活着的怪物,且待刷新的怪物队列也为空,直接结束战斗进入下一回合的准备阶段
const pendingCount = smc.vmdata.mission_data.pending_mon_num || 0;
if (monsterCount === 0 && pendingCount === 0 && smc.mission.play && !smc.mission.pause && this.currentPhase === MissionPhase.Battle) {
if (this.currentWave >= MAX_WAVE) {
// 20 回合通关
this.open_Victory(null, false);
} else {
// oops.message 全局事件总线:清屏庆祝(横幅/奖励由 BattleBannerComp 消费需在推进回合前派发clearTime 归零前取值)
const allAlive = this.checkAllHeroAlive();
const fastClear = this.clearTime < WAVE_DURATION * 0.5 ? 2
: this.clearTime < WAVE_DURATION * 0.75 ? 1 : 0;
oops.message.dispatchEvent(GameEvent.WaveClear, {
wave: this.currentWave,
clearTime: this.clearTime,
allAlive,
fastClear,
});
oops.message.dispatchEvent("TimeUpAdvanceWave");
}
return;
}
smc.vmdata.mission_data.mon_num = monsterCount;
const { max, resume } = this.getMonsterThresholds();
smc.vmdata.mission_data.mon_max = max;
const stopSpawn = !!smc.mission.stop_spawn_mon;
if (stopSpawn) {
// 降至恢复阈值以下 → 恢复刷怪
if (monsterCount <= resume) smc.mission.stop_spawn_mon = false;
return;
}
// 超过上限 → 暂停刷怪
if (monsterCount >= max) smc.mission.stop_spawn_mon = true;
}
/** 检测场上英雄是否全员存活(清屏 Perfect 判定用,独立于评分统计逻辑) */
private checkAllHeroAlive(): boolean {
let hasHero = false;
let allAlive = true;
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.HERO) {
hasHero = true;
if (attrs.is_dead) allAlive = false;
}
});
return hasHero && allAlive;
}
/**
* 英雄全灭检测:若场上无存活英雄且处于战斗中,触发结算弹窗。
* @param heroCount 当前存活英雄数量
*/
private handleHeroWipe(heroCount: number) {
if (heroCount > 0) return;
if (!smc.mission.play || smc.mission.pause) return;
if (this.currentPhase !== MissionPhase.Battle) return;
this.open_Victory(null, true);
}
/**
* 回合超时强制结束(僵局兜底):
* 1. 预扣留存分(残留怪销毁后 mon_num 会被下一次同步清零BattleEnd 的累加会漏扣,故在此显式预扣并置防重标志)。
* 2. 销毁全部残留怪(不触发 MonDead、不掉金币——超时是对"清不掉"的惩罚)。
* 3. 复用正常回合推进流DynamicTuner 因 clearTime 远超阈值会自动放水,无需特殊处理。
*/
private onBattleTimeout() {
// 防重入:同一回合只触发一次
if (this.currentPhase !== MissionPhase.Battle) return;
smc.vmdata.scores.wave_remain_monsters += smc.vmdata.mission_data.mon_num;
this.skipRemainScoreOnBattleEnd = true;
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.MON && !attrs.is_dead) {
entity.destroy();
}
});
smc.vmdata.mission_data.mon_num = 0;
mLogger.log(this.debugMode, 'MissionComp', `[WaveTimeout] wave=${this.currentWave} 超时强制结束`);
if (this.currentWave >= MAX_WAVE) {
this.open_Victory(null, false);
} else {
oops.message.dispatchEvent("TimeUpAdvanceWave");
}
}
// ======================== 清理 ========================
/** 清理所有英雄和技能 ECS 实体 */
private cleanComponents() {
if (!this.heroViewMatcher || !this.skillViewMatcher) return;
const heroEntities: ecs.Entity[] = [];
ecs.query(this.heroViewMatcher).forEach(entity => {
heroEntities.push(entity);
});
heroEntities.forEach(entity => {
entity.destroy();
});
const skillEntities: ecs.Entity[] = [];
ecs.query(this.skillViewMatcher).forEach(entity => {
skillEntities.push(entity);
});
skillEntities.forEach(entity => {
entity.destroy();
});
}
/** 回收所有战斗对象池Monster / Skill / Tooltip并清理场景节点 */
private clearBattlePools() {
Monster.clearPools();
Skill.clearPools();
Tooltip.clearPool();
this.clearBattleSceneNodes();
}
/** 清理战斗场景中的 HERO 和 SKILL 根节点下的所有子节点 */
private clearBattleSceneNodes() {
const scene = smc.map?.MapView?.scene;
const layer = scene?.entityLayer?.node;
if (!layer) return;
const heroRoot = layer.getChildByName("HERO");
const skillRoot = layer.getChildByName("SKILL");
if (heroRoot) {
for (let i = heroRoot.children.length - 1; i >= 0; i--) {
heroRoot.children[i].destroy();
}
}
if (skillRoot) {
for (let i = skillRoot.children.length - 1; i >= 0; i--) {
skillRoot.children[i].destroy();
}
}
}
/** 获取战斗层的英雄和技能节点数量(用于性能监控) */
private getBattleLayerNodeCount() {
const scene = smc.map?.MapView?.scene;
const layer = scene?.entityLayer?.node;
if (!layer) return { heroNodes: 0, skillNodes: 0 };
const heroRoot = layer.getChildByName("HERO");
const skillRoot = layer.getChildByName("SKILL");
return {
heroNodes: heroRoot?.children.length || 0,
skillNodes: skillRoot?.children.length || 0
};
}
// ======================== 性能监控面板 ========================
/** 性能监控相关代码 */
/** 初始化性能监控面板:在 time_node 下创建 Label */
private initMemoryPanel() {
if (!this.showMemoryPanel || !this.time_node) return;
let panel = this.time_node.getChildByName("mem_panel");
if (!panel) {
panel = new Node("mem_panel");
panel.parent = this.time_node;
panel.setPosition(0, -32, 0);
}
let label = panel.getComponent(Label);
if (!label) {
label = panel.addComponent(Label);
}
label.fontSize = 16;
label.lineHeight = 20;
this.memoryLabel = label;
}
/** 移除性能监控面板 */
private removeMemoryPanel() {
const panel = this.time_node?.getChildByName("mem_panel");
if (panel) {
panel.destroy();
}
this.memoryLabel = null;
this.lastMemoryText = "";
}
/**
* 更新性能监控面板内容(每 0.5 秒一次):
* 显示 堆内存 / 增长趋势 / 帧率 / 实体数量 / 对象池状态 等信息。
*/
private updateMemoryPanel(dt: number) {
if (!this.showMemoryPanel || !this.memoryLabel) return;
this.perfDtAcc += dt;
this.perfFrameCount += 1;
this.memoryRefreshTimer += dt;
if (this.memoryRefreshTimer < 0.5) return;
this.memoryRefreshTimer = 0;
let heroCount = 0;
ecs.query(this.heroViewMatcher).forEach(() => {
heroCount++;
});
let skillCount = 0;
ecs.query(this.skillViewMatcher).forEach(() => {
skillCount++;
});
const monPool = Monster.getPoolStats();
const skillPool = Skill.getPoolStats();
const tooltipPool = Tooltip.getPoolStats();
const layerNodes = this.getBattleLayerNodeCount();
const perf = (globalThis as any).performance;
const heapBytes = perf && perf.memory ? perf.memory.usedJSHeapSize : 0;
let heapMB = heapBytes > 0 ? heapBytes / 1024 / 1024 : -1;
if (heapMB > 0 && this.heapBaseMB < 0) {
this.heapBaseMB = heapMB;
this.heapPeakMB = heapMB;
this.heapTrendBaseMB = heapMB;
this.heapTrendTimer = 0;
}
if (heapMB > this.heapPeakMB) {
this.heapPeakMB = heapMB;
}
this.heapTrendTimer += 0.5;
if (heapMB > 0 && this.heapTrendBaseMB > 0 && this.heapTrendTimer >= 10) {
const deltaMB = heapMB - this.heapTrendBaseMB;
this.heapTrendPerMinMB = (deltaMB / this.heapTrendTimer) * 60;
this.heapTrendBaseMB = heapMB;
this.heapTrendTimer = 0;
}
const heapText = heapMB > 0 ? heapMB.toFixed(1) : "N/A";
const heapDeltaText = this.heapBaseMB > 0 && heapMB > 0 ? (heapMB - this.heapBaseMB).toFixed(1) : "N/A";
const heapPeakText = this.heapPeakMB > 0 ? this.heapPeakMB.toFixed(1) : "N/A";
const avgDt = this.perfFrameCount > 0 ? this.perfDtAcc / this.perfFrameCount : 0;
const fps = avgDt > 0 ? 1 / avgDt : 0;
this.perfDtAcc = 0;
this.perfFrameCount = 0;
const text =
`Heap:${heapText}MB Δ:${heapDeltaText} Peak:${heapPeakText}\n` +
`Trend:${this.heapTrendPerMinMB.toFixed(2)}MB/min\n` +
`Perf dt:${(avgDt * 1000).toFixed(1)}ms fps:${fps.toFixed(1)}\n` +
`Ent H:${heroCount} S:${skillCount} N:${layerNodes.heroNodes}/${layerNodes.skillNodes}\n` +
`Pool M:${monPool.total}(${monPool.paths}) K:${skillPool.total}(${skillPool.paths}) T:${tooltipPool.total}`;
if (text === this.lastMemoryText) return;
this.lastMemoryText = text;
this.memoryLabel.string = text;
}
/** ECS 组件移除时销毁节点 */
reset() {
this.PhaseTime = null as any;
this.heroViewMatcher = null;
this.skillViewMatcher = null;
this.heroAttrsMatcher = null;
if (this.node && this.node.isValid) {
this.node.destroy();
}
}
}