ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小游戏斗地主联机服务器实战:Node.js 房间管理与断线重连

微信小游戏斗地主联机服务器实战:Node.js 房间管理与断线重连 简介这是一套面向微信小游戏开发者与Node.js后端学习者的斗地主项目源码适合想打通小游戏前端与实时服务端通信的初中级开发者参考。压缩包共253个文件约5.95MB以162个js脚本为核心配合60张jpg图片资源、9个xml配置、8个json数据文件及3个proto协议文件另有pem、crt证书与md说明文档覆盖客户端逻辑、静态素材与服务器通信协议等模块。项目后端基于Node.js搭建承担登录验证、游戏状态同步、玩家交互与数据存储等职责并借助WebSocket实现实时双向通信前端则依托HTML5与JavaScript完成界面与出牌交互。目录结构包含package.json依赖声明、服务器入口文件、models游戏逻辑、routes路由、controllers控制器、public静态资源与config配置等典型分层便于理解完整工程组织方式。目前已有427人学习下载可作为研究微信小游戏斗地主架构、实时通信与游戏逻辑设计的实践样本。1. 微信小游戏斗地主 Node.js 服务器一套能跑起来的联机骨架长什么样微信小游戏里做斗地主难点从来不在牌型判断而在「三个人怎么坐在同一张桌子上」。单机版斗地主随便找个算法就能跑可一旦要联机房间怎么建、出牌怎么同步、掉线怎么兜底全是服务器端的事。这套nodejs-server-wechat-landLordGame方案要解决的正是这个问题用 Node.js 起一个轻量服务器把微信小游戏客户端和三个玩家的牌局状态串起来。它适合两类人——手里已经有小游戏前端、缺一个能跑的服务端骨架的开发者以及想拿斗地主当练手项目、把 Node.js 网络编程和状态同步一次搞明白的人。读完你应该能自己搭出房间管理、出牌广播、断线重连这三块核心逻辑而不是停在「知道要做服务器」这一步。斗地主这个场景对服务器有个天然要求它是回合制、强状态、低并发的。一桌就三个人同时在线几千桌也就万级连接用不着上重型架构Node.js 的事件循环加 WebSocket 恰好够用。但它的状态又很「粘」——谁手里剩什么牌、轮到谁、上一手是谁出的这些必须由服务器说了算客户端只能提交意图。想清楚这一点后面所有代码才有落脚点。下面按「先立骨架、再填逻辑、最后避坑」的顺序往下走。2. 房间与牌局状态服务器到底该管哪些数据2.1 为什么状态必须放在服务器斗地主最容易翻车的地方是让客户端自己算牌。新手常犯的错是每个客户端本地维护一份牌堆出牌时各自扣牌靠广播对齐。听起来省事实际上一旦有人网络抖动、消息乱序三份牌堆立刻对不上出现「我手里有这张牌别人也打出来了」的玄学现象。正确做法是服务器持有唯一权威状态客户端只做两件事把玩家的操作意图发上去把服务器下发的状态渲染出来。服务器要维护的状态分三层。第一层是连接层socketId 到 userId 的映射谁在线、谁断了。第二层是房间层房间号、三个座位、房主是谁、当前阶段等待/发牌/出牌/结算。第三层是牌局层三家手牌、底牌、当前出牌方、上一手牌型、出牌记录。这三层分开存好处是掉线重连时只动连接层牌局层原封不动玩家回来还能接着打。2.2 用一张表把房间对象定下来在写代码前先把房间的数据结构用表格固定避免边写边改导致字段满天飞。字段类型含义备注roomIdstring房间唯一标识6 位数字便于口头分享seatsarray三个座位每项含 userId、socketId、readyphasestring当前阶段waiting/dealing/playing/settlinghandsobject三家手牌key 为 userIdvalue 为牌数组bottomCardsarray三张底牌叫地主后归属地主currentTurnstring当前出牌方 userId出牌后立即更新lastPlayobject上一手牌含 userId、cards、typepassCountnumber连续过牌次数到 2 时重置回合这张表就是服务器的「黑匣子」任何时刻 dump 出来都能还原牌局。字段命名尽量直白别用缩写三个月后回来看还能秒懂。2.3 最小可跑的服务器骨架下面这段是房间管理和连接建立的最小实现用ws库起 WebSocket 服务。先跑通「连上、进房、坐下」这条链路再往上叠牌局逻辑。// server.js —— 最小房间骨架 const WebSocket require(ws); const wss new WebSocket.Server({ port: 3000 }); // 全局房间表roomId - room 对象 const rooms new Map(); // 连接表socket - { userId, roomId } const clients new Map(); function createRoom(roomId) { return { roomId, seats: [], // 最多 3 个 { userId, socket, ready } phase: waiting, hands: {}, bottomCards: [], currentTurn: null, lastPlay: null, passCount: 0, }; } wss.on(connection, (socket) { clients.set(socket, { userId: null, roomId: null }); socket.on(message, (raw) { let msg; try { msg JSON.parse(raw); } catch (e) { return socket.send(JSON.stringify({ type: error, msg: bad json })); } // 进房没有就建有就坐 if (msg.type join) { const { roomId, userId } msg; let room rooms.get(roomId); if (!room) { room createRoom(roomId); rooms.set(roomId, room); } if (room.seats.length 3) { return socket.send(JSON.stringify({ type: error, msg: room full })); } room.seats.push({ userId, socket, ready: false }); clients.set(socket, { userId, roomId }); // 广播当前座位情况 broadcast(room, { type: seats, seats: room.seats.map(s s.userId) }); } }); socket.on(close, () { const info clients.get(socket); if (info info.roomId) { const room rooms.get(info.roomId); if (room) { room.seats room.seats.filter(s s.socket ! socket); broadcast(room, { type: seats, seats: room.seats.map(s s.userId) }); } } clients.delete(socket); }); }); function broadcast(room, payload) { const data JSON.stringify(payload); room.seats.forEach(s { if (s.socket.readyState WebSocket.OPEN) s.socket.send(data); }); }逻辑说明rooms用 Map 存所有房间clients反向记录每个 socket 属于谁这样断线时能快速定位房间。join消息做了三件事——查房、建房、占座占满三人就拒绝。broadcast只发给房间里还在线的座位避免往已断开的 socket 写数据报错。参数说明端口 3000 可改生产环境建议走环境变量roomId由客户端生成或服务器分配都行这里交给客户端传userId必须由微信登录态换来的 openid 派生不能信客户端随便传的字符串否则谁都能冒充别人。这一步的鉴权后面第 4 章会展开。3. 发牌、叫地主、出牌把规则翻译成消息协议3.1 消息协议先定代码才不会乱联机项目最怕的就是「想到哪写到哪」消息类型东一个西一个。开工前先把协议列成表客户端和服务器照着同一份表实现联调时能省一半时间。消息 type方向关键字段作用joinC→SroomId, userId进房占座readyC→S-准备dealS→Chands, bottomCards发牌结果手牌只发本人callScoreC→Sscore叫分/叫地主landlordS→CuserId, bottomCards通知地主归属playC→Scards出牌passC→S-过牌playedS→CuserId, cards, type广播出牌结果turnS→CuserId通知轮到谁settleS→Cresult结算注意deal这条手牌是私密信息绝不能广播。服务器要给每个座位单独发一份只带他自己的牌。这是新手最容易漏的安全点一旦广播抓包就能看到别人的牌。3.2 发牌与叫地主的最小实现发牌逻辑本身简单54 张牌洗牌后每人 17 张留 3 张底牌。关键是发完之后要进入叫地主阶段并且把「轮到谁叫」也纳入状态机。// 洗牌 发牌 function buildDeck() { const suits [♠, ♥, ♣, ♦]; const ranks [3,4,5,6,7,8,9,10,J,Q,K,A,2]; const deck []; for (const s of suits) for (const r of ranks) deck.push(s r); deck.push(小王, 大王); return deck; } function shuffle(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; } function dealCards(room) { const deck shuffle(buildDeck()); room.hands {}; room.seats.forEach((seat, idx) { room.hands[seat.userId] deck.slice(idx * 17, idx * 17 17); }); room.bottomCards deck.slice(51); // 最后 3 张 room.phase calling; room.currentTurn room.seats[0].userId; // 从第一个座位开始叫 // 私密下发每人只收到自己的手牌 room.seats.forEach(seat { seat.socket.send(JSON.stringify({ type: deal, hand: room.hands[seat.userId], bottomCount: 3, })); }); broadcast(room, { type: turn, userId: room.currentTurn, action: call }); }逻辑说明buildDeck生成 54 张牌用字符串表示牌面方便调试时直接看。shuffle是标准 Fisher-Yates 洗牌别用sort(() Math.random() - 0.5)那个分布不均匀老手能感觉出来牌型偏。发牌时按座位切片每人 17 张最后 3 张留底。下发环节用seat.socket.send逐个发而不是broadcast这是保证手牌私密的关键。参数说明牌面用「花色点数」字符串比用数字编码可读性好代价是稍微占带宽斗地主这点数据量无所谓。currentTurn初始设为第一个座位实际项目里应该随机或按进房顺序避免固定座位总是先叫。3.3 出牌校验服务器必须自己判一遍客户端可以给出「这手牌能不能出」的提示但服务器必须独立校验不能信客户端说「我出的是对子」。校验分两步先看牌型是否合法再看是否大得过上一手。// 简化版牌型识别返回 { type, value } 或 null function parseCards(cards) { if (!cards || cards.length 0) return null; const rankOrder [3,4,5,6,7,8,9,10,J,Q,K,A,2,小王,大王]; const ranks cards.map(c c.replace(/[♠♥♣♦]/, )); const counts {}; ranks.forEach(r counts[r] (counts[r] || 0) 1); const values Object.values(counts).sort((a, b) b - a); if (cards.length 1) return { type: single, value: rankOrder.indexOf(ranks[0]) }; if (cards.length 2 ranks[0] ranks[1]) return { type: pair, value: rankOrder.indexOf(ranks[0]) }; if (cards.length 3 values[0] 3) return { type: triple, value: rankOrder.indexOf(ranks[0]) }; if (cards.length 4 values[0] 4) return { type: bomb, value: rankOrder.indexOf(ranks[0]) }; if (cards.length 2 ranks.includes(小王) ranks.includes(大王)) return { type: rocket, value: 999 }; // 顺子、连对、飞机等省略按同样思路扩展 return null; } // 判断能否压过上一手 function canBeat(play, lastPlay) { if (!lastPlay) return true; // 首出随便出 if (play.type rocket) return true; // 王炸最大 if (play.type bomb lastPlay.type ! bomb) return true; if (play.type ! lastPlay.type) return false; return play.value lastPlay.value; }逻辑说明parseCards先把牌面拆成点数统计每个点数出现几次再按张数和重复情况判断牌型。这里只实现了单张、对子、三张、炸弹、王炸顺子和飞机按同样套路扩展即可——核心是「先归类、再比较」。canBeat处理压制关系首出无限制王炸通吃炸弹压非炸弹同类型比大小。参数说明rankOrder决定大小顺序注意 2 比 A 大、小王大王最大这个数组顺序不能错错了整个比大小全乱。value用indexOf得到顺子比较时要比对首尾不能只比单张这是扩展时容易踩的坑。4. 微信登录态与断线重连联机稳不稳就看这两块4.1 用 code 换 openid别信客户端传的 userId微信小游戏登录的标准流程是客户端调wx.login()拿到临时 code把 code 发给服务器服务器拿 code 去微信接口换 openid 和 session_key。openid 才是可信身份客户端自己传的 userId 一律不认。// 服务器侧用 code 换 openid示意需替换为实际请求 const axios require(axios); async function code2openid(code) { const appid process.env.WX_APPID; const secret process.env.WX_SECRET; const url https://api.weixin.qq.com/sns/jscode2session; const res await axios.get(url, { params: { appid, secret, js_code: code, grant_type: authorization_code }, }); if (res.data.errcode) throw new Error(res.data.errmsg); return res.data.openid; // 用这个作为 userId }逻辑说明appid和secret必须放服务器环境变量绝不能写进小游戏前端代码否则被人扒出来就能冒充你的小程序。换到的 openid 作为玩家唯一标识后续所有房间、牌局都绑这个 id。参数说明grant_type固定为authorization_codecode只能用一次且五分钟过期所以客户端每次登录都要重新wx.login()不能缓存 code。生产环境要把 openid 存库配合自建 token 做会话保持避免每次都请求微信接口。4.2 断线重连把状态补回去斗地主最影响体验的就是掉线。玩家切个后台、信号抖一下回来发现牌局没了直接退游。正确做法是连接断开时不销毁牌局只标记该座位离线保留一段时间玩家重连后凭 userId 找回房间服务器把当前手牌和牌局状态重新下发。// 重连处理凭 userId 找回房间并补发状态 socket.on(message, (raw) { const msg JSON.parse(raw); if (msg.type reconnect) { const { userId } msg; // 遍历房间找这个玩家 for (const room of rooms.values()) { const seat room.seats.find(s s.userId userId); if (seat) { seat.socket socket; // 换上新连接 seat.online true; clients.set(socket, { userId, roomId: room.roomId }); // 补发自己的手牌 当前轮到谁 上一手牌 socket.send(JSON.stringify({ type: resume, hand: room.hands[userId], currentTurn: room.currentTurn, lastPlay: room.lastPlay, phase: room.phase, })); broadcast(room, { type: online, userId }); return; } } socket.send(JSON.stringify({ type: error, msg: no room found })); } });逻辑说明重连的核心是「以 userId 为准替换 socket」。座位对象里的socket换成新连接牌局数据hands、currentTurn、lastPlay原样保留然后一次性补发给客户端。客户端收到resume后直接重建界面玩家感觉不到掉过线。参数说明实际项目里要给离线座位加超时比如 60 秒内没重连就判定逃跑触发托管或结算。online字段用于 UI 显示「对方掉线中」。注意重连时不要重新发牌否则牌局就废了。4.3 心跳保活别让连接悄悄死掉移动网络下 WebSocket 经常被中间设备静默断开客户端和服务器都以为还连着。加心跳是标配客户端定时发 ping服务器回 pong超过两三个周期没收到就主动断开并标记离线。// 服务器侧心跳检测 const HEARTBEAT_INTERVAL 30000; setInterval(() { wss.clients.forEach(socket { if (socket.isAlive false) return socket.terminate(); socket.isAlive false; socket.ping(); }); }, HEARTBEAT_INTERVAL); wss.on(connection, (socket) { socket.isAlive true; socket.on(pong, () { socket.isAlive true; }); });逻辑说明每 30 秒一轮先把isAlive置 false 再发 ping收到 pong 就置回 true。下一轮如果还是 false说明这个连接已经死了直接terminate。这是ws官方推荐的保活写法比自己造轮子可靠。参数说明30 秒是移动端的经验值太短费电、太长发现不了断线。客户端侧也要配合收到 ping 自动回 pongws客户端库默认支持业务层可以再加一层自定义心跳用于检测应用层卡死。5. 避坑与排查这些坑我基本都踩过5.1 现象三个人进房第四个人也能坐进来原因座位数判断用了room.seats.length 3但断线玩家没从seats里移除导致「幽灵座位」占位或者并发进房时两个请求同时通过判断。解决进房逻辑加锁或用原子判断断线时把座位标记online: false而不是直接删除重连超时后再真正移除。判断满员时只算online或保留中的座位。5.2 现象出牌后别人看到的牌和出牌人不一致原因客户端本地先扣牌再发消息服务器广播时又扣了一次或者消息乱序导致played先于play到达。解决客户端出牌只发意图不本地扣牌等服务器played广播回来再更新界面。所有状态变更以服务器广播为准客户端做纯渲染。5.3 现象npm 报「无法加载文件 npm.ps1因为在此系统上禁止运行脚本」原因Windows PowerShell 默认执行策略限制跟项目本身无关但新手装环境时十有八九撞上。解决用管理员身份打开 PowerShell执行Set-ExecutionPolicy RemoteSigned或者干脆改用 CMD 或 Git Bash 跑 npm 命令。这个坑和斗地主没关系但卡在这一步的人特别多先过了这关再谈联机。5.4 现象本地能连部署到服务器后小游戏连不上原因微信小游戏要求所有网络请求走 HTTPS/WSS且域名要在小程序后台配置为合法域名。本地ws://localhost:3000能跑线上必须wss://加备案域名。解决服务器上配 Nginx 反代 WebSocket套上证书把wss://yourdomain.com/game配进小游戏后台的 socket 合法域名。IP 直连和自签证书都不行。5.5 现象牌局跑着跑着服务器内存一直涨原因房间结算后没从rooms里删clients里断开的 socket 没清理长时间运行内存泄漏。解决结算后延迟几分钟删除房间close事件里务必clients.delete(socket)并定期扫描空房间。上线前用process.memoryUsage()观察或者挂个简单的内存监控。6. 把牌型判断抽成独立模块顺手加个回放写到后面你会发现parseCards和canBeat这两块逻辑会越来越胖——顺子、连对、飞机、四带二全塞在服务器主文件里改一处怕碰坏另一处。我的习惯是第一天就把牌型判断抽成独立模块rules.js只暴露两个函数parse(cards)和compare(a, b)服务器只管调用不关心内部怎么判。这样单测好写客户端也能复用同一份规则做提示前后端逻辑天然一致省掉「客户端说能出、服务器说不能」的扯皮。// rules.js —— 独立牌型模块前后端可共用 const RANK_ORDER [3,4,5,6,7,8,9,10,J,Q,K,A,2,小王,大王]; function parse(cards) { // 返回 { type, value, length }无法识别返回 null // 顺子、连对、飞机在这里统一处理 } function compare(a, b) { // a 能否压过 b返回 boolean // 王炸 炸弹 同类型比 value } module.exports { parse, compare };抽出来之后加一个「回放」功能几乎是顺手的事。因为服务器每一步都记了lastPlay和出牌人只要把整局的played消息按顺序存进数组结算时一起下发客户端就能逐帧重放。回放对调试帮助极大——线上有人反馈「这局有问题」把回放数据拉出来一看是牌型判错还是消息丢了一目了然。我一般会在房间对象里加一个history: []每次出牌 push 一条{ userId, cards, ts }成本极低收益很高。验证方法也简单写一组固定牌型的用例跑parse和compare覆盖单张、对子、三张、炸弹、王炸、顺子边界比如 A 能不能接 K、2 能不能进顺子。斗地主的顺子规则里 2 和王不能参与这个边界最容易写错测试用例里必须钉死。跑通这组用例牌型这块基本就不会在线上翻车了。最后说个习惯这套骨架我建议先在本地用三个浏览器标签页模拟三个玩家跑通再上真机。真机调试时把服务器日志打到文件里别只靠 console微信开发者工具的网络面板看不到 WebSocket 的帧内容服务器日志才是你唯一的后悔药。联机项目 80% 的问题都能靠「服务器状态 dump 消息日志」定位剩下 20% 才是网络本身。希望帮到你。本文还有配套的精品资源点击获取
返回列表