1. 调整关卡UI文本格式,添加回合数显示并缩小字体 2. 新增监听回合进度数据源,更新注释描述 3. 重构阶段过滤逻辑,补充PrepareStart/End阶段的排除 4. 将回合开始提示移至BattleStart阶段播放,优化提示显示时机 5. 修正回合管理相关注释与逻辑,移除旧的首回合延迟提示
1275 lines
54 KiB
TypeScript
1275 lines
54 KiB
TypeScript
/**
|
||
* @file MissionComp.ts
|
||
* @description 任务(关卡)核心控制组件(UI + 逻辑层)
|
||
*
|
||
* 职责:
|
||
* 1. 管理单关(一关一局)的 **完整生命周期**:初始化 → 5 节自动连战 → 本关结算。
|
||
* 2. 在战斗阶段每帧更新战斗计时器、同步怪物数量、检测英雄全灭 / 怪物全灭。
|
||
* 3. 驱动节间自动推进:节间不弹结算,背景滚动过渡 + 英雄归位 + 怪物从右侧跑入,自动开下一节。
|
||
* 4. 处理关卡进度持久化(只记到关,节恒从 1 开始)。
|
||
* 5. 第 5 节(Boss 节)胜利后弹结算(VictoryComp);英雄全灭判负弹结算。
|
||
*
|
||
* 关键设计:
|
||
* - mission_start() 初始化所有游戏数据 → 恢复存档进度(大关)→ 进入第 1 节。
|
||
* - 每节不清场、不重新三选一:英雄保留并自动归位到左侧编队,节间仅做一次过场推进。
|
||
* - 节间过场:BattleEnd 判定非末节 → 进入 PrepareStart(in_fight=false)触发英雄编队归位,
|
||
* 同时播放背景滚动,倒计时结束自动进入下一节。
|
||
* - 第 5 节胜利 → open_Victory(final) 弹结算,确认退出并存档进下一关。
|
||
* - cleanComponents() / clearBattlePools() 仅在整关开始/结束时执行,节间不调用。
|
||
*/
|
||
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 { MoveSystem } from "../hero/MoveComp";
|
||
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, WAVE_DURATION, BATTLE_TIMEOUT } from "./RogueConfig";
|
||
import { MissionMonCompComp } from "./MissionMonComp";
|
||
import {
|
||
getProgressText,
|
||
getMonsterLv,
|
||
BOSS_SECTION,
|
||
SECTIONS_PER_STAGE,
|
||
} from "../common/config/StageSet";
|
||
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, // 结算阶段(VictoryComp 弹窗,等待玩家点击下一节/下一关/退出)
|
||
}
|
||
|
||
/** 结算类型(驱动 VictoryComp 按钮分流与文案):一关一局后仅 失败 / 通关 两种 */
|
||
type SettleType = "defeat" | "final";
|
||
|
||
//@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;
|
||
|
||
|
||
// ======================== 配置参数 ========================
|
||
|
||
// ======================== 编辑器绑定节点 ========================
|
||
|
||
/** 开始战斗按钮 */
|
||
@property(Node)
|
||
start_btn: Node = null!
|
||
/** 战斗计时 Label(mm:ss) */
|
||
@property(Label)
|
||
time_label: Label = 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;
|
||
/** 节间过场倒计时档位:统一时长(一关一局节间自动推进,节奏紧凑) */
|
||
private static readonly COUNTDOWN_TRANSITION = 2.5;
|
||
/** 上一次显示的时间字符串(避免重复设置) */
|
||
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;
|
||
/** 当前回合数(= 当前小节号,驱动 HUD 回合/进度显示) */
|
||
private currentWave: number = 0;
|
||
/** 当前节是否包含 Boss(驱动 Boss 预警) */
|
||
private isBossWave: boolean = false;
|
||
/** 超时回合已在 onBattleTimeout 预扣留存分,BattleEnd 需跳过重复累加 */
|
||
private skipRemainScoreOnBattleEnd: boolean = false;
|
||
/** 上节清场时间快照(含清场加速还原,供评分统计) */
|
||
private lastWaveClearTime: number = 0;
|
||
/** 当前任务阶段 */
|
||
public currentPhase: MissionPhase = MissionPhase.None;
|
||
/** 是否处于回合间倒计时状态 */
|
||
private isWaveCountdown: boolean = false;
|
||
/** 本局是否胜利(open_Victory 时记录,供结算弹窗使用) */
|
||
private _victory: boolean = false;
|
||
/** 本次结算类型(通关/失败,供结算弹窗按钮分流) */
|
||
private _settleType: SettleType = "defeat";
|
||
/** 通关时结算页展示的标题("本关通关"/"本章通关"),失败结算为空 */
|
||
private _settleTitle: string = "";
|
||
/** 结算弹窗延迟(秒):等待最后一个英雄/怪物死亡动画播完再切 BattleEnd */
|
||
private static readonly SETTLE_DELAY = 1.5;
|
||
/** 结算是否已预约调度(防止 0.2s 同步周期内重复 scheduleOnce) */
|
||
private _settleScheduled: boolean = false;
|
||
|
||
// ======================== 关卡进度状态 ========================
|
||
|
||
/** 当前章节号 */
|
||
public currentChapter: number = 1;
|
||
/** 当前关卡号(1~10) */
|
||
public currentStage: number = 1;
|
||
/** 当前小节号(1~SECTIONS_PER_STAGE) */
|
||
public currentSection: number = 1;
|
||
|
||
// ======================== 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.on(GameEvent.RestartMission, this.onRestartMission, this)
|
||
this.start_btn?.on(NodeEventType.TOUCH_END, this.onStartFightBtnClick, this)
|
||
// 开局免费三选一选完后自动进入战斗
|
||
oops.message.on("OpeningPicksDone", this.onOpeningPicksDone, 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();
|
||
}
|
||
|
||
onDestroy() {
|
||
// 解除节间过场移动动画锁,避免泄漏到下一关
|
||
MoveSystem.transitionMoveLock = false;
|
||
// 退出关卡时恢复地图背景动画
|
||
smc.map.MapView.scene.mapLayer.playAnimations()
|
||
super.onDestroy();
|
||
if (this.start_btn && this.start_btn.isValid) {
|
||
this.start_btn.off(NodeEventType.TOUCH_END, this.onStartFightBtnClick, this);
|
||
}
|
||
oops.message.off("OpeningPicksDone", this.onOpeningPicksDone, this);
|
||
}
|
||
|
||
/**
|
||
* 帧更新:
|
||
* - 非播放 / 暂停状态 → 跳过
|
||
* - 战斗中 → 同步怪物状态、更新计时器
|
||
*/
|
||
protected update(dt: number): void {
|
||
if (!smc.mission.play) return
|
||
|
||
// 如果是暂停状态,且不在 BattleEnd 阶段(全灭时需要自动流转),才真正停止 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 互磨不死)时强制结算整局
|
||
if (this.clearTime >= BATTLE_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_label && this.time_label.isValid) {
|
||
this.time_label.string = str;
|
||
}
|
||
this.lastTimeStr = str;
|
||
}
|
||
}
|
||
|
||
// ======================== 回合倒计时 ========================
|
||
|
||
/** 进入节间过场:停止倒计时状态(背景滚动与英雄归位已在节间过场启动时同步触发) */
|
||
private startWaveCountdown() {
|
||
this.waveCountdown = MissionComp.COUNTDOWN_TRANSITION;
|
||
this.isWaveCountdown = true;
|
||
this.updateCountdownUI(true);
|
||
}
|
||
|
||
/** 更新倒计时 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;
|
||
}
|
||
|
||
/** 结束节间过场:停背景滚动,进入战斗 */
|
||
private stopWaveCountdown() {
|
||
this.isWaveCountdown = false;
|
||
this.lastTimeSecond = -1;
|
||
// 过场结束,停掉背景滚动(战斗中保持静止)
|
||
smc.map?.MapView?.scene?.mapLayer?.stopAnimations();
|
||
// 解除英雄移动动画锁:归位后恢复待机
|
||
MoveSystem.transitionMoveLock = false;
|
||
}
|
||
|
||
// ======================== 奖励与广告 ========================
|
||
|
||
/** 奖励发放(预留) */
|
||
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. 恢复关卡进度存档。
|
||
* 5. 进入准备阶段并显示 loading。
|
||
*/
|
||
async mission_start() {
|
||
this.unscheduleAllCallbacks();
|
||
this.cleanComponents();
|
||
this.data_init()
|
||
this.loadStageProgress();
|
||
oops.message.dispatchEvent(GameEvent.FightReady)
|
||
this.changePhase(MissionPhase.Prepare);
|
||
// 派发首个 SectionStart,MissionMonComp 据此生成开局怪物
|
||
oops.message.dispatchEvent(GameEvent.SectionStart, {
|
||
chapter: this.currentChapter,
|
||
stage: this.currentStage,
|
||
section: this.currentSection,
|
||
});
|
||
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/PrepareStart/PrepareEnd 准备阶段、Battle 战斗中阶段;
|
||
// 回合提示统一由 BattleStart 的"第 X 回合开始"承担,准备相关阶段不弹提示)
|
||
if (targetPhase !== MissionPhase.None &&
|
||
targetPhase !== MissionPhase.Prepare &&
|
||
targetPhase !== MissionPhase.PrepareStart &&
|
||
targetPhase !== MissionPhase.PrepareEnd &&
|
||
targetPhase !== MissionPhase.Battle) {
|
||
this.playTooltipAnim(phaseName);
|
||
}
|
||
|
||
// 重置状态机的计时器
|
||
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();
|
||
// 回合开始提示:"第 X 回合开始"(准备阶段不显示,战斗开始才播)
|
||
this.playTooltipAnim(`第 ${this.currentWave} 回合开始`);
|
||
// Boss 预警:Boss 同步场立即登场(eta=0)
|
||
if (this.isBossWave) {
|
||
oops.message.dispatchEvent(GameEvent.BossWarning, {
|
||
wave: this.currentWave,
|
||
eta: 0,
|
||
});
|
||
}
|
||
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 hasHero = false;
|
||
ecs.query(this.heroAttrsMatcher).forEach(entity => {
|
||
const attrs = entity.get(HeroAttrsComp);
|
||
if (attrs && attrs.fac === FacSet.HERO) {
|
||
hasHero = true;
|
||
}
|
||
});
|
||
// 英雄为真实死亡(实体已销毁),本节死亡数取死亡记录长度
|
||
const heroDeathCount = smc.mission.dead_heroes.length;
|
||
|
||
// 快照清场时间供评分统计(onNewWave 会清零 clearTime,必须提前快照)
|
||
this.lastWaveClearTime = this.clearTime;
|
||
// 【评分系统 - 战绩分】记录全员存活的胜利节数(额外加分)
|
||
if (hasHero && heroDeathCount === 0) {
|
||
smc.vmdata.scores.wave_all_alive_count++;
|
||
}
|
||
|
||
// 通关判定:Boss 节(末节)胜利即视为本关通关
|
||
smc.vmdata.scores.passed_wave_20 = true;
|
||
}
|
||
|
||
// 战斗结束阶段,按 FightSet.WAVE_HEAL_RATE 恢复所有英雄血量
|
||
this.healAllHeroes();
|
||
|
||
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:
|
||
// 节间过场倒计时结束:推进到下一节、停背景滚动、派发 SectionStart 刷怪,进入 PrepareEnd 自动开战
|
||
this.stopWaveCountdown();
|
||
this.advanceToNextSection();
|
||
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 计时结束后分流:
|
||
// - 局仍在进行(节间清场,非末节)→ 进入 PrepareStart 过节间过场,自动连战下一节
|
||
// - 已暂停(open_Victory 结算:末节通关/失败)→ 进入 Settle 弹结算
|
||
if (smc.mission.play && !smc.mission.pause) {
|
||
this.changePhase(MissionPhase.PrepareStart);
|
||
} else {
|
||
this.changePhase(MissionPhase.Settle);
|
||
|
||
if (smc.mission.play) {
|
||
// 通过 open_Victory 进来:暂停并弹结算(通关/失败)
|
||
smc.mission.pause = true;
|
||
oops.gui.open(UIID.Victory, {
|
||
victory: this._victory,
|
||
all_alive: this.checkAllHeroAlive(),
|
||
rewards: this.rewards,
|
||
game_data: this.game_data,
|
||
can_revive: this.revive_times > 0,
|
||
// 结算类型:final=通关(确认退出),defeat=失败(再来一次/退出)
|
||
settle_type: this._settleType,
|
||
settle_title: this._settleTitle,
|
||
// 关卡难度度量(= 大关号,驱动胜利奖励随进度增长)
|
||
mon_lv: getMonsterLv(this.currentStage),
|
||
// 失败奖励判定数据:本关坚持时间 + 击杀统计(kills=已刷出-存活)
|
||
defeat_stats: {
|
||
clear_time: this.clearTime,
|
||
...this.querySectionKillStats(),
|
||
},
|
||
});
|
||
} else {
|
||
// play 已为 false(fight_end 强制结束)→ 直接清理
|
||
this.cleanComponents();
|
||
this.clearBattlePools();
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 进入战斗:
|
||
* - 恢复刷怪
|
||
* - 标记战斗中
|
||
* - 隐藏开始按钮
|
||
* - 分发 FightStart 事件
|
||
* - 触发英雄战斗开始技能
|
||
*/
|
||
to_fight() {
|
||
this.changePhase(MissionPhase.PrepareEnd);
|
||
}
|
||
|
||
/**
|
||
* 触发英雄的战斗开始技能
|
||
*/
|
||
private triggerHeroBattleSkills() {
|
||
let triggerCount = 1;
|
||
triggerCount += FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.StartCount);
|
||
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(SkillTriggerType.FStart, 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();
|
||
}
|
||
|
||
/** 结算弹窗"再来一次"事件:关闭弹窗并重开本关 */
|
||
private onRestartMission() {
|
||
oops.gui.remove(UIID.Victory);
|
||
this.restartCurrentMission();
|
||
}
|
||
|
||
/** 开局免费三选一选完,自动进入战斗(等效于点击开始按钮) */
|
||
private onOpeningPicksDone() {
|
||
if (this.currentPhase !== MissionPhase.Prepare) return;
|
||
if (!smc.mission.play || smc.mission.pause) return;
|
||
this.to_fight();
|
||
}
|
||
|
||
|
||
/**
|
||
* 打开结算弹窗(一关一局:仅整关结束时触发——Boss 节通关 / 英雄全灭判负 / 超时):
|
||
* - 暂停游戏、切断节间流转
|
||
* - 胜利即 final(通关),存档推进到下一关;失败即 defeat(进度不变,可复活/再来一次)
|
||
* - 切入 BattleEnd,2s 后由 autoNextPhase 自动弹出 VictoryComp
|
||
*
|
||
* @param e 事件对象(未使用)
|
||
* @param is_hero_dead 是否因英雄全灭触发
|
||
* @param victory 是否胜利(默认 false)
|
||
*/
|
||
open_Victory(e: any, is_hero_dead: boolean = false, victory: boolean = false) {
|
||
// 战斗结束(通关/失败),标记暂停状态以切断节间流转逻辑
|
||
smc.mission.pause = true;
|
||
this._victory = victory;
|
||
this._settleTitle = "";
|
||
|
||
if (!victory) {
|
||
// 失败:进度不变,结算窗走复活/再来一次/退出流程
|
||
this._settleType = "defeat";
|
||
} else {
|
||
// 通关(Boss 节胜利):结算窗显示通关标题,确认退出并存档进下一关
|
||
this._settleType = "final";
|
||
this._settleTitle = this.currentStage >= 10 ? "本章通关" : "本关通关";
|
||
// 存档推进到下一关第 1 节(仅写存档,内存进度保留用于本局结算显示);
|
||
// 第 10 关通关即本章全通,存档停留等待后续章节扩展
|
||
if (this.currentStage < 10) {
|
||
smc.updateStageProgress({
|
||
chapter: this.currentChapter,
|
||
stage: this.currentStage + 1,
|
||
section: 1,
|
||
});
|
||
if ((smc as any).updateCloudData) {
|
||
(smc as any).updateCloudData();
|
||
}
|
||
mLogger.log(this.debugMode, 'MissionComp',
|
||
`[StageProgress] 本关通关,存档推进: ${this.currentChapter}-${this.currentStage + 1}-1`);
|
||
}
|
||
}
|
||
// 直接切入 BattleEnd,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
|
||
this.currentPhase = MissionPhase.None;
|
||
this.currentWave = 1;
|
||
this.isBossWave = false;
|
||
this.skipRemainScoreOnBattleEnd = false;
|
||
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));
|
||
// 银币为局外持久货币,每局不重置,仅通过日常积攒,无需初始化
|
||
// 【评分系统 - 效率分】记录初始获得的金币收入
|
||
smc.vmdata.scores.gold_earned += smc.vmdata.mission_data.coin;
|
||
}
|
||
|
||
// ======================== 关卡进度管理 ========================
|
||
|
||
/**
|
||
* 加载关卡进度存档(一关一局:只恢复大关,节恒从 1 开始)
|
||
* 从 smc.data.stage_progress 恢复章节/大关,缺省从 1-1-1 开始
|
||
*/
|
||
private loadStageProgress() {
|
||
const progress = smc.data.stage_progress;
|
||
if (progress && progress.chapter >= 1 && progress.stage >= 1) {
|
||
this.currentChapter = progress.chapter;
|
||
this.currentStage = progress.stage;
|
||
} else {
|
||
this.currentChapter = 1;
|
||
this.currentStage = 1;
|
||
}
|
||
// 一关一局:进入关卡始终从第 1 节开打
|
||
this.currentSection = 1;
|
||
// 加载完成后将权威进度镜像到 vmdata,驱动 HUD 实时显示
|
||
smc.syncStageProgressToView();
|
||
mLogger.log(this.debugMode, 'MissionComp',
|
||
`[StageProgress] 加载进度: ${getProgressText(this.currentChapter, this.currentStage, this.currentSection)}`);
|
||
}
|
||
|
||
/**
|
||
* 推进到下一小节(节间自动推进):
|
||
* 1. 节号 +1,派发 SectionStart 触发 MissionMonComp 生成下一节怪物(从右侧跑入)。
|
||
* 2. 不清理英雄/技能实体、不重置局内数据、不重新三选一——英雄保留并自动归位(编队排队)。
|
||
* 3. 进度实时镜像到 HUD,但不写持久存档(存档只在整关通关时推进到下一关)。
|
||
*
|
||
* 调用时机:PrepareStart 过场倒计时结束(autoNextPhase)。
|
||
* 保证:仅 1~4 节清场后触发,末节(BOSS_SECTION)已走 open_Victory 结算,不会推进。
|
||
*/
|
||
private advanceToNextSection() {
|
||
this.currentSection++;
|
||
// 节号实时同步到 HUD(内存进度,不持久化;整关通关时才统一写大关存档)。
|
||
// 直接写 vmdata 视图层而非走 syncStageProgressToView(后者从 data 读,而 data 仍停留在大关起点)
|
||
const view = smc.vmdata.stage_progress;
|
||
view.chapter = this.currentChapter;
|
||
view.stage = this.currentStage;
|
||
view.section = this.currentSection;
|
||
oops.message.dispatchEvent(GameEvent.STAGE_PROGRESS_UPDATE);
|
||
// 触发 SectionStart 事件,MissionMonComp 据此生成下一节怪物(从右侧跑入)
|
||
oops.message.dispatchEvent(GameEvent.SectionStart, {
|
||
chapter: this.currentChapter,
|
||
stage: this.currentStage,
|
||
section: this.currentSection,
|
||
});
|
||
mLogger.log(this.debugMode, 'MissionComp',
|
||
`[StageProgress] 进入小节: ${getProgressText(this.currentChapter, this.currentStage, this.currentSection)}`);
|
||
}
|
||
|
||
/**
|
||
* 重开本关:清理战场并重置数据,从本关第 1 节重新开始
|
||
* Why: 一关一局,失败"再来一次"即整关重打(节恒从 1 开始)
|
||
*/
|
||
private restartCurrentMission() {
|
||
// 清理战场与局内数据
|
||
this.cleanComponents();
|
||
this.clearBattlePools();
|
||
this.data_init();
|
||
// 重置结算状态
|
||
this._victory = false;
|
||
this._settleType = "defeat";
|
||
this._settleTitle = "";
|
||
// 一关一局:重开从第 1 节开始
|
||
this.currentSection = 1;
|
||
// 进入准备阶段
|
||
this.changePhase(MissionPhase.Prepare);
|
||
// 触发 MissionStart 重置开局免费抽卡
|
||
oops.message.dispatchEvent(GameEvent.MissionStart, {});
|
||
oops.message.dispatchEvent(GameEvent.FightReady);
|
||
// 触发 SectionStart 重新生成本关第 1 节怪物
|
||
oops.message.dispatchEvent(GameEvent.SectionStart, {
|
||
chapter: this.currentChapter,
|
||
stage: this.currentStage,
|
||
section: this.currentSection,
|
||
});
|
||
mLogger.log(this.debugMode, 'MissionComp',
|
||
`[StageProgress] 重开本关: ${getProgressText(this.currentChapter, this.currentStage, this.currentSection)}`);
|
||
}
|
||
|
||
// ======================== 回合管理 ========================
|
||
|
||
/**
|
||
* 新一回合事件回调(SectionStart 时由 MissionMonComp 派发,wave = 回合号):
|
||
* 1. 更新当前回合号与 HUD 进度。
|
||
* 2. 按回合配置判定是否含 Boss(驱动 Boss 预警)。
|
||
*
|
||
* 注意:回合推进在 autoNextPhase(PrepareStart) 完成,此处仅同步显示与 Boss 预警;
|
||
* "第 X 回合开始"提示改在 BattleStart(战斗开始)时播放,准备阶段不显示。
|
||
*
|
||
* @param event 事件名
|
||
* @param data { wave: number, bossWave?: boolean }
|
||
*/
|
||
private onNewWave(event: string, data: any) {
|
||
const wave = Number(data?.wave ?? 0);
|
||
if (wave <= 0) return;
|
||
|
||
// Boss 预警:本回合怪物列表含 Boss 时置位(Boss 回合 Boss 延迟登场,BattleStart 时发预警)
|
||
this.isBossWave = !!data?.bossWave;
|
||
|
||
this.currentWave = wave;
|
||
smc.vmdata.mission_data.level = wave;
|
||
// 金币改为怪物死亡掉落(见 HeroAtkSystem),不再每回合固定发放
|
||
this.lastTimeSecond = -1;
|
||
this.clearTime = 0;
|
||
this.update_time();
|
||
}
|
||
|
||
// ======================== 怪物数量管理 ========================
|
||
|
||
/**
|
||
* 同步怪物刷新状态(降频执行,每 0.2 秒一次):
|
||
* 1. 遍历所有 HeroAttrsComp 实体,统计怪物和英雄数量。
|
||
* 2. 检测英雄全灭 / 怪物全灭。
|
||
*
|
||
* @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) {
|
||
// 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,
|
||
});
|
||
|
||
if (this.currentSection >= BOSS_SECTION) {
|
||
// Boss 节(末节)清场 → 本关通关,延迟弹结算(等死亡动画播完)
|
||
this.scheduleSettle(false, true);
|
||
} else {
|
||
// 非末节清场 → 立即启动节间过场(背景滚动 + 英雄归位同帧启动),自动连战下一节
|
||
this.beginSectionTransition();
|
||
}
|
||
return;
|
||
}
|
||
|
||
smc.vmdata.mission_data.mon_num = monsterCount;
|
||
}
|
||
|
||
/** 检测场上英雄是否全员存活(清屏 Perfect 判定用,独立于评分统计逻辑) */
|
||
private checkAllHeroAlive(): boolean {
|
||
let hasHero = false;
|
||
ecs.query(this.heroAttrsMatcher).forEach(entity => {
|
||
const attrs = entity.get(HeroAttrsComp);
|
||
if (attrs && attrs.fac === FacSet.HERO) {
|
||
hasHero = true;
|
||
}
|
||
});
|
||
// 英雄为真实死亡(实体已销毁),有死亡记录即非全员存活
|
||
return hasHero && smc.mission.dead_heroes.length === 0;
|
||
}
|
||
|
||
/**
|
||
* 查询本节击杀统计(转交结算弹窗做失败奖励判定)
|
||
* @returns { kills, total },查询不到 MissionMonComp 时返回 0/0
|
||
*/
|
||
private querySectionKillStats(): { kills: number; total: number } {
|
||
const entities = ecs.query(ecs.allOf(MissionMonCompComp));
|
||
const comp = entities[0]?.get(MissionMonCompComp);
|
||
if (!comp) return { kills: 0, total: 0 };
|
||
return comp.getSectionKillStats();
|
||
}
|
||
|
||
/**
|
||
* 英雄全灭检测:若场上无存活英雄且处于战斗中,预约延迟结算(等待死亡动画播完再弹窗)。
|
||
* @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.scheduleSettle(true, false);
|
||
}
|
||
|
||
/**
|
||
* 预约延迟结算:最后一个英雄/怪物死亡时,延迟 SETTLE_DELAY 秒等待死亡动画播完,
|
||
* 再切入 BattleEnd(由 autoNextPhase 流转弹窗),避免结算窗立即盖住死亡表现。
|
||
* @param is_hero_dead 是否因英雄全灭触发
|
||
* @param victory 是否胜利
|
||
*/
|
||
private scheduleSettle(is_hero_dead: boolean, victory: boolean) {
|
||
if (this._settleScheduled) return;
|
||
this._settleScheduled = true;
|
||
this.scheduleOnce(() => {
|
||
this._settleScheduled = false;
|
||
// 延迟期间局内状态可能已变化(如复活、任务结束),兜底校验
|
||
if (!smc.mission.play || smc.mission.pause) return;
|
||
if (this.currentPhase !== MissionPhase.Battle) return;
|
||
// 胜利/失败统一走结算弹窗:open_Victory 判定通关/失败
|
||
this.open_Victory(null, is_hero_dead, victory);
|
||
}, MissionComp.SETTLE_DELAY);
|
||
}
|
||
|
||
/**
|
||
* 启动节间过场(非末节清场):
|
||
* 1. 立即切出战斗(in_fight=false)→ 英雄触发编队排队、自动归位到左侧站位。
|
||
* 2. 同时恢复地图背景滚动,表现玩家推进(背景与英雄归位同帧启动,避免一先一后)。
|
||
* 3. 切 BattleEnd,2s 后由 autoNextPhase 进入 PrepareStart 过场倒计时,倒计时结束自动连战下一节。
|
||
*
|
||
* Why: 不等 SETTLE_DELAY 死亡动画,让"归位 + 背景滚动"在清场瞬间就启动,过渡更连贯。
|
||
*/
|
||
private beginSectionTransition() {
|
||
smc.mission.in_fight = false;
|
||
smc.vmdata.mission_data.in_fight = false;
|
||
// 恢复地图背景滚动(与英雄归位同步启动,2 倍速表现推进)
|
||
smc.map?.MapView?.scene?.mapLayer?.playAnimations();
|
||
// 过场期间英雄保持移动动画:即使已归位也持续行军,地图停才回待机
|
||
MoveSystem.transitionMoveLock = true;
|
||
this.changePhase(MissionPhase.BattleEnd);
|
||
}
|
||
|
||
/**
|
||
* 战斗超时强制结束(僵局兜底):
|
||
* 1. 预扣留存分(残留怪销毁后 mon_num 会被下一次同步清零,BattleEnd 的累加会漏扣,故在此显式预扣并置防重标志)。
|
||
* 2. 销毁全部残留怪(不触发 MonDead、不掉金币——超时是对"清不掉"的惩罚)。
|
||
* 3. 一场定胜负,超时按怪物全灭同等处理——直接结算整局。
|
||
*/
|
||
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', `[BattleTimeout] 超时强制结算`);
|
||
|
||
// 超时按怪物全灭同等处理——视为胜利(但通常会有英雄死亡,all_alive 由结算时实时检测)
|
||
this.open_Victory(null, false, true);
|
||
}
|
||
|
||
// ======================== 清理 ========================
|
||
|
||
/** 清理所有英雄和技能 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/MON/BOSS 和 SKILL 根节点下的所有子节点 */
|
||
private clearBattleSceneNodes() {
|
||
const scene = smc.map?.MapView?.scene;
|
||
const layer = scene?.entityLayer?.node;
|
||
if (!layer) return;
|
||
for (const name of ["HERO", "MON", "BOSS", "SKILL"]) {
|
||
const root = layer.getChildByName(name);
|
||
if (root) {
|
||
for (let i = root.children.length - 1; i >= 0; i--) {
|
||
root.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
|
||
};
|
||
}
|
||
|
||
// ======================== 性能监控面板 ========================
|
||
|
||
/** 性能监控相关代码 */
|
||
|
||
/** 初始化性能监控面板:在本组件节点下创建 Label */
|
||
private initMemoryPanel() {
|
||
if (!this.showMemoryPanel) return;
|
||
let panel = this.node.getChildByName("mem_panel");
|
||
if (!panel) {
|
||
panel = new Node("mem_panel");
|
||
panel.parent = this.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.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();
|
||
}
|
||
}
|
||
}
|