简介:这份资源是面向Web前端与游戏开发初学者的WebSocket在线游戏实战Demo,围绕实时双向通信这一核心,帮助读者理解持久连接、握手升级、数据帧结构等协议基础,并落地到多用户同步、服务器主动推送等典型游戏场景。压缩包共488个文件,以391个JavaScript源码为主体,辅以55张jpg与10张png图片素材、14个ico图标,另有5个Go语言服务端文件及少量html页面、md说明文档,整体约2.96MB,体量轻便,便于本地运行与二次修改。目前已有94人学习下载。Demo涵盖服务端连接管理与消息广播、客户端socket通信、JSON数据交换格式,以及断线重连、WSS安全传输、负载均衡与数据压缩等优化思路,读者可据此搭建基础联机环境,掌握实时游戏通信的构建流程与排错方法。
1. 从“轮询卡成幻灯片”说起:这套 WebSocket 在线游戏 Demo 到底解决什么
做过实时对战 Demo 的人都踩过同一个坑:前端用setInterval每 500 毫秒拉一次状态,本地两人测试时丝滑,一放到多人场景就原形毕露——延迟忽高忽低、操作不同步、服务器被无效请求打满。这不是代码写得烂,是 HTTP 轮询这套模型天生不适合“状态高频变化 + 多端强一致”的游戏场景。这份基于 WebSocket 的在线游戏开发 Demo,要解决的就是这件事:把“客户端问、服务器答”的拉模式,换成服务器主动推的双向长连接,让房间内的位置同步、操作广播、状态裁决都跑在一条持久通道上。它适合两类人:正在做网络编程课设、需要一份能跑通“连接—房间—同步”闭环的学生,以及想快速摸清实时游戏服务端骨架的开发者。下面按需求、架构、模块实现、避坑、验收的顺序,把这份 Demo 拆成一份可以直接照着改的方案。
2. 需求与技术选型:为什么是 WebSocket 而不是轮询或长轮询
2.1 先把实时性需求量化,选型才有依据
在线游戏 Demo 的核心指标不是“能不能连上”,而是“状态多久同步一次、误差能不能接受”。通常的做法是先把需求写成可测的数字:房间内玩家操作到其他端可见的延迟控制在 100 毫秒以内;单房间支持 2~8 人;每秒广播频率 10~20 次;断线后 5 秒内能感知并清理。把这些数字摆出来,选型就不是拍脑袋了。
对比三种常见方案:短轮询实现最简单,但空请求占比高,延迟下限等于轮询间隔;长轮询靠服务器挂起请求改善延迟,可每次消息后都要重建连接,头部开销和连接管理成本都不低;WebSocket 一次握手升级后全双工,消息帧头只有 2~14 字节,天然适合高频小包。对游戏这种“双向、高频、小消息”的场景,WebSocket 是性价比最高的那档。血泪经验是:别为了省事用轮询硬扛,等到多人同屏时你会发现延迟抖动根本压不住。
2.2 技术栈与目录结构:一份能跑起来的最小骨架
这份 Demo 的定位是“可运行的教学骨架”,所以技术栈刻意保持轻量:服务端用 Node.js +ws库,前端用原生 JavaScript + Canvas,不引入重型框架,避免读者被构建工具劝退。目录按职责切分,方便后续替换任意一层:
game-demo/ ├── server/ │ ├── index.js # 入口:HTTP 服务 + WebSocket 升级 │ ├── room.js # 房间管理:创建、加入、离开、广播 │ └── player.js # 玩家状态:坐标、血量、连接引用 ├── client/ │ ├── index.html # 画布与入口 │ ├── net.js # 连接封装:重连、心跳、消息分发 │ └── game.js # 渲染循环与输入采集 └── shared/ └── protocol.js # 前后端共用的消息类型常量shared/protocol.js单独抽出来是关键设计:消息类型只定义一次,前后端require同一份,避免“服务端发move、前端监听moveTo”这种低级错位。我一般会在这里把消息类型写成常量对象,任何新增消息都先改这里,再改两端处理逻辑,形成约束。
2.3 消息协议设计:定长字段还是 JSON
Demo 阶段用 JSON 足够,可读性好、调试方便,缺点是体积比二进制大。协议设计上遵循“一条消息一个意图”的原则,典型消息如下:
// shared/protocol.js // 前后端共用的消息类型,新增消息必须先在这里登记 const MSG = { JOIN: 'join', // 客户端请求加入房间 JOINED: 'joined', // 服务端确认加入,回传玩家 id 与房间快照 INPUT: 'input', // 客户端上报操作(方向、动作) STATE: 'state', // 服务端广播权威状态 LEAVE: 'leave', // 离开房间 PING: 'ping', // 心跳 PONG: 'pong' }; module.exports = { MSG };这里有个设计取舍值得说清:客户端只上报“输入意图”(我按了左键),不上报“结果状态”(我在坐标 x=120)。状态由服务端计算后广播,这叫服务端权威。为什么?因为如果让客户端直接改坐标,改包或作弊就能瞬移,多人场景下必然翻车。Demo 虽小,但这个边界一开始就要划清,否则后面加校验会推倒重来。
3. 总体架构设计:连接层、房间层、状态层的职责划分
3.1 三层结构:一条消息从按键到别人屏幕的完整路径
整个系统分三层。连接层负责握手、心跳、断线检测,是“管道”;房间层负责玩家归属、加入离开、广播范围,是“调度”;状态层负责根据输入计算权威状态,是“大脑”。一条输入消息的路径是:客户端采集按键 →net.js封装成INPUT帧 → 服务端连接层收到 → 路由到对应房间 → 状态层更新该玩家状态 → 房间层向房内其他连接广播STATE→ 各客户端渲染。
这个分层的好处是每层可独立替换:想换传输层,只动连接层;想加观战模式,只动房间层的广播范围;想加物理碰撞,只动状态层。很多初学者把所有逻辑塞进connection.on('message')一个回调里,Demo 能跑,但一加功能就成一团乱麻,这是最常见的架构债。
3.2 房间管理的数据结构:为什么用 Map 而不是数组
房间和玩家的查找是高频操作,数据结构选错会直接拖慢广播。通常的做法是用两级Map:rooms: Map<roomId, Room>,Room.players: Map<playerId, Player>。Map的增删查接近 O(1),而数组查找是 O(n),房间人数一多差距就出来了。房间对象至少持有:房间 id、玩家集合、创建时间、最大人数。
// server/room.js class Room { constructor(id, maxPlayers = 8) { this.id = id; this.maxPlayers = maxPlayers; this.players = new Map(); // playerId -> Player this.createdAt = Date.now(); } // 加入房间:先校验容量,再落库,最后广播 join(player) { if (this.players.size >= this.maxPlayers) { return { ok: false, reason: 'room_full' }; } this.players.set(player.id, player); return { ok: true }; } // 广播时排除发送者自己,避免本地状态被回环覆盖 broadcast(senderId, payload) { const data = JSON.stringify(payload); for (const [id, p] of this.players) { if (id !== senderId && p.socket.readyState === 1) { p.socket.send(data); } } } }broadcast里两个细节是踩坑换来的:一是排除发送者,否则本地预测和服务器回包打架,画面会抖;二是检查readyState === 1(OPEN),断线但未清理的连接直接send会抛异常,把整个广播循环打断。参数maxPlayers默认 8,是 Demo 场景下的合理上限,改大要同步考虑广播频率和带宽。
3.3 心跳与断线检测:别等 TCP 自己告诉你
WebSocket 建立在 TCP 上,但 TCP 的半开连接可能几分钟都不报错,玩家早关页面了,服务端还占着房间名额。所以必须应用层心跳:服务端每 15 秒发PING,客户端回PONG,连续两次没回就判定掉线并清理。这个机制在 Demo 里不能省,否则你演示时关掉一个标签页,房间人数永远不减,答辩现场很尴尬。
// server/index.js 片段:心跳巡检 const HEARTBEAT_INTERVAL = 15000; setInterval(() => { for (const player of allPlayers.values()) { if (player.isAlive === false) { // 上一轮没收到 PONG,判定掉线 player.socket.terminate(); roomManager.removePlayer(player.id); continue; } player.isAlive = false; // 先置为待确认 player.socket.send(JSON.stringify({ type: MSG.PING })); } }, HEARTBEAT_INTERVAL);收到PONG时把isAlive置回true。参数 15000 毫秒是经验值:太短会误杀弱网玩家,太长掉线感知慢。演示环境可以调到 5000 加快反馈,生产环境一般 15~30 秒。
4. 核心模块实现:连接封装、输入同步与状态广播
4.1 客户端连接封装:重连、队列与消息分发
客户端最容易写崩的地方是“连接断了怎么办”。裸写new WebSocket()然后onmessage里堆逻辑,一旦断线整个游戏就卡死。合格的做法是封装一个Net类,负责三件事:自动重连(指数退避)、发送队列(断线期间的操作先缓存)、按消息类型分发到回调。
// client/net.js class Net { constructor(url) { this.url = url; this.handlers = new Map(); // msgType -> callback this.queue = []; // 断线期间待发消息 this.retry = 0; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.retry = 0; this.flush(); // 连上后补发缓存 }; this.ws.onmessage = (e) => { const msg = JSON.parse(e.data); const cb = this.handlers.get(msg.type); if (cb) cb(msg); }; this.ws.onclose = () => this.reconnect(); } reconnect() { // 指数退避:1s、2s、4s… 上限 10s,避免断网时疯狂重连 const delay = Math.min(1000 * 2 ** this.retry++, 10000); setTimeout(() => this.connect(), delay); } send(type, payload) { const data = JSON.stringify({ type, ...payload }); if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(data); } else { this.queue.push(data); // 未连接则入队 } } flush() { while (this.queue.length) this.ws.send(this.queue.shift()); } on(type, cb) { this.handlers.set(type, cb); } }指数退避的2 ** retry是核心:断网瞬间如果每 100 毫秒重连一次,浏览器和服务端都会被拖垮。上限 10 秒防止无限增长。flush在重连成功后补发缓存,但要注意:位置类消息补发意义不大(旧位置已过时),通常只补发“加入房间”这类关键指令,其余直接丢弃。这个取舍要在send里按消息类型区分,别一股脑全补。
4.2 输入采集与发送节流:别让每次按键都发一包
键盘事件触发频率远高于渲染帧率,按住方向键时keydown会连发。如果每个事件都send,带宽和服务器压力都会飙升。通常的做法是:输入只记录到本地状态,在渲染循环里按固定频率(比如每秒 20 次)打包发送当前输入向量。
// client/game.js 片段:输入采集与节流发送 const input = { up: false, down: false, left: false, right: false }; const SEND_INTERVAL = 50; // 20 次/秒 let lastSend = 0; function loop(now) { if (now - lastSend >= SEND_INTERVAL) { net.send(MSG.INPUT, { input }); lastSend = now; } render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);SEND_INTERVAL = 50对应 20Hz,是 Demo 里延迟和带宽的平衡点。调到 16(约 60Hz)更跟手但流量翻三倍;调到 100 省流量但操作发飘。这个参数是调优时第一个要动的旋钮,建议在验收时专门测三档对比。
4.3 服务端权威状态计算与广播
服务端收到INPUT后,根据输入向量更新该玩家坐标,做边界裁剪,然后向房内广播。这里的关键是“服务器说了算”:客户端本地可以先做预测渲染(让操作看起来即时),但最终以服务器回包为准,回包到达时平滑校正。
// server/index.js 片段:处理输入并广播 socket.on('message', (raw) => { const msg = JSON.parse(raw); if (msg.type === MSG.INPUT) { const player = playerManager.get(socket.id); if (!player) return; // 服务端权威计算:速度 * 时间步长,限制在画布内 const SPEED = 4; if (msg.input.left) player.x = Math.max(0, player.x - SPEED); if (msg.input.right) player.x = Math.min(WIDTH, player.x + SPEED); if (msg.input.up) player.y = Math.max(0, player.y - SPEED); if (msg.input.down) player.y = Math.min(HEIGHT, player.y + SPEED); player.room.broadcast(socket.id, { type: MSG.STATE, players: serializeRoom(player.room) // 只发必要字段 }); } });SPEED = 4是每帧位移像素,配合 20Hz 输入频率,实际速度约 80 像素/秒。serializeRoom要只挑id、x、y、hp这类渲染必需字段,别把整个玩家对象(含 socket 引用)序列化出去——既浪费带宽,又可能把内部结构泄露给客户端,这是安全上的低级错误。
4.4 本地预测与平滑校正:让操作“跟手”又不飘
纯等服务端回包,操作会有 50~100 毫秒的滞后感。Demo 里加一层轻量预测:本地按键立即移动本地渲染坐标,收到服务器STATE后,如果偏差小于阈值就直接对齐,偏差大就用插值在几帧内平滑过去。阈值一般设 8~16 像素,太小会频繁校正导致抖动,太大则作弊空间变大。这块是体验分水岭,但别过度设计,Demo 阶段插值用线性即可。
5. 避坑与排查:这类实时 Demo 最容易翻车的五个点
5.1 现象:本地双开正常,三人以上就不同步
原因通常是广播时把发送者也包含进去了,或者状态序列化时漏了某个玩家字段,导致各端看到的玩家集合不一致。解决:广播统一走Room.broadcast并排除发送者;序列化函数单独写单测,断言输出字段齐全。排查时在服务端打印每次广播的玩家 id 列表,和客户端收到的对比,一眼就能定位。
5.2 现象:演示几分钟后连接莫名断开
多半是心跳缺失或代理层超时。WebSocket 经过某些中间层时,长时间无数据会被判定空闲而切断。解决:应用层心跳必须实现,且间隔小于中间层的空闲超时(通常 60 秒,所以 15~30 秒心跳是安全的)。另外检查PING是否真的发出去了——有同学把心跳写在setInterval里但忘了send,等于没做。
5.3 现象:断线重连后玩家“分身”
原因是旧连接没被清理,新连接又注册了一个玩家,房间里出现两个同 id 或同名的实体。解决:重连时客户端带上原playerId,服务端在JOIN处理里先按 id 查找并踢掉旧连接,再绑定新 socket。清理逻辑要幂等,重复调用不报错。
5.4 现象:消息偶发解析失败,服务端抛异常
JSON.parse遇到半包或非法数据会直接抛错,未捕获就会让整个进程挂掉。解决:message回调外层包try/catch,解析失败记日志并丢弃该帧,不要中断服务。同时校验msg.type是否在白名单内,防止未知消息进入业务分支。
5.5 现象:画面越玩越卡,内存持续上涨
常见于广播时每帧JSON.stringify整个房间,或者事件监听器只加不减。解决:序列化结果在状态未变时可复用;客户端on注册的回调在页面卸载时清理;服务端定期巡检rooms,空房间及时删除,别让Map无限增长。
6. 验收与答辩:怎么证明这套 Demo 真的“实时”
验收不是“能跑就行”,要拿数据说话。我一般按三步走:功能闭环、指标测量、异常演练。功能闭环指两人加入同一房间,A 移动 B 能看到,B 离开 A 能看到人数变化;指标测量用浏览器开发者工具的 Network 面板看 WS 帧频率是否稳定在 20Hz 左右,用两端打时间戳估算端到端延迟;异常演练包括拔网线重连、关标签页后房间人数是否在心跳周期内减少、发送畸形消息服务端是否存活。
答辩时最容易被问的三个问题,提前准备好答案:为什么用 WebSocket 不用轮询(答延迟和开销的量化对比);服务端权威怎么防作弊(答客户端只报输入、状态由服务端算);断线怎么处理(答心跳加指数退避重连)。把这三条讲清楚,比演示花哨特效更有说服力。
| 验收项 | 合格标准 | 测量方法 |
|---|---|---|
| 连接建立 | 1 秒内完成握手 | Network 面板看 WS 建立耗时 |
| 状态同步频率 | 稳定 18~22 帧/秒 | 统计 10 秒内 STATE 帧数 |
| 端到端延迟 | 局域网内 < 100ms | 两端时间戳差值 |
| 断线感知 | 30 秒内房间人数更新 | 关页面后观察服务端日志 |
| 异常健壮性 | 畸形消息不导致进程退出 | 手动发送非法 JSON |
从那以后我每次交付这类实时 Demo,都会强制走一遍“拔网线 + 关页面 + 发脏数据”三连测,因为翻车从来不在正常路径上。希望这套结构和验收清单能帮到你,照着改就能交出一份站得住的方案。
本文还有配套的精品资源,点击获取