Add design for WoW-style 3-phase boss combat: mixed triggers (hp threshold OR elapsed time), incremental skill adds/removes, BossPhaseComp marker component, and BossPhaseSystem with stub performance hooks. Plan ids use the 10000 id range. Reviewed by three sub-agents against ECS framework and combat chain sources.
16 KiB
Boss 多阶段战斗系统设计(Boss Phase System)
日期:2026-10-06 状态:已评审(3 个子智能体审查通过,硬伤已修订) 关联文件:
heroSet.ts/RogueConfig.ts/Mon.ts/HeroAttrsComp.ts/GameEvent.ts
1. 背景与目标
为 Boss 添加类似魔兽世界的多阶段战斗模式:
- 固定 3 个阶段(P1/P2/P3),Boss 出生即 P1
- 混合触发切换:血量阈值为主,战斗时长辅助(同一切换点内 OR 语义,任一满足即切)
- 混合式技能:核心技能(普攻 + RogueConfig 注入的大招基线)贯穿全程,阶段技能增量轮换(adds/removes)
- 阶段切换的表现层(演出/无敌/清场)留空钩子,本次只搭骨架,后续迭代填充
2. 架构总览与数据流
方案选型:增量驱动式(方案 1)。每个切换点声明触发条件 + adds/removes + 属性修正,
切换 = BossPhaseSystem 增量改写 model.skills,与现有 _testSkills / RogueConfig.ults
动态注入模式完全一致,SCastSystem / HeroAtkSystem 零改动。
heroSet.ts (heroInfo) 【配置声明层】
└─ phase_ids: [10001, 10002] ← Boss 允许的阶段方案
↓ 生成时读取
RogueConfig.ts (StageSpawningEngine.makeBoss) 【消费层】
└─ 从 phase_ids 随机选 1 个 → GeneratedMonster.phase_plan_id
↓ 传递(唯一接线点 MissionMonComp.ts:224)
Mon.ts (load) 【初始化层】
└─ is_boss 且方案存在 → this.add(BossPhaseComp),index = 0
(P1 = 基线技能,无需注入)
↓ 每帧检测
BossPhaseSystem (新 ECS 系统) 【运行时层】
└─ filter: allOf(BossPhaseComp, HeroAttrsComp)
→ removes 移除 + adds 注入 + attr_mod 修正 + 资源预热
→ 空钩子 onPhaseExit/onPhaseEnter (TODO)
→ 广播 GameEvent.BossPhaseChange
关键决策:
- 新增轻量标记组件
BossPhaseComp:ECS matcher 按 allOf 过滤,只遍历带阶段方案的 Boss HeroAttrsComp零新增字段(阶段状态全在 BossPhaseComp;属性修正直接改 ap/defense 现有字段)- 计时下沉到组件(
BossPhaseComp.elapsed),随 reset 天然归零,无全局状态
3. 配置层设计
3.1 新文件 assets/script/game/common/config/BossPhaseSet.ts
方案 id 使用 10000 段(已验证全项目空闲;9100 段被 TalentSet 9101-9199 与 ICardSet 9101~9110 双重占用,禁用)。
/** 阶段切换触发条件(混合触发:任一条件满足即切换) */
export interface PhaseTrigger {
/** 血量百分比阈值(1~99):Boss 血量比例 ≤ 该值时触发 */
hp_pct?: number;
/** Boss 在场战斗秒数:超过该时长触发 */
time?: number;
}
/** 切换瞬间一次性属性修正(基于当前值链式乘算,(1 + pct/100)) */
export interface PhaseAttrMod {
ap_pct?: number; // 攻击 ±%
defense_pct?: number; // 防御 ±%(注意 applyDefense 除法递减收益非线性)
}
/** 注入技能条目(与 RogueConfig 大招条目同构) */
export interface PhaseSkillEntry {
s_uuid: number;
cd?: number; // 缺省 8s(强制兜底,防 NaN 污染 CD)
overrides?: SkillOverrides;
}
/** 单个切换点定义(P1→P2 或 P2→P3) */
export interface PhaseSwitchDef {
trigger: PhaseTrigger;
/** 注入的技能(追加进 skills 表,SCastSystem 按 CD 轮转)。禁止操作普攻首键 */
adds?: PhaseSkillEntry[];
/** 移除的技能 s_uuid 列表(含 RogueConfig 注入的大招)。禁止操作普攻首键 */
removes?: number[];
attr_mod?: PhaseAttrMod;
}
/** 阶段方案:固定 2 个切换点 = 3 阶段,P1 为基线 */
export interface BossPhasePlan {
name: string;
switches: [PhaseSwitchDef, PhaseSwitchDef];
}
/** 方案表:key 为方案 id(10000 段) */
export const BossPhaseSet: Record<number, BossPhasePlan> = {
10001: {
name: "狂暴进化",
switches: [
{ trigger: { hp_pct: 70 }, adds: [{ s_uuid: 6102, overrides: { ap: 180 } }], removes: [6100] },
{ trigger: { hp_pct: 40, time: 60 }, adds: [{ s_uuid: 6103 }], attr_mod: { ap_pct: 20 } },
],
},
};
validateBossPhaseSet(): string[] 校验规则:
- 恰好 2 个切换点;每个 trigger 至少含一个条件
hp_pct ∈ (0, 100);time > 0;多个 time 严格递增- hp_pct 前缀规则:hp_pct 切换点必须构成前缀——一旦出现 time-only 切换点, 其后的切换点禁止再携带 hp_pct(拦截"P2 time / P3 hp_pct=80"乱序连切)
- 存在 hp_pct 的相邻切换点严格递减(70 > 40)
- adds/removes 的 s_uuid 存在于 SkillSet(error 级)
- removes 的 s_uuid 应在该 Boss 可达技能集内(heroInfo.skills ∪ 大招池 ∪ 前置 adds,warn 级)
3.2 heroSet.ts 改动(2 处)
heroInfo接口新增可选字段(与dis?/bonus?风格一致):
/** Boss 专属:允许的阶段方案 id 列表(引用 BossPhaseSet,生成时随机取一) */
phase_ids?: number[];
- 在 6101~6106 真正的 Boss 配置上加示例:
phase_ids: [10001](注意 6006 骷髅领主也在 BossPools 内但未配置 phase_ids,走兜底路径)
3.3 RogueConfig.ts 改动(4 处)
GeneratedMonster接口加phase_plan_id?: numbermakeBoss():baseInfo.phase_ids非空数组时随机选 1 个写入, 否则undefined(防 NaN:arr?.length ? arr[rand] : undefined)validateRogueConfig()追加调用validateBossPhaseSet()+ 校验 heroInfo.phase_ids 引用的方案存在- 顺带修复现存 bug:
_testSkills池化残留(Boss 回池复用为小怪会继承大招)——MissionMonComp.spawnMonster改为无条件赋值(mon as any)._testSkills = monData.skills ?? undefined
依赖方向:RogueConfig → BossPhaseSet → SkillSet,无循环导入。
4. 运行时层设计
4.1 新文件 assets/script/game/hero/BossPhaseComp.ts
/** Boss 阶段标记组件:挂载即表示该实体参与阶段状态机 */
@ecs.register('BossPhase')
export class BossPhaseComp extends ecs.Comp {
/** 生效的阶段方案 id(BossPhaseSet key) */
plan_id: number = 0;
/** 当前阶段索引:0=P1 / 1=P2 / 2=P3 */
index: number = 0;
/** Boss 在场战斗累计时长(秒),time 触发条件基准 */
elapsed: number = 0;
reset() { this.plan_id = 0; this.index = 0; this.elapsed = 0; }
}
禁止缓存绝对血量阈值:hp/ap 在 Mon.load 后被 MissionMonComp 外部覆盖
(MissionMonComp.ts L239-246),阶段系统每帧实时用 model.hp / model.hp_max * 100 比对。
4.2 Mon.ts 改动
load()增加第 8 个可选参数phase_plan_id: number = 0(当前签名 7 参;唯一调用点MissionMonComp.ts:224同步追加实参)- 在技能表构建完成后:
// Boss 阶段初始化:P1 = 基线技能,仅挂标记组件并记录方案
if (is_boss && phase_plan_id > 0 && BossPhaseSet[phase_plan_id]) {
const phase = this.add(BossPhaseComp);
phase.plan_id = phase_plan_id;
phase.index = 0;
}
destroy()中、remove(HeroAttrsComp)之前显式this.remove(BossPhaseComp)(触发 reset + 回组件池;ECS 组件无自动销毁钩子,必须手动 remove)reset()与destroy()对齐,同样补 remove(防旁路)
4.3 新文件 assets/script/game/hero/BossPhaseSystem.ts
/**
* Boss 阶段系统:检测混合触发条件(血量阈值 OR 在场时长)并执行阶段切换
* Why: 独立系统符合 M/V/S 分层;filter 按 BossPhaseComp 过滤,每局 Boss 唯一,开销可忽略
*/
@ecs.register('BossPhaseSystem')
export class BossPhaseSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate {
filter() { return ecs.allOf(BossPhaseComp, HeroAttrsComp); }
/** 框架自动遍历匹配实体逐个回调;dt 从 this.dt 取(秒) */
update(e: ecs.Entity): void {
const phase = e.get(BossPhaseComp);
const model = e.get(HeroAttrsComp);
// 守卫:非战斗/暂停/已死亡跳过(死亡同帧不切阶段,防死尸换阶段)
if (!smc.mission.in_fight || smc.mission.pause) return;
if (model.is_dead) return;
// P3 后不再检测
if (phase.index >= 2) return;
phase.elapsed += this.dt;
const plan = BossPhaseSet[phase.plan_id];
if (!plan) return;
const def = plan.switches[phase.index];
// 混合触发:任一条件满足即切换(血量实时计算,不缓存绝对值)
const hpPct = model.hp / model.hp_max * 100;
const hpHit = def.trigger.hp_pct !== undefined && hpPct <= def.trigger.hp_pct;
const timeHit = def.trigger.time !== undefined && phase.elapsed >= def.trigger.time;
if (hpHit || timeHit) {
this.executePhaseSwitch(e, def);
}
}
private executePhaseSwitch(e: ecs.Entity, def: PhaseSwitchDef) {
const phase = e.get(BossPhaseComp)!;
const model = e.get(HeroAttrsComp)!;
const from = phase.index;
this.onPhaseExit(e); // 空钩子:TODO 阶段退场演出
// 1. 移除旧技能(仅大招键,绝不操作普攻首键——键序契约见 §5)
if (def.removes) {
for (const s_uuid of def.removes) delete model.skills[s_uuid];
}
// 2. 注入新技能(cd 强制兜底 8s,防 undefined → NaN 污染 CD)
if (def.adds) {
for (const add of def.adds) {
if (!add || !add.s_uuid || !SkillSet[add.s_uuid]) continue;
model.skills[add.s_uuid] = {
uuid: add.s_uuid,
lv: 1,
cd: add.cd ?? 8,
ccd: 0,
overrides: add.overrides,
};
// 资源预热:新技能 prefab 首次异步加载会"丢一发",切换时预加载
// 路径格式与 Skill.ts L112 对齐(game/skill/atk/{sp_name},Prefab 类型)
const path = `game/skill/atk/${SkillSet[add.s_uuid].sp_name}`;
oops.res.load(path, Prefab);
}
}
// 3. 属性修正:基于当前值链式乘算(P3 基于 P2 后的值)
const mod = def.attr_mod;
if (mod) {
if (mod.ap_pct) model.ap = Math.round(model.ap * (1 + mod.ap_pct / 100));
if (mod.defense_pct) model.defense = Math.round(model.defense * (1 + mod.defense_pct / 100));
}
phase.index++;
this.onPhaseEnter(e); // 空钩子:TODO 阶段入场演出(无敌/清场/横幅后续在此实现)
// 广播:oops.message.dispatchEvent(GameEvent.BossPhaseChange, payload)
}
/** TODO: 阶段退场表现(震屏/变暗等),后续迭代实现 */
private onPhaseExit(e: ecs.Entity) { }
/** TODO: 阶段入场表现(怒吼/无敌帧/技能横幅等),后续迭代实现 */
private onPhaseEnter(e: ecs.Entity) { }
}
要点:
@ecs.register装饰器在模块加载时自动注册系统(App 级单例), 唯一要求:BossPhaseSystem.ts 被 import(从 Mon.ts import BossPhaseComp 的同文件链自然带动, 实施时确认 import 链可达)- 不使用系统级 battleTime(per-entity update 中累加会乘 N 倍),计时在组件 elapsed
4.4 GameEvent.ts 新增枚举(近期 PascalCase 惯例)
/** Boss 阶段切换(data: { eid: number, planId: number, fromPhase: number, toPhase: number }),表现层监听此事件实现演出 */
BossPhaseChange = "BossPhaseChange",
payload 用 eid 定位实体(防裸引用已回收实体);监听方按项目规范在 onDestroy 中注销。
4.5 TestMode 调试通道
TestModeConfig 加 phasePlanId: number = 0 与 isBoss: boolean = false;
generateTestWave() 的 isBoss 硬编码 false(RogueConfig.ts L369)同步改为读配置,
并将 phasePlanId 传入 GeneratedMonster——否则调试开关配了也不生效。
5. 明示的设计决策(避免 QA 争议)
| 决策 | 说明 |
|---|---|
| ccd: 0 语义 | 切换后需等满一个完整 CD 才首次施放新技能(非立即轰) |
| attr_mod 口径 | 链式复利:P3 基于 P2 修正后的当前值乘算,配置时按此口径 |
| 前摇交叉语义 | 切换瞬间已在空的弹道按出手快照结算(SDataCom 值拷贝机制天然保证),可能出现"P1 技能携带 P2 攻击力落地",属可接受行为 |
| skills 键序契约 | JS delete+set 会把键移到对象末尾,而 buildSkillCandidates(SCastSystem.ts L627-631)硬假设首键=普攻垫底。removes/adds 绝不允许操作普攻首键(校验 + 注释双重约束);若未来需换普攻,必须整体重建 skills 表保证首键 |
| speed 不修正 | HeroAttrsComp.speed 是移速(仅 MoveComp 消费),攻速走 BuffComp 聚合通道。attr_mod 不含 speed_pct;攻速强化留给后续 buff 通道迭代 |
| hp_max 不修正 | 避免 hp 同步策略(保百分比/保绝对值/回满)问题,后续需要时单独设计 |
| 触发技不阶段化 | atking/atked/dead 等存于 runtime_* 缓存(独立于 model.skills),本次不覆盖,列为后续迭代 |
| 施法不打断 | 切换只改 model.skills;施法闭包捕获 SkillSet 静态配置 + 伤害走值拷贝快照,进行中的施法/飞行弹道不受影响 |
6. 错误处理与校验
- 运行时兜底:BossPhaseSet 查不到方案 → 不挂组件,Boss 退化为单阶段(现状行为),
oops.log.warn - 校验:
validateBossPhaseSet()规则见 §3.1;validateRogueConfig()追加调用(启动期暴露配置错误) - 对象池复用安全:BossPhaseComp 随 destroy 手动 remove → reset 归零 → 回组件池;
_testSkills残留 bug 顺带修复(§3.3 第 4 条)
7. 测试方案
- 单阶段回归:不配 phase_ids 的 Boss(如 6006)行为与现状完全一致
- 阶段切换:TestMode 配
isBoss: true+phasePlanId: 10001+ 低血量阈值, 打 Boss 观察切换调试日志(phase P1→P2,受 debugMode 控制) - time 触发:高防阵容拖时间验证 time 条件独立生效
- 乱序拦截:手工配"P2 time-only / P3 hp_pct"方案,确认 validate 报错
- 池化复用:Boss 死亡后连续刷普通怪,确认小怪不继承 Boss 大招(_testSkills 修复验证)
- 键序:Boss 切换后普攻仍垫底、大招按 CD 轮转正常
8. 文件清单
| 文件 | 操作 | 内容 |
|---|---|---|
assets/script/game/common/config/BossPhaseSet.ts |
新建 | 方案表 + 类型 + validate |
assets/script/game/hero/BossPhaseComp.ts |
新建 | 标记组件 |
assets/script/game/hero/BossPhaseSystem.ts |
新建 | 检测与切换系统 + 空钩子 |
assets/script/game/common/config/heroSet.ts |
修改 | heroInfo 加 phase_ids 字段 + Boss 示例配置 |
assets/script/game/map/RogueConfig.ts |
修改 | GeneratedMonster.phase_plan_id + makeBoss 消费 + validate 追加 + TestMode 通道 |
assets/script/game/map/MissionMonComp.ts |
修改 | load 传参接线 + _testSkills 残留修复 |
assets/script/game/hero/Mon.ts |
修改 | 第 8 参 + BossPhaseComp 挂载/移除 |
assets/script/game/common/config/GameEvent.ts |
修改 | BossPhaseChange 枚举 |
9. 后续迭代(本次不做)
- 阶段切换演出:onPhaseExit/onPhaseEnter 钩子填充(无敌帧、清场、怒吼、横幅)
- 触发技(atking/atked/dead/call)阶段化:runtime_* 缓存重建通道
- 攻速强化:走 buffManager timed buff(Attrs.speed 修饰器)通道
- 阶段专属索敌/移动行为(如 P3 召唤小怪、冲锋走位)
- 换普攻支持:skills 整表重建保证首键契约