4 Commits

Author SHA1 Message Date
pan
3c9496d753 build: add missing typescript component meta files
add meta config files for several game component scripts
2026-08-11 19:02:16 +08:00
pan
409113e269 feat: 完成肉鸽回合制游戏核心玩法迭代
新增连杀系统、金币飞行特效、波次HUD、屏幕震动等表现功能,重构怪物刷出逻辑与难度动态调节,调整回合回血比例,优化游戏节奏与体验。

主要变更:
1.  调整回合回血比例从0.5到0.4,优化前期节奏
2.  新增连杀计数与奖励系统,支持5/10/20连杀触发对应表现
3.  实现怪物死亡掉落金币的抛物线飞行特效
4.  增加波次进度HUD,显示剩余怪物与全局回合进度
5.  新增屏幕震动工具与战斗横幅统一展示系统
6.  重构怪物刷出逻辑,支持按回合类型调整刷怪间隔,加入清场加速机制
7.  优化动态难度调节算法,增加滞回与指数平滑,避免难度突变
8.  新增Boss预警、登场事件与回合清屏事件,完善事件总线
9.  调整怪物数量阈值与回合倒计时档位,适配新的节奏设计
2026-08-11 19:00:23 +08:00
pan
d57f4c14e0 chore: 清理过期设计文档并新增刷怪心流改造方案
删除了英雄UI重构和技能模板重构的旧设计文档,新增刷怪节奏优化的完整实施计划文档,包含三个阶段的具体改造步骤、验证方案和执行约束。
2026-08-11 17:19:08 +08:00
pan
b13178166a style(skill atk prefab): 统一调整技能预制体的本地缩放值
批量修改多个攻击技能预制体的_lscale参数,将不同的xy缩放值统一调整为适配统一视觉尺寸的数值,包括0.3、0.35、0.5等适配值,保证技能特效显示风格一致
2026-08-11 15:48:39 +08:00
42 changed files with 1240 additions and 228 deletions

View File

@@ -1,49 +0,0 @@
# 技能配置重构实施计划
## 1. 探索阶段总结 (Current State Analysis)
经过对代码库的探索,当前技能触发系统及相关配置状态如下:
- `SkillSet.ts` 包含了所有技能的基座配置 `SkillConfig` 和技能字典 `SkillSet`
- `heroSet.ts` 中的 `HeroInfo` 存放英雄和怪物的配置,目前 `call`, `dead`, `fstart`, `fend` 被定义为 `number[]`,而 `atking``atked` 被定义为 `{s_uuid: number, t_num: number}[]`
- `HeroAttrsComp.ts` 内部存储了与配置一致的触发技能结构。
- `SkillTriggerHelper.ts` 负责判定并向外派发 `GameEvent.TriggerSkill` 事件,由 `SCastSystem.ts` 监听并执行 `forceCastTriggerSkill`
- 目前 `SCastSystem.ts` 在收集技能目标和施放技能时,都是直接从 `SkillSet[s_uuid]` 读取 `config`,没有针对具体角色的差异化机制。
## 2. 拟议变更 (Proposed Changes)
### 2.1 修改 `SkillSet.ts`
- **新增接口**:定义 `SkillOverrides` 接口,包含所有可被角色覆盖的技能参数(如 `TGroup`, `ap`, `hit_count`, `buffs` 等,全部为可选字段)。
- **新增函数**:编写 `mergeSkillParams(config, overrides?)` 函数,将基座 `config` 和角色覆盖 `overrides` 进行合并,返回一个新的 `SkillConfig` 对象。
### 2.2 修改 `heroSet.ts`
- **扩展接口**
-`HSkillInfo` 接口中增加 `overrides?: SkillOverrides;`
-`heroInfo` 接口中的触发字段 `call`, `dead`, `fstart`, `fend`, `atking`, `atked` 统一更新为 `{ s_uuid: number; t_num: number; overrides?: SkillOverrides }[]` 结构。
- **更新配置示例**:按照设计文档更新英雄 `5001`, `5002`, `5301`, `5302` 的配置,为特定的触发技能添加 `overrides` 字段(如盾骑士 5002 全队护盾覆盖)。
### 2.3 修改 `HeroAttrsComp.ts`
- **同步类型**:将 `call`, `dead`, `fstart`, `fend`, `atking`, `atked` 的类型同步改为与 `heroInfo` 一致的 `{ s_uuid: number; t_num: number; overrides?: SkillOverrides }[]`
### 2.4 修改 `SkillTriggerHelper.ts`
- **派发支持**
- 更新 `dispatchSingle` 方法签名,增加 `overrides?: SkillOverrides` 参数,并在 `oops.message.dispatchEvent(GameEvent.TriggerSkill, {...})` 中将其传入。
- 更新 `handleCall`, `handleDead`, `handleArrayTrigger` 处理逻辑,将原先对 `number[]` 的处理改为对 `{s_uuid, t_num, overrides}` 对象数组的处理,并提取 `overrides` 传递给 `dispatchArray`
- 更新 `handleAtking`, `handleAtked` 中的 `dispatchSingle` 调用,传入 `atkConfig.overrides`
### 2.5 修改 `SCastSystem.ts` (关键运行时逻辑)
- **事件监听更新**:在 `onTriggerSkill` 方法的 `args` 参数定义中补充 `overrides?: SkillOverrides`,并传递给 `forceCastTriggerSkill`
- **合并逻辑上移(架构优化)**
-`forceCastTriggerSkill``castSkill` 的**方法入口处**(而非 `applyFriendlySkillEffects` 内部),第一时间调用 `mergeSkillParams(config, overrides)` 获取 `effective` 技能配置。
- 将后续所有关于阵营判定(如 `effective.TGroup`)、目标收集、以及传递给 `applyFriendlySkillEffects` / `applyEnemySkillEffects` 的参数全部替换为 `effective`
- **为何如此设计**:如果在原设计中仅在 `applyFriendlySkillEffects` 入口处合并,那么前置的**目标选择逻辑**(依赖 `TGroup` 判定是 `Self` 还是 `Team`)将会使用未合并的基础配置,导致类似“自己加盾变为全队加盾”的 `TGroup` 覆盖无法生效。将合并操作前置可以彻底解决这一问题。
- **主动技能支持**:在 `pickCastSkill` 中,读取 `heroAttrs.skills[s_uuid]?.overrides` 并进行合并判定,同时将 `overrides` 放入返回的 `castPlan` 中,以便 `castSkill` 使用。
## 3. 假设与决策 (Assumptions & Decisions)
- **统一触发结构**:虽然 `call`, `dead`, `fstart`, `fend` 不严格需要 `t_num`,但为了类型统一并完全遵守设计规范,统一采用了包含 `t_num` 的对象结构。
- **合并前置决策**:如上所述,坚决在施放方法入口处进行 `mergeSkillParams` 以保证目标收集逻辑能够感知到 `TGroup` 的变化。这比设计规范中要求的修改范围略有扩大,但对于系统功能的正确实现是必须的。
- **卡牌技能影响**:卡牌技能(`forceCastCardSkill`)当前没有绑定角色的 `overrides`,因此维持读取基础 `SkillSet` 逻辑不变。
## 4. 验证步骤 (Verification steps)
1. 编译 TypeScript 代码,确保 `HeroAttrsComp`, `heroSet`, `SCastSystem` 等修改后的接口和类型无报错。
2. 启动游戏或运行测试,确认 `5001` (见习战士) 触发的基础护盾只对自己生效。
3. 确认 `5002` (盾骑士) 受击触发的护盾技能正确地为全队附加护盾并且护盾值ap与次数hit_count符合 `overrides` 配置。
4. 确认所有旧版英雄技能在无 `overrides` 时能够正确回退到 `SkillSet` 的默认配置,游戏运转正常无异常日志。

View File

@@ -1,55 +0,0 @@
# 重构场上英雄UI表现及交互计划
## 1. 目标与现状分析
**现状**
目前游戏中 `HInfoComp.ts` 负责在界面下方显示场上英雄的信息(生命、攻击、出售),由 `MissionCardComp` 管理 6 个固定槽位。
`HeroViewComp.ts` 负责战斗场景中英雄实体的动画表现。
**目标**
1. 保留 `HInfoComp.ts` 组件及预制体,但**取消其在底部的常驻显示**,将其改造为**弹窗形式**(类似 `IBoxComp`)。
2. 在战斗或准备阶段,玩家**直接点击场上的英雄模型**`HeroViewComp`)时,弹出 `HInfoComp` 面板。
3. 清理 `MissionCardComp.ts` 中管理底层 `HInfoComp` 的旧逻辑。
## 2. 具体修改步骤
### 2.1 注册 HInfo 为独立弹窗
* 修改 `assets/script/game/common/config/GameUIConfig.ts`
*`UIID` 枚举中添加 `HInfo`
*`UIConfigData` 中注册:`[UIID.HInfo]: { layer: LayerType.UI, prefab: "gui/element/hnode" }`
### 2.2 改造 HInfoComp.ts
* **数据传入**:添加 `onAdded(args: { eid: number })`,根据 `eid` 查询 `HeroAttrsComp` 实体进行数据绑定。
* **自驱动刷新**:原先由外部驱动刷新,现在添加 `update(dt: number)` 生命周期,在内部调用 `this.refresh()` 以保持血量等信息实时更新。
* **移除旧逻辑**:删除 `node_index``refreshByNodeIndex` 等固定槽位相关的代码。
* **交互恢复**:取消注释 `bindEvents``unbindEvents`,恢复出售按钮的点击事件。出售完成后调用 `oops.gui.remove(UIID.HInfo)`。打开 `IBox` 的点击逻辑可保持不变(或者作为详情按钮)。
* **添加关闭机制**:考虑到它是弹窗,可以添加一个点击非按钮区域关闭自身的功能,或者点击英雄之外的区域关闭。为简单起见,可以暂时复用点击面板打开 IBox同时关闭 HInfo并在 HInfo 添加额外的关闭按钮,或由 UI 框架自动处理(如果注册为 PopUp 并带有背景)。如果它是纯 UI可以点击其他地方关闭。这里我们让它在打开 `IBox` 后关闭自己:`oops.gui.remove(UIID.HInfo)`
### 2.3 清理 MissionCardComp.ts
* **移除属性**:删除 `@property(Node) hero_info_node``@property(Prefab) hero_info_prefab` 及其编辑器绑定。
* **移除内部状态**:删除 `cachedHInfoComps``heroInfoSyncTimer`
* **移除生命周期调用**:在 `onLoad``update``onMissionStart``onMissionEnd``onDestroy``reset``enterPreparePhase``enterBattlePhase` 中,删除所有涉及 `HInfoComp` 实例创建、刷新、显隐控制、销毁的代码。
### 2.4 修改 HeroViewComp.ts 添加点击交互
* **绑定事件**:在 `onLoad` 中为英雄模型节点绑定点击事件 `this.node.on(NodeEventType.TOUCH_END, this.onHeroClicked, this);`,并在 `reset` 等清理处解绑。
* **点击回调逻辑**
```typescript
private onHeroClicked(event: EventTouch) {
if (!this.model) return;
if (this.model.fac !== FacSet.HERO) return; // 仅对玩家英雄生效
const eid = this.ent?.eid;
if (!eid) return;
// 呼出英雄信息弹窗
oops.gui.remove(UIID.HInfo);
oops.gui.open(UIID.HInfo, { eid: eid });
}
```
## 3. 验证步骤
1. 进入战斗,确认下方不再有常驻的英雄信息面板。
2. 点击场上的英雄模型,确认能弹出该英雄的 `HInfoComp` 弹窗。
3. 观察弹窗内的血量和攻击力是否能随战斗实时刷新。
4. 点击弹窗上的出售按钮,确认英雄消失、金币增加且弹窗关闭。
5. 点击弹窗上的信息区域,确认能弹出 `IBoxComp` 详情面板。

View File

@@ -0,0 +1,247 @@
# 刷怪心流改造 · 实施步骤与执行方案
> 基于对 RogueConfig.ts / MissionMonComp.ts / MissionComp.ts 的多 agent 调研与方案讨论产出。
> 目标:让刷怪节奏产生"爽感"与心流Flow核心公式**爽感 = 密度 × 可清性****心流 = 铺垫 → 峰值 → 释放循环 + 即时正反馈**。
---
## 阶段划分总览
```
阶段一P0 修复4 项) → 安全网与曲线修正,不动体验结构
阶段二P1 节奏与 DDA5 项) → 核心体验重塑,依赖链严格
阶段三P2-P3 反馈层5 项) → 情绪反馈补全,可并行开发
```
---
## 阶段一P0 修复
### Step 1.1 — P0-3 刷怪暂停死链路(先做:泄压阀)
**改动文件**MissionMonComp.ts、MissionComp.ts
| # | 操作 | 位置 |
|---|---|---|
| 1 | `update()` 在 pending 统计之后、批次推进之前插入 `if (smc.mission.stop_spawn_mon) return;` | MissionMonComp L106 后 |
| 2 | 阈值对齐:`maxMonsterCount` 80→60`resumeMonsterCount` 45→40 | MissionComp L86-88 |
| 3 | `changePhase(PrepareEnd)` 分支补 `smc.mission.stop_spawn_mon = false;`(防开局冻结 4 秒) | MissionComp L492-495 |
**验证**:临时 `maxMonsterCount=20` 进放松回合,确认到 20 只停刷、降 15 后续刷wave 1 开局节奏无延迟。
**Commit**`fix(mission): consume stop_spawn_mon in MissionMonComp update`
---
### Step 1.2 — P0-4 回合僵局超时(安全网)
**改动文件**RogueConfig.ts、MissionComp.ts
| # | 操作 | 位置 |
|---|---|---|
| 1 | 新增常量 `WAVE_TIMEOUT = 75`(含 JSDoc | RogueConfig 节奏常量区 |
| 2 | MissionComp 新增字段 `skipRemainScoreOnBattleEnd``data_init` 复位 | MissionComp 运行时状态区 + L774 |
| 3 | Battle 阶段 update 追加超时检测 `if (this.clearTime >= timeout) this.onBattleTimeout();`timeout 取 `isBossWave ? 90 : WAVE_TIMEOUT` | MissionComp L245-251 |
| 4 | 新增 `onBattleTimeout()`:先扣 `wave_remain_monsters` → 销毁全部 MON 实体 → `mon_num=0` → 走 `TimeUpAdvanceWave` / `open_Victory` | MissionComp 回合管理区 |
| 5 | BattleEnd 的 `wave_remain_monsters += mon_num` 加防重判断 | MissionComp L522-523 |
**验证**:临时 `WAVE_TIMEOUT=8`,确认 8 秒准点收束、评分已扣、factor 放水TestModeConfig 高血怪模拟僵局验证 75s 前不结束。
**Commit**`feat(mission): add wave battle timeout fallback`
---
### Step 1.3 — P0-1 Wave 1 错位修正
**改动文件**RogueConfig.ts单文件
| # | 操作 | 位置 |
|---|---|---|
| 1 | `getWaveType``wave % 5 === 1``wave % 5 === 4` | L80 |
| 2 | 同步 4 处注释WaveType 枚举注释L37、文件头节奏注释L21-23、WaveConfigs 表头注释L329-332、表内"放松回合"行内注释从 wave 6/11/16 搬到 4/9/14 | 见左 |
**验证**`getWaveType(1..20)` 断言输出 `N N N R P` × 4 循环;实机确认 wave 1 = 18 只、wave 4 ≈ 40 只低强度。
**Commit**`fix(rogue): move relax wave to pre-boss slot (wave%5==4)`
---
### Step 1.4 — P0-2 Boss 压轴 + 预警
**改动文件**RogueConfig.ts、MissionComp.ts、GameEvent.ts
| # | 操作 | 位置 |
|---|---|---|
| 1 | `SquadLibrary` 新增 `boss_guard`weight:01 重甲+2 近战) | RogueConfig L191-198 |
| 2 | `GeneratedMonster` 增加可选字段 `isBossGuard?: boolean` | RogueConfig L426-450 |
| 3 | `generateWave` 重构第 3/4/8 步Boss 不 push 首位 → 小队拼装 → 护卫队 `makeBossGuards()` → Boss 压队尾 → 批次统一分配后强制 Boss+护卫 `batch = BATCH_COUNT-1``remaining` 改为 `-= 4` | RogueConfig L509-554 |
| 4 | `makeBoss``spawnIndex:0, batch:0` 改为占位默认值+注释 | RogueConfig L660-687 |
| 5 | GameEvent 新增 `BossWarning = "BossWarning"` | GameEvent.ts |
| 6 | `changePhase(BattleStart)``if (this.isBossWave)` 派发 `BossWarning { wave, eta: 20 }` | MissionComp L497-500 |
| 7 | MissionComp 从 RogueConfig 补导入 `BATCH_INTERVAL, BATCH_COUNT` | MissionComp L51 |
**验证**wave 5 确认 0s/10s 两批纯杂兵、20s Boss 带 3 护卫进场、总怪数=21`BossWarning` 仅在 5/10/15/20 派发。
**Commit**`feat(rogue): spawn boss in final batch with guards and warning event`
---
## 阶段二P1 节奏与 DDA依赖链严格
依赖链:**P1-5 → P1-1 → P1-2 + P1-3同批→ P1-4**
### Step 2.1 — P1-5 数值语义澄清(等价变换先行)
**改动文件**RogueConfig.ts单文件
| # | 操作 |
|---|---|
| 1 | 删除第 5 步(乘 hp_mul/ap_mul第 6 步改为拆分 `hpScale = targetPower × hp_mul / totalBasePower``apScale = targetPower × ap_mul / totalBasePower` |
| 2 | 更新文件头公式注释L12-16`hp_mul/ap_mul` 字段注释 |
| 3 | 17/18/19 启用 `power_adjust: 1.05 / 1.10 / 1.20` |
| 4 | `validateRogueConfig` 增加 `power_adjust ∈ [0.8, 1.3]` 校验 |
**验证**:改造前后固定 heroPower 打桩,逐怪 hp/ap 断言相等等价回归17/18/19 总强度阶梯断言。
**Commit**`refactor(rogue): unify hp/ap mul into power scaling formula`
---
### Step 2.2 — P1-1 批次递增 + 间隔参数化
**改动文件**RogueConfig.ts、MissionMonComp.ts
| # | 操作 |
|---|---|
| 1 | 新增 `BATCH_RATIO = [0.25, 0.35, 0.40]``FINALE_SQUAD_COUNT = 2``SPAWN_INTERVAL_BY_TYPE`Normal 0.18 / Pressure 0.25 / Relax 0.12 |
| 2 | 批次分配从 `i % 3` 改为 `assignBatches()`:配额切分 + 第三批补 2 个最强小队(`pickStrongestSquad``calcHeroPower(样本)×count` 评分);放松回合跳过收尾加压;`totalCount` 预留收尾小队名额防 `slice` 截掉 |
| 3 | MissionMonComp`MON_SPAWN_INTERVAL` 静态常量改为实例字段 `spawnInterval``onPhasePrepareEnd``getWaveType(currentWave)` 查表;`releaseCurrentBatch` 末尾的 spawnTimer 初始化同步改 |
**与 P0-2 的合并点**`assignBatches` 需保留"Boss+护卫强制最后一批"逻辑。
**验证**debugMode 日志打印每批只数与最强小队 id放松回合 54 只应 ~6.5s 倾泻完。
**Commit**`feat(rogue): progressive batch ratio and per-type spawn interval`
---
### Step 2.3 — P1-2 + P1-3 清场加速与 DDA 重做(**必须同 commit**
**改动文件**RogueConfig.ts、MissionMonComp.ts、MissionComp.ts、GameSet.ts
P1-2 部分MissionMonComp
| # | 操作 |
|---|---|
| 1 | 新增状态:`batchReleasedCount``batchFastForwarded``aliveCheckTimer``waveEarlySkipTotal`public 只读) |
| 2 | `releaseCurrentBatch` 记录当批数量update 中 0.2s 节流调 `checkBatchEarlyAdvance()` |
| 3 | `checkBatchEarlyAdvance`:存活比例 ≤25% 且本批已放完 → `batchTimer += 2s``waveEarlySkipTotal += 2`(每批一次) |
P1-3 部分RogueConfig + MissionComp + GameSet
| # | 操作 |
|---|---|
| 1 | `DynamicTuner` 重写:连续映射 `desired = 1 + (0.8 - clearTime/30) × 0.5`(死亡锚定 0.8)、滞回 2 回合、指数靠拢 50%、区间 [0.7, 1.3]、新增 `streak` 字段 |
| 2 | MissionComp BattleEnd`effectiveClearTime = clearTime + MonComp.waveEarlySkipTotal` 后传入 adjust顺手写入 `lastWaveDeathCount`/`lastWaveClearTime` 快照(供 Step 2.4 |
| 3 | `FightSet.WAVE_HEAL_RATE` 0.5→0.4;修正 MissionComp L676"恢复70%"幽灵注释 |
**验证**:脚本模拟三种 clearTime 曲线(恒 12s / 恒 25s / 交替)打印 20 回合 factor 轨迹;强 build 单回合时长应从 ~30s 压到 ~24-26s。
**Commit**`feat(rogue): early batch advance and continuous DDA with hysteresis`
---
### Step 2.4 — P1-4 动态回合倒计时
**改动文件**MissionComp.ts单文件
| # | 操作 |
|---|---|
| 1 | 新增档位常量 `COUNTDOWN_FAST=2.5 / NORMAL=4.0 / FULL=5.0`,删除/降级 `WAVE_COUNTDOWN_DURATION` |
| 2 | `startWaveCountdown` 改调 `computeCountdown()`有死亡→5sclearTime<65%→2.5s,否则 4s读 Step 2.3 写入的快照字段) |
**验证**:快清场回合倒计时显示 3→2→1有死亡回合给满 5s。
**Commit**`feat(mission): adaptive wave countdown based on last wave performance`
---
## 阶段三P2-P3 反馈层
### Step 3.1 — 事件与工具基建 + P2-2 清屏庆祝 + P2-3 Boss 演出
**改动文件**GameEvent.ts、MissionComp.ts、MissionMonComp.ts、**新增** ScreenShake.ts、**新增** BattleBannerComp.ts、mission.prefab编辑器
| # | 操作 |
|---|---|
| 1 | GameEvent 新增:`WaveClear` / `BossWarning`Step 1.4 已加则跳过)/ `BossSpawn` / `CoinFly` / `ComboReach`,启用 `MonDead` |
| 2 | 新增 `ScreenShake` 静态工具(抖 `smc.map.MapView.node`,强度/时长参数,收敛回原位) |
| 3 | 新增 `BattleBannerComp`:统一横幅通道(右进 backOut → 停留 → 左出 backIn监听 WaveClear/BossWarning/ComboReach0.3s 去抖 |
| 4 | MissionComp 清屏分支 dispatch `WaveClear { wave, clearTime, allAlive, fastClear }`(通关分支不派发) |
| 5 | MissionMonComp `addMonsterAtGrid` isBoss 分支 dispatch `BossSpawn { pos }` |
| 6 | 奖励规则fastClear=1 → +2 金,=2 → +5 金allAlive → +1 刷新石(走 MissionEconomy |
| 7 | **编辑器操作**mission.prefab 新增 `banner` 节点Label+背景),挂 BattleBannerComp |
**Commit**`feat(battle): wave clear banner, boss warning and screen shake`
---
### Step 3.2 — P2-1 波次 HUD + P2-4 金币飞行
**改动文件****新增** WaveHudComp.ts、**新增** CoinFlyComp.ts、HeroAtkSystem.ts、mission.prefab编辑器
| # | 操作 |
|---|---|
| 1 | `WaveHudComp`:监听 NewWave 缓存 total0.2s 轮询 `mon_num + pending_mon_num` 刷新进度条;静态生成 20 格旗帜(`getWaveType(i)===Pressure` 置 Boss 帧),当前回合脉动 |
| 2 | HeroAtkSystem `scheduleDrop` 后 dispatch `CoinFly { worldPos, gold, isBoss }``onDeath` MON 分支 dispatch `MonDead` |
| 3 | `CoinFlyComp`:独立 NodePool上限 303-5 枚Boss 8 枚)金币抛物线散开→加速飞向钱包;金币帧运行时取编辑器绑定的 `coinIconSrc.spriteFrame`**账务立即结算、飞行纯表现** |
| 4 | **编辑器操作**prefab 新增 `wave_hud`wave_lab/remain_lab/progress/flags`fly_layer`(最高 sibling挂组件并拖绑定 |
**Commit**`feat(battle): wave progress HUD and coin fly animation`
---
### Step 3.3 — P3-1 连杀 Combo
**改动文件****新增** ComboComp.ts、mission.prefab编辑器
| # | 操作 |
|---|---|
| 1 | `ComboComp`:监听 MonDead2s 滑动窗口计数5/10/20 阈值派发 `ComboReach { count, tier }`MissionEnd 清零 |
| 2 | BattleBannerComp 消费 ComboReach分级文案/颜色/震屏tier2 `shake(8,0.3)`+ 金币爆发 2/5/10 |
| 3 | **编辑器操作**prefab 挂 ComboComp |
**Commit**`feat(battle): kill combo system with tiered rewards`
---
## 执行约束
**每次 commit 前必做**
1. `validateRogueConfig()` 返回空数组
2. GetDiagnostics 检查改动文件无 TS 错误
3. 手动验证项按各 Step 的验证清单执行
**Commit message** 遵循项目规范Conventional Commits、英文、祈使句、≤50 字符)。
**资源依赖**不阻塞开发可后期补Boss 旗帜帧 ×1、清屏/Boss 预警/连杀音效 ×5、金币入袋音效 ×1——占位方案flash/dun/Hit/Critical/Fire/button 复用。
**风险最高的两步**Step 1.1PrepareEnd 时序坑)和 Step 2.3P1-2/P1-3 必须同批)。实施时优先单独验证这两点。
---
## 附录:关键数值速查
| 参数 | 现值 | 目标值 | 所属 Step |
|---|---|---|---|
| getWaveType Relax 位 | wave%5==1 | wave%5==4 | 1.3 |
| Boss 批次 | batch 0 | batch 2 + 3 护卫 | 1.4 |
| maxMonsterCount / resume | 80 / 45 | 60 / 40 | 1.1 |
| WAVE_TIMEOUT | 无 | 75sBoss 90s | 1.2 |
| BATCH_RATIO | 33/33/33 | 25/35/40 + 收尾小队×2 | 2.2 |
| 刷怪间隔 | 0.3s 统一 | Normal 0.18 / Pressure 0.25 / Relax 0.12 | 2.2 |
| 清场加速 | 无 | 存活≤25% 时 batchTimer += 2s | 2.3 |
| DynamicTuner | ±0.05 步进 [0.5, 2.0] | 连续映射+滞回 [0.7, 1.3] | 2.3 |
| WAVE_HEAL_RATE | 0.5 | 0.4 | 2.3 |
| 回合间倒计时 | 固定 5s | 2.5 / 4 / 5 三档 | 2.4 |
| power_adjust 17/18/19 | 未使用 | 1.05 / 1.10 / 1.20 | 2.1 |

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.4,
"y": 0.4,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.4,
"y": 0.4,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.4,
"y": 0.4,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -108,8 +108,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.9,
"y": 0.9,
"x": 0.5,
"y": 0.5,
"z": 1
},
"_mobility": 0,

View File

@@ -108,8 +108,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.9,
"y": 0.9,
"x": 0.5,
"y": 0.5,
"z": 1
},
"_mobility": 0,

View File

@@ -108,8 +108,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.8,
"y": 0.8,
"x": 0.5,
"y": 0.5,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.35,
"y": 0.35,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.35,
"y": 0.35,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.4,
"y": 0.4,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.4,
"y": 0.4,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.4,
"y": 0.4,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.4,
"y": 0.4,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.4,
"y": 0.4,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.4,
"y": 0.4,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -105,8 +105,8 @@
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.5,
"y": 0.5,
"x": 0.3,
"y": 0.3,
"z": 1
},
"_mobility": 0,

View File

@@ -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();
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a7e31780-029a-4f20-90ad-3e5575c9dac1",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -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,//英雄可拓展上限

View File

@@ -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",
}

View File

@@ -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,

View File

@@ -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}`);
}

View File

@@ -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音效
* 编辑器绑定bannerNodeLabel+背景节点,初始 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();
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "676e7123-f3e8-4e9e-8942-b1c025c6708e",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f607a68a-c2e6-4ebb-8439-569a24143e6e",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1bf46bb7-2f09-4487-9551-3672912c0afb",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -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++;
// 【评分系统 - 战绩分】记录每回合结束时场上留存的敌人数量(扣分项)
// 超时回合已在 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 实体 */

View File

@@ -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() { }
}

View File

@@ -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, number> = {
[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<string, SquadConfig> = {
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<number, WaveConfig> = {
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

View File

@@ -0,0 +1,123 @@
/**
* @file WaveHudComp.ts
* @description 波次进度 HUD 组件(表现层)
*
* 职责:
* 1. 本回合清剿进度剩余怪数mon_num + pending_mon_num+ 进度条。
* 2. 全局 20 回合进度格静态生成Boss 回合wave%5==0置旗帜样式当前回合脉动、已过回合置灰。
*
* 数据流NewWave 事件缓存 total/waveupdate 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: "全局回合旗帜容器(水平 Layout20 格代码生成)" })
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;
}
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "9e541e3a-c6fc-443d-9e83-a723ca0821f6",
"files": [],
"subMetas": {},
"userData": {}
}