首页 / 资讯中心 / 文章详情

Cocos Creator回合制游戏系统设计:管理器、事件总线与确定性回放

Cocos Creator回合制游戏系统设计:管理器、事件总线与确定性回放 ★ FEATURED ARTICLE
简介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(): Promisevoid; dispose(): void; } class ManagerRegistry { private static _inst: ManagerRegistry | null null; private _order: IManager[] []; private _map new Mapstring, IManager(); private _booted false; static get inst(): ManagerRegistry { if (!this._inst) this._inst new ManagerRegistry(); return this._inst; } // 按依赖顺序注册被依赖的先注册 registerT 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; } getT extends IManager(name: string): T { const m this._map.get(name); if (!m) throw new Error(管理器未注册: ${name}); return m as T; } async boot(): Promisevoid { 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 HandlerT (payload: T) void; class EventBus { // key 为事件名value 为 handler 与 owner 的绑定 private _map new Mapstring, Array{ fn: Handlerany; owner: object | null }(); onT(evt: string, fn: HandlerT, owner: object | null null): void { const list this._map.get(evt) ?? []; list.push({ fn, owner }); this._map.set(evt, list); } emitT(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 Mapstring, number(); // 路径 - 引用计数 async init(): Promisevoid { // 战斗常用资源提前进内存避免开场卡顿 await this.preloadBundle(battle); } async acquire(path: string): PromisePrefab { this._ref.set(path, (this._ref.get(path) ?? 0) 1); try { return await new PromisePrefab((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(): Promisevoid { 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里的一处注册表跑起来生效。这一段的说服力远超任何架构图。被问到“为什么不用现成框架”时别急着辩解讲清楚取舍就行现成框架省事但答辩时说不清内部机制出了问题也定位不到自己搭的这套代码量不大每一层都能讲明白为什么这么切代价是要自己维护。评审通常更认这种回答。从那以后我每次做这类项目都会在写第一行业务代码之前先把管理器注册表和验收表写出来哪怕当时觉得多余。真正救命的是那份验收表——它逼着我在动手前就想清楚“怎么证明它是对的”而不是等做完再去找理由。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站