news 2026/9/26 13:30:23

微信小游戏斗地主后端实战:Node.js+WebSocket联机对战完整拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微信小游戏斗地主后端实战:Node.js+WebSocket联机对战完整拆解

简介:基于Node.js构建的微信小游戏斗地主完整项目,前端采用HTML5技术,适合微信小游戏开发者、Node.js服务端学习者以及想了解实时棋牌游戏架构的读者。压缩包内共253个文件,容量约5.95MB,其中包含162个JavaScript文件(服务端逻辑与客户端交互)、60张JPG图片(牌面与界面素材)、9个XML配置、8个JSON数据文件、3个proto协议文件以及pem证书、Markdown文档等,目录涵盖package.json、服务端入口、models、routes、controllers、config、tests等典型模块,整体结构清晰。目前已有427人学习下载。项目展示了Node.js非阻塞I/O在棋牌服务器中的应用,涉及用户登录验证、游戏状态同步、随机发牌、WebSocket双向通信等关键实现,前端与后端结合的完整源码可帮助读者快速搭建同类型小游戏,理解从HTTP服务到实时通信的完整链路,是提升全栈开发能力的实用参考。

1. 微信小游戏-斗地主这套工程,真正的骨头在 nodejs-server

微信小游戏-斗地主这个压缩包,拿到手先别急着解压看 UI。它本质是一个联机对战工程:前端是微信小游戏适配层,后端才是一切的中心——nodejs-server负责房间管理、发牌、叫分、出牌校验和状态广播。做过牌类联机的人都有体会:斗地主最难的不是画牌桌,也不是写 AI,而是“服务端怎么把规则管死”,否则玩家改个包就能出三张王,刷个脚本就能看完三家手牌。这套项目的价值,就是把玩法权威收回到 Node.js 服务器上,客户端只当“显示器”。适合两类人:想从单机 demo 往真实联机走的开发者,以及要交付前后端完整联调方案、还得讲得清架构的从业者。下面按服务端骨架、玩法逻辑、小游戏接入、避坑、验证进阶的顺序,把它拆到能直接复现。

2. 搭 Node.js 服务端骨架:WebSocket、心跳与消息分发

2.1 为什么选 WebSocket 而不是 HTTP 轮询

斗地主的操作频率其实很低——叫分一次、出牌几次,都是秒级甚至分钟级动作,但它必须“实时双向”。用 HTTP 轮询的话,每个玩家每几秒就要发一次请求,头信息、重复建连的开销全在浪费流量和电量,而且状态广播有延迟,三个人看到的手牌不一致,体验立刻崩。这类低频带状态同步的场景,WebSocket 正好卡在点上:一次握手后保持长连接,客户端与服务端任意时刻都能主动推消息。

微信小游戏端直接用wx.connectSocket就能建立连接,不需要引入额外的 SDK;Node.js 端用ws这个库就够了,它是目前在 Node 生态里最稳的 WebSocket 实现,没有之一。使用前先把 Node.js 环境装好,LTS 版本即可,安装时记得勾选“Add to PATH”,免得后面node -v都找不到命令。

2.2 初始化项目与老生常谈的 npm 检查

服务端目录单独建一个server/,在里面初始化:

mkdir server && cd server npm init -y npm i ws

npm init -y会生成默认的 package.json,npm i ws安装 WebSocket 依赖。装完确认一下:

node -v npm ls ws

这里有一个高频翻车点:在 Windows 的 PowerShell 或 VS Code 终端里执行npm命令,经常直接报错,提示“无法加载文件 npm.ps1,因为在此系统上禁止运行脚本”。这不是 Node 装坏了,是 PowerShell 执行策略把.ps1拦下来了,解决办法放在第 5 章避坑里单独说。跑 npm 命令失败的时候先别急着重装 Node,先查这个。

2.3 先写一个能扛连接的服务端入口

先不碰斗地主规则,把连接骨架搭出来:

// server/server.js —— 微信小游戏斗地主的 Node.js 联机入口 const { WebSocketServer } = require('ws'); const PORT = process.env.PORT || 3000; // 用 Map 保存全部连接,key 是客户端在服务端的唯一 id const clients = new Map(); const wss = new WebSocketServer({ port: PORT }); // 每 30 秒做一次心跳,把已经死掉的连接踢出去 setInterval(() => { for (const [id, client] of clients) { if (!client.alive) { client.ws.terminate(); clients.delete(id); continue; } client.alive = false; client.ws.ping(); } }, 30_000); wss.on('connection', (ws) => { const id = Math.random().toString(36).slice(2); clients.set(id, { ws, alive: true }); // 客户端回 pong 就算活着 ws.on('pong', () => { const c = clients.get(id); if (c) c.alive = true; }); // 所有消息都是 JSON 文本帧,先转成对象再分发 ws.on('message', (data) => { let msg = null; try { msg = JSON.parse(data.toString()); } catch (e) { ws.send(JSON.stringify({ type: 'error', message: 'bad json' })); return; } handleMessage(id, msg, ws); }); ws.on('close', () => clients.delete(id)); ws.send(JSON.stringify({ type: 'welcome', id })); });

这段有几个关键参数要说明。clients用 Map 而不是数组,是因为 Map 可以用 id 直接 O(1) 删除连接,数组要用splice做一次遍历,连接一多必然卡。心跳间隔 30 秒是在线和假死之间的平衡点,网络差的环境建议放宽到 45 秒,短于 15 秒容易误杀正常玩家。data.toString()这一步不能省——ws库默认把收到的文本帧包装成 Buffer,直接把 Buffer 丢给JSON.parse会抛异常。

消息分发用一个简单的 switch:

function handleMessage(id, msg, ws) { switch (msg.type) { case 'join': // 加入房间 joinRoom(id, msg.roomId, msg.nickname); break; case 'bid': // 叫分 0~3 bid(id, msg.score); break; case 'play': // 出牌 playCards(id, msg.cards); break; case 'pass': // 不出 passTurn(id); break; default: ws.send(JSON.stringify({ type: 'error', message: 'unknown type' })); } }

join里的roomId是后面房间模块的挂载点。斗地主一桌三个人,服务器可以同时挂很多桌,每个玩家在握手后拿到的id是 socket 层的身份,进了房间还要再分配一个座位号。这套“连接 id 与房间 id 分离”的写法,后面做断线重连时非常省事——重连只是换 socket 连接,房间里的身份和手牌不必重建。

3. 斗地主玩法逻辑下沉到 Node.js:发牌、叫分与出牌校验

3.1 服务端是唯一裁判,客户端永远不可信

小游戏端再怎么加密,终究跑在玩家设备上,改包、开脚本都是成本极低的事。所以牌型、手牌、出牌顺序、谁是大王,全部以服务端内存里的状态为准。客户端每次出牌,服务端要做三件事:这张牌在不在玩家手里、组成的牌型合不合法、能不能压过上一手。前两件靠服务端维护的“手牌差集”完成,第三件靠牌型判读与比较完成。

常见做法是服务端为每个玩家保存一组手牌对象数组,客户端出牌时提交cards,服务端先做差集校验(玩家手牌里是否包含提交的这些牌),再判牌型。校验通过才从手牌里删掉,然后广播。这段差集逻辑用Set加唯一牌标识来做比较方便,因为斗地主没有重复的四张一模一样牌——花色区分了它们,可以给每张牌生成一个suit+rank的字符串当作唯一 key。

3.2 54 张牌的创建、洗牌与发牌

// server/card.js —— 牌组、洗牌、发牌 const SUITS = ['♠', '♥', '♣', '♦']; function createDeck() { const deck = []; for (const suit of SUITS) { for (const rank of ['3','4','5','6','7','8','9','10','J','Q','K','A','2']) { deck.push({ suit, rank }); } } deck.push({ suit: 'joker', rank: 'joker' }); // 小王 deck.push({ suit: 'JOKER', rank: 'JOKER' }); // 大王 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 deal(deck) { shuffle(deck); return { hands: [ deck.slice(0, 17), deck.slice(17, 34), deck.slice(34, 51) ], bottom: deck.slice(51) // 3 张底牌 }; }

洗牌用了 Fisher-Yates 算法,遍历一次就把整副牌打乱,标准做法。注意slice的区间要写对,前 17 张给玩家一,17 到 34 给玩家二,34 到 51 给玩家三,剩下 3 张是底牌。经常有人写slice(34, 52),直接越界取到 4 张底牌——这种边界在斗地主发牌里是最常见的翻车点。

3.3 牌型判读:从 rank 映射到类型与权值

判牌是整个项目里最容易写飘的地方。先定义 rank 映射:

// server/card.js 追加 —— 牌面转数值 const RANK_MAP = { '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10, 'J': 11, 'Q': 12, 'K': 13, 'A': 14, '2': 15, 'joker': 16, 'JOKER': 17 };

这里把 2 设成 15、小王 16、大王 17,是为了后面顺子和连对做范围限制时好判断——顺子只能从 3 到 A,也就是 rank 3 到 14,2 和王都不许进顺子。判牌函数如下:

// server/card.js —— 牌型判读,返回 { type, weight, rank } function judgeCards(cards) { const ranks = cards.map(c => RANK_MAP[c.rank] ?? RANK_MAP[c]); ranks.sort((a, b) => a - b); const counts = new Array(18).fill(0); for (const r of ranks) counts[r]++; const total = ranks.length; const kinds = counts.filter(c => c > 0).length; // 火箭:双王 if (total === 2 && counts[16] === 1 && counts[17] === 1) { return { type: 'rocket', weight: 1700, rank: 17 }; } // 炸弹:四张同 rank const bombRank = counts.findIndex(c => c === 4); if (bombRank > -1 && total === 4) { return { type: 'bomb', weight: 1000 + bombRank * 10, rank: bombRank }; } // 单张 / 对子 / 三条 if (total === 1) return { type: 'single', weight: ranks[0], rank: ranks[0] }; if (total === 2 && kinds === 1) return { type: 'pair', weight: ranks[0] * 10, rank: ranks[0] }; if (total === 3 && kinds === 1) return { type: 'triple', weight: ranks[0] * 10, rank: ranks[0] }; // 三带一 / 三带二:必须有一个三张和一个单/一对 const tripleRank = counts.findIndex(c => c === 3); if (tripleRank > -1 && total === 4 && kinds === 2) { return { type: 'triple_one', weight: tripleRank * 10, rank: tripleRank }; } if (tripleRank > -1 && total === 5 && kinds === 2) { return { type: 'triple_two', weight: tripleRank * 10, rank: tripleRank }; } // 顺子:5 张起,每张恰好出现一次,3~A 连续 if (total >= 5 && kinds === total) { const low = ranks[0]; const high = ranks[total - 1]; if (low >= 3 && high <= 14 && high - low + 1 === total) { return { type: 'straight', weight: high * 100 + total, rank: high }; } return null; } // 连对:3 对起,成对且连续,3~A if (total >= 6 && total % 2 === 0) { const uniq = [...new Set(ranks)]; if (uniq.length === total / 2 && uniq[0] >= 3 && uniq[uniq.length - 1] <= 14 && uniq.every((r, i) => i === 0 || r === uniq[i - 1] + 1)) { return { type: 'consecutive_pair', weight: uniq[uniq.length - 1] * 100 + uniq.length, rank: uniq[uniq.length - 1] }; } return null; } return null; }

说明几个设计意图。weight 的计算没有统一公式,但方向一致:同类型的牌靠它比较大小,比如单张weight = rank,对子weight = rank * 10,顺子weight = high * 100 + total。这套权重只在同类型之间比较,跨类型必须走“炸弹/火箭压制”逻辑,所以权值怎么搭不互相冲突就行。顺子的连续性判断用high - low + 1 === total,如果牌是 3、4、5、6、7,则 7-3+1=5 等于张数,说明连续;如果是 3、4、5、6、8,则 8-3+1=6 不等于 5,直接排除。kinds === total确保没有重复牌,因为顺子不允许对子混进去。

这一版牌型支持到单张、对子、三条、三带一、三带二、顺子、连对、炸弹、火箭。四带二和飞机不带,在这个函数里会返回null,也就是判非法。做第一版联机 demo 时砍掉这两类牌型完全够用,等基础跑通再按后面最后一章的思路补。

3.4 出牌校验与一个能跑的叫分流程

// server/room.js —— 出牌校验 function canPlay(current, last) { if (!last) return true; // 每轮第一手随便出 if (current.type === 'rocket') return true; if (current.type === 'bomb') { if (last.type === 'rocket') return false; if (last.type === 'bomb') return current.weight > last.weight; return true; } if (current.type === last.type) return current.weight > last.weight; return false; }

这个函数的判断顺序是有讲究的:先看炸弹和火箭之间的压制关系,再看同类型比权重。炸弹不能压火箭,这条必须写在炸弹分支里,不然 rocket 会被普通炸弹误压。last为 null 的处理也很关键——每轮地主先出牌时上家为空,直接放行。

叫分逻辑用最简模型:三个玩家按座位顺序,每人叫一次 0 到 3,3 分直接当庄,三轮完后取最高分,平局取先叫者,全员 0 分流局重发。状态机切成“准备、叫分、出牌、结算”四个阶段就够了。核心代码如下:

function bid(room, playerIndex, score) { if (room.phase !== 'bid') return; if (room.bids[playerIndex] !== null || room.bidCount >= 3) return; room.bids[playerIndex] = score; room.bidCount++; if (score === 3) { finishBid(room, playerIndex); return; } if (room.bidCount === 3) { const max = Math.max(...room.bids); if (max === 0) { // 流局,重新洗牌发牌,重置房间 resetRoom(room); return; } finishBid(room, room.bids.indexOf(max)); } }

finishBid(room, landlordIndex)里做的事就是把底牌推给地主,然后广播seat消息告诉所有人谁是地主谁当农民。这里提醒一句:玩家的手牌在服务端始终是“未知与已知”分裂的——服务端知道全部 17 张,客户端只能收到自己那 17 张,不要图省事把整副牌广播出去,否则任何玩家都能看到三家手牌。

4. 微信小游戏端接入:connectSocket、协议设计与真机联调

4.1 小游戏环境的网络差异先搞清楚

微信小游戏没有 DOM、没有window,不能直接用浏览器里的new WebSocket()那套,得用微信提供的wx.connectSocket。它在底层封装了 WebSocket,API 形态接近但回调风格是微信式的。开发阶段有个大坑:开发者工具默认校验域名合法性,必须去“详情 - 本地设置”里勾选“不校验合法域名”,否则连ws://192.168.x.x:3000直接失败。

真机上调试还有一层:手机必须和电脑在同一个局域网。常见做法是电脑开 Wi-Fi 热点,或者手机连同一个路由器。如果两者跨网段,工具里能连、手机永远连不上。

4.2 消息协议:先定一张表再写代码

服务端和客户端之间走 JSON 文本帧,每个消息带一个type字段。把协议先定死,前后端并行开发不打架:

type方向关键载荷说明
welcome服务端 → 客户端idsocket 建立后下发连接 id
join客户端 → 服务端roomId, nickname加入房间
seat服务端 → 客户端seats, landlord三人座位与地主
deal服务端 → 客户端cards只发当前玩家自己的 17 张
bid客户端 → 服务端score叫分 0 到 3
bid_result服务端 → 客户端scores, landlord叫分结果,谁当庄
play客户端 → 服务端cards玩家尝试出牌
play_result服务端 → 客户端playerId, cards广播这手牌
pass客户端 → 服务端-不出
state服务端 → 客户端phase, turn, lastPlay全量状态同步,断线重连用
error服务端 → 客户端message非法操作提示

state这条务必要有。头一版我图省事只推增量事件,结果玩家一断线重连,客户端状态就和服务端对不上,只能强退重进。后来加了state全量同步,重连后拉一次,客户端直接把整个牌桌覆盖刷新,体验才算立住。

4.3 客户端封装:注意 onMessage 返回的是 ArrayBuffer

// client/net.js —— 小游戏端 WebSocket 封装 const config = require('./config'); let ws = null; const handlers = {}; function connect() { ws = wx.connectSocket({ url: config.wsUrl }); ws.onOpen(() => console.log('connected', config.wsUrl)); ws.onMessage((res) => { // 小游戏 onMessage 返回 ArrayBuffer,必须转文本 const text = new TextDecoder('utf-8').decode(res.data); let msg = null; try { msg = JSON.parse(text); } catch (e) { console.warn('bad message', text); return; } const fn = handlers[msg.type]; if (fn) fn(msg); }); ws.onError((e) => console.error('ws error', e)); ws.onClose(() => console.log('ws closed')); } function send(obj) { if (ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); } else { console.warn('socket not ready'); } } function on(type, fn) { handlers[type] = fn; } module.exports = { connect, send, on };

这里必须解释清楚:微信小游戏SocketTask.onMessage返回的res.data是 ArrayBuffer 而不是字符串。很多新手直接JSON.parse(res.data)得到一个SyntaxError,然后开始怀疑是服务端消息发错了。TextDecoder在小游戏基础库 2.x 之后可用,如果你的基础库版本太老,可以用wx.arrayBufferToBase64(res.data)拿 Base64 再转字符串,但最省事的还是升级基础库。

config.js 单独放地址,方便换环境:

// client/config.js module.exports = { // 改成自己电脑的局域网 IP,真机调试时必须用这个,不能是 127.0.0.1 wsUrl: 'ws://192.168.1.100:3000' };

4.4 真机联调四步走

  1. 电脑上跑ipconfig(macOS 用ifconfig | grep inet)查局域网 IP。
  2. 服务端启动node server.js,确认监听在0.0.0.0:3000(ws库默认绑定所有网卡,不用额外配置)。
  3. Windows 防火墙弹窗时点“允许访问”,如果没弹,手动加一条入站规则放行 TCP 3000 端口。
  4. 手机连同一 Wi-Fi,在微信开发者工具点“真机调试”,扫码后看手机上能否连上ws://192.168.x.x:3000。

真机如果一直onError,在手机上把 Wi-Fi 断开重连一次,有时候是手机缓存了旧 IP 解析。这一步的玄学成分比想象中多,但 80% 的根因还是“电脑和手机不在同网段”,先查这个再查别的。

5. 联调避坑:npm 脚本策略与 5 个高频翻车点

5.1 PowerShell 里 npm 直接报错“禁止运行脚本”

现象:在 VS Code 或 Cursor 终端里敲npm -v,报错信息是npm : 无法加载文件 d:\program files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。

原因:Windows PowerShell 的 ExecutionPolicy 默认是 Restricted,.ps1后缀的脚本一律禁止执行。npm 的 Windows 安装包提供的是npm.ps1包装器,所以被拦。

解决:开一个管理员 PowerShell 执行:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

RemoteSigned的含义是本地脚本放行,从互联网下载的脚本必须有签名。改完重开终端即可。如果是公司电脑被组策略锁死,直接改用 CMD 跑 npm——CMD 不经过 PowerShell 执行策略,这是最省事的绕法。

5.2 真机连不上本地 WebSocket,开发者工具却正常

现象:开发者工具里一切正常,手机扫码后 socket 一直触发onError,控制台报WebSocket connection failed。

原因:三个可能性按概率排——手机和电脑不在同一个网段;Windows 防火墙拦了 3000 端口;config.wsUrl里写的还是127.0.0.1。其中第三个最常见,因为在开发者工具里127.0.0.1指的就是本机,但手机上的127.0.0.1指手机自己,完全找不到电脑。

解决:改config.wsUrl为电脑局域网 IP;确认手机与电脑同 Wi-Fi;如果防火墙没弹窗,手动加规则:

netsh advfirewall firewall add rule name="ws3000" dir=in action=allow protocol=TCP localport=3000

调试结束后用netsh advfirewall firewall delete rule name="ws3000"删掉这条规则,别把端口永久裸在外面。

5.3 服务端启动报require('ws')找不到模块

现象:node server.js启动报Cannot find module 'ws',但package.json里明明写了ws依赖。

原因:大概率是npm i ws没在当前目录执行,或者装到了别的路径。还有一种可能是server.js不在server/根目录,Node 的require是从当前文件所在目录逐级向上找node_modules的,路径错一层就找不到。

解决:在项目根目录执行npm ls ws查看模块是否真的在;不在就重新npm i ws。确认server.js和node_modules同级,不要再挪到子目录里运行。

5.4 牌型判读的边界坑:2 进了顺子,334455 被认为顺子

现象:玩家手里2 3 4 5 6,服务端居然判成合法顺子;3 3 4 4 5 5也被当成顺子。

原因:判顺子时只检查了连续性和张数,没有约束最低牌和最高牌的范围。rank 里 2 是 15,王是 16、17,必须把最高限制在 14(A)以内。334455的问题是kinds不等于total,重复牌混进去了,连续判断被跳过了重复检查。

解决:顺子分支里必须同时满足low >= 3 && high <= 14 && high - low + 1 === total;kinds === total保证无重复。连对分支同样限制uniq[0] >= 3 && uniq[uniq.length - 1] <= 14,这样 2 和王进不了连对。建议每改一次判牌就写一组边界用例,后面第 6 章会给测试写法。

5.5 小游戏端收消息 JSON.parse 总报错

现象:客户端收到play_result后JSON.parse直接抛异常,打印res.data看到的是<ArrayBuffer>。

原因:微信小游戏把 socket 消息按二进制交给onMessage,res.data就是 ArrayBuffer。直接当字符串解析当然失败。

解决:统一在onMessage里先new TextDecoder('utf-8').decode(res.data)再JSON.parse。如果基础库版本不支持 TextDecoder,改用wx.arrayBufferToBase64(res.data),再decodeURIComponent处理一遍。这两种方式二选一,不要混着用。

5.6 服务端一重启,房间全部消失

现象:node server.js重启后,三个玩家全部掉线,房间状态全没了,必须重新建房。

原因:房间对象存在进程内存里,进程一结束,数据全部蒸发。

解决:开发阶段能接受,每次重启就让玩家重新建房,不算 bug。做生产环境时要把房间快照定时写到 Redis 或 MongoDB,启动时扫描未完成房间并恢复。再补一个房间 TTL:超过 30 分钟没有操作的房间自动销毁,不然内存里会堆积一堆永远没人进的死房间,这在“联网房间管理”里是标准做法。

6. 跑通后怎么验证:机器人压测、单元测试与断线重连

联调跑通只是开始,服务端扛不扛得住才是关键。写一个机器人压测脚本,模拟多名玩家同时进房、叫分、出牌:

// test/bot.js —— 模拟玩家并发连接 const WebSocket = require('ws'); const URL = 'ws://127.0.0.1:3000'; const BOTS = 6; for (let i = 0; i < BOTS; i++) { const ws = new WebSocket(URL); ws.on('open', () => { ws.send(JSON.stringify({ type: 'join', roomId: 'room-' + (i % 2), nickname: 'bot' + i })); }); ws.on('message', (data) => { const msg = JSON.parse(data.toString()); // 简单打印,肉眼确认每帧都能收到 console.log(msg.type, msg.roomId || ''); }); }

压测时留意两件事:连接数拉满时clients.size是否正常增长,以及进程内存是否平稳。六路机器人同时跑,Node 进程内存涨幅应该在几十兆内波动,如果一路上涨不回落,说明有连接或定时器没释放。

对牌型判读单独写单元测试,用 Node 自带的node:test,不用引额外框架:

// test/card.test.js const { test } = require('node:test'); const assert = require('node:assert'); const { judgeCards } = require('../server/card'); test('三带一', () => { const hand = [{ suit: '♠', rank: '3' }, { suit: '♥', rank: '3' }, { suit: '♣', rank: '3' }, { suit: '♦', rank: '4' }]; const result = judgeCards(hand); assert.equal(result.type, 'triple_one'); }); test('顺子不能含 2', () => { const hand = [{ suit: '♠', rank: '2' }, { suit: '♥', rank: '3' }, { suit: '♣', rank: '4' }, { suit: '♦', rank: '5' }]; const result = judgeCards(hand); assert.equal(result, null); });

我叫分和出牌那一版只覆盖到基本牌型,跑通后建议把优先级最高的三件事排进去:断线重连后依赖state全量同步恢复牌桌;房主 30 秒无响应自动换人托管,托管后直接按最小可用牌出;把服务端从裸 ws 迁到 wss,上正式服务器时再做合法域名校验。小游戏真机上线对域名和协议都有硬性要求,本地联调没问题不代表线上没问题,这块提前留出时间,别等提审才改。

最后说个习惯:做联机项目,客户端代码再怎么短平快都行,服务端这层必须按“永不信任客户端”的立场写。我第一版斗地主把判牌写在客户端图省事,结果内测当天就被刷分脚本打了个满盘皆输,后来才把规则全部收进 Node 端。这套工程的每一行if,不是在判断牌,而是在判断人性。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 13:29:56

Substrate区块链开发框架:从核心概念到自定义链实操

我第一次打开 Substrate 的 node-template 时&#xff0c;第一反应是&#xff1a;这玩意儿到底能干什么&#xff1f;后来我才慢慢搞清楚&#xff0c;它不是一条现成的链&#xff0c;而是一套可以让你把链“拼”出来的开发框架。简单说&#xff0c;Substrate 是用 Rust 编写的区…

作者头像 李华
网站建设 2026/9/26 13:29:52

批量发送短信接口集成方案:从队列调度到回执处理的完整实践

1. 为什么我要自己封装一套群发短信方案 那个下午我到现在还记得&#xff0c;系统刚上线&#xff0c;运营说要给全量注册用户发一条版本升级通知&#xff0c;当时代码里写着的是for循环逐条调用服务商的单发接口。程序跑了两个多小时&#xff0c;跑到三分之一被服务商限频&…

作者头像 李华
网站建设 2026/9/26 13:29:45

AI编码助手为何搬离聊天框?新形态与实战避坑指南

上周在技术交流群里看到一条提问&#xff0c;大意是“AI怎么教都不会写这个功能”&#xff0c;点进去一看&#xff0c;他把需求整段贴在聊天框里&#xff0c;AI答了一大篇&#xff0c;他又追问了两轮&#xff0c;最后代码还是自己动手改的。这场景我太熟悉了。过去两年里&#…

作者头像 李华
网站建设 2026/9/26 13:29:40

IK分词器实战:从原理到配置,解决中文搜索痛点

“IK分词器”这个标题我太熟悉了。如果你在Java生态里做搜索相关的开发&#xff0c;尤其是用过Elasticsearch或者老牌的Lucene&#xff0c;几乎绕不开这个名字。网上关于IK的帖子很多&#xff0c;但大多是“安装一下、换个分词器、跑个示例”的浅层介绍&#xff0c;真正把原理、…

作者头像 李华
网站建设 2026/9/26 13:28:48

Git Revert:团队协作中安全回滚的唯一正确姿势

1. 别再删分支、硬重置、改远程历史了——Git Revert 是唯一安全的“后悔药”你刚在团队协作的主分支上 commit 了一段调试用的日志打印&#xff0c;顺手 push 上去了&#xff1b;你误把本地测试环境的数据库配置文件 commit 进了 feature 分支&#xff0c;还 merge 到了 devel…

作者头像 李华