最近在做一个多人实时对战的小游戏项目之前一直用的 HTTP 请求一到需要实时同步玩家位置、发射子弹这种场景就抓瞎了延迟高不说频繁请求服务器压力也大。于是研究了一下 WebSocket在 Cocos Creator 里从头实现了一遍把踩过的坑和总结的方案记录下来希望能帮到同样入门的朋友。1. 为什么实时游戏必须用 WebSocket刚开始做游戏很容易想到用 HTTP 短连接。比如玩家点击“攻击”就发个请求给服务器服务器处理完再返回结果。这在卡牌、回合制游戏里还行但到了动作类、实时对战类游戏里问题就大了延迟无法忍受每个 HTTP 请求都要经历“建立连接 - 发送请求 - 等待响应 - 关闭连接”的过程。玩家移动一下等服务器响应回来可能已经过去几百毫秒体验非常差。服务器压力大假设有 1000 个玩家在线每个玩家每秒操作 10 次服务器每秒就要处理上万次 HTTP 连接的建立和销毁开销巨大。无法服务器主动推送HTTP 是“一问一答”服务器没法主动告诉客户端“其他玩家移动了”或者“有敌人靠近了”。WebSocket 就是为了解决这些问题而生的。它一旦握手成功就会建立一条持久的、全双工的 TCP 连接。之后客户端和服务器可以随时、主动地向对方发送数据就像打电话一样不用每次都拨号。这对于实时位置同步、聊天、战斗指令同步等场景是刚需。2. 几种实时通信方案怎么选在 Cocos Creator 里我们主要有几种选择原生 WebSocket浏览器和 Node.js 环境都支持的标准 API。优点是非常轻量、标准、无需额外库。缺点是功能比较基础断线重连、心跳等需要自己实现。对于追求轻量和可控的项目它是很好的起点。Socket.IO一个功能非常丰富的库在原生 WebSocket 之上封装了房间、自动重连、二进制支持等多种功能。如果你的项目需要快速搭建一个功能完善的实时系统并且不介意引入一个额外的库Socket.IO 是很好的选择。不过它会增加包体大小。WebRTC这是一个更底层的 P2P点对点通信技术延迟可以做到极低常用于视频通话和需要超低延迟的对战。但它配置复杂通常需要信令服务器一般也用 WebSocket来协助建立 P2P 连接。对于大部分手游来说用 WebSocket 作为中心服务器的通信方式已经足够。我的选择是原生 WebSocket。理由很简单作为入门学习从最基础的开始理解整个机制很重要其次自己的项目规模不大自己封装一个管理器也能满足需求保持项目简洁。3. 核心实现一步步打造 WebSocket 管理器一个健壮的 WebSocket 连接不能只是简单的new WebSocket()我们需要一个能管理连接状态、处理重连、收发消息的服务。下面我们分步来实现。3.1 第一步创建 WebSocket 服务类与连接状态机首先我们定义一个枚举来清晰管理连接状态这比用数字或字符串更可靠。// WebSocketService.ts export enum ConnectionState { Disconnected, // 未连接 Connecting, // 连接中 Connected, // 已连接 Reconnecting, // 重连中 Error // 错误状态 } export class WebSocketService { private ws: WebSocket | null null; private state: ConnectionState ConnectionState.Disconnected; private reconnectAttempts: number 0; private readonly maxReconnectAttempts: number 5; private readonly reconnectInterval: number 3000; // 重连间隔3秒 private heartbeatIntervalId: number | null null; private readonly heartbeatTime: number 30000; // 心跳间隔30秒 // 消息监听器列表 [消息类型, 回调函数] private listeners: Mapstring, Array(data: any) void new Map(); }3.2 第二步实现连接与基础方法连接方法需要返回 Promise便于我们异步处理连接成功或失败。/** * 连接到 WebSocket 服务器 * param url 服务器地址例如 ws://localhost:8080 */ public connect(url: string): Promisevoid { return new Promise((resolve, reject) { if (this.state ConnectionState.Connecting || this.state ConnectionState.Connected) { console.warn(WebSocket 正在连接或已连接); reject(new Error(WebSocket is already connecting or connected)); return; } this.setState(ConnectionState.Connecting); this.ws new WebSocket(url); this.ws.onopen () { console.log(WebSocket 连接成功); this.setState(ConnectionState.Connected); this.reconnectAttempts 0; // 重置重连计数 this.startHeartbeat(); // 开始心跳 resolve(); }; this.ws.onerror (error) { console.error(WebSocket 连接错误:, error); this.setState(ConnectionState.Error); reject(error); }; this.ws.onclose (event) { console.log(WebSocket 连接关闭代码: ${event.code}, 原因: ${event.reason}); this.stopHeartbeat(); this.setState(ConnectionState.Disconnected); // 非主动关闭且未超过重试次数则尝试重连 if (event.code ! 1000 this.reconnectAttempts this.maxReconnectAttempts) { this.handleReconnect(); } }; // 收到消息的事件处理 this.ws.onmessage this.handleIncomingMessage.bind(this); }); } /** * 发送消息到服务器 * param data 要发送的数据这里用泛型实际可以是定义好的协议对象 */ public sendT(data: T): void { if (this.state ! ConnectionState.Connected || !this.ws) { console.error(WebSocket 未连接无法发送消息); return; } // 将数据序列化为字符串后续会改为二进制 const message JSON.stringify(data); this.ws.send(message); } /** * 处理服务器推送过来的消息 * param event 消息事件对象 */ private handleIncomingMessage(event: MessageEvent): void { try { const rawData event.data; // 这里先按JSON字符串处理后面会扩展二进制 const message JSON.parse(rawData); const { type, data } message; // 假设消息格式为 { type: messageType, data: {...} } // 通知所有监听该类型消息的回调 const callbacks this.listeners.get(type); if (callbacks) { callbacks.forEach(callback callback(data)); } } catch (error) { console.error(处理消息时出错:, error, event.data); } } /** * 订阅特定类型的消息 * param type 消息类型如 playerMove, chat * param callback 收到消息时的回调函数 */ public on(type: string, callback: (data: any) void): void { if (!this.listeners.has(type)) { this.listeners.set(type, []); } this.listeners.get(type)!.push(callback); } /** * 取消订阅 * param type 消息类型 * param callback 要移除的回调函数不传则移除该类型所有监听 */ public off(type: string, callback?: (data: any) void): void { if (!this.listeners.has(type)) return; if (callback) { const callbacks this.listeners.get(type)!; const index callbacks.indexOf(callback); if (index -1) callbacks.splice(index, 1); } else { this.listeners.delete(type); } }3.3 第三步设计消息协议与引入 Protobuf直接用 JSON 字符串发送消息在初期很方便但数据量大、序列化/反序列化慢。对于实时游戏我们更推荐使用ProtobufProtocol Buffers。Protobuf 优点二进制编码体积比 JSON 小很多节省带宽。序列化和反序列化速度极快。有严格的.proto文件定义前后端不易出错。首先定义一个简单的协议文件game.protosyntax proto3; package game; // 玩家移动消息 message PlayerMove { string playerId 1; float x 2; float y 3; float timestamp 4; } // 聊天消息 message ChatMessage { string from 1; string content 2; } // 包装消息用一个枚举区分类型 message GameMessage { enum MsgType { PLAYER_MOVE 0; CHAT 1; HEARTBEAT 2; } MsgType type 1; oneof payload { PlayerMove playerMove 2; ChatMessage chat 3; } }然后使用protobufjs库将.proto文件编译成 TypeScript 可用的代码。在 Cocos Creator 项目中安装npm install protobufjs编译并生成类型文件后我们的发送和接收方法就可以升级了import { GameMessage } from ./proto/game_pb; // 假设生成的TS文件 export class WebSocketService { // ... 其他代码 ... /** * 发送 Protobuf 消息 * param message 构造好的 GameMessage 对象 */ public sendProtobuf(message: GameMessage): void { if (this.state ! ConnectionState.Connected || !this.ws) { console.error(WebSocket 未连接无法发送消息); return; } // 将 Protobuf 对象序列化成二进制 ArrayBuffer const buffer message.serializeBinary(); this.ws.send(buffer); } /** * 处理二进制消息 (升级版) * param event 消息事件对象 */ private handleIncomingMessage(event: MessageEvent): void { try { const rawData event.data; let gameMsg: GameMessage; if (typeof rawData string) { // 兼容旧版JSON格式可选 const jsonMsg JSON.parse(rawData); gameMsg GameMessage.deserializeBinary(new Uint8Array(jsonMsg)); // 假设JSON里存的是二进制数组 } else { // 主流处理二进制 ArrayBuffer const arrayBuffer rawData as ArrayBuffer; gameMsg GameMessage.deserializeBinary(new Uint8Array(arrayBuffer)); } // 根据消息类型分发给不同的处理器 switch (gameMsg.getType()) { case GameMessage.MsgType.PLAYER_MOVE: const move gameMsg.getPlayerMove(); if (move) { this.emit(playerMove, move.toObject()); } break; case GameMessage.MsgType.CHAT: const chat gameMsg.getChat(); if (chat) { this.emit(chat, chat.toObject()); } break; // ... 处理其他类型 ... } } catch (error) { console.error(处理 Protobuf 消息时出错:, error); } } // 简单的发布消息方法替代之前的 on/off 通知逻辑 private emit(type: string, data: any): void { const callbacks this.listeners.get(type); if (callbacks) { callbacks.forEach(cb cb(data)); } } }3.4 第四步实现心跳包与断线重连网络不稳定是常态心跳包用于检测连接是否存活断线重连则是保障体验的必备机制。/** * 开始发送心跳包 */ private startHeartbeat(): void { this.stopHeartbeat(); // 防止重复开启 this.heartbeatIntervalId setInterval(() { if (this.state ConnectionState.Connected this.ws) { const heartbeatMsg new GameMessage(); heartbeatMsg.setType(GameMessage.MsgType.HEARTBEAT); // 可以携带时间戳等信息 this.sendProtobuf(heartbeatMsg); console.debug(发送心跳包); } }, this.heartbeatTime) as unknown as number; // Cocos 中 setInterval 返回的是 number } /** * 停止心跳包 */ private stopHeartbeat(): void { if (this.heartbeatIntervalId ! null) { clearInterval(this.heartbeatIntervalId); this.heartbeatIntervalId null; } } /** * 处理断线重连逻辑 */ private handleReconnect(): void { if (this.reconnectAttempts this.maxReconnectAttempts) { console.error(已达到最大重连次数停止重连); return; } this.setState(ConnectionState.Reconnecting); this.reconnectAttempts; console.log(尝试第 ${this.reconnectAttempts} 次重连等待 ${this.reconnectInterval}ms); setTimeout(() { // 这里需要你保存上一次连接的url这里用示例url this.connect(ws://your-server-url).catch(err { console.error(重连失败:, err); }); }, this.reconnectInterval); } private setState(newState: ConnectionState): void { this.state newState; // 这里可以触发一个状态变化事件方便UI更新 console.log(WebSocket 状态变更为: ${ConnectionState[newState]}); }4. 性能优化要点二进制 vs JSON上面已经用 Protobuf 解决了。简单对比一下一个包含位置信息的对象JSON 可能占 50 字节Protobuf 二进制可能只有 10 字节在大量高频消息下优势明显。主线程优化WebSocket 的回调onmessage是在主线程执行的。如果消息处理逻辑很复杂比如解析大量实体状态可能会阻塞渲染导致卡顿。解决方案是在handleIncomingMessage中只做最轻量的反序列化和消息分类。将具体的业务逻辑如更新玩家位置放入schedule或setTimeout中异步执行或者分发到具体的业务管理器去处理避免阻塞主回调。5. 实战避坑指南WebGL 与 CORS如果你的游戏发布到 Web 平台并且服务器地址和网页地址不同域可能会遇到 CORS 问题。WebSocket 协议本身不受同源策略限制但 WebSocket 握手阶段发出的 HTTP 请求头Origin可能会被服务器拒绝。解决方案是确保你的 WebSocket 服务器正确配置允许来自你游戏域的连接即处理Origin头。内存泄漏在on方法中注册的回调函数如果来自某个组件一定要在组件销毁时比如onDestroy生命周期调用off方法取消订阅否则这个组件无法被垃圾回收。安卓热更新后连接失效在一些安卓 WebView 或小游戏平台热更新重启游戏逻辑但不清除全局上下文后旧的 WebSocket 连接可能还在但上下文已经变了容易出错。稳妥的做法是在游戏启动或场景加载时先检查并关闭旧的连接再创建新的连接。6. 延伸如何实现房间匹配系统有了稳定的 WebSocket 连接实现一个简单的房间匹配系统就水到渠成了。思路如下消息定义在game.proto中增加JoinRoomRequest、RoomInfo、PlayerJoined等消息类型。客户端流程玩家点击“快速匹配”客户端发送JoinRoomRequest消息。等待服务器回复RoomInfo包含房间号、其他玩家列表等。收到后切换游戏场景到战斗房间并开始同步房间内所有玩家的状态。服务器逻辑维护一个“匹配队列”和一个“房间列表”。收到玩家匹配请求将其加入队列。定时检查队列凑够一定人数比如2人就创建一个房间生成房间ID并将房间信息发送给队列中的玩家同时将他们从队列移除加入房间的玩家列表。将房间内任一玩家的状态更新如移动广播给房间内所有其他玩家。写在最后从零开始把 WebSocket 集成到 Cocos Creator 项目里确实需要花点时间但一旦跑通那种实时交互的流畅感是 HTTP 无法比拟的。自己封装管理器虽然前期麻烦点但后期维护和扩展会非常清晰。建议大家在理解上述流程后亲自敲一遍代码并根据自己游戏的业务逻辑调整消息协议和状态管理。下一步可以尝试结合帧同步或状态同步算法打造更复杂的多人游戏体验。希望这篇笔记能帮你少走弯路