
简介回合制游戏是游戏开发中经典的品类其核心不在于华丽的渲染而在于清晰的逻辑分层与可靠的通信机制。在联机对战的场景下开发者需要同时处理客户端表现、服务端权威裁决以及网络交互。Cocos Creator凭借可视化的编辑器和完善的组件系统适合搭建战斗界面与播放入场动画Node.js则依靠事件驱动和轻量级特性配合WebSocket实现低频、有状态的消息交换天然契合回合制战斗的回合裁定与数据同步。这类项目从基础概念到工程实现覆盖了状态机设计、消息协议定义、房间管理、联调排错等关键技术点是理解联机游戏服务端架构的绝佳切入点。无论是课程设计、毕业设计还是初次尝试独立开发这套技术栈都能帮助开发者快速构建一个可运行的双人对战原型并为后续扩展技能系统、账号体系或部署上云打下扎实基础最终落地为一个完整的作品。 看到这个项目标题我第一反应是这不就是典型的“看起来简单做起来全是细节”的练手项目嘛。Cocos Creator 做前端界面和战斗演出Node.js 做后端实时通信和回合管理VSCode 当主力编辑器——这套组合在个人开发者和小型游戏团队里非常常见很多回合制卡牌项目的前身就是这么搭起来的。我最近刚把一个类似的 demo 从零做到能双人稳定联机对战期间踩了不少坑也总结出一些值得说的经验这篇就围绕这个项目来分享。先说清楚这个项目到底能干什么。它解决了两个核心问题一是用 Cocos Creator 怎么把回合制战斗的界面和状态变化做出来二是用 Node.js 怎么搭建一个轻量级的实时对战服务端让两个玩家不在同一台设备上也能公平对战。整个流程说白了就是客户端负责表现服务端负责裁决。如果你正准备做课程设计、毕设或者想入门联机游戏开发这个项目是很好的切入点——技术栈不复杂但覆盖了游戏开发中“表现层 逻辑层 网络层”的完整链路。1. 项目定位与技术选型分析1.1 为什么用 Cocos Creator 做客户端选 Cocos Creator 不是因为它是唯一的选择而是它在这个项目里的“性价比”太高了。回合制战斗游戏对实时渲染的要求不高对 UI 组织、场景切换、按钮事件、动画播报这些功能要求特别多而这正好是 Cocos Creator 的强项。它的编辑器是可视化的你可以直接在编辑器里把战斗场景的层级结构拖出来不用像纯代码写 UI 那样逐行调坐标。举个例子战斗界面里的角色信息面板、血条、技能按钮、战斗日志这些在 Cocos Creator 中就是一层 Canvas 下的不同节点。每个节点挂一个自定义的 Component 脚本来控制自己的行为比如血条节点挂一个HpBarController按钮节点挂一个SkillButtonHandler。这种组件化的设计让整个项目的逻辑边界非常清晰调试的时候也能快速定位问题出在哪个环节。还有一点Cocos Creator 支持直接导出 Web 版这在联调阶段特别方便。你可以在浏览器里开两个页面模拟两个玩家不用真机也能完整走一遍对战流程。等流程没问题了再考虑打包微信小游戏或者安卓包。1.2 Node.js 作为服务端的适用性评估有同学可能会问一个回合制游戏干嘛不直接在一台设备上两个人轮流按何必搞个服务器这是两个完全不同的体验。同屏轮流操作虽然实现简单但它没法回答一个问题两个人不在同一个屏幕前怎么办而加入 Node.js 服务端之后这个项目就从“单机双人”变成了“联机对战”难度和含金量完全是两回事。Node.js 做这个项目的服务端最关键的理由是回合制的通信压力太低了。你想一下回合制游戏不需要像 MOBA 那样每帧同步位置和技能它只需要在“玩家出招”和“服务端结算”这两个时间点交换一下数据可能一整局下来也就发了几十条消息。这种低频、有状态的消息模式用 Node.js 的 WebSocket 来做非常顺手而且开发效率极高。再加上 Node.js 用的是 JavaScript和 Cocos Creator 客户端用的 TypeScript 语法天然接近前后端只需要维护一套 JSON 格式的消息协议联动调试的时候不用来回切换语言思维。团队里如果只有一个人开发用同一种语言体系能省下非常多的时间。1.3 VSCode 开发环境的搭建要点VSCode 在这个项目里扮演的角色是“集成的开发环境”它本身不参与游戏运行但一套好用的编辑器配置能显著提升开发效率。这一节给还没搭过环境的同学一个完整的参考流程。首先是 Node.js 的安装。这里提醒一句安装时选择 LTS长期支持版本不要盲目追求最新大版本。我在这个项目里吃过亏一开始装了 Node.js 24.x 的预览版结果ws这个核心库跑起来之后偶尔会报莫名奇妙的错误换成 LTS 版本之后一切正常。安装完成后在终端里执行node -v和npm -v能正常输出版本号就说明基础环境没问题。然后是 Cocos Creator。去官网下载 Dashboard然后在 Dashboard 里选择编辑器版本。这个项目建议直接用 3.8 以上的版本因为 3.x 版本的组件系统和 TypeScript 支持比 2.x 成熟很多而且网上能找到的资料也更新。安装完编辑器之后在 VSCode 里安装一个名为Cocos Creator的扩展插件它能让 VSCode 识别 Cocos 的 API 提示写脚本的时候会有代码补全省去查文档的功夫。还有两个非常实用的 VSCode 插件ESLint 和 Prettier。JavaScript 和 TypeScript 的语法比较灵活两个人协作或者一个人写很多代码时没有统一的代码规范很容易乱。我习惯把 ESLint 配成保存时自动修复格式这样写出来的代码风格非常统一review 的时候也省心。2. 整体架构设计与通信协议2.1 前后端分层架构先画个“脑图”把这个项目的架构捋清楚。整个系统分三块客户端Cocos Creator、服务端Node.js、通信层WebSocket。客户端负责游戏所有表现层的内容包括主菜单、匹配等待、战斗场景的 UI 显示、战斗动画播放、按钮交互。客户端不直接决定战斗结果它只是把玩家的操作意图发送给服务端。服务端负责核心逻辑层的内容包括玩家匹配、战斗流程控制、伤害计算、胜负判定。服务端是唯一的“权威来源”也就是说服务端说谁赢了谁就赢了客户端不能自己改血量。通信层客户端和服务端之间用 WebSocket 建立一个持久连接然后双方发送 JSON 格式的字符串消息。分层的理念和现实世界里的“裁判”很像。两个拳手在台上打客户端表现层但记分、判定谁倒下的是裁判服务端。裁判不会亲自去打但他掌握最终裁判权。这样设计有一个非常实际的好处反作弊和逻辑统一。如果让客户端自己算伤害那玩家改一下本地数据就能做出一刀 9999 的效果。而服务端统一结算客户端不管怎么被改最终结果还是服务端说了算。2.2 回合制战斗状态机设计回合制战斗的核心是状态机的设计。我见过很多新手一开始就把战斗流程写成一连串的 if-else比如“如果玩家按下攻击然后血量减少然后检查胜负”这种写法在简单场景下能跑但一旦要加技能、加道具、加回合限制逻辑就乱成一团。正确的方式是定义一套明确的战斗状态。这个项目的状态机可以概括为WAITING两人都已进入房间但还没准备好等待双方“准备完成”。PLAYER_TURN轮到某个玩家出招。SETTLE服务端处理玩家指令计算伤害、判断是否暴击、检查胜负。ENDED一方血量归零或者游戏主动结束。状态机的跳转条件要写得很明确。比如从PLAYER_TURN到SETTLE的唯一条件是“服务端收到了当前行动方的出招指令”。从SETTLE回到PLAYER_TURN的条件是“结算完成且双方都还活着”。这个设计的好处是任何时刻你都能说出游戏当前处于什么阶段下一步该做什么排查问题的时候特别高效。实际开发中我更建议把状态机定义成一个枚举然后在服务端的主循环里用一个switch来分发状态行为。这样代码结构非常简单每加一种状态只需要新增一个 case 分支不会影响其他逻辑。2.3 客户端与服务端的消息协议定义通信协议是联机游戏最容易“互相对不上”的地方。我见过不少项目栽在这里服务端发的消息字段是playerId客户端读的是pid两边对不上查了两天才发现是字段名的问题。所以协议一定要在动手写业务逻辑之前先定好。这个项目需要的消息类型不多核心就是四类匹配类{ type: match, playerId: 1001 }行动类{ type: action, playerId: 1001, action: attack, skillId: 1 }结算类{ type: settle, round: 1, attacker: 1001, target: 1002, damage: 35, hp: { 1001: 100, 1002: 65 } }结果类{ type: gameover, winner: 1001, reason: hp_zero }协议的格式我强烈建议用 JSON。虽然二进制协议比如 Protocol Buffers性能更好但对一个简单的回合制项目来说完全没有必要。JSON 有一个巨大的好处是可视化调试——你在服务端打印日志的时候直接输出字符串就能看懂不需要解码。另外每个消息必须带一个type字段作为路由标识。服务端拿到消息之后先读type然后分发给对应的处理函数。这是最基础的消息分发机制像这样写wss.on(connection, (ws) { ws.on(message, (data) { const msg JSON.parse(data.toString()); switch (msg.type) { case match: handleMatch(ws, msg); break; case action: handleAction(ws, msg); break; default: break; } }); });一个很重要的细节是所有需要网络传输的字段命名一定要用英文不要用拼音缩写。以前看有人把玩家 ID 写成wjId玩家 ID 的拼音首字母后来自己都忘了是什么意思。这个习惯在单机项目里无所谓在联机项目里就是灾难。3. 核心战斗系统的实现细节3.1 战斗场景的搭建与角色数据配置战斗场景是整个项目最核心的画面。在 Cocos Creator 里我会把场景节点树整理成下面这个结构Canvas ├── Background ├── PlayerPanel (玩家信息区域) │ ├── NameLabel │ ├── HpBar │ └── AvatarNode ├── EnemyPanel (敌方信息区域) │ ├── NameLabel │ ├── HpBar │ └── AvatarNode ├── ActionLogPanel (战斗日志滚动区域) │ └── LogLabel ├── SkillPanel (技能按钮区域) │ ├── AttackButton │ ├── Skill1Button │ └── Skill2Button └── RoundLabel节点树的好处是层级关系一目了然。每个节点挂对应的脚本组件比如HpBar节点上挂一个HpBarControllerSkillPanel上挂一个SkillPanelController相互之间通过事件通信不直接互相操作这种解耦方式减少了很多不必要的麻烦。角色的属性数据我建议用一个独立的 TypeScript 配置类来管理不要硬编码在场景里。比如export interface RoleConfig { name: string; maxHp: number; attack: number; defense: number; skills: SkillConfig[]; }这样每个角色的基础属性一目了然以后加新角色只需要往配置表里加一条数据。实际开发中我的经验是数据配置和逻辑代码一定要分离——数据用静态 JSON 或专门的配置文件逻辑用 TS 脚本这样未来策划要调数值不需要去翻代码。3.2 客户端战斗流程串联客户端这场戏怎么演可以拆成几个环节玩家点击按钮 → 发送行动指令 → 等待服务端确认 → 根据服务端返回的结算数据更新场景。整个过程客户端更像是一个“演员”所有的剧情走向都是服务端给的剧本。在 Cocos Creator 的组件里按钮点击事件的绑定非常简单。给AttackButton这个节点挂一个Button组件然后在SkillPanelController的onLoad里注册点击回调import { _decorator, Component, Button } from cc; import { GameClient } from ./GameClient; ccclass(SkillPanelController) export class SkillPanelController extends Component { onLoad() { const attackBtn this.node.getChildByName(AttackButton).getComponent(Button); attackBtn.node.on(Button.EventType.CLICK, this.onAttackClick, this); } private onAttackClick() { GameClient.instance.sendAction(attack); } }发完消息之后有一个细节很容易被忽略要立刻把按钮禁用掉否则玩家在等待服务端回包期间狂点按钮会导致连续发出多条指令服务端那边就会乱套。我的做法是在发送指令后调用一个setButtonsEnabled(false)等到收到结算消息再统一恢复。这个细节会直接影响项目的健壮性。收到服务端的结算消息后客户端要做的事情就很固定了更新血条数值 → 刷新战斗日志 → 判断是否进入下一回合。血条的值我习惯不直接改 UI 文本而是用一个Tween组件做平滑过渡这样视觉上会更有反馈感哪怕只是练手项目观感也能提升一个档次。3.3 服务端回合结算逻辑编写服务端的核心函数就是“结算一次行动”。这个逻辑要保证确定性——同样的输入一定要得到同样的输出否则两个客户端看到的结果会不一致。伤害计算我采用最经典的公式function calculateDamage(attacker, defender, skill) { const baseDamage attacker.attack * skill.multiplier; const reducedDamage baseDamage - defender.defense * 0.5; const finalDamage Math.max(Math.round(reducedDamage), 1); // 暴击判定 const isCrit Math.random() * 100 skill.critRate; return isCrit ? finalDamage * 1.5 : finalDamage; }这个公式有几个关键点。第一Math.max(..., 1)的作用是防止伤害被防御减成负数或零保证每次攻击至少造成 1 点伤害。第二暴击倍率直接乘在最终伤害上简单直观。第三最后用Math.round取整保证伤害值没有小数避免 UI 上出现 35.7 这种奇怪的数字。服务端处理一次行动的流程大概是这样的根据msg.playerId找到当前的行动方。验证当前是否真的是这个人的回合。根据msg.action和msg.skillId读取技能配置计算伤害。更新目标的血量。检查血量是否小于等于 0如果是则游戏结束。把结算消息包括伤害值、双方当前血量、回合结束状态广播给房间内的所有客户端。这里要注意服务端管着战斗数据的状态必须把所有状态数据放在内存里维护而不是靠客户端上报。也就是说服务端自己记录每个玩家当前的 hp、attacker 是谁、下一回合轮到谁。只有这样才能保证逻辑的权威性。const room { players: [], currentTurn: null, round: 1, status: WAITING, };4. Node.js 服务端的工程化实现4.1 初始化 WebSocket 服务器与消息路由服务端的技术选型核心就是 WebSocket 库。我用的是ws它是最底层的 WebSocket 实现没有多余的上层封装特别适合学习理解 WebSocket 的原理。如果你未来想做得更复杂再换socket.io也不迟它自带房间管理和重连机制。服务端入口文件的经典结构是这样的const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); wss.on(connection, (ws) { console.log(新客户端连接); ws.on(message, (data) { const msg JSON.parse(data.toString()); routeMessage(ws, msg); }); ws.on(close, () { console.log(客户端断开连接); }); }); function routeMessage(ws, msg) { switch (msg.type) { case match: handleMatch(ws); break; case action: handleAction(ws, msg); break; default: ws.send(JSON.stringify({ type: error, message: unknown type })); } }这里有一个很有用的设计给每个 WebSocket 连接维护一个playerId的映射。因为 WebSocket 的ws对象本身是唯一标识一个连接的好方式我们可以直接给它挂属性ws.playerId null;这样后续发消息的时候就能直接从ws对象上知道这条消息是谁发来的不需要客户端在每次消息里反复上传自己的 ID也避免了被人伪造 ID 去控制别人角色的安全漏洞。4.2 房间管理与匹配机制双人对战需要两个玩家凑在一起所以服务端要维护一个“匹配池”。我的实现思路非常简单维护一个等待队列有一个玩家进来就先放进队列第二个玩家进来就把两人凑成一个房间。let waitingPlayer null; function handleMatch(ws) { if (waitingPlayer null) { waitingPlayer ws; ws.send(JSON.stringify({ type: match, status: waiting })); } else { const playerA waitingPlayer; const playerB ws; waitingPlayer null; createRoom(playerA, playerB); } }匹配成功之后需要给两个客户端发送“匹配到对手”的通知同时初始化战斗状态。这里有一个细节就是我习惯给双方各发一条init消息里面带上自己的角色信息和对手的角色信息。为什么因为客户端只知道自己的视角它需要知道对方面板该显示什么、谁先手、自己当前的位置是左还是右这些信息都要服务端在开局的时候下发。发消息的时候两个玩家收到的不应该是一模一样的内容而是基于各自视角的内容。比如玩家 A 收到side: player玩家 B 收到side: enemy。这样客户端渲染己方和敌方的位置时就非常明确了。4.3 客户端 WebSocket 连接与消息处理客户端这边我用一个全局单例类GameClient来管理 WebSocket 连接。它的主要职责是建立连接、发送消息、接收消息、把不同类型的消息分发给不同的场景处理。export class GameClient { private ws: WebSocket | null null; private static _instance: GameClient | null null; static get instance(): GameClient { if (!this._instance) { this._instance new GameClient(); } return this._instance; } connect(url: string) { this.ws new WebSocket(url); this.ws.onmessage (event) { const msg JSON.parse(event.data as string); this.handleMessage(msg); }; } send(data: object) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } }在Cocos Creator的浏览器环境中WebSocket 是原生支持的所以不需要额外引入库。但要注意在 Cocos Creator 3.x 里TypeScript 的全局WebSocket类型可能会和ws类型冲突所以我在客户端这边用的类型是浏览器的WebSocket而不是服务端的WebSocket。这个坑看起来不起眼但在编译的时候会报类型错误解决方法是加一个类型声明文件或者直接使用typeof WebSocket断言。收到服务端消息后最简单的处理方式是做一个事件分发private handleMessage(msg: any) { switch (msg.type) { case init: this.onGameInit(msg); break; case settle: this.onSettle(msg); break; case gameover: this.onGameOver(msg); break; } }这个switch-case分发的模式和前两天后端路由的做法是一致的。整个消息链路保持对称前后端对照来看非常舒服。5. 联调实战与常见问题排查5.1 本地联调环境怎么快速跑起来本地开发阶段最理想的联调方式是Cocos Creator 编辑器以预览模式跑一个 Web 页面同时 Node.js 服务端在本机跑一个进程你在浏览器里开两个标签页来模拟两个玩家页签 A 操控玩家 1页签 B 操控玩家 2这样就能完整验证整个对战流程。第一个需要注意的坑是 WebSocket 的地址。Cocos Creator 编辑器预览模式会分配一个随机端口而 WebSocket 的地址要写成ws://127.0.0.1:8080这里的 8080 是 Node.js 服务端的端口跟 Cocos 的预览端口是两个独立的东西。很多新手会搞混把ws://127.0.0.1:7456这种 Cocos 的预览端口当成 WebSocket 端口结果服务端根本连不上因为 7456 端口上跑的是网页服务不是 WebSocket 服务。第二个坑是跨域问题。如果服务端没有配置跨域许可浏览器里的 WebSocket 连接会被同源策略拦下来。ws库其实默认不做跨域校验但保险起见我在服务端加了两个响应头wss.on(connection, (ws, req) { // 顺便响应一下跨域头防患于未然 });其实ws在服务端接收 WebSocket 握手时跨域校验是交给 HTTP 层的所以通常不用额外配置。但如果你用socket.io就需要在cors配置里允许页面地址。第三服务端改代码之后必须重启进程才能生效这是 Node.js 的机制。开发阶段想省事可以安装nodemon来自动监听文件变更并重启服务这个工具对日常开发效率提升很大。5.2 高频踩坑速查表下面这份速查表是我从实际开发中总结的涵盖了联机项目高频出问题的地方。每个问题后面都附了排查思路和解决方法。现象可能原因排查与解决客户端连接 WebSocket 失败服务端没启动或端口被占用先node server.js启动服务端再用 netstat -ano消息能发出去但收不到回包消息路由没匹配到对应type客户端打印发送的消息体服务端打印收到的消息体逐一比对字段名点击按钮连发多条指令发消息后没禁用按钮发送后立即setButtonsEnabled(false)收到结算再恢复血量显示不刷新消息中字段名不匹配服务端发hp客户端读hp用console.log查看两端的数据结构打包后 WebSocket 连不上打包后的地址被 web 容器覆盖把 WebSocket 地址写成配置项打包时单独检查ws://地址是否写死两个客户端显示的战况不一致服务端对不同客户端下发了不同格式的数据检查服务端是否正确按玩家视角分别构造消息或者客户端解析逻辑有状态残留排查联机问题有一招特别管用在服务端和客户端都打印日志但加上时间戳。这样能直观看出是哪一端延迟高、消息是否丢失、是否重复。我在服务端每个消息入口打印一行日志格式是[时间戳] 收到 {type} 来自 {playerId}客户端收到消息也打印一行[时间戳] 收到 {type}两端对照问题出在哪个环节一眼就能定位。5.3 网络通信调试的实用技巧WebSocket 的调试说难不难但要用对工具。这里推荐几个我常用的方式。浏览器开发者工具DevTools的 Network 标签页里直接点“WS”筛选能看到所有 WebSocket 连接的帧消息包括发送和接收的内容。这是客户端侧最直观的调试方式。服务端这边我用的是一种极简的日志中间件思路。在每个消息处理函数里丢一行日志记录关键字段。比如function handleAction(ws, msg) { console.log([${new Date().toISOString()}] player ${ws.playerId} action ${msg.action}); // 结算逻辑... }很多联机问题本质上就是“客户端以为服务端发了damage: 35实际发的是damage: 35.5”这种误差肉眼看不出来但打上日志之后立刻暴露。所以我的建议是联调阶段尽量多打日志不要嫌烦这些都是排查问题的第一手资料。还有一个小技巧写一个简单的“模拟客户端”脚本用 Node.js 的ws库模拟两个客户端连接服务端然后自动发指令走一局这样就能在不需要开 Cocos Creator 的情况下做服务端自动化测试。虽然项目简单这个自动化测试脚本会给你节省大量手工测试的时间。6. 经验总结与扩展方向6.1 练手项目但不要“练手的心态”这套技术栈看起来都很基础但真正把这些组件拼接起来比想象中要花更多时间。我踩过最大的坑就是一开始低估了“联机”这两个字的分量。单机项目里你改一个状态界面立刻刷新联机项目里一个状态变更要经过客户端 → 服务端 → 客户端三个节点中间任何环节出问题表现就会不一致。所以做这个项目的时候宁可把通信协议和状态机先设计好再写代码也不要急着先画界面然后发现逻辑跑不通。还有一点关于版本管理这个项目虽然是一个.zip压缩包但我建议从头开始就用 Git 做版本管理每个功能模块一个提交。别觉得“小项目不需要版本管理”当你改了几天代码发现战斗逻辑回不去了Git 就体现出价值了。我一般会在 GitHub 上建私人仓库把 Cocos Creator 项目和服务端放在同一个仓库的不同目录下结构非常清晰。6.2 后续可以怎么扩展这个项目做完之后往深度扩展的方向非常多。最顺理成章的下一步是加技能系统。现在战斗逻辑里硬编码了技能倍率和暴击率往后可以把它做成配置表让策划可以在不改代码的情况下添加新技能。还可以加入道具系统让玩家在回合内使用血瓶、增加攻击力的 buff 等。加完功能之后就可以考虑把 Node.js 服务端跑在真实的云服务器上这样两个玩家就能跨设备联机了。这一步要做的改动不多TCP 端口放行、公网 IP 的 WebSocket 地址改成服务器的仅此而已。但做了这一步你的项目就从一个本地 demo 变成了一个真正能和朋友一起玩的联机游戏。再往后如果想更加工程化可以考虑引入 Redis 做房间缓存、加上账号系统、登录鉴权、房间对战历史记录等等。这些都是从“搞懂原理”到“做产品”之间要跨过的坎。但从学习角度来说这个项目已经把联机游戏最核心的骨架——客户端表现、服务端权威逻辑、消息协议、状态管理——全部练了一遍这个积累是实打实的。回到标题那句话“一个简单的双人对战的回合制战斗游戏”确实简单但它不该停留在简单本身。把这个简单项目吃透后面不管是做更难的游戏还是做更复杂的实时交互应用这套架构思维和动手经验都能直接用上。本文还有配套的精品资源点击获取