From 409113e269ec06626d126244225c9b1d123f042e Mon Sep 17 00:00:00 2001 From: pan Date: Tue, 11 Aug 2026 19:00:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E8=82=89=E9=B8=BD?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=88=B6=E6=B8=B8=E6=88=8F=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E7=8E=A9=E6=B3=95=E8=BF=AD=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增连杀系统、金币飞行特效、波次HUD、屏幕震动等表现功能,重构怪物刷出逻辑与难度动态调节,调整回合回血比例,优化游戏节奏与体验。 主要变更: 1. 调整回合回血比例从0.5到0.4,优化前期节奏 2. 新增连杀计数与奖励系统,支持5/10/20连杀触发对应表现 3. 实现怪物死亡掉落金币的抛物线飞行特效 4. 增加波次进度HUD,显示剩余怪物与全局回合进度 5. 新增屏幕震动工具与战斗横幅统一展示系统 6. 重构怪物刷出逻辑,支持按回合类型调整刷怪间隔,加入清场加速机制 7. 优化动态难度调节算法,增加滞回与指数平滑,避免难度突变 8. 新增Boss预警、登场事件与回合清屏事件,完善事件总线 9. 调整怪物数量阈值与回合倒计时档位,适配新的节奏设计 --- assets/script/game/common/ScreenShake.ts | 45 +++ .../script/game/common/SingletonModuleComp.ts | 2 + assets/script/game/common/config/GameEvent.ts | 9 + assets/script/game/common/config/GameSet.ts | 2 +- assets/script/game/hero/HeroAtkSystem.ts | 11 + assets/script/game/map/BattleBannerComp.ts | 143 ++++++++++ assets/script/game/map/CoinFlyComp.ts | 101 +++++++ assets/script/game/map/ComboComp.ts | 72 +++++ assets/script/game/map/MissionComp.ts | 128 ++++++++- assets/script/game/map/MissionMonComp.ts | 85 +++++- assets/script/game/map/RogueConfig.ts | 263 ++++++++++++++---- assets/script/game/map/WaveHudComp.ts | 123 ++++++++ 12 files changed, 904 insertions(+), 80 deletions(-) create mode 100644 assets/script/game/common/ScreenShake.ts create mode 100644 assets/script/game/map/BattleBannerComp.ts create mode 100644 assets/script/game/map/CoinFlyComp.ts create mode 100644 assets/script/game/map/ComboComp.ts create mode 100644 assets/script/game/map/WaveHudComp.ts diff --git a/assets/script/game/common/ScreenShake.ts b/assets/script/game/common/ScreenShake.ts new file mode 100644 index 00000000..eb5b85dc --- /dev/null +++ b/assets/script/game/common/ScreenShake.ts @@ -0,0 +1,45 @@ +/** + * @file ScreenShake.ts + * @description 屏幕震动静态工具(表现层) + * + * 项目内无现成相机震动能力(MapViewScene.camera 可能为 null 且从未使用), + * 改为抖动地图根节点 smc.map.MapView.node(地图+实体一起震,UI 层不受影响)。 + * + * 用法: + * ScreenShake.shake(10, 0.4); // 强度 10 像素,持续 0.4 秒 + */ +import { tween, Tween, v3, Vec3 } from "cc"; +import { smc } from "./SingletonModuleComp"; + +export class ScreenShake { + /** 地图根节点的基准位置(首次震动时快照,用于收敛回原位,防多次震动漂移) */ + private static basePos: Vec3 | null = null; + + /** + * 触发一次屏幕震动 + * @param strength 像素振幅(建议 4~10) + * @param duration 总时长(秒,建议 0.2~0.4) + */ + static shake(strength: number = 8, duration: number = 0.3) { + const target = smc.map?.MapView?.node; + if (!target || !target.isValid) return; + + if (!this.basePos) this.basePos = target.position.clone(); + + // 打断进行中的震动(新震动覆盖旧震动,不做强度叠加,避免失控) + Tween.stopAllByTarget(target); + + const steps = Math.max(1, Math.floor(duration / 0.04)); + const t = tween(target); + for (let i = 0; i < steps; i++) { + t.to(0.04, { + position: v3( + this.basePos.x + (Math.random() * 2 - 1) * strength, + this.basePos.y + (Math.random() * 2 - 1) * strength, + this.basePos.z), + }); + } + // 收敛回原位 + t.to(0.05, { position: this.basePos }).start(); + } +} diff --git a/assets/script/game/common/SingletonModuleComp.ts b/assets/script/game/common/SingletonModuleComp.ts index b50e49b6..82b13142 100644 --- a/assets/script/game/common/SingletonModuleComp.ts +++ b/assets/script/game/common/SingletonModuleComp.ts @@ -85,6 +85,8 @@ export class SingletonModuleComp extends ecs.Comp { game_pause: false, mission_data: { mon_num: 0,//怪物数量 + pending_mon_num: 0,//待刷出怪物数量(MissionMonComp 每帧写入,供回合结束检测与 HUD 进度) + wave_early_skip: 0,//本回合清场加速累计节省的秒数(MissionComp 用于还原 DDA 判定口径) hero_num: 0,//英雄数量 hero_max_num: FightSet.HERO_MAX_NUM,//英雄可召唤上限 hero_extend_max_num: FightSet.HERO_MAX_NUM + 1,//英雄可拓展上限 diff --git a/assets/script/game/common/config/GameEvent.ts b/assets/script/game/common/config/GameEvent.ts index 7196b3ee..fbc241de 100644 --- a/assets/script/game/common/config/GameEvent.ts +++ b/assets/script/game/common/config/GameEvent.ts @@ -88,5 +88,14 @@ export enum GameEvent { UseEquipCard = "UseEquipCard", // 装备卡购买使用事件 RemoveEquipBox = "RemoveEquipBox", // 装备盒销毁事件 HeroBoxEmptyClick = "HeroBoxEmptyClick", // 英雄面板空槽位点击事件(展示英雄卡池) + BossWarning = "BossWarning", // Boss 回合战斗开始预警(payload: { wave, eta },eta 为 Boss 预计进场秒数) + /** 回合清屏(场上+待刷怪全灭),payload: { wave, clearTime, allAlive, fastClear } */ + WaveClear = "WaveClear", + /** Boss 实体刷出瞬间(payload: { pos },供登场震屏/音效定位) */ + BossSpawn = "BossSpawn", + /** 金币飞行表现(payload: { worldPos, gold, isBoss },账务已即时结算,纯视觉事件) */ + CoinFly = "CoinFly", + /** 连杀达阈值(payload: { count, tier },tier 0/1/2 对应 5/10/20 连杀) */ + ComboReach = "ComboReach", } diff --git a/assets/script/game/common/config/GameSet.ts b/assets/script/game/common/config/GameSet.ts index 924e23a7..30ef3dc6 100644 --- a/assets/script/game/common/config/GameSet.ts +++ b/assets/script/game/common/config/GameSet.ts @@ -46,7 +46,7 @@ export enum FightSet { CSKILL_START_X = -340, CSKILL_START_Y = 30, SHIELD_MAX = 5, - WAVE_HEAL_RATE = 0.5, // 回合结束时所有英雄恢复最大生命值的比例 + WAVE_HEAL_RATE = 0.4, // 回合结束时所有英雄恢复最大生命值的比例(与 DDA 放水兜底分层:回血保节奏,放水防崩盘) PUNCTURE_DOWN = 50, REFRESH_COST = 2, BASE_COST = 5, diff --git a/assets/script/game/hero/HeroAtkSystem.ts b/assets/script/game/hero/HeroAtkSystem.ts index 8fc5e7e8..5b06dbbd 100644 --- a/assets/script/game/hero/HeroAtkSystem.ts +++ b/assets/script/game/hero/HeroAtkSystem.ts @@ -468,6 +468,11 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda if (TAttrsComp.fac === FacSet.MON) { // 怪物死亡处理 this.scheduleDrop(entity); + // oops.message 全局事件总线:怪物死亡(连杀计数等表现层消费) + oops.message.dispatchEvent(GameEvent.MonDead, { + isBoss: !!TAttrsComp.is_boss, + worldPos: entity.get(HeroViewComp)?.node?.worldPosition?.clone() ?? null, + }); } else if (TAttrsComp.fac === FacSet.HERO) { // 英雄死亡处理 this.scheduleHeroDeath(entity); @@ -495,6 +500,12 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda if (gold <= 0) return; MissionEconomy.addCoin(gold); + // oops.message 全局事件总线:金币飞行表现(账务已即时结算,飞行纯视觉) + oops.message.dispatchEvent(GameEvent.CoinFly, { + worldPos: entity.get(HeroViewComp)?.node?.worldPosition?.clone() ?? null, + gold, + isBoss: !!TAttrsComp.is_boss, + }); mLogger.log(this.debugMode, 'HeroAtkSystem', ` ${TAttrsComp.hero_name} 死亡掉落金币 ${gold}(monType=${monType}, isBoss=${TAttrsComp.is_boss})`); } diff --git a/assets/script/game/map/BattleBannerComp.ts b/assets/script/game/map/BattleBannerComp.ts new file mode 100644 index 00000000..aa3b15b8 --- /dev/null +++ b/assets/script/game/map/BattleBannerComp.ts @@ -0,0 +1,143 @@ +/** + * @file BattleBannerComp.ts + * @description 战斗横幅统一通道组件(表现层) + * + * 职责: + * 1. 监听清屏(WaveClear)/ Boss 预警(BossWarning)/ 连杀(ComboReach)事件,播分级横幅。 + * 2. 横幅动画:右侧飞入(backOut)→ 中央停留 → 左侧飞出(backIn),与 MissionComp.playTooltipAnim 同风格。 + * 3. 清屏奖励结算(fastClear 金币 / allAlive 刷新石),走 MissionEconomy 静态接口。 + * 4. Boss 登场(BossSpawn)触发震屏 + 音效。 + * + * 使用 oops-framework 模块:oops.message(事件解耦)、oops.audio(音效)。 + * 编辑器绑定:bannerNode(Label+背景节点,初始 active=false)。 + */ +import { _decorator, Node, Label, tween, Tween, v3, Color } from "cc"; +import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; +import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; +import { GameEvent } from "../common/config/GameEvent"; +import { MissionEconomy } from "./MissionEconomy"; +import { ScreenShake } from "../common/ScreenShake"; +import { WAVE_DURATION } from "./RogueConfig"; + +const { ccclass, property } = _decorator; + +/** 清屏奖励:迅速清场(< 75% 时长) */ +const FAST_CLEAR_COIN = 2; +/** 清屏奖励:极速清场(< 50% 时长) */ +const ULTRA_CLEAR_COIN = 5; +/** 连杀分级金币爆发(tier 0/1/2 对应 5/10/20 连杀) */ +const COMBO_COIN = [2, 5, 10]; + +@ccclass('BattleBannerComp') +export class BattleBannerComp extends CCComp { + /** 横幅节点(Label+背景,初始隐藏),编辑器拖拽绑定 */ + @property({ type: Node, tooltip: "横幅节点(Label+背景,初始 active=false)" }) + bannerNode: Node | null = null; + + /** 上次横幅播放时间戳(去抖:0.3s 内的新横幅直接打断旧的,不叠加 tween) */ + private lastBannerTime: number = 0; + + onLoad() { + // oops.message 全局事件总线:清屏 / Boss 预警 / Boss 登场 / 连杀 + oops.message.on(GameEvent.WaveClear, this.onWaveClear, this); + oops.message.on(GameEvent.BossWarning, this.onBossWarning, this); + oops.message.on(GameEvent.BossSpawn, this.onBossSpawn, this); + oops.message.on(GameEvent.ComboReach, this.onComboReach, this); + } + + onDestroy() { + oops.message.off(GameEvent.WaveClear, this.onWaveClear, this); + oops.message.off(GameEvent.BossWarning, this.onBossWarning, this); + oops.message.off(GameEvent.BossSpawn, this.onBossSpawn, this); + oops.message.off(GameEvent.ComboReach, this.onComboReach, this); + } + + // ======================== 事件处理 ======================== + + /** 清屏庆祝:分级文案 + 奖励结算 */ + private onWaveClear(event: string, data: { wave: number; clearTime: number; allAlive: boolean; fastClear: number }) { + if (data.allAlive) { + this.playBanner("Perfect!", new Color(255, 80, 80), 1.2); + MissionEconomy.addRefreshStone(1); + oops.audio.playEffect("music/flash"); + } else if (data.fastClear === 2) { + this.playBanner("极速清场!", new Color(255, 170, 0), 1.15); + MissionEconomy.addCoin(ULTRA_CLEAR_COIN); + oops.audio.playEffect("music/flash"); + } else if (data.fastClear === 1) { + this.playBanner("迅速清场!", new Color(255, 220, 60), 1.05); + MissionEconomy.addCoin(FAST_CLEAR_COIN); + } else { + this.playBanner("Clear!", new Color(255, 255, 255), 1.0); + } + } + + /** Boss 回合预警:红字横幅 */ + private onBossWarning(event: string, data: { wave: number; eta: number }) { + this.playBanner("强敌来袭!", new Color(255, 60, 60), 1.3); + oops.audio.playEffect("music/flash"); + } + + /** Boss 登场:震屏 + 闷响 */ + private onBossSpawn() { + ScreenShake.shake(10, 0.4); + oops.audio.playEffect("music/dun"); + } + + /** 连杀达阈值:分级文案 + 震屏 + 金币爆发 */ + private onComboReach(event: string, data: { count: number; tier: number }) { + const tier = Math.min(data.tier, 2); + const colors = [new Color(255, 230, 80), new Color(255, 150, 30), new Color(255, 60, 60)]; + const scales = [1.0, 1.2, 1.4]; + this.playBanner(`${data.count} 连杀!`, colors[tier], scales[tier]); + if (tier === 1) ScreenShake.shake(4, 0.2); + if (tier === 2) ScreenShake.shake(8, 0.3); + MissionEconomy.addCoin(COMBO_COIN[tier]); + // 占位音效分级(后续可替换为升调 combo 音效) + oops.audio.playEffect(tier === 2 ? "music/Fire" : tier === 1 ? "music/Critical" : "music/Hit"); + } + + // ======================== 横幅播放 ======================== + + /** + * 播放横幅:右进 → 中央停留 → 左出 + * @param text 文案 + * @param color 文字颜色 + * @param scale 整体缩放(分级表现力) + */ + private playBanner(text: string, color: Color, scale: number = 1.0) { + if (!this.bannerNode || !this.bannerNode.isValid) return; + + // 去抖:0.3s 内的新横幅直接打断旧的 + const now = Date.now(); + if (now - this.lastBannerTime < 300) { + Tween.stopAllByTarget(this.bannerNode); + } + this.lastBannerTime = now; + + this.bannerNode.active = true; + this.bannerNode.setScale(v3(scale, scale, 1)); + + const label = this.bannerNode.getComponentInChildren(Label); + if (label) { + label.string = text; + label.color = color; + label.updateRenderData(true); + } + + Tween.stopAllByTarget(this.bannerNode); + const startPos = v3(1200, 0, 0); + const centerPos = v3(0, 0, 0); + const endPos = v3(-1200, 0, 0); + this.bannerNode.setPosition(startPos); + + tween(this.bannerNode) + .to(0.4, { position: centerPos }, { easing: "backOut" }) + .to(0.8, { position: v3(-40, 0, 0) }, { easing: "sineInOut" }) + .to(0.35, { position: endPos }, { easing: "backIn" }) + .call(() => { + this.bannerNode!.active = false; + }) + .start(); + } +} diff --git a/assets/script/game/map/CoinFlyComp.ts b/assets/script/game/map/CoinFlyComp.ts new file mode 100644 index 00000000..0e4d4466 --- /dev/null +++ b/assets/script/game/map/CoinFlyComp.ts @@ -0,0 +1,101 @@ +/** + * @file CoinFlyComp.ts + * @description 金币飞行表现组件(表现层,纯视觉) + * + * 职责: + * 1. 监听 CoinFly 事件(怪物死亡掉金币,账务已由 MissionEconomy 即时结算)。 + * 2. 从怪物位置生成金币 icon,抛物线散开后加速飞入钱包 icon。 + * 3. 独立 NodePool 管理金币节点(上限 30),不复用伤害飘字池。 + * + * 使用 oops-framework 模块:oops.message(事件解耦)。 + * 编辑器绑定:flyLayer(全屏容器,最高 sibling)、coinIconSrc(钱包 coin/icon 节点,取其 spriteFrame)。 + */ +import { _decorator, Node, Sprite, UITransform, tween, v3, Vec3, NodePool } from "cc"; +import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; +import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; +import { GameEvent } from "../common/config/GameEvent"; + +const { ccclass, property } = _decorator; + +/** 普通怪金币枚数 */ +const COIN_COUNT_NORMAL = 3; +/** Boss 金币枚数 */ +const COIN_COUNT_BOSS = 8; +/** 对象池上限(防节点泄漏) */ +const POOL_MAX = 30; + +@ccclass('CoinFlyComp') +export class CoinFlyComp extends CCComp { + @property({ type: Node, tooltip: "金币飞行容器(全屏节点,置于最高 sibling 盖在所有 UI 之上)" }) + flyLayer: Node | null = null; + + @property({ type: Node, tooltip: "钱包金币 icon 节点(运行期取其 spriteFrame 克隆)" }) + coinIconSrc: Node | null = null; + + /** 金币节点对象池 */ + private pool: NodePool = new NodePool(); + + onLoad() { + // oops.message 全局事件总线:金币飞行 + oops.message.on(GameEvent.CoinFly, this.onCoinFly, this); + } + + onDestroy() { + oops.message.off(GameEvent.CoinFly, this.onCoinFly, this); + this.pool.clear(); + } + + private onCoinFly(event: string, data: { worldPos: Vec3 | null; gold: number; isBoss: boolean }) { + if (!this.flyLayer || !this.coinIconSrc || !data.worldPos) return; + + const uiTransform = this.flyLayer.getComponent(UITransform); + if (!uiTransform) return; + + const startPos = uiTransform.convertToNodeSpaceAR(data.worldPos); + const endPos = uiTransform.convertToNodeSpaceAR(this.coinIconSrc.worldPosition); + const count = data.isBoss ? COIN_COUNT_BOSS : COIN_COUNT_NORMAL; + + for (let i = 0; i < count; i++) { + this.spawnCoin(startPos, endPos, i * 0.03); + } + } + + /** 生成一枚金币:上抛散开 → 加速飞向钱包 → 回收 */ + private spawnCoin(startPos: Vec3, endPos: Vec3, delay: number) { + const coin = this.pool.size() > 0 ? this.pool.get()! : this.createCoinNode(); + coin.parent = this.flyLayer; + coin.setPosition(startPos); + coin.setScale(v3(1, 1, 1)); + + const scatter = v3( + startPos.x + (Math.random() * 120 - 60), + startPos.y + 60 + Math.random() * 40, + 0); + + tween(coin) + .delay(delay) + .to(0.25, { position: scatter }, { easing: "quadOut" }) + .to(0.4, { position: endPos }, { easing: "quadIn" }) + .call(() => this.recycleCoin(coin)) + .start(); + } + + private createCoinNode(): Node { + const node = new Node("coin_fly"); + node.addComponent(UITransform).setContentSize(32, 32); + const sp = node.addComponent(Sprite); + const srcSp = this.coinIconSrc?.getComponent(Sprite); + if (srcSp?.spriteFrame) { + sp.spriteFrame = srcSp.spriteFrame; + } + return node; + } + + private recycleCoin(coin: Node) { + if (this.pool.size() >= POOL_MAX) { + coin.destroy(); + return; + } + this.pool.put(coin); + } +} diff --git a/assets/script/game/map/ComboComp.ts b/assets/script/game/map/ComboComp.ts new file mode 100644 index 00000000..6b1a3c18 --- /dev/null +++ b/assets/script/game/map/ComboComp.ts @@ -0,0 +1,72 @@ +/** + * @file ComboComp.ts + * @description 连杀 Combo 计数组件(逻辑层,事件驱动) + * + * 职责: + * 1. 监听 MonDead 事件,2s 滑动窗口内累计连杀数。 + * 2. 达阈值(5/10/20)时派发 ComboReach 事件,由 BattleBannerComp 消费分级表现。 + * 3. 窗口超时或整局结束(MissionEnd)清零。 + * + * 使用 oops-framework 模块:oops.message(事件解耦)。 + * 本组件无 UI 绑定,挂 mission.prefab 任意节点即可。 + */ +import { _decorator } from "cc"; +import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; +import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; +import { GameEvent } from "../common/config/GameEvent"; +import { smc } from "../common/SingletonModuleComp"; + +const { ccclass } = _decorator; + +/** 连杀窗口(秒):窗口内每次击杀续期 */ +const COMBO_WINDOW = 2; +/** 连杀阈值档位:x5 / x10 / x20 */ +const COMBO_TIERS = [5, 10, 20]; + +@ccclass('ComboComp') +export class ComboComp extends CCComp { + /** 当前连杀数 */ + private comboCount: number = 0; + /** 滑动窗口剩余时间(秒) */ + private windowTimer: number = 0; + + onLoad() { + // oops.message 全局事件总线:怪物死亡 / 整局结束 + oops.message.on(GameEvent.MonDead, this.onMonDead, this); + oops.message.on(GameEvent.MissionEnd, this.resetCombo, this); + } + + onDestroy() { + oops.message.off(GameEvent.MonDead, this.onMonDead, this); + oops.message.off(GameEvent.MissionEnd, this.resetCombo, this); + } + + private onMonDead() { + this.comboCount++; + this.windowTimer = COMBO_WINDOW; + + const tier = COMBO_TIERS.indexOf(this.comboCount); + if (tier >= 0) { + // oops.message 全局事件总线:连杀达阈值(横幅/震屏/金币爆发由 BattleBannerComp 消费) + oops.message.dispatchEvent(GameEvent.ComboReach, { + count: this.comboCount, + tier, + }); + } + } + + private resetCombo() { + this.comboCount = 0; + this.windowTimer = 0; + } + + protected update(dt: number) { + if (!smc.mission.play || smc.mission.pause) return; + if (this.windowTimer > 0) { + this.windowTimer -= dt; + if (this.windowTimer <= 0) { + this.comboCount = 0; + } + } + } +} diff --git a/assets/script/game/map/MissionComp.ts b/assets/script/game/map/MissionComp.ts index c039d24b..aee1c006 100644 --- a/assets/script/game/map/MissionComp.ts +++ b/assets/script/game/map/MissionComp.ts @@ -48,7 +48,7 @@ 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 } from "./RogueConfig"; +import { spawningEngine, MAX_WAVE, DynamicTuner, WAVE_TIMEOUT, WAVE_TIMEOUT_BOSS, BATCH_INTERVAL, BATCH_COUNT, WAVE_DURATION } from "./RogueConfig"; const { ccclass, property } = _decorator; /** 任务(关卡)生命周期阶段 */ @@ -82,10 +82,10 @@ export class MissionComp extends CCComp { // ======================== 配置参数 ======================== - /** 怪物数量上限(超过后暂停刷怪) */ - private maxMonsterCount: number = 80; - /** 怪物数量恢复阈值(降至此值以下恢复刷怪) */ - private resumeMonsterCount: number = 45; + /** 怪物数量上限(超过后暂停刷怪):略高于单回合上限 MAX_MONSTERS(54),只有跨回合堆积才触发泄压 */ + private maxMonsterCount: number = 60; + /** 怪物数量恢复阈值(降至此值以下恢复刷怪,需留空间接纳后续批次,单批最大约 18 只) */ + private resumeMonsterCount: number = 40; // ======================== 编辑器绑定节点 ======================== @@ -128,8 +128,12 @@ export class MissionComp extends CCComp { PhaseTime: Timer = new Timer(1) /** 回合间倒计时(秒) */ private waveCountdown: number = 0; - /** 回合间倒计时总时长(秒) */ - private readonly WAVE_COUNTDOWN_DURATION: number = 5; + /** 倒计时档位:快清场(压缩垃圾时间,奖励强 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 = ""; /** 上一次显示的秒数(避免重复计算) */ @@ -160,6 +164,12 @@ export class MissionComp extends CCComp { 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; /** 是否处于回合间倒计时状态 */ @@ -248,6 +258,12 @@ export class MissionComp extends CCComp { 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(); + } } } @@ -275,13 +291,20 @@ export class MissionComp extends CCComp { // ======================== 回合倒计时 ======================== - /** 进入回合间倒计时:重置倒计时并显示提示 */ + /** 进入回合间倒计时:按上回合战况分档(快清场压缩垃圾时间,有死亡保留运营窗口) */ private startWaveCountdown() { - this.waveCountdown = this.WAVE_COUNTDOWN_DURATION; + 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; @@ -491,12 +514,21 @@ export class MissionComp extends CCComp { 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: @@ -520,7 +552,11 @@ export class MissionComp extends CCComp { // 【评分系统 - 战绩分】每回合胜利加分 smc.vmdata.scores.wave_win_count++; // 【评分系统 - 战绩分】记录每回合结束时场上留存的敌人数量(扣分项) - smc.vmdata.scores.wave_remain_monsters += smc.vmdata.mission_data.mon_num; + // 超时回合已在 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; @@ -537,8 +573,15 @@ export class MissionComp extends CCComp { }); // 【动态难度调节】根据本回合战况自动放水 / 加压 - DynamicTuner.adjust(this.clearTime, heroDeathCount); - mLogger.log(this.debugMode, 'MissionComp', `[DynamicTuner] wave=${this.currentWave} clearTime=${this.clearTime.toFixed(1)}s deaths=${heroDeathCount} factor=${DynamicTuner.factor.toFixed(2)}`); + // 清场加速节省的时间还原进口径,避免"打得好"被节奏加速+强度加压双重惩罚 + 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++; @@ -553,7 +596,7 @@ export class MissionComp extends CCComp { // 触发战斗结束技能(fend) this.triggerHeroBattleSkills(false); - // 战斗结束阶段,给予所有英雄恢复70%血量的技能效果 + // 战斗结束阶段,按 FightSet.WAVE_HEAL_RATE 恢复所有英雄血量 this.healAllHeroes(); // 【新增】派发每回合战斗结束事件,供卡牌技能监听(区别于整局结束的 MissionEnd) @@ -674,7 +717,7 @@ export class MissionComp extends CCComp { } /** - * 战斗结束阶段治疗所有英雄(包括墓地英雄),恢复70%最大生命值 + * 战斗结束阶段治疗所有英雄(包括墓地英雄),按 FightSet.WAVE_HEAL_RATE 恢复最大生命值比例 */ private healAllHeroes() { const healRateBoost = FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.WaveHeal); @@ -788,6 +831,9 @@ export class MissionComp extends CCComp { 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 = ""; @@ -901,6 +947,16 @@ export class MissionComp extends CCComp { // 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; @@ -919,6 +975,20 @@ export class MissionComp extends CCComp { 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 当前存活英雄数量 @@ -930,6 +1000,36 @@ export class MissionComp extends CCComp { 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 实体 */ diff --git a/assets/script/game/map/MissionMonComp.ts b/assets/script/game/map/MissionMonComp.ts index 188f4820..77fa5fc6 100644 --- a/assets/script/game/map/MissionMonComp.ts +++ b/assets/script/game/map/MissionMonComp.ts @@ -22,7 +22,7 @@ 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, MAX_MONSTERS, BATCH_COUNT, BATCH_INTERVAL } from "./RogueConfig"; +import { spawningEngine, GeneratedMonster, TestModeConfig, MAX_MONSTERS, BATCH_COUNT, BATCH_INTERVAL, getWaveType, SPAWN_INTERVAL_BY_TYPE } from "./RogueConfig"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { MonMoveComp } from "../hero/MonMoveComp"; @@ -37,7 +37,7 @@ export class MissionMonCompComp extends CCComp { private static readonly MON_DROP_HEIGHT = 0; /** 怪物统一从右侧 X=400 出生点逐个刷出,随后向左推进 */ private static readonly MON_SPAWN_X = 400; - /** 逐个刷怪间隔(秒):保证怪物排成纵队,避免堆叠 */ + /** 逐个刷怪默认间隔(秒):保证怪物排成纵队,避免堆叠;运行时按回合类型查 SPAWN_INTERVAL_BY_TYPE 覆盖 */ private static readonly MON_SPAWN_INTERVAL = 0.3; /** @@ -88,6 +88,20 @@ export class MissionMonCompComp extends CCComp { private spawnQueue: GeneratedMonster[] = []; /** 逐个刷怪累计计时(秒) */ private spawnTimer: number = 0; + /** 当前回合的逐个刷怪间隔(秒),按回合类型查 SPAWN_INTERVAL_BY_TYPE */ + private spawnInterval: number = MissionMonCompComp.MON_SPAWN_INTERVAL; + /** 当前批已刷出的怪物数(清场加速存活比例分母) */ + 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; + /** 清场加速提前量(秒):等效 batchTimer 快进,保底批间隔不被瞬间叠爆 */ + private static readonly BATCH_EARLY_SKIP = 2.0; // ======================== 生命周期 ======================== @@ -105,6 +119,10 @@ export class MissionMonCompComp extends CCComp { } smc.vmdata.mission_data.pending_mon_num = pendingCount; + // 场上怪物超阈值时暂停释放:冻结批次推进与逐个刷出计时(不推进计时器,恢复后从断点平滑续接) + // 注意 pending 统计必须在 return 之前执行,保证回合结束检测(pending==0)语义不受暂停影响 + if (smc.mission.stop_spawn_mon) return; + // 分批释放:按 BATCH_INTERVAL 节奏推进批次 if (this.isReleasing) { this.batchTimer += dt; @@ -112,12 +130,19 @@ export class MissionMonCompComp extends CCComp { this.batchTimer = 0; this.advanceBatch(); } + + // 清场加速:0.2s 节流检测当前批存活比例,清得快则快进批次计时(学 PvZ 血量阈值提前刷新) + this.aliveCheckTimer += dt; + if (this.aliveCheckTimer >= 0.2) { + this.aliveCheckTimer = 0; + this.checkBatchEarlyAdvance(); + } } - // 逐个刷怪:按 MON_SPAWN_INTERVAL 节奏从队列释放 + // 逐个刷怪:按 spawnInterval 节奏从队列释放 if (this.spawnQueue.length > 0) { this.spawnTimer += dt; - if (this.spawnTimer >= MissionMonCompComp.MON_SPAWN_INTERVAL) { + if (this.spawnTimer >= this.spawnInterval) { this.spawnTimer = 0; const monData = this.spawnQueue.shift()!; const targetPosIndex = this.waveSpawnedCount % MissionMonCompComp.MON_POSITIONS.length; @@ -127,7 +152,7 @@ export class MissionMonCompComp extends CCComp { } } - start() {} + start() { } private setupWaveData(monsters: GeneratedMonster[]) { // 按批次分组 @@ -167,6 +192,10 @@ export class MissionMonCompComp extends CCComp { this.isReleasing = false; this.spawnQueue = []; this.spawnTimer = 0; + this.batchReleasedCount = 0; + this.batchFastForwarded = false; + this.aliveCheckTimer = 0; + this.waveEarlySkipTotal = 0; // 预生成第一回合数据以获取数量和 Boss 信息 const monsters = spawningEngine.generateWave(this.currentWave); @@ -195,6 +224,9 @@ export class MissionMonCompComp extends CCComp { private onPhasePrepareEnd() { this.resetSlotSpawnData(); + // 按回合类型确定本回合刷怪间隔(放松回合快速倾泻,压力回合稍慢聚焦) + this.spawnInterval = SPAWN_INTERVAL_BY_TYPE[getWaveType(this.currentWave)] ?? MissionMonCompComp.MON_SPAWN_INTERVAL; + // 准备结束阶段:启动分批释放, // 第一批立即转入 spawnQueue,后续批次由 update 按 BATCH_INTERVAL 推进。 this.startBatchRelease(); @@ -238,10 +270,39 @@ export class MissionMonCompComp extends CCComp { for (const m of batch) { this.spawnQueue.push(m); } + this.batchReleasedCount = batch.length; + this.batchFastForwarded = false; batch.length = 0; // 让首个怪物在下一帧立即刷出,避免额外延迟 - this.spawnTimer = MissionMonCompComp.MON_SPAWN_INTERVAL; + this.spawnTimer = this.spawnInterval; + } + + /** + * 清场加速检测:当前批已放完且存活比例 ≤ BATCH_EARLY_RATIO 时,快进批次计时器。 + * 只奖励"清得快"的 build(压缩批间垃圾时间),弱 build 清不完则不触发、不叠加压力。 + */ + private checkBatchEarlyAdvance() { + if (this.batchFastForwarded) return; + if (this.currentBatch >= BATCH_COUNT - 1) return; + if (this.spawnQueue.length > 0) return; // 本批还没放完,不判断 + if (this.batchReleasedCount <= 0) return; + + 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.batchTimer += 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] batch=${this.currentBatch} alive=${alive}/${this.batchReleasedCount},快进 ${MissionMonCompComp.BATCH_EARLY_SKIP}s`); + } } // ======================== 槽位管理 ======================== @@ -264,6 +325,11 @@ export class MissionMonCompComp extends CCComp { this.isReleasing = false; this.currentBatch = 0; this.batchTimer = 0; + this.batchReleasedCount = 0; + this.batchFastForwarded = false; + this.aliveCheckTimer = 0; + this.waveEarlySkipTotal = 0; + smc.vmdata.mission_data.wave_early_skip = 0; } // ======================== 怪物生成 ======================== @@ -292,6 +358,11 @@ export class MissionMonCompComp extends CCComp { 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; @@ -307,5 +378,5 @@ export class MissionMonCompComp extends CCComp { } /** ECS 组件移除时触发 */ - reset() {} + reset() { } } diff --git a/assets/script/game/map/RogueConfig.ts b/assets/script/game/map/RogueConfig.ts index 9233581b..5d17154a 100644 --- a/assets/script/game/map/RogueConfig.ts +++ b/assets/script/game/map/RogueConfig.ts @@ -9,18 +9,19 @@ * 4. MonSkillSet - 怪物技能池(atking / atked / dead 等全触发类型) * 5. RogueSpawningEngine - 生成引擎(按英雄强度反推怪物强度) * - * 核心公式: + * 核心公式(最终强度 = heroPower × typeRatio × power_adjust × DDA × hp/ap_mul): * heroPower = Σ calcHeroPower(HeroInfo[uuid], lv) (场上存活英雄) * targetPower = heroPower × 回合类型系数 × wave.power_adjust × DynamicTuner.factor - * scale = targetPower ÷ Σ 怪物基础强度 - * 每只怪: hp ×= scale, ap ×= scale + * hpScale = targetPower × wave.hp_mul ÷ Σ 怪物基础强度 + * apScale = targetPower × wave.ap_mul ÷ Σ 怪物基础强度 + * 每只怪: hp ×= hpScale, ap ×= apScale * * 回合节奏: * - 最大 20 回合,第 20 回合通关 * - 每回合 30 秒,固定分 3 批,每 10 秒释放一批 * - 普通回合 18~36 只,放松回合 × 1.5 = 27~54 只 * - wave % 5 === 0 → 压力回合(必带 Boss,强度高、数量少) - * - wave % 5 === 1 → 放松回合(数量 × 1.5,强度低,爽快清屏) + * - wave % 5 === 4 → 放松回合(数量 × 1.5,强度低,爽快清屏,大战前夜收割补给) */ import { HeroInfo, MonType, MonTypeName, calcHeroPower, TriggerGrouped, LvReviveEntry, heroInfo } from "../common/config/heroSet"; @@ -35,7 +36,7 @@ import { HeroAttrsComp } from "../hero/HeroAttrsComp"; export enum WaveType { Normal = 0, // 普通回合 Pressure = 1, // 压力回合(wave % 5 === 0,必带 Boss) - Relax = 2, // 放松回合(wave % 5 === 1,量大强度低) + Relax = 2, // 放松回合(wave % 5 === 4,量大强度低,大战前夜的收割补给) } /** 回合类型名称 */ @@ -70,6 +71,28 @@ export const BATCH_INTERVAL = WAVE_DURATION / BATCH_COUNT; /** 每回合怪物硬上限(放松回合 36 × 1.5 = 54) */ export const MAX_MONSTERS = 54; +/** Boss 护卫队数量(与 Boss 同批压轴进场,占用回合总名额) */ +export const BOSS_GUARD_COUNT = 3; + +/** 批次怪物数量占比(铺垫 → 加压 → 高潮,第三批另有收尾小队加压) */ +export const BATCH_RATIO: number[] = [0.25, 0.35, 0.40]; + +/** 收尾高潮批额外补入的最强小队数量(非放松回合生效) */ +export const FINALE_SQUAD_COUNT = 2; + +/** 按回合类型的逐个刷怪间隔(秒):放松回合快速倾泻造潮水感,压力回合稍慢便于聚焦 */ +export const SPAWN_INTERVAL_BY_TYPE: Record = { + [WaveType.Normal]: 0.18, + [WaveType.Pressure]: 0.25, + [WaveType.Relax]: 0.12, +}; + +/** 回合战斗超时(秒):超过后强制结束回合(残留怪销毁并扣留存分,DynamicTuner 因 clearTime 过大自动放水) */ +export const WAVE_TIMEOUT = 75; + +/** Boss 回合超时(秒):Boss 压轴进场(第 20 秒),击杀耗时更长,放宽兜底阈值 */ +export const WAVE_TIMEOUT_BOSS = 90; + /** * 获取指定回合的回合类型 * @param wave 回合(1 起) @@ -77,7 +100,7 @@ export const MAX_MONSTERS = 54; */ export function getWaveType(wave: number): WaveType { if (wave % 5 === 0) return WaveType.Pressure; - if (wave % 5 === 1) return WaveType.Relax; + if (wave % 5 === 4) return WaveType.Relax; // 大战前夜的收割补给:清杂攒金币备战 Boss return WaveType.Normal; } @@ -88,42 +111,66 @@ export function getWaveType(wave: number): WaveType { * 与硬编码系数并存,用于根据战况实时微调难度。 * * 用法示例(MissionComp 每回合结束时调用): - * DynamicTuner.adjust(clearTime, heroDeathCount); + * DynamicTuner.adjust(effectiveClearTime, heroDeathCount); * - * 调节规则(内部硬编码): - * - 清场时间 < 20s 且无英雄死亡 → factor += 0.05(加压) - * - 清场时间 > 28s 或有英雄死亡 → factor -= 0.05(放水) - * - factor 范围钳制 [0.5, 2.0] + * 设计原则(真隐形 DDA): + * - 连续映射:desired = 1 + (0.8 - clearTime/WAVE_DURATION) × K,清场越快要价越高,非离散跳变 + * - 滞回:连续同方向判定满 HYSTERESIS 回合才生效,偶发超神/崩盘不立即拉阀门 + * - 指数靠拢:每回合向 desired 移动 50%,避免突变被玩家察觉 + * - 总幅度钳制 [0.7, 1.3](±30%),防止橡皮筋效应 + * - 英雄死亡直接锚定 desired=0.8(温和放水),不与慢清场放水叠加 */ export const DynamicTuner = { /** 当前难度系数(默认 1.0,>1 加压,<1 放水) */ factor: 1.0, - /** 系数下限(最多放水到 50%) */ - MIN_FACTOR: 0.5, - /** 系数上限(最多加压到 200%) */ - MAX_FACTOR: 2.0, - /** 单次调节步长 */ - STEP: 0.05, + /** 系数下限(最多放水到 70%) */ + MIN_FACTOR: 0.7, + /** 系数上限(最多加压到 130%) */ + MAX_FACTOR: 1.3, + /** 连续映射增益:clearTime 每偏离基准 100% 时长,factor 偏移 K */ + K: 0.5, + /** 滞回:连续同方向判定满 N 回合才生效 */ + HYSTERESIS: 2, + /** 连续方向计数(>0 加压倾向,<0 放水倾向) */ + streak: 0, /** * 根据上一回合战况自动调节难度 - * @param clearTime 清场耗时(秒) + * @param clearTime 清场耗时(秒,已含清场加速提前量的还原口径) * @param heroDeathCount 英雄死亡数 + * @returns 本回合是否实际调整了 factor */ - adjust(clearTime: number, heroDeathCount: number): void { - if (heroDeathCount > 0 || clearTime > WAVE_DURATION * 0.95) { - // 有英雄死亡或清场过慢 → 放水 - this.factor = Math.max(this.MIN_FACTOR, this.factor - this.STEP); - } else if (clearTime < WAVE_DURATION * 0.65 && heroDeathCount === 0) { - // 清场过快且无死亡 → 加压 - this.factor = Math.min(this.MAX_FACTOR, this.factor + this.STEP); + adjust(clearTime: number, heroDeathCount: number): boolean { + // 1) 连续映射期望系数:基准 0.8×时长不动,更快加压、更慢放水;死亡锚定 0.8 + let desired: number; + if (heroDeathCount > 0) { + desired = 1 + (0.8 - 1.2) * this.K; // = 0.8 + } else { + desired = 1 + (0.8 - clearTime / WAVE_DURATION) * this.K; } + desired = Math.min(this.MAX_FACTOR, Math.max(this.MIN_FACTOR, desired)); + + // 2) 滞回:连续同方向满 HYSTERESIS 回合才向 desired 靠拢 + const dir = Math.sign(desired - 1); + if (dir === 0) { + this.streak = 0; + return false; + } + this.streak = (Math.sign(this.streak) === dir) ? this.streak + dir : dir; + if (Math.abs(this.streak) < this.HYSTERESIS) return false; + + // 3) 指数靠拢:每回合向 desired 移动 50%,避免跳变 + const old = this.factor; + this.factor = old + (desired - old) * 0.5; + this.factor = Math.min(this.MAX_FACTOR, Math.max(this.MIN_FACTOR, this.factor)); + return this.factor !== old; }, /** 重置调节器(每局开始时调用) */ reset(): void { this.factor = 1.0; + this.streak = 0; }, }; @@ -195,6 +242,8 @@ export const SquadLibrary: Record = { long_line: { id: "long_line", name: "远程线列组", weight: 7, slots: [{ type: MonType.Long, count: 2 }, { type: MonType.Support, count: 1 }] }, heavy_shield: { id: "heavy_shield", name: "重盾堡垒组", weight: 5, slots: [{ type: MonType.Heavy, count: 1 }, { type: MonType.Melee, count: 2 }] }, summoner_cult: { id: "summoner_cult", name: "召唤教派组", weight: 4, slots: [{ type: MonType.Summoner, count: 1 }, { type: MonType.Long, count: 2 }] }, + /** Boss 护卫队(weight=0 不进随机池,仅供引擎在 Boss 回合直接引用,与 Boss 同批压轴进场) */ + boss_guard: { id: "boss_guard", name: "Boss 护卫队", weight: 0, slots: [{ type: MonType.Heavy, count: 1 }, { type: MonType.Melee, count: 2 }] }, }; // ======================== 5. 怪物技能池 ======================== @@ -309,9 +358,9 @@ export interface WaveConfig { base_count: number; /** 可选小队 id 池,引擎按权重抽取拼装到 base_count */ squad_pool: string[]; - /** HP 强化倍率(硬编码,逐回合递进) */ + /** HP 成长乘区(并入强度缩放分子,终值 = heroPower × 系数 × hp_mul ÷ Σ基础强度) */ hp_mul: number; - /** AP 强化倍率(硬编码,逐回合递进) */ + /** AP 成长乘区(同 hp_mul,独立控制怪物肉度与输出的成长比例) */ ap_mul: number; /** 强度微调(放水 / 加压,默认 1.0) */ power_adjust?: number; @@ -328,7 +377,7 @@ export interface WaveConfig { * * 心流循环(5 回合一循环): * wave % 5 === 0 → 压力回合(必带 Boss,强度高、数量少) - * wave % 5 === 1 → 放松回合(数量 × 1.5,强度低) + * wave % 5 === 4 → 放松回合(数量 × 1.5,强度低,大战前夜收割补给) * 其余 → 普通回合(标准强度) * * 强度递进:hp_mul / ap_mul 每 5 回合一档,压力回合额外提升。 @@ -338,34 +387,35 @@ export const WaveConfigs: Record = { 1: { base_count: 18, squad_pool: ["melee_grunt"], hp_mul: 1.00, ap_mul: 1.00 }, 2: { base_count: 21, squad_pool: ["melee_grunt", "mixed_balanced"], hp_mul: 1.00, ap_mul: 1.00 }, 3: { base_count: 24, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.05, ap_mul: 1.00 }, + // 放松回合:量大好清,Boss 前收割补给 4: { base_count: 27, squad_pool: ["mixed_balanced", "long_line", "heavy_shield"], hp_mul: 1.10, ap_mul: 1.05 }, // 压力回合:第一 Boss 5: { base_count: 21, squad_pool: ["melee_grunt", "mixed_balanced", "heavy_shield"], hp_mul: 1.15, ap_mul: 1.10, boss_wave: true, boss_skill_pool: ["boss_rage"] }, // ===== 第二循环:引入技能怪 ===== - // 放松回合:量大好清 6: { base_count: 30, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.10, ap_mul: 1.05 }, 7: { base_count: 30, squad_pool: ["melee_grunt", "heavy_shield", "long_line"], hp_mul: 1.15, ap_mul: 1.10, skill_pool: ["tough"] }, 8: { base_count: 33, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.20, ap_mul: 1.15, skill_pool: ["berserk"] }, + // 放松回合:量大好清,Boss 前收割补给 9: { base_count: 33, squad_pool: ["heavy_shield", "long_line", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["berserk", "tough"] }, // 压力回合:第二 Boss 10: { base_count: 24, squad_pool: ["melee_grunt", "assassin_squad", "mixed_balanced"], hp_mul: 1.30, ap_mul: 1.25, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron"] }, // ===== 第三循环:组合多样化 ===== - // 放松回合 11: { base_count: 33, squad_pool: ["assassin_squad", "long_line", "summoner_cult", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["leech"] }, 12: { base_count: 33, squad_pool: ["heavy_shield", "melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.30, ap_mul: 1.25, skill_pool: ["tough", "warcry"] }, 13: { base_count: 36, squad_pool: ["assassin_squad", "summoner_cult", "mixed_balanced"], hp_mul: 1.35, ap_mul: 1.30, skill_pool: ["berserk", "leech"] }, + // 放松回合:量大好清,Boss 前收割补给 14: { base_count: 36, squad_pool: ["heavy_shield", "long_line", "melee_grunt"], hp_mul: 1.40, ap_mul: 1.35, skill_pool: ["berserk", "tough", "legacy"] }, // 压力回合:第三 Boss(中期高潮) 15: { base_count: 27, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.50, ap_mul: 1.40, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron", "boss_doom"] }, - // ===== 第四循环:终极阶段 ===== - // 放松回合 + // ===== 第四循环:终极阶段(17~19 power_adjust 逐步爬坡,为最终 Boss 蓄势) ===== 16: { base_count: 36, squad_pool: ["assassin_squad", "heavy_shield", "long_line"], hp_mul: 1.45, ap_mul: 1.40, skill_pool: ["leech", "warcry"] }, - 17: { base_count: 36, squad_pool: ["melee_grunt", "summoner_cult", "mixed_balanced"], hp_mul: 1.50, ap_mul: 1.45, skill_pool: ["berserk", "legacy"] }, - 18: { base_count: 36, squad_pool: ["assassin_squad", "long_line", "heavy_shield"], hp_mul: 1.55, ap_mul: 1.50, skill_pool: ["berserk", "tough", "leech"] }, - 19: { base_count: 36, squad_pool: ["mixed_balanced", "summoner_cult", "melee_grunt"], hp_mul: 1.60, ap_mul: 1.55, skill_pool: ["berserk", "tough", "legacy", "warcry"] }, + 17: { base_count: 36, squad_pool: ["melee_grunt", "summoner_cult", "mixed_balanced"], hp_mul: 1.50, ap_mul: 1.45, skill_pool: ["berserk", "legacy"], power_adjust: 1.05 }, + 18: { base_count: 36, squad_pool: ["assassin_squad", "long_line", "heavy_shield"], hp_mul: 1.55, ap_mul: 1.50, skill_pool: ["berserk", "tough", "leech"], power_adjust: 1.10 }, + // 放松回合:量大好清,最终 Boss 前收割补给 + 19: { base_count: 36, squad_pool: ["mixed_balanced", "summoner_cult", "melee_grunt"], hp_mul: 1.60, ap_mul: 1.55, skill_pool: ["berserk", "tough", "legacy", "warcry"], power_adjust: 1.20 }, // 压力回合:最终 Boss 20: { base_count: 30, squad_pool: ["assassin_squad", "heavy_shield", "summoner_cult", "mixed_balanced"], hp_mul: 1.80, ap_mul: 1.70, boss_wave: true, boss_skill_pool: ["boss_doom", "boss_rage"] }, }; @@ -405,6 +455,9 @@ export function validateRogueConfig(): string[] { if (cfg.base_count < 1 || cfg.base_count > MAX_MONSTERS) { errors.push(`Wave ${wave} base_count=${cfg.base_count} 越界 (1~${MAX_MONSTERS})`); } + if (cfg.power_adjust !== undefined && (cfg.power_adjust < 0.8 || cfg.power_adjust > 1.3)) { + errors.push(`Wave ${wave} power_adjust=${cfg.power_adjust} 越界 (0.8~1.3)`); + } } // 2. 校验 SquadLibrary 中所有 type 在 MonList 中有怪 @@ -429,6 +482,8 @@ export interface GeneratedMonster { hp: number; ap: number; isBoss: boolean; + /** 是否为 Boss 护卫队成员(与 Boss 同批压轴进场,供 UI/统计识别) */ + isBossGuard?: boolean; spawnIndex: number; /** 本怪所属批次(0~2,由 MissionMonComp 按 BATCH_INTERVAL 释放) */ batch: number; @@ -499,42 +554,59 @@ export class RogueSpawningEngine { const powerAdjust = cfg.power_adjust ?? 1.0; const targetPower = heroPower * typeRatio * powerAdjust * DynamicTuner.factor; - // 2. 确定怪物总数(放松回合 × 1.5) + // 2. 确定怪物总数(放松回合 × 1.5;普通/压力回合预留收尾高潮批名额,防 slice 截掉) let totalCount = cfg.base_count; if (waveType === WaveType.Relax) { totalCount = Math.round(totalCount * RELAX_COUNT_MUL); + } else { + totalCount += this.estimateFinaleCount(cfg.squad_pool); } totalCount = Math.min(totalCount, MAX_MONSTERS); - // 3. Boss 位(压力回合必带 Boss,占 1 个名额) - const monsters: GeneratedMonster[] = []; + // 3. Boss 位(压力回合必带 Boss):先记录延后挂载,使其压轴进场而非第 0 秒开场 + let boss: GeneratedMonster | null = null; let remaining = totalCount; if (cfg.boss_wave) { - monsters.push(this.makeBoss(wave, cfg)); - remaining -= 1; + boss = this.makeBoss(wave, cfg); + remaining -= 1 + BOSS_GUARD_COUNT; // Boss 1 只 + 护卫队名额 } // 4. 小队拼装:按权重从 squad_pool 抽小队,累计到 remaining - const squadMonsters = this.assembleSquads(cfg.squad_pool, remaining, wave); - monsters.push(...squadMonsters); + const monsters: GeneratedMonster[] = this.assembleSquads(cfg.squad_pool, remaining, wave); - // 5. 应用硬编码 HP/AP 倍率 - for (const m of monsters) { - m.hp = Math.max(1, Math.round(m.hp * cfg.hp_mul)); - m.ap = Math.max(1, Math.round(m.ap * cfg.ap_mul)); + // 4.5 收尾高潮批:非放松回合额外补入最强小队,与 Boss 一样压轴(先补入再统一缩放,保证强度自洽) + const finaleSquad = waveType !== WaveType.Relax ? this.pickStrongestSquad(cfg.squad_pool) : null; + if (finaleSquad) { + for (let s = 0; s < FINALE_SQUAD_COUNT; s++) { + for (const slot of finaleSquad.slots) { + for (let c = 0; c < slot.count; c++) { + const m = this.makeMonster(slot.type, wave, 0); + m.batch = BATCH_COUNT - 1; // 标记收尾批,第 8 步不再覆盖 + monsters.push(m); + } + } + } } - // 6. 按英雄强度反推缩放系数 + // 4.6 Boss 与护卫队压队尾,使其在批次分配后落入最后一批(回合内高潮点) + if (boss) { + monsters.push(...this.makeBossGuards(wave)); + monsters.push(boss); + } + + // 5. 按英雄强度反推缩放系数(hp_mul/ap_mul 并入目标强度乘区,而非预先乘到怪物上, + // 保证配置表语义单一:最终强度 = heroPower × typeRatio × power_adjust × DDA × hp/ap_mul) const totalBasePower = monsters.reduce((sum, m) => { const info = HeroInfo[m.uuid]; return sum + (info ? calcHeroPower(info, 1) : m.hp + m.ap); }, 0); if (totalBasePower > 0 && targetPower > 0) { - const scale = targetPower / totalBasePower; + const hpScale = (targetPower * cfg.hp_mul) / totalBasePower; + const apScale = (targetPower * cfg.ap_mul) / totalBasePower; for (const m of monsters) { - m.hp = Math.max(1, Math.round(m.hp * scale)); - m.ap = Math.max(1, Math.round(m.ap * scale)); + m.hp = Math.max(1, Math.round(m.hp * hpScale)); + m.ap = Math.max(1, Math.round(m.ap * apScale)); } } @@ -547,9 +619,15 @@ export class RogueSpawningEngine { } } - // 8. 分配批次(0~2,均匀分布) + // 8. 分配批次:按 BATCH_RATIO 递增加权(铺垫 → 加压 → 高潮),收尾小队/Boss/护卫保持最后一批 + this.assignBatches(monsters); + if (boss) { + const lastBatch = BATCH_COUNT - 1; + for (const m of monsters) { + if (m.isBoss || m.isBossGuard) m.batch = lastBatch; + } + } for (let i = 0; i < monsters.length; i++) { - monsters[i].batch = i % BATCH_COUNT; monsters[i].spawnIndex = i; } @@ -656,8 +734,8 @@ export class RogueSpawningEngine { return result; } - /** 生成 Boss(首位) */ - private makeBoss(wave: number, cfg: WaveConfig): GeneratedMonster { + /** 生成 Boss(压轴位:batch/spawnIndex 为占位值,由 generateWave 统一分配并强制最后一批) */ + private makeBoss(wave: number, _cfg: WaveConfig): GeneratedMonster { const isMeleeBoss = Math.random() < 0.5; const type = isMeleeBoss ? MonType.MeleeBoss : MonType.LongBoss; @@ -681,13 +759,82 @@ export class RogueSpawningEngine { hp: Math.round(baseHp * bossBonusHpMul), ap: baseAp, isBoss: true, - spawnIndex: 0, - batch: 0, // Boss 固定第一批 + spawnIndex: 0, // 占位值,由 generateWave 第 8 步统一分配 + batch: 0, // 占位值,generateWave 会强制 Boss 进入最后一批 }; } - /** 生成普通怪物 */ - private makeMonster(type: MonType, wave: number, spawnIndex: number): GeneratedMonster { + /** 生成 Boss 护卫队(复用 boss_guard 小队模板),与 Boss 同批压轴进场 */ + private makeBossGuards(wave: number): GeneratedMonster[] { + const squad = SquadLibrary["boss_guard"]; + const guards: GeneratedMonster[] = []; + for (const slot of squad.slots) { + for (let i = 0; i < slot.count; i++) { + const g = this.makeMonster(slot.type, wave, 0); + g.isBossGuard = true; + guards.push(g); + } + } + return guards; + } + + /** + * 批次分配:按 BATCH_RATIO 递增加权切分(铺垫 → 加压 → 高潮)。 + * 已预标记 batch 的怪(收尾小队 / Boss / 护卫)不参与切分,保持最后一批。 + */ + private assignBatches(monsters: GeneratedMonster[]): void { + const normal = monsters.filter(m => m.batch !== BATCH_COUNT - 1 && !m.isBoss && !m.isBossGuard); + const n = normal.length; + if (n === 0) return; + + let cursor = 0; + for (let b = 0; b < BATCH_COUNT - 1; b++) { + const quota = Math.round(n * BATCH_RATIO[b]); + for (let k = 0; k < quota && cursor < n; k++, cursor++) { + normal[cursor].batch = b; + } + } + // 剩余全部进入高潮批 + for (; cursor < n; cursor++) { + normal[cursor].batch = BATCH_COUNT - 1; + } + } + + /** 预估收尾高潮批额外补入的怪物数量(用于 totalCount 预留名额) */ + private estimateFinaleCount(pool: string[]): number { + const squad = this.pickStrongestSquad(pool); + if (!squad) return 0; + let per = 0; + for (const slot of squad.slots) per += slot.count; + return per * FINALE_SQUAD_COUNT; + } + + /** + * 识别小队池中最强小队:按槽位 MonType 基础强度(calcHeroPower 1 级样本)× 数量加权求和。 + * 注意不能用 squad.weight——它是"出现频率"语义而非强度。 + */ + private pickStrongestSquad(pool: string[]): SquadConfig | null { + let best: SquadConfig | null = null; + let bestScore = -1; + for (const id of pool) { + const sq = SquadLibrary[id]; + if (!sq) continue; + let score = 0; + for (const slot of sq.slots) { + const uuids = MonList[slot.type]; + const sample = uuids && uuids.length ? HeroInfo[uuids[0]] : null; + score += (sample ? calcHeroPower(sample, 1) : 100) * slot.count; + } + if (score > bestScore) { + bestScore = score; + best = sq; + } + } + return best; + } + + /** 生成普通怪物(wave 保留参数位,供后续按回合差异化基础属性扩展) */ + private makeMonster(type: MonType, _wave: number, spawnIndex: number): GeneratedMonster { let uuids = MonList[type]; if (!uuids || uuids.length === 0) { // 兜底 Melee diff --git a/assets/script/game/map/WaveHudComp.ts b/assets/script/game/map/WaveHudComp.ts new file mode 100644 index 00000000..23c55197 --- /dev/null +++ b/assets/script/game/map/WaveHudComp.ts @@ -0,0 +1,123 @@ +/** + * @file WaveHudComp.ts + * @description 波次进度 HUD 组件(表现层) + * + * 职责: + * 1. 本回合清剿进度:剩余怪数(mon_num + pending_mon_num)+ 进度条。 + * 2. 全局 20 回合进度格:静态生成,Boss 回合(wave%5==0)置旗帜样式,当前回合脉动、已过回合置灰。 + * + * 数据流:NewWave 事件缓存 total/wave;update 0.2s 降频轮询 vmdata(与 syncMonsterSpawnState 同节奏)。 + * 使用 oops-framework 模块:oops.message(事件解耦)。 + * + * 编辑器绑定:waveLab / remainLab / progress / flagsRoot(水平 Layout 容器,20 格代码生成)。 + */ +import { _decorator, Node, Label, ProgressBar, Sprite, Color, tween, Tween, v3, UITransform } from "cc"; +import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops"; +import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; +import { GameEvent } from "../common/config/GameEvent"; +import { smc } from "../common/SingletonModuleComp"; +import { getWaveType, WaveType, MAX_WAVE } from "./RogueConfig"; + +const { ccclass, property } = _decorator; + +@ccclass('WaveHudComp') +export class WaveHudComp extends CCComp { + @property({ type: Label, tooltip: "回合文本(第 x/20 回合)" }) + waveLab: Label | null = null; + + @property({ type: Label, tooltip: "剩余怪数文本" }) + remainLab: Label | null = null; + + @property({ type: ProgressBar, tooltip: "本回合清剿进度条" }) + progress: ProgressBar | null = null; + + @property({ type: Node, tooltip: "全局回合旗帜容器(水平 Layout,20 格代码生成)" }) + flagsRoot: Node | null = null; + + /** 本回合怪物总数(NewWave 事件载荷缓存) */ + private waveTotal: number = 0; + /** HUD 刷新节流计时器 */ + private hudTimer: number = 0; + /** 20 格旗帜节点缓存(Boss 位为旗帜样式) */ + private flagNodes: Node[] = []; + + onLoad() { + // oops.message 全局事件总线:新回合 + oops.message.on(GameEvent.NewWave, this.onNewWave, this); + this.buildFlags(); + } + + onDestroy() { + oops.message.off(GameEvent.NewWave, this.onNewWave, this); + } + + /** 静态生成 20 格回合进度(Boss 位按 getWaveType 静态预知,无需运行数据) */ + private buildFlags() { + if (!this.flagsRoot) return; + for (let i = 1; i <= MAX_WAVE; i++) { + const cell = new Node(`flag_${i}`); + cell.addComponent(UITransform).setContentSize(14, 14); + const sp = cell.addComponent(Sprite); + // Boss 位用警示色方块,普通位用圆点色(无美术资源时以颜色区分,后续可换 spriteFrame) + const isBoss = getWaveType(i) === WaveType.Pressure; + sp.color = isBoss ? new Color(220, 50, 50) : new Color(120, 120, 120); + sp.sizeMode = Sprite.SizeMode.CUSTOM; + cell.parent = this.flagsRoot; + this.flagNodes.push(cell); + } + } + + private onNewWave(event: string, data: { wave: number; total: number; bossWave: boolean }) { + this.waveTotal = data.total; + if (this.waveLab) this.waveLab.string = `第 ${data.wave}/${MAX_WAVE} 回合`; + this.refreshFlags(data.wave); + } + + /** 刷新旗帜状态:已过回合置暗,当前回合 Boss 旗脉动 */ + private refreshFlags(currentWave: number) { + for (let i = 0; i < this.flagNodes.length; i++) { + const cell = this.flagNodes[i]; + const wave = i + 1; + const sp = cell.getComponent(Sprite)!; + const isBoss = getWaveType(wave) === WaveType.Pressure; + if (wave < currentWave) { + sp.color = new Color(70, 70, 70); // 已过回合置暗 + cell.setScale(v3(1, 1, 1)); + Tween.stopAllByTarget(cell); + } else if (wave === currentWave) { + sp.color = isBoss ? new Color(255, 80, 80) : new Color(255, 220, 100); + if (isBoss) this.playFlagPulse(cell); + } else { + sp.color = isBoss ? new Color(220, 50, 50) : new Color(120, 120, 120); + cell.setScale(v3(1, 1, 1)); + Tween.stopAllByTarget(cell); + } + } + } + + /** 当前回合 Boss 旗缩放脉动 */ + private playFlagPulse(cell: Node) { + Tween.stopAllByTarget(cell); + tween(cell) + .to(0.5, { scale: v3(1.4, 1.4, 1) }) + .to(0.5, { scale: v3(1, 1, 1) }) + .union() + .repeatForever() + .start(); + } + + protected update(dt: number) { + this.hudTimer += dt; + if (this.hudTimer < 0.2) return; + this.hudTimer = 0; + + const md = smc.vmdata.mission_data; + const remain = (md.mon_num || 0) + (md.pending_mon_num || 0); + if (this.remainLab) this.remainLab.string = `剩余 ${remain}`; + if (this.progress) { + this.progress.progress = this.waveTotal > 0 + ? Math.min(1, Math.max(0, (this.waveTotal - remain) / this.waveTotal)) + : 0; + } + } +}