简介:这是一套面向微信小游戏开发者与Node.js后端学习者的斗地主项目源码,适合想打通小游戏前后端、理解实时对战服务器架构的初中级开发者参考。压缩包共253个文件,约5.95MB,以162个js脚本为核心,涵盖服务器入口、游戏逻辑与路由控制;另有60张jpg图片资源、9个xml与8个json配置、3个proto协议文件,以及pem、crt证书和md说明文档,整体结构接近真实项目工程。项目后端基于Node.js搭建,负责登录验证、游戏状态同步、玩家交互与数据存储,客户端与服务器间通过WebSocket实现实时通信,proto文件则用于定义消息格式。目录中可见package.json依赖声明、server启动文件、models与routes等模块划分,便于读者梳理分层设计。目前已有427人学习下载,可作为研究微信小游戏斗地主完整实现、学习高并发实时通信与游戏逻辑组织的实践素材。
1. 微信小游戏斗地主:从一份 nodejs-server 压缩包到能跑起来的联机牌桌
微信小游戏里做斗地主,难点从来不在画牌面,而在“三个人怎么在同一局里看到同一副牌”。你拿到一份nodejs-server-wechat-landLordGame.zip,里面大概率是客户端小游戏工程加一个 Node.js 服务端,但直接解压双击往往连不上——因为斗地主是强状态同步的回合制游戏,服务端要维护房间、发牌、出牌合法性、断线重连,客户端只负责渲染和转发操作。这套方案适合两类人:一是想学微信小游戏联机架构的前端,二是想用 Node.js 练手实时服务端的后端。它不解决“一键上线”,但能让你在本地把“创建房间→三人准备→发牌→出牌→结算”整条链路跑通,理解状态机怎么落在代码里。下面按“先跑通、再拆解、后避坑”的顺序讲。
2. 拆开压缩包:nodejs-server 里到底该有什么
2.1 目录结构与运行前提
拿到压缩包先别急着npm install,先看目录。一个能跑的斗地主服务端通常长这样:
wechat-landLordGame/ ├── server/ # Node.js 服务端 │ ├── app.js # 入口,启动 HTTP + WebSocket │ ├── package.json # 依赖清单 │ ├── room/ # 房间与牌局逻辑 │ │ ├── Room.js │ │ └── Card.js │ └── config.js # 端口、心跳、超时 ├── client/ # 微信小游戏工程 │ ├── game.js │ ├── project.config.json │ └── js/ └── README.md如果压缩包里没有package.json,说明它可能只给了源码片段,你需要自己补一个。运行前提是 Node.js 环境,Windows 上常见报错npm : 无法加载文件 ... npm.ps1,因为在此系统上禁止运行脚本,这不是 Node 装坏了,是 PowerShell 执行策略拦了脚本。解决办法是在管理员 PowerShell 里执行Set-ExecutionPolicy RemoteSigned,或者直接用 CMD 跑npm.cmd install。Mac 上装 Node 后如果node -v正常但npm找不到,检查/usr/local/bin是否在 PATH 里。
2.2 服务端最小启动命令与依赖
进入server目录,先装依赖。斗地主服务端通常需要ws(WebSocket)和express(静态资源或健康检查),如果压缩包里用了socket.io就换成对应包。不要盲目npm install全部,先看package.json的dependencies。
cd server npm install ws express node app.js启动后终端应输出类似server listening on 3000。如果报Cannot find module 'ws',说明依赖没装全;如果报EADDRINUSE,说明 3000 端口被占,改config.js里的port或执行netstat -ano | findstr :3000找到进程杀掉。这里的关键参数是端口和心跳间隔:端口要和客户端请求地址一致,心跳间隔一般设 30 秒,太短会频繁断线,太长断线检测迟钝。
// server/config.js module.exports = { port: 3000, // 服务端监听端口 heartbeat: 30000, // 心跳间隔,单位毫秒 roomTimeout: 300000, // 房间空闲超时,5 分钟 maxPlayers: 3 // 斗地主固定三人 };heartbeat决定服务端多久没收到客户端消息就判定掉线;roomTimeout决定空房间多久回收。这两个值在本地调试时可以调小,方便观察断线重连逻辑。
2.3 客户端连服务端的地址怎么填
微信小游戏开发时,本地调试要在微信开发者工具里勾选“不校验合法域名”,否则wx.connectSocket会直接失败。客户端连接地址不能写localhost,因为真机预览时手机访问不到你的电脑。常见做法是填局域网 IP,比如ws://192.168.1.100:3000,并确保电脑防火墙放行该端口。Windows 上如果连不上,去“高级安全 Windows Defender 防火墙”里新建入站规则放行 TCP 3000。
// client/js/net.js const socket = wx.connectSocket({ url: 'ws://192.168.1.100:3000', // 换成你电脑的局域网 IP success: () => console.log('connect start'), fail: (err) => console.error('connect fail', err) }); socket.onMessage((res) => { const msg = JSON.parse(res.data); // 根据 msg.type 分发到房间、出牌、结算逻辑 });地址里的 IP 必须和运行node app.js的机器一致,端口和config.js一致。如果开发者工具能连、真机不能连,九成是 IP 写成了127.0.0.1或防火墙没放行。
3. 斗地主服务端核心:房间状态机与发牌逻辑
3.1 房间对象该存哪些字段
斗地主不是“收到消息就广播”那么简单,服务端必须维护每个房间的完整状态。一个房间至少要有:玩家列表(含座位号、openid、在线状态)、牌堆、每个玩家的手牌、当前出牌者、上一手牌、地主是谁、倍数、阶段(等待/叫地主/出牌/结算)。这些字段决定了你能不能做断线重连和防作弊。
// server/room/Room.js class Room { constructor(roomId) { this.roomId = roomId; this.players = []; // [{ seat, openid, online, hand: [] }] this.deck = []; // 洗好的 54 张牌 this.currentSeat = 0; // 当前该谁出牌 this.lastPlay = null; // { seat, cards } this.landlordSeat = -1; // 地主座位,-1 表示未定 this.phase = 'waiting'; // waiting | bidding | playing | settled this.multiplier = 1; } }players数组顺序就是座位顺序,hand只存在服务端,客户端只拿到自己的手牌。lastPlay用来判断“要不要得起”,phase控制消息路由——等待阶段收到出牌消息直接丢弃。很多新手把牌局状态放在客户端,结果一改内存就能作弊,这是血泪经验。
3.2 洗牌与发牌的可复现写法
发牌要保证 54 张牌不重不漏,三人各 17 张,留 3 张底牌。用 Fisher-Yates 洗牌,不要用sort(() => Math.random() - 0.5),后者分布不均匀,斗地主里会导致某些牌型出现概率异常。
// server/room/Card.js function createDeck() { 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({ suit: s, rank: r }); } deck.push({ suit: 'joker', rank: '小王' }); deck.push({ suit: 'joker', rank: '大王' }); return deck; } 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(deck) { const hands = [[], [], []]; for (let i = 0; i < 51; i++) { hands[i % 3].push(deck[i]); } const bottom = deck.slice(51); // 3 张底牌 return { hands, bottom }; }createDeck生成 54 张,shuffle原地打乱,deal按i % 3轮流发。发完后每个玩家手牌 17 张,底牌 3 张。注意牌面排序要在服务端做一次,客户端只负责显示,否则两边排序规则不一致会导致“明明能出却提示不能出”。
3.3 出牌合法性判断的最小实现
出牌校验是斗地主最容易被忽略的部分。服务端必须判断:是不是轮到你、牌是否在手、牌型是否合法、能不能压过上一手。最小实现先支持单张、对子、三张、炸弹。
// server/room/Room.js function canPlay(hand, cards, lastPlay) { // 1. 牌必须都在手里 for (const c of cards) { const idx = hand.findIndex(h => h.suit === c.suit && h.rank === c.rank); if (idx === -1) return false; hand.splice(idx, 1); // 临时移除,校验失败要还原 } // 2. 牌型判断(简化版) const type = getCardType(cards); if (!type) return false; // 3. 压牌判断 if (lastPlay) { if (type === 'bomb' && lastPlay.type !== 'bomb') return true; if (type !== lastPlay.type) return false; if (cards.length !== lastPlay.cards.length) return false; return compareRank(cards[0], lastPlay.cards[0]) > 0; } return true; }这段代码里hand.splice是临时操作,真实项目要先拷贝一份再校验,否则校验失败手牌就少了。getCardType和compareRank需要自己补全,compareRank按 3<4<…<A<2<小王<大王 排序。参数lastPlay为null表示自由出牌。很多翻车现场是“炸弹能压单张但单张不能压炸弹”写反了,测试时先用手动构造的牌局跑一遍。
4. 微信小游戏端接入:连接、渲染与断线重连
4.1 小游戏工程里 WebSocket 的封装
微信小游戏的wx.connectSocket返回的是 SocketTask,不能像浏览器那样直接new WebSocket。要封装一层,统一处理重连、心跳、消息队列。客户端不要直接在每个页面调send,否则断线后消息丢失很难查。
// client/js/socket.js class GameSocket { constructor(url) { this.url = url; this.task = null; this.queue = []; this.connected = false; this.connect(); } connect() { this.task = wx.connectSocket({ url: this.url }); this.task.onOpen(() => { this.connected = true; this.queue.forEach(msg => this.task.send({ data: msg })); this.queue = []; this.startHeartbeat(); }); this.task.onClose(() => { this.connected = false; setTimeout(() => this.connect(), 2000); // 2 秒后重连 }); this.task.onMessage((res) => this.onMessage(JSON.parse(res.data))); } send(obj) { const msg = JSON.stringify(obj); if (this.connected) this.task.send({ data: msg }); else this.queue.push(msg); } startHeartbeat() { setInterval(() => this.send({ type: 'ping' }), 30000); } }queue解决断线期间的消息暂存,startHeartbeat每 30 秒发一次 ping,和服务端heartbeat对应。重连间隔 2 秒是经验值,太短会疯狂重试,太长玩家等得久。注意onClose里不要直接递归connect,加个setTimeout避免栈溢出。
4.2 手牌渲染与点击选牌
小游戏渲染用 Canvas,手牌通常画在底部,点击时判断坐标是否落在某张牌上。选中的牌上移一段距离表示选中。这里的关键是“服务端发来的手牌顺序”和“客户端显示顺序”要一致,否则玩家选了三张,发到服务端对不上。
// client/js/hand.js function renderHand(ctx, hand, selected) { const cardWidth = 60, cardHeight = 80; const startX = (canvas.width - hand.length * cardWidth) / 2; hand.forEach((card, i) => { const x = startX + i * cardWidth; const y = selected.includes(i) ? canvas.height - cardHeight - 20 : canvas.height - cardHeight; drawCard(ctx, card, x, y, cardWidth, cardHeight); }); } function onTouch(x, y, hand) { const cardWidth = 60, cardHeight = 80; const startX = (canvas.width - hand.length * cardWidth) / 2; const idx = Math.floor((x - startX) / cardWidth); if (idx >= 0 && idx < hand.length) return idx; return -1; }selected存的是索引数组,点击时切换。onTouch只做粗略命中判断,真机上要处理高 DPI 缩放,否则点击位置偏移。如果玩家反馈“点不中”,先检查canvas.width和实际触摸坐标是否在同一坐标系。
4.3 断线重连时服务端要补发什么
玩家掉线再回来,服务端不能只发“欢迎回来”,要补发完整房间状态:自己的手牌、当前出牌者、上一手牌、地主是谁、倍数。否则客户端界面是空的,玩家不知道发生了什么。
// server/app.js function onReconnect(ws, openid) { const room = findRoomByOpenid(openid); if (!room) return ws.send(JSON.stringify({ type: 'noRoom' })); const player = room.players.find(p => p.openid === openid); player.online = true; ws.send(JSON.stringify({ type: 'reconnect', hand: player.hand, currentSeat: room.currentSeat, lastPlay: room.lastPlay, landlordSeat: room.landlordSeat, multiplier: room.multiplier, phase: room.phase })); }findRoomByOpenid遍历所有房间,生产环境要用 Map 索引。补发字段里hand只发自己的,别把别人的手牌也发出去。如果重连后phase是playing但currentSeat不是自己,客户端要禁用出牌按钮。
5. 避坑与排查:斗地主联机最常见的 5 个翻车点
5.1 现象:开发者工具能连,真机连不上
原因:客户端地址写了localhost或127.0.0.1,真机访问的是手机自己。解决:改成电脑局域网 IP,并确认手机和电脑在同一 WiFi;Windows 防火墙放行 Node.js 或对应端口。
5.2 现象:出牌后服务端说“牌不在手里”
原因:客户端发的是牌面字符串,服务端手牌是对象,比较时suit和rank对不上;或者客户端排序和服务端排序不一致导致索引错位。解决:统一用suit+rank作为唯一标识,服务端校验时按标识查找,不要按索引。
5.3 现象:断线重连后手牌重复或丢失
原因:重连时服务端重新发牌,或者客户端没清空旧手牌就追加。解决:重连消息里带hand全量覆盖,客户端收到reconnect先清空再渲染;服务端不要重新洗牌,只补发当前状态。
5.4 现象:npm install报npm.ps1 禁止运行脚本
原因:Windows PowerShell 执行策略限制。解决:管理员 PowerShell 执行Set-ExecutionPolicy RemoteSigned,或改用 CMD 执行npm.cmd install。Mac 上如果npm命令找不到,检查 Node 安装路径是否加入 PATH。
5.5 现象:房间人数满了还能进,或者三人准备后不开局
原因:服务端没做人数上限判断,或者准备状态没同步。解决:加入房间前检查players.length < 3;准备消息要广播给房间内所有人,服务端统计三人ready后才进入bidding阶段。
6. 进阶:用状态快照做回放与防作弊校验
本地跑通之后,真正值得投入的是“状态快照”。每出一手牌,服务端把{ roomId, seat, cards, timestamp }追加到房间的history数组,结算时落盘。这样既能做回放,也能在玩家申诉时核对。我一般会在Room里加一个snapshot()方法,返回当前完整状态,定时写文件。
// server/room/Room.js snapshot() { return { roomId: this.roomId, phase: this.phase, currentSeat: this.currentSeat, landlordSeat: this.landlordSeat, multiplier: this.multiplier, hands: this.players.map(p => p.hand.length), // 只存数量,不存具体牌 lastPlay: this.lastPlay, history: this.history }; }hands只存数量不存具体牌,避免日志泄露手牌。history用来回放出牌顺序。验证方法是:本地开三个客户端,打完一局后检查history长度是否等于出牌次数,lastPlay是否和最后一手一致。如果对不上,说明某次出牌没记录,通常是消息路由漏了分支。
另一个技巧是“服务端重算”。客户端发来的出牌请求,服务端不要直接信任,而是用canPlay重新算一遍,算不过就拒绝并回发当前状态让客户端纠正。这样即使客户端被改,也出不了非法牌。我踩过的坑是:早期为了省事直接广播客户端消息,结果有人用调试工具发了一手“四个二带两王”,整局直接崩。后来加了服务端校验,世界就安静了。
这套东西值不值得做?如果你只是想学微信小游戏联机,它能把 WebSocket、状态机、断线重连、防作弊一次串起来,比看十篇概念文章管用。如果你要上线,还需要加房间匹配、排行榜、微信登录态校验,但那是下一步的事。先把本地三人牌桌跑起来,再谈别的。希望帮到你。
本文还有配套的精品资源,点击获取