1. 联机对战里消息收发为什么是“命门”
做过实时对战类项目的人都有一个共识:画面卡顿可以忍,操作延迟高也能凑合,但消息一旦丢了或者顺序乱了,这局基本就废了。我前后写过好几个联机小项目,从最早的轮询到后来的长连接,踩过的坑几乎都集中在“消息”这两个字上。这次要聊的,是我在做一个卡牌对战类联机Demo时,客户端消息收发这一层的完整实现思路。标题里提到的“游戏王”对战,本质上是一个回合制、强状态同步的卡牌对战场景,它和即时射击类游戏不一样,不需要每秒几十帧的位置同步,但对“指令的可靠送达”和“状态的最终一致”要求极高。你出一张牌,对面必须收到;你结束回合,对面必须知道。任何一条消息的丢失,都会导致两边状态对不上,玩家体验直接崩掉。
这篇文章面向的是已经有一点前端基础、想自己动手做一个联机对战Demo的开发者。不管你用的是原生JS还是框架,消息收发的核心逻辑是通用的。我会把客户端这一侧的消息收发拆开揉碎,从协议设计、连接管理、消息队列、心跳保活、断线重连到实际代码落地,一步步讲清楚。看完之后,你应该能直接把这套方案搬到自己的项目里,少走我当年走过的弯路。
先说清楚这个Demo的整体架构,不然后面聊消息收发会没有上下文。服务端我用的是Node.js加ws库,维护房间、玩家、对局状态;客户端是纯HTML+JS,通过WebSocket和服务端保持一条长连接。客户端负责渲染界面、采集玩家操作、把操作封装成消息发出去,同时接收服务端推过来的状态变更和对手操作,更新本地界面。整个通信模型是典型的“客户端发指令,服务端做裁决,再把结果广播给房间内所有玩家”。这个模型很关键,它决定了客户端消息收发的设计方向:客户端不能自己改状态,只能发“意图”,等服务器确认。
为什么强调这一点?因为很多新手做联机对战,第一反应是“我本地出一张牌,本地先扣血,然后告诉对面也扣血”。这种乐观更新在弱网下会出大问题——两边各算各的,一旦有一方消息没到,状态就分叉了。卡牌对战不像动作游戏可以容忍短暂的不一致,它的状态是离散的、可枚举的,一旦分叉就很难自动收敛。所以我在这个Demo里坚持服务端权威,客户端只做两件事:把玩家意图可靠地发上去,把服务端裁决可靠地收下来。消息收发这一层,就是保证这两件事不出错的基石。
2. 消息协议怎么设计才不容易翻车
2.1 为什么不用纯文本而选JSON结构
WebSocket本身支持发送字符串和二进制数据。我见过有人图省事,直接发“playCard:123”这种冒号分隔的字符串,解析的时候split一下。小Demo这么干确实快,但一旦消息类型多起来,字段变复杂,这种格式就是灾难。你加一个字段要改解析逻辑,字段里带个冒号整个就乱了,调试的时候肉眼也难看清嵌套结构。
我选的是JSON。原因很直接:可读性好,调试时console.log出来一目了然;扩展性强,加字段不影响老逻辑;前后端都能原生解析,不需要额外依赖。代价是比二进制多占一点带宽,但对于卡牌对战这种消息频率(每秒撑死几条)来说,这点开销完全可以忽略。真正需要省带宽的是高频同步的场景,那不是这个Demo要解决的问题。
消息的基本结构我定成这样:
{ type: "PLAY_CARD", // 消息类型,决定服务端怎么处理 seq: 1024, // 客户端自增序号,用于去重和排序 roomId: "room_001", // 房间标识 playerId: "p_9527", // 发送者标识 payload: { // 具体数据,随type变化 cardId: "card_003", targetId: "p_8888" }, timestamp: 1710000000000 // 客户端发送时间,用于延迟统计 }这里有几个字段是我踩坑之后才加上的。seq是客户端维护的自增序号,服务端收到后可以判断有没有重复或乱序。WebSocket基于TCP,理论上不会乱序,但重连之后新旧消息混在一起的情况我遇到过,有个seq就能过滤掉过期指令。timestamp主要是用来做延迟统计和排查问题,线上出问题时看一眼时间戳,能快速判断是客户端发晚了还是服务端回慢了。
2.2 消息类型划分与命名规范
消息类型我分成三大类:客户端到服务端的指令类、服务端到客户端的通知类、以及双向的控制类。命名上我统一用大写下划线,动词在前名词在后,比如PLAY_CARD、END_TURN、SURRENDER。这样一眼就能看出这条消息是干什么的,不用去翻文档。
指令类消息是客户端主动发起的,代表玩家的操作意图。这个Demo里主要有:出牌PLAY_CARD、发动效果ACTIVATE_EFFECT、结束回合END_TURN、认输SURRENDER、准备READY。每一条都对应一个明确的玩家动作,服务端收到后先做合法性校验,再更新状态。
通知类消息是服务端推给客户端的,代表状态变更。主要有:对局开始GAME_START、状态同步STATE_SYNC、对手操作OPPONENT_ACTION、回合变更TURN_CHANGE、对局结束GAME_OVER。这类消息客户端只负责接收和渲染,不做任何状态裁决。
控制类消息是双向的,用于维持连接和处理异常。主要有:心跳PING/PONG、错误ERROR、重连请求RECONNECT。这类消息不涉及游戏逻辑,但缺了它们整个通信层就不稳。
提示:消息类型一定要集中定义成常量,前后端共用一份。我早期是前端写一遍字符串、后端写一遍字符串,结果有一次前端发的是
PLAY_CARD,后端监听的是PLAYCARDS,排查了半小时才发现是拼写不一致。后来我把类型定义抽成一个单独的模块,两边都引用它,这类问题再没出现过。
2.3 消息序号与去重机制的设计考量
前面提到seq字段,这里展开说一下为什么需要它。WebSocket在正常连接下是可靠有序的,但断线重连之后情况就复杂了。假设客户端发了seq为100的出牌指令,服务端还没处理完连接就断了,客户端重连后不确定这条指令有没有生效,如果无脑重发,可能出两次牌;如果不重发,可能这张牌就丢了。
我的处理方式是:客户端维护一个lastAckedSeq,记录服务端已确认处理的最大序号。每条指令发出去后进入待确认队列,服务端处理完会回一个带相同seq的ACK。重连时客户端把待确认队列里seq大于lastAckedSeq的消息重新发一遍,服务端根据seq去重,已经处理过的直接回ACK不再执行。这样就保证了“至少一次送达”,配合服务端的去重表实现“恰好一次执行”的效果。
去重表在服务端维护,记录每个玩家最近处理过的seq范围。这个表不用太大,保留最近几百条就够,因为重连重发的窗口不会很长。这个机制听起来有点重,但对于对战类应用来说,一条指令重复执行(比如多扣一次血)比多花点带宽严重得多。
3. 客户端连接管理:从建立到保活
3.1 建立连接与状态机设计
WebSocket连接的建立本身很简单,new WebSocket(url)一行就够了。但连接是有生命周期的,从CONNECTING到OPEN到CLOSING再到CLOSED,每个阶段能做什么不能做什么都不一样。如果不管理好这个状态,就会出现“连接还没建立就发消息”或者“连接已关闭还在发消息”的情况,控制台一堆报错。
我在客户端封装了一个Connection类,内部维护一个状态字段,取值就是connecting、open、closing、closed四种。所有发送操作前先检查状态,不是open就进队列等着,而不是直接丢出去。连接建立成功后触发onopen,把状态切到open,然后flush队列里的消息。连接关闭时切到closed,根据关闭原因决定是否触发重连。
class Connection { constructor(url) { this.url = url; this.state = 'closed'; this.ws = null; this.sendQueue = []; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; } connect() { if (this.state === 'connecting' || this.state === 'open') return; this.state = 'connecting'; this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.state = 'open'; this.reconnectAttempts = 0; this.flushQueue(); this.startHeartbeat(); }; this.ws.onmessage = (event) => { this.handleMessage(event.data); }; this.ws.onclose = (event) => { this.state = 'closed'; this.stopHeartbeat(); if (!event.wasClean) { this.scheduleReconnect(); } }; this.ws.onerror = (err) => { console.error('WebSocket error:', err); }; } }这段代码里有个细节值得说:onclose里判断event.wasClean。如果是正常关闭(比如玩家主动退出),不需要重连;如果是异常断开(网络抖动、服务端重启),才触发重连。这个区分很重要,不然玩家退出后客户端还在傻傻地重连,浪费资源还刷日志。
3.2 心跳保活:为什么需要以及怎么做
WebSocket连接在理想网络下可以一直保持,但现实网络里,中间的路由器、负载均衡、运营商网关都可能在不活跃一段时间后悄悄掐断连接。而且这种掐断往往不触发onclose,客户端还以为连接活着,发消息出去石沉大海。这就是所谓的“假死连接”。
解决办法就是心跳。客户端每隔一段时间发一个PING,服务端收到回一个PONG。如果连续几个周期没收到PONG,就判定连接已死,主动关闭并重连。心跳间隔我设的是15秒,连续3次没响应(也就是45秒)判定断开。这个值怎么定的?太短了浪费流量和电量,太长了发现断线太慢。15秒是移动端和桌面端都比较平衡的值,卡牌对战对实时性要求没那么极端,这个间隔足够。
startHeartbeat() { this.heartbeatTimer = setInterval(() => { if (this.state !== 'open') return; this.pendingPing = Date.now(); this.send({ type: 'PING', timestamp: this.pendingPing }); this.missedPongs = (this.missedPongs || 0) + 1; if (this.missedPongs >= 3) { console.warn('心跳超时,判定连接已断开'); this.ws.close(); } }, 15000); } handlePong() { this.missedPongs = 0; const rtt = Date.now() - this.pendingPing; this.updateLatency(rtt); }这里顺手把RTT(往返延迟)也算出来了,显示在界面上让玩家看到自己的网络状况。这个功能不是必须的,但体验上加分不少,玩家看到延迟高会自己检查网络,而不是骂服务器卡。
注意:心跳消息不要走业务消息队列,要单独发。因为业务队列可能因为等待ACK而阻塞,心跳被堵住就失去意义了。我在
send方法里对PING做了特殊处理,直接走ws.send,不排队。
3.3 断线重连与状态恢复
断线重连不是简单地重新connect就完事。玩家断线期间,对局可能已经发生了变化——对手出了牌、回合已经切换、甚至对局已经结束。重连之后客户端必须把本地状态和服务端对齐,否则玩家看到的是一个过期的战场。
我的做法是:重连成功后,客户端发一个RECONNECT消息,带上playerId和roomId。服务端收到后,把当前房间的完整状态打包成一个STATE_SYNC消息推回来。客户端收到STATE_SYNC后,用服务端的状态完全覆盖本地状态,重新渲染界面。这个过程对玩家来说就是“卡了一下,然后画面刷新到最新”。
重连的退避策略也要讲究。不能断开后立刻疯狂重连,那样服务端压力大,而且如果是服务端重启,你重连再快也没用。我用的是指数退避:第一次等1秒,第二次2秒,第三次4秒,最多等30秒,重试5次后放弃并提示玩家手动刷新。这个策略在实测中比较稳,既不会让玩家等太久,也不会给服务端造成冲击。
scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.onReconnectFailed && this.onReconnectFailed(); return; } const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts++; setTimeout(() => { this.connect(); }, delay); }4. 消息发送:队列、确认与顺序保证
4.1 发送队列的必要性
前面反复提到发送队列,这里说清楚它解决什么问题。假设玩家手速很快,连续点了三张牌,三条PLAY_CARD消息几乎同时产生。如果直接ws.send,它们会按调用顺序进入TCP缓冲区,正常情况下没问题。但如果此时连接刚好在重连,ws对象是null或者状态不对,直接send就会报错,消息就丢了。
发送队列的作用是解耦“产生消息”和“发送消息”。消息产生后先入队,由队列管理器在连接可用时逐条发送。连接不可用时消息在队列里等着,连接恢复后自动flush。这样业务代码只管产生消息,不用关心连接状态。
send(message) { if (this.state === 'open') { this.ws.send(JSON.stringify(message)); if (message.type !== 'PING' && message.type !== 'PONG') { this.pendingQueue.push(message); } } else { this.sendQueue.push(message); } } flushQueue() { while (this.sendQueue.length > 0) { const msg = this.sendQueue.shift(); this.ws.send(JSON.stringify(msg)); if (msg.type !== 'PING' && msg.type !== 'PONG') { this.pendingQueue.push(msg); } } }pendingQueue是等待服务端ACK的队列,和sendQueue不是一回事。sendQueue是还没发出去的,pendingQueue是发出去了但还没确认的。重连时重发的是pendingQueue里的消息,sendQueue里的消息在连接恢复后正常flush即可。
4.2 消息确认与超时重发
服务端每处理完一条指令,回一个ACK消息,带上被确认的seq。客户端收到ACK后,从pendingQueue里移除对应消息。如果一条消息发出后超过一定时间(我设的是5秒)还没收到ACK,就认为可能丢了,重新发送。重发次数上限3次,超过就报错给玩家,提示操作失败。
checkPendingTimeouts() { const now = Date.now(); const timeout = 5000; this.pendingQueue = this.pendingQueue.filter(msg => { if (now - msg.timestamp > timeout) { if (msg.retries >= 3) { this.onMessageFailed && this.onMessageFailed(msg); return false; } msg.retries = (msg.retries || 0) + 1; msg.timestamp = now; this.ws.send(JSON.stringify(msg)); return true; } return true; }); }这个超时检查用一个定时器每秒跑一次。5秒这个值是根据实测RTT定的,正常网络下RTT在50到200毫秒之间,5秒足够覆盖绝大多数情况。如果玩家网络特别差,RTT超过5秒,那其实已经没法正常游戏了,报错让玩家检查网络比无限重试更合理。
4.3 顺序保证与并发控制
卡牌对战里有些操作是有顺序依赖的。比如你先出牌A触发了一个效果,再出牌B,这两条指令服务端必须按顺序处理。WebSocket本身保证TCP层面的有序,但重发机制可能打乱顺序——如果消息1超时重发,消息2已经发出去了,服务端可能先收到消息2再收到消息1。
我的处理方式是:同一玩家的指令严格串行。也就是说,消息1没有收到ACK之前,消息2不发送,在队列里等着。这样虽然牺牲了一点并发度,但保证了顺序。卡牌对战不是高频操作,玩家点一张牌到点下一张牌之间至少隔几百毫秒,串行完全不影响体验。
sendSequential(message) { if (this.inFlight) { this.waitingQueue.push(message); return; } this.inFlight = message; this.send(message); } onAck(seq) { this.pendingQueue = this.pendingQueue.filter(m => m.seq !== seq); this.inFlight = null; if (this.waitingQueue.length > 0) { const next = this.waitingQueue.shift(); this.sendSequential(next); } }这个inFlight标志就是串行的关键。同一时间只有一条指令在途,收到ACK才发下一条。实现简单,逻辑清晰,实测下来没有出现过顺序错乱的问题。
5. 消息接收:解析、分发与状态更新
5.1 消息解析与异常兜底
收到消息的第一步是解析。event.data是字符串,JSON.parse一下拿到对象。但这里有个坑:如果服务端发来的不是合法JSON(比如服务端出bug发了半截消息),JSON.parse会抛异常,如果不捕获,整个onmessage回调就挂了,后续消息可能都收不到。
handleMessage(raw) { let msg; try { msg = JSON.parse(raw); } catch (e) { console.error('消息解析失败:', raw, e); return; } if (!msg || !msg.type) { console.warn('消息格式不合法:', msg); return; } this.dispatch(msg); }解析失败直接丢弃并记日志,不影响后续消息处理。这个兜底看起来简单,但线上环境里服务端偶尔发个异常消息是常有的事,没有这层保护,客户端可能直接白屏。
5.2 消息分发器的设计
解析出来的消息要根据type分发到不同的处理函数。最直接的做法是一堆if-else或者switch-case,但消息类型多了之后这个方法会变得很长,不好维护。我用的是注册表模式:维护一个type到处理函数的映射,收到消息查表调用。
const handlers = { GAME_START: handleGameStart, STATE_SYNC: handleStateSync, OPPONENT_ACTION: handleOpponentAction, TURN_CHANGE: handleTurnChange, GAME_OVER: handleGameOver, ACK: handleAck, ERROR: handleError, PONG: handlePong }; dispatch(msg) { const handler = handlers[msg.type]; if (handler) { handler.call(this, msg); } else { console.warn('未知消息类型:', msg.type); } }这种写法的好处是扩展方便,加一个新消息类型只需要在handlers里加一行,不用改dispatch逻辑。而且每个处理函数独立,方便单独测试。
5.3 状态同步消息的处理策略
STATE_SYNC是最重要的通知类消息,它携带了服务端裁决后的完整对局状态。客户端收到后要做的是“全量覆盖”,而不是“增量合并”。为什么?因为增量合并需要客户端自己推断哪些字段变了,一旦推断逻辑和服务端不一致,状态就会分叉。全量覆盖虽然传输的数据多一点,但逻辑简单可靠,不会出错。
function handleStateSync(msg) { const state = msg.payload; // 全量覆盖本地状态 gameState.players = state.players; gameState.currentTurn = state.currentTurn; gameState.phase = state.phase; gameState.field = state.field; gameState.hand = state.hand; gameState.graveyard = state.graveyard; // 重新渲染整个界面 renderAll(); }renderAll是全量重绘,听起来性能不好,但卡牌对战的界面元素不多,全量重绘一次也就几毫秒,玩家完全感知不到。比起增量更新可能带来的状态不一致,这点性能代价完全值得。
5.4 对手操作消息的动画衔接
OPPONENT_ACTION消息用来通知客户端“对手做了什么”,主要用于播放动画。比如对手出了一张牌,客户端收到消息后先在对手区域播放出牌动画,动画结束后再更新状态。这里要注意的是,动画是纯表现层的,不能影响状态。状态以STATE_SYNC为准,OPPONENT_ACTION只是让过程看起来更自然。
function handleOpponentAction(msg) { const action = msg.payload; if (action.type === 'PLAY_CARD') { playCardAnimation(action.cardId, () => { // 动画结束后不做状态修改,等STATE_SYNC }); } }有时候OPPONENT_ACTION和STATE_SYNC会几乎同时到达,这时候动画可能还没播完状态就更新了。我的处理是给动画加一个最短播放时间,状态更新后如果动画还在播,等动画播完再渲染新状态。这样视觉上不会突兀。
6. 实战中踩过的坑与排查技巧
6.1 消息丢失的三种典型场景
第一种是连接假死。前面说的心跳就是解决这个的。没有心跳的时候,我遇到过玩家反馈“我出了牌对面没反应”,一看日志,消息发出去了,但服务端根本没收到,因为连接早就被中间设备掐了,客户端还以为活着。
第二种是重连期间的消息丢失。玩家断线重连的几秒钟里如果点了操作,消息进了sendQueue,但重连后如果直接清空队列就会丢。我的做法是重连成功后先flush队列,再发RECONNECT请求状态同步。顺序很重要,先同步状态再发积压指令,否则指令可能基于过期状态执行。
第三种是服务端处理超时但客户端没收到ACK。这种情况客户端会重发,服务端需要去重。如果服务端没做去重,就会重复执行。我早期版本就出过这个问题,玩家反馈“我明明只出了一张牌,怎么扣了两次血”。后来加了seq去重表才解决。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 消息发出无响应 | 连接假死 | 看心跳PONG是否正常 | 加心跳检测,超时重连 |
| 操作重复执行 | 重发未去重 | 查服务端日志有无重复seq | 服务端加seq去重表 |
| 状态两边不一致 | 客户端自行改状态 | 对比两端state快照 | 坚持服务端权威,全量同步 |
| 重连后状态错乱 | 未做状态同步 | 看重连后有无STATE_SYNC | 重连后强制全量同步 |
| 消息顺序错乱 | 并发发送 | 查消息seq顺序 | 同玩家指令串行发送 |
| 界面卡顿 | 全量重绘太频繁 | 看渲染耗时 | 合并渲染,用requestAnimationFrame |
6.3 调试工具与日志策略
调试WebSocket消息,浏览器开发者工具的Network面板里选WS标签,能看到所有收发的帧。这个是最基础的。但线上环境没法开DevTools,所以我在代码里加了一个消息日志开关,打开后把所有收发的消息打到console,格式化成表格方便看。
function logMessage(direction, msg) { if (!DEBUG_MODE) return; const style = direction === 'send' ? 'color: blue' : 'color: green'; console.log(`%c[${direction}] ${msg.type}`, style, msg); }日志里带上方向、类型和完整内容,排查问题时一目了然。但要注意生产环境关掉,不然console会爆。
实操心得:我习惯在每条消息里带一个
traceId,客户端生成,服务端原样带回。这样在日志里搜一个traceId就能看到一条指令从发出到确认的完整链路,排查跨端问题特别有用。
7. 消息收发的性能与体验优化
7.1 消息合并与批量发送
虽然卡牌对战消息不多,但有些场景下还是可以合并。比如玩家连续调整手牌顺序,每次调整都发一条消息太浪费,可以攒200毫秒合并成一条。这个用防抖实现。
const batchSend = debounce((messages) => { connection.send({ type: 'BATCH', payload: messages }); }, 200);但要注意,不是所有消息都能合并。有顺序依赖的指令不能合并,否则服务端处理顺序可能和玩家操作顺序不一致。我一般只对“无状态影响”的操作做合并,比如调整手牌顺序、切换视图这类。
7.2 延迟显示与网络状况反馈
前面提到算RTT,这个数据除了显示给玩家,还可以用来做动态调整。比如RTT超过500毫秒时,给操作加一个“等待中”的loading状态,让玩家知道指令发出去了但还没确认,而不是以为没点上又点一次。
function updateLatency(rtt) { const indicator = document.getElementById('latency'); if (rtt < 200) { indicator.textContent = '网络良好'; indicator.className = 'good'; } else if (rtt < 500) { indicator.textContent = '网络一般'; indicator.className = 'fair'; } else { indicator.textContent = '网络较差'; indicator.className = 'poor'; } }这个反馈看起来是小功能,但能显著减少玩家因为不知道指令有没有生效而重复操作的情况。
7.3 消息压缩的取舍
JSON消息在消息量大时可以考虑压缩,比如用短字段名或者二进制协议。但这个Demo里我没做,因为消息频率太低,压缩带来的收益微乎其微,反而增加了调试难度。我的原则是:先保证正确性和可维护性,性能优化等到真的成为瓶颈再说。卡牌对战的消息量,离瓶颈还差得远。
8. 写在最后的一些个人体会
这套消息收发机制我在几个Demo里反复打磨过,从最早的裸奔ws.send到现在的队列加确认加重连,每一步都是被实际问题逼出来的。最开始我觉得卡牌对战消息少,随便发发就行,结果线上被玩家各种反馈教做人。后来才明白,联机对战里“可靠”比“快”重要得多,一条消息丢了可能整局就废了。
如果你也在做类似的项目,我的建议是:消息层一定要封装成独立的模块,业务代码不要直接碰ws对象。这样以后换协议、加加密、做压缩,都只改一个地方。另外,日志和调试开关一定要早做,不要等到出问题才临时加,那时候你连问题复现都难。
还有一个点,测试的时候一定要模拟弱网。Chrome DevTools的Network面板可以调网络状况,调到Slow 3G试试你的重连和超时逻辑。我很多bug都是在这种极端网络下才暴露出来的,正常网络下根本看不出来。消息收发这层做扎实了,上层的游戏逻辑才能安心写,不然你永远在给通信问题擦屁股。