news 2026/11/4 3:01:17

Cocos Creator回合制战斗系统架构:调度、结算与Buff管线

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cocos Creator回合制战斗系统架构:调度、结算与Buff管线

简介:CocoKnightManager 是一套基于 Cocos Creator 的回合制游戏系统设计源码,面向希望理解战斗流程抽象、模块拆分与数据驱动配置的独立开发者与学习团队,可用于搭建角色行动顺序、技能规则和状态切换等核心机制。压缩包为 zip 格式,共 22 个文件,约 216KB,包含 7 个 json 配置文件、7 个 meta 资源描述文件、3 个 png 贴图、2 个 ts 脚本以及项目说明等,目录中配置、脚本、场景与贴图资源分层清晰,体量轻便,便于导入引擎后逐步研读。项目将回合管理器、行动规则引擎和状态机拆分为独立模块,并用 JSON 配置属性、效果与事件,能帮助读者梳理回合制游戏从指令执行到状态更新、等待暂停与模块协作的完整链路,也便于按玩法需要扩展规则。已有 840 人学习,适合作为开源案例参考,借鉴其模块化组织、数据驱动内容调整和战斗系统设计思路。

1. 一次结算顺序错乱引出的问题:CocoKnightManager 到底管什么

两个人同时按下技能,结算时却出现先扣血、后加护盾的顺序——这是我在拆 CocoKnightManager 时碰到的第一个问题。这个基于 Cocos Creator 的回合制游戏系统设计,把回合调度、实体属性、技能与状态结算拆成可替换的几层,能直接当作骑士题材战棋或回合卡牌的战斗底座。它适合已经能用 Cocos Creator 摆好 UI、但一碰到「谁先动、伤害怎么算、Buff 什么时候掉」就改一处崩三处的人。这份开源源码不教你怎么写第一个脚本,给的是一套能照着改的系统骨架:回合从哪进、单位在哪排队、伤害走哪条管线,每一层都有明确的落点。

2. 工程结构拆解:四层目录与单例挂载点

拿到一份战斗系统的源码,最怕的是打开 assets 一看,几十个脚本平铺在一起,BattleUI里顺手写着伤害公式,Player脚本里又挂着一份行动顺序。CocoKnightManager 在这点上做得比较干净,它把「表现」和「规则」从目录层就切开了,规则层不 import 任何 UI 组件,表现层只通过事件和只读数据往外拿结果。这一章先把骨架摆出来,后面几章的代码才有地方落。

2.1 目录树与四层职责边界

工程按职责分成 core、battle、data、ui 四层,battle 内部再按回合、实体、技能、状态拆子目录。这个划分不是摆设,它决定了你加一个新机制时该往哪个文件夹丢文件,也决定了哪些代码可以脱离 Cocos 运行时单独跑单元测试。

cocoknight-manager/ ├── assets/ │ ├── scripts/ │ │ ├── core/ # 全局单例、事件总线、工具 │ │ │ ├── BattleManager.ts # 战斗总入口 │ │ │ └── EventBus.ts # 轻量事件派发 │ │ ├── battle/ │ │ │ ├── turn/ # 回合调度 │ │ │ │ ├── ActionQueue.ts │ │ │ │ └── TurnScheduler.ts │ │ │ ├── entity/ # 战斗实体与属性 │ │ │ │ ├── BattleEntity.ts │ │ │ │ └── AttributeContainer.ts │ │ │ ├── skill/ # 技能执行 │ │ │ │ ├── SkillExecutor.ts │ │ │ │ └── DamagePipeline.ts │ │ │ └── buff/ # 状态效果 │ │ │ └── BuffController.ts │ │ ├── data/ # 配置表结构与加载 │ │ │ ├── ConfigTypes.ts │ │ │ └── ConfigLoader.ts │ │ └── ui/ # 表现层,只订阅事件 │ └── resources/ │ └── config/ # JSON 配置表 │ ├── units.json │ ├── skills.json │ └── buffs.json

四层的边界可以用一句话概括:core 负责「一场战斗怎么开始和结束」,battle 负责「一场战斗内部怎么跑」,data 负责「数值从哪来」,ui 负责「把结果画出来」。真正容易越界的是 battle 和 ui 之间——很多项目在技能命中后直接调playHitEffect(),结果逻辑层被迫依赖节点。这里的做法是 battle 层只emit('skill:hit', payload),ui 层自己订阅、自己播动画,逻辑层完全不知道动画存在。

提示:判断自己有没有越界,可以搜一下 battle 目录里有没有import ... from 'cc'的节点、动画、UI 相关引用。理想情况下 battle 层只依赖Vec2之类的数学类型,其余一概不碰。

2.2 战斗入口单例与场景生命周期

Cocos Creator 里挂单例最省事的办法是挂一个常驻节点上的 Component,在onLoad里给自己赋值。CocoKnightManager 走的就是这条路,但它在入口处加了一层收敛:外部只能通过startBattle()发起战斗,不能直接拿到调度器去改队列。

// assets/scripts/core/BattleManager.ts import { _decorator, Component } from 'cc'; import { TurnScheduler } from '../battle/turn/TurnScheduler'; import { EventBus } from './EventBus'; const { ccclass } = _decorator; @ccclass('BattleManager') export class BattleManager extends Component { private static _inst: BattleManager | null = null; public static get inst(): BattleManager { if (!this._inst) throw new Error('BattleManager 未挂载到常驻节点'); return this._inst; } private scheduler: TurnScheduler | null = null; onLoad() { BattleManager._inst = this; // 常驻节点,切场景不销毁 director.addPersistRootNode(this.node); } // 唯一对外入口:传关卡配置 id,内部自己装配实体和队列 public startBattle(stageId: number) { this.scheduler?.dispose(); // 上一场残留先清掉 EventBus.emit('battle:prepare', stageId); this.scheduler = new TurnScheduler(stageId); this.scheduler.run(); } public endBattle(result: 'win' | 'lose') { this.scheduler?.dispose(); this.scheduler = null; EventBus.emit('battle:end', result); } }

这段代码有三个点值得抄。第一是_inst的空值判断放在 getter 里,早失败早报错,比运行时莫名其妙undefined.hp好查。第二是startBattle里先dispose旧调度器,回合制最经典的翻车就是上一场的定时器没清、下一场开场时还在 tick。第三是endBattle把结果通过事件抛出去,胜负判定不写在调度器内部,这样以后加「逃跑」「平局」只需在外部多订阅一种结果。

参数上,stageId是关卡配置主键,不是数组下标,配置表里要能通过它查到敌方阵容、掉落、背景。run()内部会先构造实体、再建队列、最后进第一回合,顺序不能反,否则第一回合排序时实体还没进队列。

2.3 配置表结构与加载时机

回合制的数值几乎全在表里,代码只负责解释表。CocoKnightManager 用 JSON 存表,字段分单位、技能、状态三张,结构在 TypeScript 里用 interface 固定下来,加载时按接口做一层类型断言,表填错在编辑器里能第一时间看出来。

// assets/scripts/data/ConfigTypes.ts export interface UnitConfig { id: number; name: string; hp: number; atk: number; def: number; speed: number; // 越大越先手 skills: number[]; // 技能 id 列表,按顺序循环释放 aiType: 'aggressive' | 'support' | 'random'; } export interface SkillConfig { id: number; targetType: 'enemy_single' | 'enemy_all' | 'ally_single' | 'self'; power: number; // 伤害倍率,最终伤害 = atk * power cooldown: number; // 冷却回合数 buffId?: number; // 命中后附加的状态 } export interface BuffConfig { id: number; key: 'atk' | 'def' | 'speed'; // 影响哪条属性 value: number; // 百分比,0.2 表示 +20% duration: number; // 持续回合 stackType: 'refresh' | 'stack' | 'ignore'; }

加载器用resources.load读 JSON,内部做一层缓存。这里有个容易被忽略的点:JsonAsset.json返回的是对象引用,如果某处代码直接改了配置对象,后面所有用到这张表的地方都会跟着变。稳妥做法是首次加载时JSON.parse(JSON.stringify(...))深拷一份再缓存,或者在加载器里冻结对象。

// assets/scripts/data/ConfigLoader.ts import { resources, JsonAsset } from 'cc'; export class ConfigLoader { private static cache = new Map<string, unknown>(); static load<T>(path: string): Promise<T> { const hit = this.cache.get(path); if (hit) return Promise.resolve(hit as T); return new Promise((resolve, reject) => { resources.load(path, JsonAsset, (err, asset) => { if (err) return reject(err); // 深拷一份,避免运行时被就地修改污染缓存 const raw = JSON.parse(JSON.stringify(asset.json)); this.cache.set(path, raw); resolve(raw as T); }); }); } // 战斗前统一预加载,避免回合中途 await 卡帧 static async preloadBattleAssets() { await Promise.all([ this.load<UnitConfig[]>('config/units'), this.load<SkillConfig[]>('config/skills'), this.load<BuffConfig[]>('config/buffs'), ]); } }

加载时机的选择很关键。preloadBattleAssets()建议在加载界面调一次,等三张表都进缓存再进战斗场景。如果偷懒在实体构造里逐个await,第一回合会出现「单位已经进队列、技能表还没到」的空窗,表现就是第一下攻击没伤害——这类问题在真机上比模拟器更明显。

注意:resources.load的路径不带扩展名,也不带resources/前缀,写错路径时错误回调里只有一句 load failed,不会告诉你具体少了哪个文件,建议在 catch 里把 path 打出来。

3. 回合调度链路:一次行动从入队到结算的完整实现

回合制战斗的代码量通常不大,但顺序敏感度极高。同一批单位、同样的技能,先手顺序差一位,战报就完全不同。这一章沿着一场战斗的时间线往下走:单位怎么排进队列、回合状态怎么切、一次伤害从计算到派发事件要经过哪几步。

3.1 行动队列与速度排序规则

队列的职责只有两个:按速度给出行动顺序,以及在单位死亡时把它摘掉。听上去简单,但「速度相同怎么办」这条必须提前定死,否则同一份配置在不同设备上跑出战报不一致,回放功能直接废掉。

// assets/scripts/battle/turn/ActionQueue.ts export interface QueueUnit { uid: string; // 唯一 id,不是配置 id speed: number; seq: number; // 入场序号,速度相同时的兜底依据 alive: boolean; } export class ActionQueue { private units: QueueUnit[] = []; reset(units: QueueUnit[]) { this.units = units.map(u => ({ ...u })); } // 每回合重建一次顺序,不缓存排序结果 buildOrder(): QueueUnit[] { return this.units .filter(u => u.alive && u.speed > 0) .sort((a, b) => { if (b.speed !== a.speed) return b.speed - a.speed; // 速度相同按 seq 升序,保证跨设备一致 return a.seq - b.seq; }); } markDead(uid: string) { const u = this.units.find(x => x.uid === uid); if (u) u.alive = false; } get aliveCount() { return this.units.filter(u => u.alive).length; } }

seq这个字段是整个队列的后悔药。Cocos Creator 底层是 JavaScript,Array.prototype.sort在旧实现里对相等元素不保证相对顺序,虽然新引擎多数已经改成 TimSort,但把顺序交给引擎实现仍然是隐患。加一个入场序号兜底,速度相同的两个单位谁先动就变成可预期、可复现的。

buildOrder()每回合重建而不是维护一个增量列表,代价是多一次 O(n log n) 排序,收益是状态永远从当前存活单位推导出来,不会出现「单位已死但还在列表里等着出手」。回合制单位数量通常个位数到二十几个,这点开销完全可以接受。

speed > 0的过滤是为了支持「被冰冻、速度归零」这类控制效果。如果你们策划要求速度可以为负、负速度最后行动,那就把条件改成alive,排序里自然会把负数排到末尾。

3.2 回合状态机的状态切换

调度器本质是一个状态机,状态切错就会出现「动画还在播,下一个单位已经开始结算」。CocoKnightManager 把状态切成六种,其中Animating是专门用来锁住流程的。

// assets/scripts/battle/turn/TurnScheduler.ts import { ActionQueue, QueueUnit } from './ActionQueue'; import { EventBus } from '../../core/EventBus'; export enum TurnState { Idle, RoundStart, UnitTurn, Animating, // 等待表现层播完,逻辑暂停 RoundEnd, Finished, } export class TurnScheduler { private state: TurnState = TurnState.Idle; private queue = new ActionQueue(); private round = 0; private order: QueueUnit[] = []; private cursor = 0; constructor(private stageId: number) {} async run() { this.state = TurnState.RoundStart; this.round = 1; EventBus.emit('round:start', { round: this.round }); this.beginUnitTurn(); } private beginUnitTurn() { if (this.state === TurnState.Finished) return; // 每回合开头重建一次顺序 if (this.cursor === 0) { this.order = this.queue.buildOrder(); } if (this.cursor >= this.order.length) { this.endRound(); return; } const unit = this.order[this.cursor]; if (!unit || !unit.alive) { this.cursor++; return this.beginUnitTurn(); } this.state = TurnState.UnitTurn; EventBus.emit('unit:turnStart', unit.uid); } // 表现层播完动画后回调,逻辑才继续 public notifyActionDone() { if (this.state !== TurnState.Animating) return; this.cursor++; this.beginUnitTurn(); } private endRound() { this.state = TurnState.RoundEnd; EventBus.emit('round:end', { round: this.round }); this.round++; this.cursor = 0; EventBus.emit('round:start', { round: this.round }); this.state = TurnState.RoundStart; this.beginUnitTurn(); } dispose() { this.state = TurnState.Finished; this.order = []; this.queue.reset([]); } }

Animating态是这套调度里最值钱的一行设计。真实项目里,单位出手往往要等 0.5 到 1.5 秒的动画,如果逻辑不等表现,就会出现伤害数字飘完了、Buff 图标还没更新的错位。这里的约定是:逻辑层发出unit:turnStart后把自己置为Animating,表现层播完调notifyActionDone(),逻辑才推进cursor。表现层忘记回调,战斗就卡住不动——这种「卡住」比「错位」好查得多,日志里最后一条事件指向谁,问题就在谁身上。

dispose()里把状态置成Finished而不是Idle,是为了防止被异步回调二次触发。想象一个已经结束的战斗,某个延时 1 秒的技能动画回调进来了,如果状态是Idle,beginUnitTurn可能又开始跑,队列是空的,直接越界。

注意:notifyActionDone()开头必须校验当前状态,否则连续两次回调会把cursor加两次,跳过一个单位。加一行if (this.state !== TurnState.Animating) return;,能挡掉绝大多数重复回调。

3.3 结算流水线与事件派发

伤害计算本身不复杂,难的是把它拆成固定顺序的几步,让护盾、减伤、增伤、吸血这些机制有明确的插入点。CocoKnightManager 把结算做成一条纯函数管线,输入是这次攻击的上下文,输出是结果对象,中间不碰节点、不播动画。

// assets/scripts/battle/skill/DamagePipeline.ts import { BattleEntity } from '../entity/BattleEntity'; export interface DamageContext { attacker: BattleEntity; target: BattleEntity; power: number; // 技能倍率 } export interface DamageResult { raw: number; // 未修正前的伤害 absorbed: number; // 被护盾吸收的部分 real: number; // 实际掉血 killed: boolean; } export class DamagePipeline { static run(ctx: DamageContext): DamageResult { // 1. 基础伤害:攻击力 × 技能倍率 let dmg = ctx.attacker.attr.get('atk') * ctx.power; // 2. 防御减伤,保底 1 点,避免出现 0 伤害僵局 dmg = Math.max(1, dmg - ctx.target.attr.get('def')); // 3. 增伤与承伤修正,都由 Buff 层提供乘区 dmg *= ctx.attacker.attr.get('atkBuff'); dmg *= ctx.target.attr.get('takenBuff'); // 4. 护盾先吸收 const absorbed = Math.min(ctx.target.shield, dmg); ctx.target.shield -= absorbed; // 5. 剩余部分扣血,血量不允许为负 const real = dmg - absorbed; ctx.target.hp = Math.max(0, ctx.target.hp - real); return { raw: dmg, absorbed, real, killed: ctx.target.hp <= 0 }; } }

顺序就是规则。先算防御再乘 Buff,和先乘 Buff 再减防御,数值差能到百分之几十,这个顺序必须写进策划文档,否则改一次公式全盘重调。保底 1 点伤害不是拍脑袋,是防止高防单位对上低攻单位时双方都打不动、回合数耗尽判平局。

结果对象里的raw / absorbed / real三个字段是给表现层用的:飘字显示real,护盾破碎特效看absorbed,伤害统计面板用raw。逻辑层算完直接EventBus.emit('skill:hit', result),谁想显示什么自己取。这样加一个「伤害数字放大」的需求,只需要改 UI 订阅处,管线一行不动。

ctx.target.attr.get('takenBuff')返回的是乘区系数,没有 Buff 时返回 1.0 而不是 0,这一点在属性容器里要保证默认值正确,否则第一个没 Buff 的单位受到伤害直接变 0。

4. 战斗实体与技能管线:属性容器、技能执行器与 Buff 时间轴

上一章的管线是「无状态的计算」,这一章要给它接上「有状态的实体」。属性容器管数值的来龙去脉,技能执行器管一次出手打谁,Buff 控制器管状态什么时候到期。三者配合不好,最常见的症状就是「Buff 加了但面板没变」「Buff 到期了但效果还在」。

4.1 实体属性容器与修改来源

属性不能只存一个atk: number,因为同一个攻击力可能来自基础值、装备、Buff、光环四五个来源,每一份都要能单独撤掉。CocoKnightManager 的做法是基础值加修饰器列表,读取时现算。

// assets/scripts/battle/entity/AttributeContainer.ts export type ModifierType = 'flat' | 'percent'; export interface AttributeModifier { key: string; // atk / def / speed type: ModifierType; value: number; // flat 是绝对值,percent 是百分比 source: string; // 来源 id,用于整体移除 } export class AttributeContainer { private base: Record<string, number> = {}; private modifiers: AttributeModifier[] = []; setBase(key: string, value: number) { this.base[key] = value; } addModifier(m: AttributeModifier) { this.modifiers.push({ ...m }); } // 移除某个来源的全部修饰,Buff 到期就调这个 removeBySource(source: string) { this.modifiers = this.modifiers.filter(m => m.source !== source); } get(key: string): number { const flatBase = this.base[key] ?? 0; let flat = 0; let percent = 0; for (const m of this.modifiers) { if (m.key !== key) continue; if (m.type === 'flat') flat += m.value; else percent += m.value; } // 先加固定值,再乘百分比,顺序固定 return (flatBase + flat) * (1 + percent); } }

source字段是整套设计的枢纽。Buff 生效时用buff:1001作为 source 加修饰器,到期时removeBySource('buff:1001')一次性撤干净,不需要去猜这个 Buff 到底改了几条属性。装备、光环同理,各自的 source 前缀不同,互不干扰。

(flatBase + flat) * (1 + percent)这个公式意味着所有百分比是加算而不是乘算。两个 +50% 的 Buff 结果是 +100%,不是 +125%。这个选择要跟数值策划对齐,乘算会让后期数值爆炸,加算更好控制,但玩家感知上「两个大 Buff 叠加没想象中强」。这是取舍,不是对错。

get()每次调用都重新遍历修饰器列表,修饰器数量通常在十几个以内,性能没问题。如果哪天出现上百个修饰器(比如每个小怪都挂光环),再考虑加脏标记缓存。

4.2 技能配置表与执行器设计

技能执行器做的事很单一:根据配置选出目标,对每个目标跑一遍伤害管线,需要的话再挂 Buff。它不负责判断冷却、不负责 AI 决策,那些在实体层做。

// assets/scripts/battle/skill/SkillExecutor.ts import { BattleEntity } from '../entity/BattleEntity'; import { SkillConfig } from '../../data/ConfigTypes'; import { DamagePipeline } from './DamagePipeline'; import { EventBus } from '../../core/EventBus'; export class SkillExecutor { static execute(caster: BattleEntity, skill: SkillConfig, allUnits: BattleEntity[]) { const targets = this.pickTargets(caster, skill, allUnits); for (const t of targets) { if (!t.alive) continue; // 群体技能中途可能已打死目标 const result = DamagePipeline.run({ attacker: caster, target: t, power: skill.power, }); // 结果广播给表现层,伤害飘字、受击动画自己订阅 EventBus.emit('skill:hit', { caster, target: t, result }); // 命中后附加状态,由 BuffController 接管计时 if (skill.buffId) { t.buffs.add(skill.buffId, caster.uid); } } } private static pickTargets(caster: BattleEntity, skill: SkillConfig, all: BattleEntity[]) { switch (skill.targetType) { case 'enemy_single': // 简化的取第一个存活敌方,实际项目按站位或仇恨选 return all.filter(u => u.camp !== caster.camp && u.alive).slice(0, 1); case 'enemy_all': return all.filter(u => u.camp !== caster.camp && u.alive); case 'ally_single': return all.filter(u => u.camp === caster.camp && u.alive).slice(0, 1); case 'self': default: return [caster]; } } }

for循环里的if (!t.alive) continue;是群体技能的必备校验。设想一个三目标 AOE,第一个目标被打死触发了反伤或连锁死亡,第二个目标其实已经倒了,这时候还去扣血、还去挂 Buff,日志里就会出现「对尸体造成 200 伤害」,查起来很费时间。

选目标的逻辑单独抽成pickTargets,是为了以后接站位系统。现在按阵营过滤,以后要加「前排优先」「血量最低优先」,只改这一个方法,execute主体不动。slice(0, 1)取首位是占位实现,真实项目里通常要传一个选目标策略函数进来。

4.3 Buff/Debuff 的计时与叠加规则

状态系统的核心问题是「同 id 的 Buff 再加一次会怎样」。三种主流规则——刷新持续时间、叠层数、直接忽略——必须在配置表里写明,代码按配置走,不能一处stack一处refresh。

// assets/scripts/battle/buff/BuffController.ts import { BattleEntity } from '../entity/BattleEntity'; import { BuffConfig } from '../../data/ConfigTypes'; interface ActiveBuff { id: number; source: string; remain: number; stacks: number; } export class BuffController { private active: ActiveBuff[] = []; constructor(private owner: BattleEntity) {} add(buffId: number, sourceId: string) { const cfg = this.lookup(buffId); const exist = this.active.find(b => b.id === buffId && b.source === sourceId); if (exist) { if (cfg.stackType === 'refresh') { exist.remain = cfg.duration; return; } if (cfg.stackType === 'stack') { exist.stacks = Math.min(exist.stacks + 1, 5); // 上限 5 层 exist.remain = cfg.duration; return; } // ignore:已有就什么都不做 return; } this.active.push({ id: buffId, source: sourceId, remain: cfg.duration, stacks: 1 }); this.owner.attr.addModifier({ key: cfg.key, type: 'percent', value: cfg.value * 1, source: `buff:${buffId}:${sourceId}`, }); } // 每回合结束时调一次,倒计时归零就撤修饰器 tick() { const expired: ActiveBuff[] = []; for (const b of this.active) { b.remain--; if (b.remain <= 0) expired.push(b); } for (const b of expired) { this.owner.attr.removeBySource(`buff:${b.id}:${b.source}`); } this.active = this.active.filter(b => b.remain > 0); } private lookup(id: number): BuffConfig { // 实际项目从 ConfigLoader 缓存里取,这里省略 return {} as BuffConfig; } }

source拼成buff:${buffId}:${sourceId}是有讲究的。同一场战斗里,己方辅助和敌方控制可能挂同一个 id 的 Buff,如果只用buff:${buffId}作为 source,一方到期会把另一方的修饰器一起撤掉,面板属性凭空少一截,查起来像玄学。把施加者 uid 拼进去,两个来源互不影响。

tick()的调用时机必须固定在回合末。如果有的地方在回合初调、有的在单位行动后调,同等回合数的 Buff 持续时间就会差一到两回合。通常的做法是在TurnScheduler的endRound里遍历所有存活单位调一次buffs.tick(),全项目统一这一个入口。

叠层上限写死 5 是示例,真实项目应该放进BuffConfig里加一个maxStacks字段。写死的问题不是 5 这个数不好,而是以后策划想给某个特定 Buff 开 10 层,得回来改代码。

5. 常见问题与排查:回合制结算里最容易翻车的五个坑

这套结构跑通 Demo 不难,难的是接上真实素材、真实数值之后还不出错。下面五条是我在类似项目里反复踩过的,两条和数值顺序有关,三条和生命周期有关,基本覆盖了「表现和逻辑对不上」的大部分场景。

5.1 数值与结算层面的三个坑

现象一:同一份配置,两台机器打出来的战报不一样。原因是行动队列只按速度排序,速度相同的单位相对顺序交给了sort的实现。不同浏览器、不同原生环境的排序细节有差异,回放功能一对就露馅。 解决办法是给每个单位一个创建时自增的seq,排序比较器里速度相等时比较seq。补上之后,同一份配置在任何设备上的战报逐帧一致,回放只需要存随机种子和初始配置。

现象二:护盾明明还有,人却掉血了。原因是伤害管线里先扣血再减护盾,或者护盾和血量各算各的、没有走同一条管线。还有一种更隐蔽的情况:护盾值存在实体上,但DamagePipeline读的是属性容器里的shield,两处不同步。 解决办法是把护盾收进管线第 4 步,用Math.min(shield, dmg)吸收,剩下的才进第 5 步扣血。护盾值的写入口只保留实体上的一个字段,属性面板显示的护盾从同一处读。

现象三:Buff 面板显示 +20% 攻击,实际伤害没变。原因是BuffController.add里只往active列表 push 了一条记录,忘了调attr.addModifier;或者修饰器的key写成了attack,而属性容器读的是atk。这两种都是拼写级别的错误,但表现上完全一样,都是「加了没用」。 解决办法是给属性容器加一个调试方法,把所有修饰器按 key 打出来,Buff 生效后手动调一次对比。更省事的做法是在开发期给addModifier加一行console.log,谁加的、加了多少一目了然。

5.2 生命周期与资源层面的两个坑

现象四:战斗结束后回到主界面,再进战斗时伤害翻倍。原因是事件总线上的监听没有在场景销毁时移除。上一场战斗的 UI 节点虽然销毁了,但订阅还在,新一场战斗命中时,旧回调和新回调各播一次,视觉上就是双倍飘字、双倍伤害数字。 解决办法是每个订阅方在onDestroy里对称地off掉自己的回调。Cocos Creator 的EventTarget不像 DOM 那样随节点销毁自动解绑,这个必须手动做,养成「on 了就记一笔 off」的习惯。

现象五:换关卡时偶尔报空,堆栈指向调度器里的unit.uid。原因是dispose()只清了状态,没有清order数组和队列,切关卡后旧的延时回调进来,读到的还是上一场的单位 uid,去find就返回undefined。 解决办法是dispose()里把order、cursor、queue一起清空,状态置Finished,并且所有异步回调入口第一行校验状态。状态不对直接 return,比在后面每一处判空要稳妥得多。

提示:这五个坑里,三个的根因都是「异步回调没有和状态对齐」。排查时与其一行行读业务代码,不如先在所有异步入口打一条包含当前状态的日志,往往几分钟就能定位。

6. 二次开发:给技能管线加一条吸血链并验证不回归

新增机制最忌讳的是直接改SkillExecutor主循环,加着加着主循环变成一个几百行的 switch。我的做法是尽量往管线和配置两端挤,主循环保持不动。下面以加「吸血」为例,演示一条改动路径。

第一步,配置层加字段。SkillConfig里补一个可选字段lifesteal?: number,表示伤害的多少比例回血。老配置不填这个字段,行为完全不变,零回归风险。

// ConfigTypes.ts 增量修改 export interface SkillConfig { // ...原有字段不动 lifesteal?: number; // 0.3 表示造成伤害的 30% 回复自身 }

第二步,让管线把「实际伤害」吐出来。DamagePipeline已经有real字段,不用改计算逻辑。变化点在SkillExecutor命中之后:拿到result.real,按skill.lifesteal给施法者回血,并且把回血量也塞进事件里,让表现层能飘一个绿字。

// SkillExecutor.execute 循环内,扣血之后追加 if (skill.lifesteal && result.real > 0) { const heal = Math.floor(result.real * skill.lifesteal); caster.hp = Math.min(caster.maxHp, caster.hp + heal); EventBus.emit('skill:heal', { target: caster, amount: heal }); }

第三步,验证。改完别急着接 UI,先在控制台手动造一场战斗:选一个atk=100的单位,配一个power=1、lifesteal=0.3的技能,打一个def=20的目标,期望real=80、回血 24。跑三局看日志里的result和skill:heal是否成对出现,再确认施法者满血时回血不会溢出到maxHp以上。

改动位置是否影响老逻辑需要回归的用例
SkillConfig加可选字段否老配置解析
SkillExecutor加回血分支否(未配 lifesteal 时不进分支)普通伤害技能
事件新增skill:heal否(没人订阅也无副作用)表现层不报错

这套「配置加字段、执行器加分支、事件加通道」的组合,是我在这类项目里最常用的扩展姿势:能让老配置原样跑通、能让新机制只在新数据上生效、能让表现层按需订阅而不强制改所有 UI。后面再加「反伤」「连击」,基本都是照搬这三步,区别只是分支里回血换成扣血、结算一次换成循环 N 次。

从那以后我每次往战斗管线里加机制,都强制走一遍「空配置回归 + 单点数值验证 + 事件成对检查」这三步,哪怕改动只有一行。回合制的坑大多不是写的时候炸,而是叠加了三四个机制之后才冒出来,到那时候再想定位,就只能靠日志一条条倒推了。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 3:01:15

2026年前端必备图像处理工具与技术解析

1. 图像处理工具对前端与UI开发的重要性在数字产品设计领域&#xff0c;视觉呈现的质量直接影响用户体验和产品转化率。作为前端开发者和UI设计师&#xff0c;我们每天都要处理大量图像资源——从产品展示图到界面元素&#xff0c;从营销素材到图标系统。据统计&#xff0c;现代…

作者头像 李华
网站建设 2026/11/4 3:00:33

电力安全监控三维融合技术与数字孪生应用

1. 项目背景与行业痛点电力行业的安全监控体系在过去二十年里基本停留在二维平面时代。传统监控摄像头虽然数量庞大&#xff0c;但存在三个致命缺陷&#xff1a;首先是视角割裂&#xff0c;单摄像头只能提供局部画面&#xff0c;无法形成空间整体感知&#xff1b;其次是定位模糊…

作者头像 李华
网站建设 2026/11/4 3:00:17

展会展示架设计:提升品牌形象的关键要素

1. 展会展示架的市场现状与核心痛点在当今商业环境中&#xff0c;展会早已从单纯的产品陈列场所演变为品牌营销的主战场。根据全球展览业协会&#xff08;UFI&#xff09;最新数据&#xff0c;超过76%的专业观众认为展位设计质量直接影响他们对品牌实力的判断。而在这其中&…

作者头像 李华
网站建设 2026/11/4 3:00:01

Kafka架构解析与生产环境实战指南

1. 初识Kafka&#xff1a;消息系统的革命者第一次接触Kafka是在2015年处理一个实时日志分析项目时。当时我们的系统每天产生TB级的日志数据&#xff0c;传统的消息队列在吞吐量上完全无法满足需求&#xff0c;直到团队引入了Kafka。这个最初由LinkedIn开发的消息系统&#xff0…

作者头像 李华
网站建设 2026/11/4 2:58:39

矩阵分解在推荐系统中的核心原理与实践应用

1. 矩阵分解的本质与推荐系统关联矩阵分解&#xff08;Matrix Factorization&#xff09;是推荐系统领域最基础也最强大的数学工具之一。我第一次接触这个概念是在2013年开发电商推荐引擎时&#xff0c;当时面对600万用户和2000万商品的行为数据&#xff0c;传统的协同过滤算法…

作者头像 李华
网站建设 2026/11/4 2:58:34

LeetCode 27题解析:双指针移除数组元素

1. 题目解析与需求拆解今天我们来拆解LeetCode第27题"移除元素"。这道题看似简单&#xff0c;但蕴含着数组操作的核心思想&#xff0c;也是面试中的高频考点。题目要求我们原地修改输入数组&#xff0c;移除所有等于给定值的元素&#xff0c;并返回新数组的长度。在实…

作者头像 李华