ARTICLE DETAIL

资讯详情

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

微信小游戏斗地主Node.js服务器实战:从WebSocket到牌型校验

微信小游戏斗地主Node.js服务器实战:从WebSocket到牌型校验 简介一份微信小游戏斗地主的完整前后端项目源码包后端采用Node.js实现适合希望学习微信小游戏开发、Node.js实时服务器与WebSocket通信的开发者参考。资源共253个文件约5.95MB其中162个JS文件对应游戏逻辑与服务器代码60张JPG图片为牌面及界面素材另含XML、JSON、Proto等配置与通信协议文件并附带证书文件用于HTTPS/SSL连接目录结构涵盖服务器入口、数据模型、路由、静态资源等模块便于按层阅读。已有427人学习下载。通过该资源可直观掌握斗地主的游戏规则实现、多玩家状态同步、随机发牌与出牌校验等后端设计思路同时看到Node.js项目从配置到部署的完整形态对想独立完成微信小游戏前后端联调的开发者是一份不错的实战范例。1. 微信小游戏斗地主配 Node.js 服务器这套开源包到底能干什么微信小游戏斗地主这个项目真正的重心不在牌桌渲染而在联机房间那一层。客户端用微信小游戏的画布把扑克牌、计分、操作按钮画出来背后所有玩家入场、发牌、叫地主、出牌校验、胜负结算都交给 nodejs 服务器统一裁决这种“服务器权威”架构能省掉大量的设备同步问题。这个包把整套服务器代码和客户端入口放在一起你把 Node 服务起起来再用微信开发者工具打开小游戏工程就能在本地玩上三人斗地主。它适合三种人正在做微信小游戏联机需求的前端想学 WebSocket 游戏通信的后端以及拿斗地主当牌型算法练习素材的测试同学。2. 先把环境按对Node.js 安装、环境变量与项目目录拆解2.1 Node.js 安装与环境变量为什么 npm 命令会先翻车我见过不下二十个同事在这套包上第一步就卡住报错信息长这样npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这不是包的问题是 PowerShell 默认执行策略把.ps1脚本挡了。最省事的做法是直接用命令提示符cmd跑 npm而不是 PowerShell如果你偏好 PowerShell那就改一下执行策略。# 以管理员身份打开 PowerShell执行一次即可 Set-ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的意思是本机脚本允许跑从网上下载的脚本需要数字签名这样既解决了 npm 的 ps1 报错又不会把安全策略全部放开。改完以后重新开一个 PowerShell 窗口npm -v就正常了。Node.js 安装本身没什么玄学去官网下 LTS 版本一路默认安装注意安装路径里不要带中文和空格否则后面原生模块编译时会遇到路径解析问题。装完以后在 cmd 里验证两条命令node -v npm -v如果你看到版本号正常输出接着设置镜像源国内直连官方源经常超时这不是包的问题是网络路径的问题。执行一次下面的命令全局替换成 npmmirror 镜像后面npm install会快得多。npm config set registry https://registry.npmmirror.com npm config get registry镜像源只影响下载速度不改变包的语义。你在团队内部如果用私有源就跳过这一步不要两种源混着用容易出现 lock 文件校验失败。顺便说一句环境变量在 Windows 上安装 Node 时会自动配好PATH并不需要手动加除非你是免安装版。手工改环境变量容易把系统 PATH 改坏所以能用安装包就别折腾。2.2 项目目录拆解服务器端和客户端到底怎么分工一个典型的wechat-landLordGame.zip解压之后结构通常是这样的我按常见做法给你标注每一层的作用wechat-landLordGame/ ├── client/ # 微信小游戏客户端工程 │ ├── game.js # 小游戏入口注册场景与主循环 │ ├── game.json # 小游戏配置包含设备方向、网络超时 │ ├── js/ │ │ ├── net.js # WebSocket 封装连接服务器、收发消息 │ │ ├── render.js # 牌桌渲染画牌、动画、按钮 │ │ └── control.js # 玩家操作映射点牌、出牌、叫分 │ └── images/ # 牌面、背景图小游戏没有 DOM全是 drawImage ├── server/ # Node.js 服务器端 │ ├── package.json # 依赖声明ws / express / uuid 之类 │ ├── server.js # 入口创建 HTTP WebSocket 服务 │ ├── room.js # 房间管理创建房间、加入、解散 │ ├── game.js # 斗地主核心逻辑发牌、牌型判断、胜负 │ └── config.js # 端口、token 过期时间、心跳参数 └── README.md # 启动说明客户端和服务器的边界很清楚客户端只做两件事——把用户的操作上报给服务器把服务器的状态渲染成画面。所有关于“这手牌能不能出”“该谁叫地主”“这局谁赢了”的判断都在server/game.js里。这样的好处是防作弊也方便以后出机器人 AI——AI 和玩家的输入走同一条出牌校验通道。game.json里那个networkTimeout值得注意微信小游戏默认网络超时很短连接本地服务器时经常报timeout。我一般会把networkTimeout调到 10 秒再把deviceOrientation设成portrait斗地主竖屏比较自然。2.3 依赖与启动脚本npm install 之后先跑通 server.js打开server/package.json核心依赖一般不会超过这几个ws做原生 WebSocketexpress提供登录换 openid 的 HTTP 接口uuid生成房间号与玩家 ID。先安装再启动cd server npm install node server.js如果你用的是 Node 18 以上ws和express都有兼容版本安装过程不应该出现编译报错。启动后控制台会打印监听端口常见是 3000 或 8080具体以config.js为准。看到一行类似WebSocket server started on port 8080的输出说明服务器已经活着。提示本地调试时微信开发者工具里要勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”否则连不上本地服务器这个问题我在第 5 章详细说。我一般会用nodemon而不是node来启服务改一行逻辑不用手动重启npm install -g nodemon nodemon server.jsnodemon监听文件变化自动重启开发阶段效率高很多。唯一要注意的是它也依赖 Node 环境别在低版本 Node 上装最新版装不上就退回node server.js不丢人。3. 斗地主服务器核心从发牌到出牌的一套确定性校验3.1 牌型解析与比较先把“一手牌”抽象成 type key斗地主服务器端最容易被低估的活儿是牌型判断。单张、对子、三带一、顺子、连对、飞机、炸弹、火箭每一种都要能被同一套函数识别并且能比较大小。我习惯把一手牌抽象成两个字段type表示牌型key表示比较用的主键。比如单张3的 type 是SINGLEkey 是3顺子3-4-5-6-7的 type 是STRAIGHTkey 是最大那张7。炸弹比任何非炸弹都大火箭又压炸弹比较逻辑单独处理。// server/game.js —— 牌型判断核心 const CARD_RANK [3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A, 2, JOKER]; function getHandType(cards) { cards.sort((a, b) CARD_RANK.indexOf(a.rank) - CARD_RANK.indexOf(b.rank)); const len cards.length; const rankCount {}; cards.forEach(c { rankCount[c.rank] (rankCount[c.rank] || 0) 1; }); const values Object.values(rankCount).sort((a, b) a - b); const ranks Object.keys(rankCount).sort((a, b) CARD_RANK.indexOf(a) - CARD_RANK.indexOf(b)); const isJokerPair ranks.length 2 ranks.includes(JOKER); if (len 1) return { type: SINGLE, key: CARD_RANK.indexOf(ranks[0]) }; if (len 2) { if (isJokerPair) return { type: ROCKET, key: 99 }; if (values[0] 2) return { type: PAIR, key: CARD_RANK.indexOf(ranks[0]) }; return null; } if (len 3 values[0] 3) return { type: TRIPLE, key: CARD_RANK.indexOf(ranks[0]) }; if (len 4 values[0] 3 values[1] 1) { return { type: TRIPLE_ONE, key: CARD_RANK.indexOf(ranks.find(r rankCount[r] 3)) }; } if (len 4 values[0] 4) return { type: BOMB, key: CARD_RANK.indexOf(ranks[0]) }; // 顺子5 张起单牌连续2 和王不能进顺 if (len 5 values.every(v v 1) !ranks.includes(2) !ranks.includes(JOKER)) { if (CARD_RANK.indexOf(ranks[len - 1]) - CARD_RANK.indexOf(ranks[0]) len - 1) { return { type: STRAIGHT, key: CARD_RANK.indexOf(ranks[len - 1]) }; } } return null; }getHandType返回null表示这不是一个合法牌型比如两张不同单牌、四张牌里出现 211 但三张不是一个 rank直接判非法。注意我故意没有把顺子、连对、飞机全部枚举出来而是用values和连续 rank 判断去覆盖这样以后改规则只动CARD_RANK顺序就行。key统一用CARD_RANK的下标比较大小就变成数字比大小不用再处理字母 J/Q/K/A 和10的字符串比较。3.2 发牌与叫地主随机打散 叫分时间窗斗地主的随机性来自两部分洗牌算法和叫地主顺序。洗牌用 Fisher-Yates 而不是sort(() Math.random() - 0.5)原因很简单——sort的随机比较器在 V8 里并不是均匀分布偏置会直接体现在牌型分布上玩家玩几局就能感觉到“怎么总是摸到连牌”。Fisher-Yates 是教科书级的均匀洗牌代码不长但负面效果最少。function shuffle(deck) { for (let i deck.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [deck[i], deck[j]] [deck[j], deck[i]]; } return deck; } function deal() { const deck shuffle(buildDeck()); // buildDeck 生成 54 张牌包含大小王 const hands [ deck.slice(0, 17), deck.slice(17, 34), deck.slice(34, 51) ]; const bottom deck.slice(51, 54); // 3 张底牌 return { hands, bottom }; }发牌之后把hands发给三个客户端bottom先隐藏等叫地主结束才翻给所有人。叫地主的玩法不唯一这个项目里我建议用“叫分制”每个玩家按顺序叫 1 分、2 分、3 分或者不叫叫分最高的人成为地主拿到底牌如果三个人都不叫这局流掉重新发牌。服务器要做的不是等玩家输入而是开一个叫分时间窗超时默认为不叫这样不会因为某人挂机卡死整局。时间窗我一般设 15 秒客户端在 15 秒内点按钮上报分数服务器收集后广播结果。这里有个边界要注意如果某玩家断线重连发生在叫分窗口内他的窗口不应该重新开启而是沿用断线前剩余时间。把这套逻辑放在 room 状态机里而不是用setTimeout裸计时否则断线重连会瞬间出现两个并存的计时器。3.3 服务器权威出牌校验为什么不能让客户端说了算有的同学为了省事把“这手牌能不能出”的判断放在客户端服务器只转发。这在局域网试玩没问题一旦上线就会被外挂打穿——抓包改一条消息就能出三张牌。这个项目的正确姿势是客户端上报要出的牌服务器重新查一遍牌库确认这些牌真的在玩家手里、牌型合法、并且压过了上家然后才更新局面并广播。function validatePlay(player, cards, lastPlay) { if (!player.cards.includes(cards)) return { ok: false, reason: 牌不在手牌中 }; const hand getHandType(cards); if (!hand) return { ok: false, reason: 非法牌型 }; if (lastPlay !canBeat(hand, lastPlay)) return { ok: false, reason: 管不上上家 }; return { ok: true, hand }; } function canBeat(hand, lastHand) { if (hand.type ROCKET) return true; if (hand.type BOMB lastHand.type ! BOMB) return true; if (hand.type BOMB lastHand.type BOMB) return hand.key lastHand.key; if (hand.type ! lastHand.type) return false; return hand.key lastHand.key; }validatePlay接收玩家对象、要出的牌数组、上家的牌型对象返回一个统一的结果。客户端不会知道canBeat内部用的是key下标比较它只收到{ok:true}或者{ok:false, reason:管不上上家}。这种设计的额外好处是三层校验互通玩家、机器人、测试脚本走的是同一套接口后续做自动化测试很方便。服务器把每个玩家的剩余手牌数存在 room 里每当有人ok出牌就把player.cards减去对应牌张广播新的手牌数。谁先打到 0 谁赢如果出的是最后一手牌直接结算。这里有个细节最后一张牌不能走validatePlay里的“压过上家”逻辑吗当然要走规则是“必须压过上一手”管上才能出管不上就得过。哪怕你只剩一张也得能管上才能走完。4. 微信小游戏接入登录换 openidWebSocket 建连与消息协议4.1 wx.login 拿 code服务器 code2session 换 openid微信小游戏不能像网页一样直接拿用户信息它是先调wx.login()拿到一个临时code再把code发给服务器服务器拿着code去微信接口换openid和session_key。openid是玩家唯一标识你的房间系统、断线重连、对局记录都靠它索引。// client/js/net.js —— 客户端登录流程 const code await new Promise((resolve, reject) { wx.login({ success: res resolve(res.code), fail: reject }); }); const res await fetch(http://your.server.com/api/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ code }) }); const { token, openid } await res.json();服务器收到code后拿appid secret code调微信官方接口jscode2session返回的数据里就有openid。这个接口要求服务器能访问微信的公网接口本地联调时注意你的开发机能不能出网。// server/login.js —— 服务器端换 openid const resp await fetch(https://api.weixin.qq.com/sns/jscode2session, { method: GET, params: { appid: config.appid, secret: config.secret, js_code: code, grant_type: authorization_code } }); const { openid, session_key } await resp.json();拿到openid之后不要把它直接暴露给客户端而是签发一个自己的 token存在内存或 Redis 里客户端后续 WebSocket 建连就用 token 做鉴权。这样即使 token 泄露也能单独吊销不用去微信侧做任何操作。本地调试没有 appid 怎么办我一般会在config.js里加一个devMode: true当devMode开启时跳过微信接口用code本身拼一个假 openid比如dev_openid_${code}这样本地不依赖微信就能跑通整个流程。4.2 WebSocket 连接与房间匹配heartbeat 与 roomId登录完成拿到 token客户端就通过 WebSocket 连接服务器。微信小游戏支持wx.connectSocket但 WebSocket 协议同样适用。服务器用ws库监听连接连接建立后客户端第一件事不是进入房间而是发一条auth消息带上 token服务器把这个连接绑定到用户 session 上。// server/server.js —— WebSocket 连接与身份绑定 const WebSocket require(ws); const wss new WebSocket.Server({ port: config.port }); wss.on(connection, (ws, req) { ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); ws.on(message, (data) { const msg JSON.parse(data.toString()); handleMessage(ws, msg); }); }); function handleMessage(ws, msg) { if (msg.type auth) { const user verifyToken(msg.token); if (!user) { ws.send(JSON.stringify({ type: error, code: 401 })); return; } ws.user user; ws.send(JSON.stringify({ type: auth_ok, roomId: getOrCreateRoom(user.id) })); } }heartbeat是联机游戏的基本保障。我习惯开局服务和客户端各维护一个定时器服务器每 30 秒发一次 ping客户端收到回 pong服务器如果在 50 秒内没收到 pong 就主动断开这条连接。断线后客户端要重连重连时靠roomId找回之前所在房间所以auth_ok里一定要把roomId返回给客户端客户端缓存下来断线重联时带上roomId和 token 一起恢复。一个小坑微信小游戏在切后台时会自动断掉 WebSocket回到前台才会重连所以不要在本地 try 本地 catch 里死等连接要监听wx.onSocketClose主动触发重连。这个我也在第 5 章展开。4.3 消息协议约定type、data、seq 三层结构多人联机最大的沟通成本是消息协议不统一。这个项目里我采用一个比较朴素的三层结构type表示消息类型data是具体负载seq是自增序号用来做去重和复盘。所有消息都用 JSON 字符串在 WebSocket 上传递。{ type: play_cards, data: { cards: [{ rank: 3, suit: spade }], roomId: room_001 }, seq: 42 }服务器返回时把seq原样带回客户端收到后可以用seq丢弃重复消息。比如网络重传导致同一条play_cards被送了两次客户端靠seq就能发现序号重复直接忽略。这比单纯比较type可靠得多。type方向说明auth / auth_okclient - server / server - client登录鉴权create_room / join_roomclient - server创建/加入房间deal_cardsserver - client发牌包含手牌和底牌call_scoreclient - server叫地主分数play_cardsclient - server出牌上报play_resultserver - client出牌结果广播给全部玩家turn_changeserver - client轮到谁出牌game_overserver - client本局结算heartbeat双向心跳 ping/pong每个type的data字段必须固定 schema比如cards统一用{rank, suit}数组不要在一种消息里用[3S,4S]字符串、另一种用{rank:3,suit:S}对象解析逻辑会疯狂发散。我在服务器入口加了validateMsg(msg)不符合 schema 的消息直接丢弃并记日志不往业务层传。这个习惯帮我省了不少排查时间尤其在多人联调时纯前端同学经常把字段名写错。5. 避坑从 npm 脚本权限到 WebSocket 连不上的 5 个高频问题5.1 npm.ps1 权限报错PowerShell 脚本策略挡路现象在 PowerShell 里敲npm install报错npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。原因Windows PowerShell 默认执行策略是Restricted不允许运行.ps1脚本而 npm 在 Windows 上是通过 npm.ps1 这个包装脚本执行的。解决用管理员身份执行一次Set-ExecutionPolicy RemoteSigned -Scope CurrentUser然后重新打开 PowerShell。或者干脆换 cmd / Git Bash绕开 PowerShell 策略。注意只要改CurrentUser范围即可不要全机器放开全机器放开会有安全风险。5.2 微信小游戏连不上本地 WebSocket 服务器现象客户端控制台报WebSocket connection failed服务器端看不到任何连接日志。原因微信小游戏要求 WebSocket 地址必须是wss://并且域名要在微信公众平台配置合法域名。本地调试用的是ws://127.0.0.1:8080既不是 wss 也不是合法域名默认会被拦掉。解决在微信开发者工具右上角“详情 - 本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。这只是开发期方案上线前必须把服务器部署到公网配好域名、SSL 证书再把域名加到公众平台的小游戏服务器域名白名单里。5.3 WebSocket 连接不稳定切后台回来就断现象手机锁屏再解锁后对局进度丢失有时重连成功但房间还在有时直接提示“牌局不存在”。原因微信小游戏切后台超过一段时间系统会杀掉 WebSocket 连接这不是服务器的问题。但如果服务器没有做断线重连恢复机制玩家回到前台就找不到原来的房间了。解决客户端在wx.onSocketClose里触发重连重连时带上 token 和 roomId服务器在auth消息里发现 roomId 已存在时把新连接替换进房间成员列表保留对局中间状态。注意替换连接时要清理旧连接的定时器否则一个玩家会同时存在两条活跃连接广播消息会被发两次。5.4 出牌顺序错乱两个人同时出牌现象牌局中出现两个玩家同时出牌或者服务器广播的顺序和实际点击顺序不一致。原因客户端在 WebSocketonMessage回调里直接更新 UI但多个回调事件并发时没有排队导致界面状态被后到的事件覆盖。服务器端如果也把handleMessage异步执行不按消息序号顺序处理同样会出现乱序。解决服务器收到的每条消息都打上seq序号按room维度维护一个消息队列串行处理每个房间的消息。客户端收到带seq的消息后丢弃比自己已处理序号小的保证 UI 永远按服务器顺序更新。协议层面我在第 4 章就是按这个思路设计的临上线如果发现乱序优先检查是不是有人直接用setTimeout乱调接口了。5.5 斗地主 AI 调用牌型判断时死循环现象机器人有时会卡在“思考出什么牌”这一步CPU 占用飙升随后服务器超时把机器人踢出房间。原因AI 在遍历出牌组合时用了递归深搜组合数过大没有剪枝或者牌型比较函数里出现了A 和 2大小关系写反导致的永远无法匹配。这类问题不是网络造成的是纯算法问题但表现却在“服务器卡死”。解决给 AI 的搜索深度加上限比如最多只尝试 3 层牌型比较统一走CARD_RANK下标而不是硬编码字符串比较。另外建议把getHandType用在 AI 查询里时先按张数排序再做剪枝单张、对子、三带一这些最常用牌型单独走快速通道不要每次都递归全组合。加上这层之后我这边机器人的平均决策时间从几百毫秒降到了几十毫秒。6. 进阶把服务器调稳——本地压测、pm2 守护与证书配置联机游戏服务器的翻车往往不在逻辑而在稳定性。我把这套斗地主服务器部署到云服务器之前习惯先做一轮本地压测用脚本模拟 30 个玩家同时连入、同时出牌看服务器会不会内存暴涨或者消息积压。压测脚本不复杂用 Node 自带模块就能写// test/stress.js —— 模拟 30 个玩家并发玩一局 const WebSocket require(ws); const players []; const total 30; for (let i 0; i total; i) { const ws new WebSocket(ws://localhost:8080); ws.on(open, () { ws.send(JSON.stringify({ type: auth, token: test_${i} })); }); ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.type deal_cards) { ws.send(JSON.stringify({ type: call_score, data: { score: 1 } })); } if (msg.type turn_change msg.data.player i % 3) { ws.send(JSON.stringify({ type: play_cards, data: { cards: [] } })); } }); players.push(ws); }压测看三个指标连接全部建立的时间、一局结束的时间、服务器内存是否只增不减。如果内存一直涨优先查room.js里房间和玩家有没有在结算后被正确释放这是最常见的泄漏点。压测通过之后我用 pm2 把server.js挂成守护进程设置内存上限自动重启pm2 start server.js --name landlord-room --max-memory-restart 500M pm2 save pm2 startup--max-memory-restart 500M的意思是一旦进程内存超过 500MB 就自动重启防止某个 bug 拖垮整台机器。pm2 startup保证服务器重启后 Node 服务跟着起来不用人肉上去敲命令。上线前的最后一步是证书。微信小游戏要求wss://所以服务器必须配 TLS。常见做法是买一台云服务器装好 Nginx 做反向代理用 Certbot 自动申请和续期 Let’s Encrypt 证书。Nginx 把 443 端口的 WebSocket 转发到 Node 的 8080server { listen 443 ssl; server_name game.example.com; ssl_certificate /etc/letsencrypt/live/game.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/game.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }proxy_set_header Upgrade和Connection是 WebSocket 走 Nginx 的关键丢了这两行客户端连接会一直握手失败。我的习惯是把 heartbeat 间隔设成 25 秒服务器 50 秒没收到 pong 就断连配合 pm2 的内存重启和日志切割这套配置在一台 2 核 4G 的机器上稳定扛过 100 人同时在线。斗地主房间四人一桌也算是够用了。整个项目从本地跑通到上线的路径并不长按这个顺序把环境、逻辑、协议、部署逐个踩完这套 Node.js 服务器不只会跑还能成为你以后做其他联机小游戏的基础样板。希望帮到你。本文还有配套的精品资源点击获取
返回列表