简介CocoKnightManager 是一套基于 Cocos Creator 的回合制游戏系统设计开源工程面向希望掌握回合制玩法架构的初中级游戏开发者与独立团队帮助其解决战斗流程编排、角色状态切换与规则扩展等核心问题。压缩包共 22 个文件约 216KB以 json 配置、meta 资源描述、png 贴图、ts 脚本及 fire 场景文件为主另含 README 说明与工程配置结构轻量、便于直接导入引擎研读。已有 838 人学习下载。资源围绕模块化与数据驱动展开包含回合管理器、行动规则引擎与状态机等组件将行动顺序、指令执行与状态更新抽象为可组合模块属性与效果可通过数据配置调整便于非程序人员参与内容迭代。读者可据此理解回合制系统的分层设计与代码组织方式并借鉴其开源实现快速搭建原型或扩展自有玩法。1. 从一份“能跑但不敢交”的回合制游戏说起做课程设计和毕设的人常遇到一个尴尬局面游戏能跑起来角色能动、战斗能打但一旦被问“你的系统是怎么设计的”就只剩一句“用 Cocos Creator 做的”。问题不在代码量而在结构——战斗逻辑和界面渲染搅在一起加个新技能要改五个文件存档格式改一次就全线崩。CocoKnightManager 这套方案针对的正是这个痛点把一款回合制游戏的战斗、角色、关卡、存档拆成职责清晰的系统层让“系统开源”不只是把代码丢出去而是把设计思路一并交付。它适合正在做游戏方向毕设或课设的学生也适合需要一份可复用回合制骨架的独立开发者。下面按需求、架构、模块实现、避坑、验收的顺序把这份方案完整拆开。2. 需求与选型为什么回合制游戏必须先把系统边界划清回合制游戏看起来比即时战斗简单实际上对状态管理的要求更高。一次战斗里行动顺序、技能冷却、buff 叠加、伤害结算、胜负判定全部串行发生任何一处状态错乱都会让整场战斗变成玄学。所以这套方案的第一件事不是写代码而是把“谁负责什么”定死。2.1 功能需求拆解四个必须闭环的子系统从玩家视角看一款回合制游戏至少要闭环四件事角色能养成、战斗能推进、关卡能解锁、进度能保存。对应到系统设计上就是四个子系统子系统核心职责关键数据角色系统属性计算、等级成长、装备加成基础属性、成长曲线、装备槽战斗系统行动队列、技能释放、伤害结算、胜负判定行动点、技能表、buff 列表关卡系统关卡配置、敌人编队、通关条件、奖励发放关卡 ID、敌人组、掉落表存档系统序列化、版本兼容、异常恢复存档版本号、校验字段这张表不是摆设它决定了后面每个模块的接口边界。我一般会要求每个子系统只暴露“输入状态 输出结果”内部实现对外不可见。比如战斗系统接收“当前行动者 目标 技能 ID”返回“结算后双方状态 日志条目”界面层只负责把日志渲染成飘字和血条动画。这样做的直接好处是战斗逻辑可以脱离界面单独跑单元测试调数值时不用一遍遍点鼠标。需求里还有一个容易被忽略的点——回合制的“回合”到底以什么为单位。是每个角色一个行动回合还是每方一个行动回合这套方案采用的是“行动值队列”每个单位有一个速度属性速度决定行动值增长速率行动值满则入队。这个设计比固定轮流更灵活也解释了为什么后面战斗系统要单独维护一个队列结构而不是简单用一个数组轮询。2.2 技术选型Cocos Creator 的分层策略选 Cocos Creator 做回合制核心考量是它的组件化模型和跨平台导出能力。但组件化用不好就是灾难——很多人把所有逻辑塞进挂载在节点上的脚本里结果场景一复杂就找不到逻辑在哪。这套方案的做法是明确三层表现层挂在节点上的 Component只做输入监听、动画播放、UI 刷新不写任何数值计算。逻辑层纯 TypeScript 类不继承 Component不引用节点负责战斗结算、属性计算、关卡判定。数据层配置表JSON 运行时状态对象配置与状态分离方便热更和调试。逻辑层不依赖引擎 API 这一点很关键。它意味着你可以在 Node 环境里直接跑战斗模拟批量验证数值平衡而不需要启动编辑器。很多同学做毕设时数值全靠手点一场战斗点十分钟改一个系数又要重来血泪经验就是没把逻辑层抽干净。提示Cocos Creator 的脚本生命周期onLoad、start、update只应该出现在表现层。逻辑层用普通类 显式调用避免依赖引擎的帧循环否则回合制会被 update 的时序问题拖垮。2.3 数据驱动配置表与运行时状态分离回合制游戏的数值调整频率极高如果把技能伤害写死在代码里每次改数值都要重新编译。这套方案把所有可调参数放进配置表{ skills: [ { id: skill_slash, name: 横斩, power: 1.2, cost: 2, targetType: single_enemy, effects: [{ type: damage, attr: atk, scale: 1.2 }] } ], levels: [ { id: level_01, enemies: [enemy_slime, enemy_slime], winCondition: all_enemies_dead, rewards: { exp: 50, gold: 30 } } ] }配置表在启动时加载为只读对象运行时状态另存一份可变副本。这样做的原因是配置表可能被多个战斗实例共享直接改配置会导致下一场战斗继承上一场的脏数据。我见过有项目在战斗里直接修改技能对象的冷却字段结果第二场战斗开局技能全在冷却排查了半天才发现是引用共享。分离之后运行时状态用深拷贝生成战斗结束即丢弃干净利落。3. 总体架构设计三层结构与四个模块的职责划分架构图不用画得很花但每个箭头代表一次调用必须能说清方向。这套方案的整体结构是表现层向下调用逻辑层逻辑层读写数据层数据层不反向依赖任何一层。3.1 分层调用关系与依赖方向表现层UI / 动画 / 输入 ↓ 调用 逻辑层BattleSystem / CharacterSystem / LevelSystem / SaveSystem ↓ 读写 数据层ConfigTable / RuntimeState / SaveData依赖方向单一意味着逻辑层可以独立测试表现层可以替换比如从 2D 换到 3D 只改表现层。实际落地时表现层通过一个事件总线接收逻辑层的结算结果而不是直接持有逻辑对象。事件总线在这里不是过度设计而是解耦的必要手段——战斗系统结算完发一个battle:turnEnd事件UI 收到后播动画逻辑层完全不知道 UI 的存在。3.2 四个核心模块的接口约定每个模块对外只暴露少量方法内部状态私有。接口约定如下// 战斗系统对外接口 interface IBattleSystem { startBattle(levelId: string, team: CharacterState[]): BattleInitResult; submitAction(action: BattleAction): TurnResult; getBattleState(): ReadonlyBattleState; } // 角色系统对外接口 interface ICharacterSystem { createCharacter(configId: string, level: number): CharacterState; applyEquipment(state: CharacterState, equipId: string): CharacterState; levelUp(state: CharacterState, exp: number): CharacterState; } // 关卡系统对外接口 interface ILevelSystem { getLevelConfig(levelId: string): LevelConfig; checkWinCondition(state: BattleState): boolean; grantRewards(levelId: string): RewardResult; } // 存档系统对外接口 interface ISaveSystem { save(slot: number, data: SaveData): void; load(slot: number): SaveData | null; migrate(raw: unknown): SaveData; }接口里所有状态对象都是只读或返回新对象不允许外部直接改内部字段。这是防止“界面层偷偷改血量”这类问题的第一道闸。submitAction返回TurnResult而不是直接改状态是为了让一次行动的所有结算结果伤害、buff 变化、死亡、行动队列重排打包返回UI 按顺序播放逻辑和表现不会错位。3.3 事件总线与状态同步机制回合制战斗的节奏是“一次行动 → 一串结算 → 一段动画”。如果逻辑层结算完立刻改 UI动画还没播完状态就变了玩家会看到血条先掉、伤害数字后飘的错乱。事件总线的做法是逻辑层把TurnResult拆成有序事件列表表现层按队列逐个消费消费完再通知逻辑层进入下一回合。// 逻辑层产出有序事件 function buildTurnEvents(result: TurnResult): BattleEvent[] { const events: BattleEvent[] []; for (const hit of result.hits) { events.push({ type: damage, target: hit.targetId, value: hit.damage }); if (hit.isDead) events.push({ type: death, target: hit.targetId }); } for (const buff of result.buffChanges) { events.push({ type: buff, target: buff.targetId, buffId: buff.buffId, delta: buff.delta }); } events.push({ type: turnEnd, nextActor: result.nextActorId }); return events; }事件列表的顺序就是动画播放顺序turnEnd永远在最后表现层消费到它才允许玩家输入下一次行动。这个机制解决的是“动画期间玩家连点导致重复提交”的常见问题——输入锁由表现层根据事件队列是否清空来控制逻辑层不需要关心。4. 核心模块实现战斗结算、角色成长与存档兼容这一章落到具体实现。代码不追求完整可运行但关键路径要能看懂、能照搬结构。4.1 行动队列与回合推进行动队列用最小堆实现按行动值排序行动值相同则按速度降序。每次行动后该单位的行动值减去 100一个行动周期然后重新入堆。速度属性影响的是行动值增长速率在每回合结束时统一累加。class ActionQueue { private heap: ActorEntry[] []; push(actor: ActorEntry): void { this.heap.push(actor); this.siftUp(this.heap.length - 1); } pop(): ActorEntry | undefined { if (this.heap.length 0) return undefined; const top this.heap[0]; const last this.heap.pop()!; if (this.heap.length 0) { this.heap[0] last; this.siftDown(0); } return top; } // 每回合结束按速度累加行动值 tick(speedMap: Mapstring, number): void { for (const entry of this.heap) { entry.actionValue speedMap.get(entry.id) ?? 10; } this.heap.sort((a, b) b.actionValue - a.actionValue || b.speed - a.speed); } private siftUp(i: number): void { while (i 0) { const parent (i - 1) 1; if (this.compare(this.heap[i], this.heap[parent]) 0) break; [this.heap[i], this.heap[parent]] [this.heap[parent], this.heap[i]]; i parent; } } private siftDown(i: number): void { const n this.heap.length; while (true) { let smallest i; const l i * 2 1, r i * 2 2; if (l n this.compare(this.heap[l], this.heap[smallest]) 0) smallest l; if (r n this.compare(this.heap[r], this.heap[smallest]) 0) smallest r; if (smallest i) break; [this.heap[i], this.heap[smallest]] [this.heap[smallest], this.heap[i]]; i smallest; } } private compare(a: ActorEntry, b: ActorEntry): number { return b.actionValue - a.actionValue || b.speed - a.speed; } }actionValue是行动值初始为 0每回合按速度累加达到阈值这里用 100即可行动。tick方法里的排序是简化写法生产环境应该用堆重建而不是全量 sort但逻辑等价。参数speedMap从角色状态里取速度受 buff 影响时在结算前更新。这个队列的好处是加速 buff 只需要改速度值减速同理不需要改队列结构。4.2 伤害结算管线与 buff 叠加规则伤害结算按固定管线走基础伤害 → 属性加成 → 暴击判定 → 防御减免 → buff 修正 → 最终取整。每一步单独函数方便调试时打印中间值。function calcDamage(attacker: CharacterState, defender: CharacterState, skill: SkillConfig): DamageResult { const base attacker.atk * skill.power; const crit Math.random() attacker.critRate ? attacker.critDamage : 1; const afterCrit base * crit; const afterDef afterCrit * (100 / (100 defender.def)); const buffMod getBuffModifier(attacker, damage_dealt) * getBuffModifier(defender, damage_taken); const final Math.max(1, Math.floor(afterDef * buffMod)); return { damage: final, isCrit: crit 1 }; }buff 叠加规则要提前定死否则后期数值会失控。这套方案采用“同类 buff 取最高值、不同类乘算”的规则攻击加成和防御加成属于不同类乘算两个攻击加成属于同类取最高。getBuffModifier内部按 buff 类型分组每组只保留绝对值最大的那个。这个规则写进配置表注释里调数值的人一看就懂避免“为什么两个加攻 buff 只生效一个”的反复询问。4.3 角色成长曲线与装备加成角色成长用分段线性曲线每 10 级一个段段内线性插值。这样比纯指数曲线好调前期不会太陡后期不会太平。function calcBaseAttr(config: CharacterConfig, level: number): Attributes { const seg Math.floor((level - 1) / 10); const t ((level - 1) % 10) / 10; const from config.growth[seg]; const to config.growth[seg 1] ?? from; return { hp: lerp(from.hp, to.hp, t), atk: lerp(from.atk, to.atk, t), def: lerp(from.def, to.def, t), speed: lerp(from.speed, to.speed, t), }; }装备加成在基础属性算完之后叠加百分比加成基于基础值计算固定值直接相加。顺序不能反否则百分比会吃到装备的固定值数值膨胀。这个顺序在角色系统的applyEquipment里固定外部无法干预。4.4 存档序列化与版本迁移存档最容易翻车的地方是版本升级。这套方案在存档里写死version字段加载时先跑迁移链const MIGRATIONS: Recordnumber, (raw: any) any { 1: (raw) ({ ...raw, version: 2, equipment: raw.equips ?? [] }), 2: (raw) ({ ...raw, version: 3, talents: raw.talents ?? {} }), }; function migrate(raw: any): SaveData { let data raw; while (data.version CURRENT_VERSION) { const fn MIGRATIONS[data.version]; if (!fn) throw new Error(missing migration for v${data.version}); data fn(data); } return data as SaveData; }每个迁移函数只负责一个版本跨度链式执行。equips改名为equipment这种字段变更、新增talents字段这种结构变更都在迁移里处理旧存档不会因为字段缺失直接崩。存档写入时加一个校验字段比如关键数据的哈希加载时校验失败则回退到上一个备份槽这是后悔药式的兜底。5. 避坑与排查回合制项目里最容易翻车的五件事5.1 状态被界面层直接修改现象战斗打完后角色血量显示不对或者下一场战斗开局血量继承上一场的残血。原因UI 脚本直接持有角色状态对象的引用动画里为了“看起来对”手动改了hp字段逻辑层不知情。解决所有状态对象对外只读UI 只消费事件列表里的数值不写回。用 TypeScript 的Readonly或冻结对象强制约束改状态必须走逻辑层接口。5.2 行动队列在 buff 生效后没有重排现象给敌人上了减速 buff但敌人还是按原速度行动或者加速 buff 后行动顺序没变。原因速度在战斗开始时算了一次就缓存了buff 变化后没有触发队列重算。解决速度不作为缓存值每次tick时从当前状态实时读取buff 增删时标记队列脏位下一回合开始前重建堆。5.3 配置表被运行时污染现象第一场战斗用了某个技能第二场战斗该技能冷却初始值不对或者伤害系数变了。原因逻辑层直接修改了从配置表加载的对象而配置对象是共享引用。解决配置表加载后深冻结运行时状态用深拷贝生成技能冷却等可变字段只存在于运行时状态里。5.4 存档字段缺失导致加载崩溃现象旧版本存档在新版本加载时报undefined is not an object玩家进度丢失。原因新增字段没有默认值迁移链缺失或顺序错误。解决每个版本变更必须配迁移函数加载时先跑迁移再校验校验失败回退备份槽并记录日志便于定位。5.5 动画未播完就允许下一次输入现象玩家快速连点一次行动被提交两次伤害翻倍或者行动队列错乱。原因输入锁只判断了“是否在动画中”但动画结束事件和逻辑层回合结束事件不同步。解决输入锁由事件队列驱动turnEnd事件消费完才解锁逻辑层对重复提交做幂等校验同一回合的第二次提交直接丢弃。6. 验收与答辩怎么证明这套系统真的立住了验收不是看游戏能不能通关而是看系统边界是否经得起追问。我一般会准备三样东西一份模块接口清单、一组脱离界面的逻辑测试、一段可复现的存档迁移演示。接口清单直接拿第 3 章的四个接口表答辩时被问“战斗和界面怎么解耦”指着submitAction返回TurnResult说清楚就行。逻辑测试用 Node 直接跑不启动编辑器比如构造两个角色、固定随机种子、连续提交十次行动断言最终血量和行动顺序符合预期。这段测试代码本身就是证据——它证明战斗逻辑不依赖引擎可以独立验证。// 脱离界面的战斗回归测试示例 const battle new BattleSystem(configTable); battle.startBattle(level_01, [createTestHero()]); const actions [skill_slash, skill_slash, skill_slash]; for (const skillId of actions) { const result battle.submitAction({ actorId: hero, skillId, targetId: enemy_0 }); expect(result.events.length).toBeGreaterThan(0); } expect(battle.getBattleState().enemies.every(e e.hp 0)).toBe(true);存档迁移演示更简单准备一份 v1 存档 JSON加载后打印迁移后的字段再存回 v3 格式对比关键字段是否齐全。答辩时被问“旧存档怎么办”直接跑一遍比任何口头解释都有说服力。验收表可以按这个维度自查验收项通过标准检查方式模块解耦逻辑层不 import 任何 cc 模块全局搜索 import 语句战斗可测无界面下能跑完整场战斗Node 脚本执行配置隔离连续两场战斗互不影响同技能连打两场对比冷却存档兼容v1 存档能加载并迁移到当前版本迁移脚本 字段断言输入安全动画期间重复提交被丢弃模拟连点提交最后说一个答辩技巧主动暴露一个你踩过的坑比被问出来强。比如主动讲“我们一开始把速度缓存了加 buff 后行动顺序不对后来改成实时读取”评委听到的是排查过程而不是背稿。从那以后我每次做回合制项目都会先把行动队列和状态只读这两件事写进设计文档第一页再动一行代码。希望这套结构能帮你把“能跑”变成“敢交”。本文还有配套的精品资源点击获取