简介:CocoKnightManager 是一套面向 Cocos Creator 开发者的开源回合制游戏系统设计样例,适合想了解战斗流程抽象、模块拆分与数据驱动配置的初中级开发者,也可作为搭建原型时的参考骨架。压缩包共 22 个文件,约 216KB,包含 TypeScript 脚本、JSON 配置、场景与资源描述文件、PNG 贴图以及 README 说明等,整体结构轻量,便于快速浏览工程组织方式。项目围绕回合制玩法拆出回合管理器、行动规则引擎与角色状态机,分别处理行动顺序、攻击防御与技能规则、状态切换及模块协作,并通过 JSON 等方式配置属性、效果和事件,降低逻辑与内容耦合。现有 840 人学习,读者可从中梳理回合流程控制、状态管理与配置表设计的实现思路,并结合开源代码理解模块化系统如何落到 Cocos Creator 工程中。
1. 从一次「回合结算后血量对不上」说起:这套系统设计要解决什么
一个用 Cocos Creator 做的回合制 Demo,战斗打完回到角色面板,血量和刚才结算出来的数值对不上;想加一个新职业,翻遍工程发现伤害公式散在三个脚本里,改完一处忘一处。问题不在某个算法写错,而在于整个工程没有一层统一的游戏系统:资源、事件、存档、战斗回合、UI 各自为政,谁都能改谁。CocoKnightManager 就是围绕这件事展开的系统设计——把这类横切关注点收进受管的管理层,业务脚本只负责描述“这一回合发生了什么”。它适合正在做回合制毕设或课设、需要一套既能讲清楚又能跑起来的结构的人;整套工程按开源仓库的方式组织,模块边界、接口定义和启动顺序都能单独拆出来看。
2. 需求拆解与技术选型:回合制在 Cocos Creator 上该怎么分层
回合制和实时游戏最大的差别不在战斗公式,而在状态推进的方式:玩家的每一次输入都是离散的,但屏幕上的表现是连续的。这个错位决定了系统设计的第一条约束——逻辑必须能一次性算完,表现再按自己的节奏播。想清楚这条,后面绝大多数模块边界都是它的推论。
2.1 回合制的运行时特征与模块边界
先看运行时到底发生了什么。一个回合里,玩家点技能,逻辑层结算出“谁掉了多少血、触发了哪条被动、谁死了”,然后表现层放动画、飘字、切镜头,动画播完才允许下一个单位行动。这个过程有两个关键性质:一是存在明确的收敛点,回合结束时必须有一份权威状态,所有面板显示的都是这份状态的投影;二是资源加载峰值集中,战斗开场和技能首次播放是两个尖峰,错过就得卡帧。
模块边界就是从这里切出来的。战斗逻辑不知道 UI 存在,UI 不知道伤害公式,存档不持有任何节点引用。把这三条写成约束,跨模块的隐式依赖基本就断了。
| 模块 | 输入 | 输出 | 明确不做的事 |
|---|---|---|---|
| 战斗逻辑 | 单位快照、行动指令 | 结算事件序列 | 不播动画、不读节点 |
| 表现层 | 结算事件序列 | 屏幕效果 | 不改任何数值 |
| 存档 | 纯数据 DTO | 可序列化字符串 | 不存节点、不存函数 |
| 资源层 | 资源路径 | 实例化后的节点 | 不关心谁在用 |
这张表的作用是答辩时的挡箭牌。评审问“为什么 UI 不直接改血条数值”,答案就在第四列。
2.2 技术选型:为什么 Manager 用纯 TS 单例而不是常驻组件
Cocos Creator 的脚本体系里,挂到节点上的 Component 有生命周期,节点销毁它就没;而管理器要跨场景活下来。所以 Manager 这一层我一般写成纯 TypeScript 类,导出单例,不继承 Component、不挂节点。代价是不能用update和onLoad,得自己写显式的init()和dispose()。
好处也很直接:切场景时不用担心管理器被连带干掉;单元测试可以直接 new 出来跑,不需要构造引擎环境。事件分发同理不用节点自带的emit,自己写一个轻量 EventBus,因为节点销毁后挂在它身上的监听会一起消失,而战斗结算经常需要“UI 已经销毁了但事件还在路上”的场景。
// core/ManagerRegistry.ts // 管理器注册表:显式定义启动顺序,避免"第一次访问才初始化"的隐式问题 type ManagerCtor = new () => IManager; interface IManager { readonly name: string; init(): Promise<void>; dispose(): void; } class ManagerRegistry { private static _inst: ManagerRegistry | null = null; private _order: IManager[] = []; private _map = new Map<string, IManager>(); private _booted = false; static get inst(): ManagerRegistry { if (!this._inst) this._inst = new ManagerRegistry(); return this._inst; } // 按依赖顺序注册:被依赖的先注册 register<T extends IManager>(ctor: ManagerCtor): T { if (this._booted) throw new Error('管理器必须在 boot 之前注册'); const m = new ctor(); this._order.push(m); this._map.set(m.name, m); return m as T; } get<T extends IManager>(name: string): T { const m = this._map.get(name); if (!m) throw new Error(`管理器未注册: ${name}`); return m as T; } async boot(): Promise<void> { for (const m of this._order) await m.init(); // 串行,保证依赖已就绪 this._booted = true; } // 逆序销毁,先关上层再关底层 dispose(): void { [...this._order].reverse().forEach((m) => m.dispose()); this._order.length = 0; this._map.clear(); this._booted = false; } }这段的重点不在代码本身,而在三个决定。register在boot之后调用直接抛错,是把初始化顺序问题从“运行时某个字段是 undefined”提前成“启动时立刻报错”,血泪经验:这类问题在真机上往往表现为随机崩溃,排查成本极高。init用await串行,是因为配置表管理器必须先于资源管理器就绪,资源管理器必须先于战斗管理器就绪。dispose逆序,保证销毁过程中下层还活着。
2.3 目录结构与模块职责
工程按层分目录,不按文件类型分。按类型分(把所有脚本堆进 scripts)在几十个文件后必然失控。
assets/ core/ # 与业务无关:EventBus、RNG、对象池、序列化 config/ # 配置表与静态数据加载 managers/ # ResManager、AudioManager、SaveManager、UIManager battle/ # BattleManager、TurnScheduler、SkillResolver、BuffSystem view/ # 战斗界面、角色面板、飘字与特效 data/ # DTO 定义,只放纯数据结构 scenes/ # 启动场景、战斗场景、主城场景core不 import 任何其他目录,这条铁律保证它可复用。data只放 interface 和常量,不放 class 方法,因为它要能被序列化。view可以 importbattle的类型,但battle绝对不能 importview的任何东西——这条如果破了,逻辑和表现就会开始互相污染,最后没人能单独测战斗。
3. 总体架构设计:三层结构与七个管理器的职责划分
分层不是画图好看,是为了回答“这段代码应该放哪”和“改了这里会影响到谁”。三层结构在回合制项目里够用,再细就是过度设计,再粗就会回到“一个 BattleManager 写三千行”的老路。
3.1 核心层、业务层、表现层的依赖方向
依赖是单向的:表现层依赖业务层,业务层依赖核心层,反向只允许通过事件。业务层不知道屏幕上有几个血条,表现层也永远不参与数值计算。
核心层三件东西最值得写:带种子的随机数生成器、对象池、序列化工具。RNG 单拎出来是因为战斗回放要能复现——同样的种子和同样的操作序列,必须得到同样的结果,这是后面存档和联调验收的基础。对象池是为了飘字和特效这类高频短命节点,实例化和销毁的开销在移动端很显眼。序列化工具负责把 DTO 转成字符串并把版本号带上,方便以后加字段做迁移。
业务层的划分按“谁拥有状态”来。BattleManager 拥有整场战斗的状态,TurnScheduler 只拥有“现在轮到谁”,SkillResolver 是无状态的纯计算,BuffSystem 拥有挂在单位身上的持续效果。无状态的东西越多越好测,有状态的东西越少越好。
表现层的核心原则是“只读”。BattleView 订阅结算事件,把事件翻译成动画队列,动画播完回调通知 TurnScheduler 继续。它不缓存任何战斗数值,需要显示时向 BattleManager 要。
3.2 事件总线与生命周期的注册销毁顺序
事件总线要解决两个问题:类型安全和批量解绑。前者靠 TypeScript 的映射类型,后者靠 owner 标记。
// core/EventBus.ts type Handler<T> = (payload: T) => void; class EventBus { // key 为事件名,value 为 handler 与 owner 的绑定 private _map = new Map<string, Array<{ fn: Handler<any>; owner: object | null }>>(); on<T>(evt: string, fn: Handler<T>, owner: object | null = null): void { const list = this._map.get(evt) ?? []; list.push({ fn, owner }); this._map.set(evt, list); } emit<T>(evt: string, payload: T): void { // 复制一份再遍历,防止回调里增删监听导致跳过 [...(this._map.get(evt) ?? [])].forEach((it) => it.fn(payload)); } // 界面关闭时按 owner 一次性摘干净,比手写 off 靠谱 offByOwner(owner: object): void { this._map.forEach((list, key) => { const rest = list.filter((it) => it.owner !== owner); rest.length ? this._map.set(key, rest) : this._map.delete(key); }); } } export const bus = new EventBus();emit里先复制数组这一步,是踩过坑才加的:某个回调在响应事件时顺手off了自己,直接遍历原数组会导致后面的监听被跳过,表现为“偶尔有一次伤害不播动画”。offByOwner是给 UI 用的,界面关闭时传this一次性清干净,比在每个onDestroy里手写七八个off可靠得多。
启动顺序定为:配置表 → 资源 → 音频 → 存档 → 战斗 → UI。这个顺序不是随便排的,存档管理器要读配置表里的初始角色数据,战斗管理器要预加载战斗场景的资源包。销毁顺序反过来,先关 UI 再关战斗,保证界面不会在关闭过程中收到已经销毁的战斗事件。
3.3 回合流程状态机:逻辑先行、表现回放
回合流程用显式状态机描述,不用 if-else 堆在update里。状态集合是:战斗开始 → 回合开始 → 单位行动 → 结算执行 → 单位结束 → (还有活着的单位就回到单位行动)→ 回合结束 → 战斗结束。
关键设计是“结算执行”这一步:它一次性把所有结果算完,产生一个事件序列,然后交给表现层播。表现层播完发回一个actionDone事件,状态机才继续。这样一来,即使动画时长改了、跳过动画了、加速两倍速了,战斗结果都完全一致。
// battle/TurnScheduler.ts(节选) enum TurnState { BattleStart, RoundStart, ActorTurn, Resolving, ActorEnd, RoundEnd, BattleEnd } class TurnScheduler { private _state: TurnState = TurnState.BattleStart; private _queue: UnitSnapshot[] = []; // 本回合的行动顺序,按速度排序 // 推进到下一个状态;表现层完成的信号也从这里进来 tick(signal?: 'actionDone'): void { switch (this._state) { case TurnState.ActorTurn: { const actor = this._queue[0]; this._state = TurnState.Resolving; // 一次性算完,返回事件序列供表现层回放 const events = SkillResolver.resolve(actor, this._queue[1]); bus.emit('battle:resolved', events); bus.emit('battle:playback', events); // 表现层订阅 break; } case TurnState.Resolving: if (signal === 'actionDone') { this._queue.shift(); this._state = this._queue.length ? TurnState.ActorTurn : TurnState.RoundEnd; } break; // RoundStart / RoundEnd / BattleEnd 省略:负责重置行动队列、结算 Buff 时序 } } }Resolving状态下如果没有收到actionDone就一直停着,这是有意为之:宁可卡住也不允许逻辑抢跑。为了避免真的卡死,表现层在播放入口加一个超时兜底,超时后强制回调并打日志,这样动画配置出错时能在日志里第一时间看出来,而不是让玩家面对一个永远转圈的战斗。
4. 关键模块实现:资源、存档、战斗结算的具体落地
设计说到这,接下来是最容易被问“到底怎么写的”部分。四个模块挑出来讲,因为它们是这套系统里最容易写歪、也最容易在答辩时被追问的。
4.1 资源管理与预制体加载:引用计数与失败兜底
资源管理要解决三件事:加载、复用、释放。Cocos Creator 自带的资源包机制已经做了加载和缓存,缺的是“谁在用”的记账。
// managers/ResManager.ts(节选) class ResManager implements IManager { readonly name = 'Res'; private _ref = new Map<string, number>(); // 路径 -> 引用计数 async init(): Promise<void> { // 战斗常用资源提前进内存,避免开场卡顿 await this.preloadBundle('battle'); } async acquire(path: string): Promise<Prefab> { this._ref.set(path, (this._ref.get(path) ?? 0) + 1); try { return await new Promise<Prefab>((res, rej) => { // 加载失败必须有回调出口,否则 Promise 永远 pending,场景卡在 loading assetManager.loadBundle('battle', (err) => { if (err) return rej(err); resources.load(path, Prefab, (e, p) => (e ? rej(e) : res(p))); }); }); } catch (e) { this._ref.set(path, Math.max(0, (this._ref.get(path) ?? 1) - 1)); // 失败要回滚计数 throw e; } } release(path: string): void { const n = (this._ref.get(path) ?? 1) - 1; if (n <= 0) { this._ref.delete(path); resources.release(path, Prefab); // 计数归零才真正释放 } else { this._ref.set(path, n); } } }三个细节值得说。第一,加载失败时的计数回滚:忘记回滚会让计数永远大于零,资源再也释放不掉,长时间游玩内存持续上涨,这种问题在短时间的答辩演示里完全看不出来,上线后才爆。第二,peacquire返回的是 Prefab 而不是实例化的节点,实例化交给调用方,方便复用同一个 Prefab 造多个单位。第三,预加载放在init里而不是战斗开场,用启动时的几百毫秒换战斗中的流畅,这个取舍在回合制里几乎总是划算的。
4.2 存档与战斗快照:只存纯数据,用种子做回放
存档最容易犯的错是把节点、组件、甚至函数一起塞进 JSON。正确的做法是定义一组 DTO,只描述数据,且带版本号。
// data/BattleSnapshot.ts export interface UnitDTO { id: string; hp: number; maxHp: number; buffs: Array<{ key: string; stacks: number; remainRounds: number }>; } export interface BattleSnapshot { version: number; // 存档结构版本,改动时用于迁移 seed: number; // 随机种子,回放必须靠它 round: number; units: UnitDTO[]; actions: Array<{ round: number; actorId: string; skillId: string; targetId: string }>; } // 存档时只序列化快照,不碰任何节点 export function saveBattle(snap: BattleSnapshot): string { return JSON.stringify({ ...snap, version: 1 }); } // 读取时先校验版本,再做迁移 export function loadBattle(raw: string): BattleSnapshot { const data = JSON.parse(raw) as BattleSnapshot; if (data.version > 1) throw new Error('存档版本高于当前客户端'); if (data.version < 1) { /* 老版本字段补齐 */ } return data; }seed和actions这两个字段是存档设计的核心。因为战斗过程是确定性的——同样的种子、同样的行动序列,必然得到同样的结果——存档只要记录这两样,体积就很小,而且天然支持回放。想看某一场战斗怎么输的,把种子和操作序列丢进去重跑一遍,逐帧对比就行,比翻日志快得多。
version字段现在看起来多余,等你要在角色数据里加一个 “护盾值” 就会出现大量老存档读不出来的情况。加个版本号、写几行迁移代码,成本极低,后悔药提前备好。
4.3 回合结算顺序:顺序本身就是规则
结算顺序没有“标准答案”,但必须有唯一答案,并且写死在一个地方。我的做法是在SkillResolver里按固定链条执行,每一步的输出是下一步的输入。
// battle/SkillResolver.ts(节选) export function resolve(actor: UnitSnapshot, target?: UnitSnapshot): BattleEvent[] { const events: BattleEvent[] = []; // 1. 回合开始类的持续伤害先结算(中毒、灼烧) events.push(...BuffSystem.tickRoundStart(actor)); // 2. 技能前置判定:沉默、眩晕会直接中断 if (BuffSystem.has(actor, 'silence')) return events; // 3. 命中与暴击:随机数必须走带种子的 RNG const hit = rng.float() <= calcHitRate(actor, target); if (!hit) return events.concat({ type: 'miss', actorId: actor.id }); const crit = rng.float() <= actor.critRate; const dmg = calcDamage(actor, target, crit); // 4. 扣血走统一入口,护盾、减伤、上限截断都在这一个函数里 events.push(applyDamage(target, dmg, crit)); // 5. 死亡结算放在最后,避免已死单位还能触发被动 if (target.hp <= 0) events.push(...BuffSystem.onDeath(target)); return events; }顺序为什么重要:如果先算死亡再算持续伤害,一个被毒死的单位还能在死前打出一次被动,结果和玩家的直觉不符,而且很难在测试里发现。如果把暴击随机数放在命中判定之前,即使未命中也会消耗一次随机数,回放时序列就会错位。applyDamage统一入口是为了让护盾、减伤、伤害上限这些规则只有一份实现——分散写是数值崩坏的头号原因。
4.4 表现层订阅:串行队列播放,允许跳过
表现层拿到事件序列后不能并发播,否则两段动画叠在一起。用队列串行消费,每个事件播完再取下一个。
// view/BattleView.ts(节选) class BattleView { private _queue: BattleEvent[] = []; private _playing = false; onEnable(): void { bus.on('battle:playback', this.enqueue, this); // owner 传 this,关闭时批量解绑 } onDisable(): void { bus.offByOwner(this); this._queue.length = 0; // 界面关了,未播的事件直接丢弃 } private enqueue(events: BattleEvent[]): void { this._queue.push(...events); if (!this._playing) this.playNext(); } private async playNext(): Promise<void> { const evt = this._queue.shift(); if (!evt) { this._playing = false; bus.emit('battle:actionDone'); // 播放结束,通知状态机继续 return; } this._playing = true; await this.playOne(evt); // 内部用 tween,支持 2 倍速参数 this.playNext(); } }onDisable里清空队列这一步很关键。战斗中途退出到主城,队列里还压着十几个未播事件,如果不清理,它们会在下次进入战斗时被播出来,表现为“刚开打就莫名其妙掉血”。重置播放速度的逻辑也放在这里,避免上一局的 2 倍速带到下一局。
5. 避坑与排查:上线前最容易翻车的五个点
下面五条都是在同类项目里反复出现的,按“现象 → 原因 → 解决”写,方便对着排查。
5.1 启动时随机报「undefined 不是对象」
现象是每次启动崩溃的位置不一样,本地跑十次可能只崩一次。原因是管理器初始化顺序不对,某个管理器在自己的init里访问了还没初始化的另一个管理器,而get只在第一次访问时创建实例。解决办法是把所有管理器的注册和init收敛到注册表的boot里串行执行,并且在get里对未注册的名字直接抛错,让问题在启动的第一秒暴露,而不是在玩家点开某个面板时暴露。
5.2 加速两倍后战斗结果变了
现象是开了倍速,同样的阵容打同样的敌人,胜负不一样。原因多半是逻辑挂在了update(dt)里用累计时间推进,帧率或倍速一变,单位行动的时间点就偏移了。回合制的逻辑必须离散推进,所有状态变化由事件驱动,update只负责表现层的插值。改法是让TurnScheduler只在收到actionDone时前进一步,倍速只影响 tween 的时长,不影响逻辑。
5.3 切场景后旧界面还在响应事件
现象是回到主城后点按钮,控制台报“节点已销毁”,或者战斗界面凭空弹出。原因是事件监听挂在了被销毁的节点上但没有解绑,总线里还留着回调。解决办法是给EventBus加 owner 机制,每个界面在onDisable里调用offByOwner(this),禁止在业务逻辑里用匿名函数注册监听——匿名函数没法解绑,这是最隐蔽的一种泄漏。
5.4 存档越存越大,最后读取失败
现象是玩得越久,存档字符串越长,某一刻开始读档直接抛异常。原因通常是 DTO 里混进了节点引用或者互相引用的对象,JSON.stringify遇到循环引用会直接报错,遇到节点会写进去一大堆无关字段。解决办法是把所有存档结构收在data/目录下,只允许出现原始类型、数组和普通对象,加一个开发期的校验函数在存盘前跑一遍,发现非纯数据就立刻报错。
5.5 回放对不上,随机数成了玄学
现象是用录好的操作序列重跑,伤害数值和原来不一致。原因只有一种:用了Math.random。解决办法是全项目禁用Math.random,统一走带种子的 RNG,种子在战斗开始时生成并写入快照。这条如果一开始没做,后期再补会非常痛苦,因为要挨个排查所有随机调用点,而且改完还得重新验证已有的回放数据。
6. 验收与答辩:怎么证明这套系统设计站得住
系统设计类的项目最怕讲成“我用了单例模式、我用了事件总线”,这些都是手段,评审想听的是“为什么必须是它,换掉会怎样”。答辩前我会准备一张验收表,每一条都能当场演示,而不是靠嘴说。
| 验收项 | 判定方法 | 通过标准 |
|---|---|---|
| 逻辑与表现解耦 | 把动画时长改成 0.1 秒重打一场 | 战斗结果与正常速度完全一致 |
| 确定性回放 | 用快照里的种子和操作序列重跑 | 每一步的伤害数值逐条对上 |
| 存档健壮性 | 存入中途退出、老版本存档各读一次 | 均能正确恢复,无报错 |
| 模块扩展 | 新增一个 Buff,只改一个文件 | 不触碰战斗主流程代码 |
| 事件无泄漏 | 进出战斗场景十次,观察监听数量 | 数量回到初始值 |
演示流程控制在三分钟:先打一场完整战斗,中途退出一次再重进,展示状态恢复;然后把倍速调到两倍重打一遍,对比结算数字;最后打开工程,现场新增一个“每回合回血”的 Buff,只改BuffSystem里的一处注册表,跑起来生效。这一段的说服力远超任何架构图。
被问到“为什么不用现成框架”时,别急着辩解,讲清楚取舍就行:现成框架省事,但答辩时说不清内部机制,出了问题也定位不到;自己搭的这套代码量不大,每一层都能讲明白为什么这么切,代价是要自己维护。评审通常更认这种回答。
从那以后我每次做这类项目,都会在写第一行业务代码之前先把管理器注册表和验收表写出来,哪怕当时觉得多余。真正救命的是那份验收表——它逼着我在动手前就想清楚“怎么证明它是对的”,而不是等做完再去找理由。希望帮到你。
本文还有配套的精品资源,点击获取