☰
中国象棋联机微信小程序源码实战:规则引擎与WebSocket架构解析
2026/9/26 15:54:59 网站建设 项目流程

简介:这是一份基于微信小程序的中国象棋联机对战完整源码,主要面向小程序开发者和棋类游戏初学者,演示如何通过局域网实现双人对战。项目可在微信开发者工具中直接打开编译运行,包含单机游戏与联机对战的完整逻辑,并配有作者撰写的两篇详解文章,便于对照学习。压缩包内共38个文件,以js逻辑脚本、json配置、wxss样式和wxml页面结构为主,另附md说明文档,整体仅27KB,结构紧凑。目前已有1702人学习下载,适合想快速上手微信小程序游戏开发、理解局域网通信与多人对战实现的读者。通过该项目可以了解棋盘绘制、走棋规则、扫码加入房间、局域网数据传输等关键环节,并在此基础上扩展出棋力或联网功能。

1. 中国象棋联机微信小程序源码:先看清三件事再动手

搜「中国象棋-联机游戏-微信小程序源码」的人,大多是想找一个能跑通前后端的完整项目,要么交课程设计,要么给产品做原型验证。但把源码拿到手你会发现,真正的难点不在「象棋」,而在「联机」和「小程序」这两个词上。一套能用的源码通常包含三个层:棋盘规则引擎(走法、将军、胜负判定)、实时通信服务(房间、状态同步、断线重连)、微信小程序前端(棋盘渲染、触摸操作、登录鉴权)。本文按这个顺序,把每一层怎么做、参数怎么调、坑在哪讲清楚,适合正在做微信小程序项目实例的开发者,也适合拿 Node 或 Java 课程设计案例源码做参考的人。别急着下载就跑,先搞清楚你要改哪里。

2. 把中国象棋规则引擎做对:二维棋盘、走法生成与胜负判定闭环

2.1 棋盘怎么存:二维数组编码与「可读性优先」的取舍

中国象棋棋盘是 10 行 9 列,最直接的表示就是二维数组。常见做法是每个位置放一个整数,0 表示空,正数表示红方棋子,负数表示黑方棋子。这个编码在调试和打印时特别直观,服务端校验和前端本地预览用同一份代码,出 bug 也容易排查,比上来就用 bitboard 位棋盘实际得多。

// 10x9 棋盘,红方在下(第 9 行是红方底线),黑方在上 const START_BOARD = [ [-1,-2,-3,-4,-5,-4,-3,-2,-1], // 第 0 行,黑方车马相仕将士相马车 [ 0, 0, 0, 0, 0, 0, 0, 0, 0], [ 0,-6, 0, 0, 0, 0, 0,-6, 0], // 黑方炮 [-7, 0,-7, 0,-7, 0,-7, 0,-7], // 黑方卒 [ 0, 0, 0, 0, 0, 0, 0, 0, 0], [ 0, 0, 0, 0, 0, 0, 0, 0, 0], [ 7, 0, 7, 0, 7, 0, 7, 0, 7], // 红方兵 [ 0, 6, 0, 0, 0, 0, 0, 6, 0], // 红方炮 [ 0, 0, 0, 0, 0, 0, 0, 0, 0], [ 1, 2, 3, 4, 5, 4, 3, 2, 1], // 红方车马相仕帅仕相马车 ];

编码上把双方棋子取相反数,判断颜色时piece > 0就是红方,piece < 0就是黑方,写走法生成时不用再查表。帅用 5、将用 -5,将军判断时直接找 ±5。之所以不把棋子编码成字符串,是因为整数做数组下标和乘法判断正负更省事,服务端并发校验时性能也更稳。

提示:有人用 bitboard 表示棋盘,查询快、格调高,但联机小程序服务端并发量不大,二维数组的可读性能省下大量调试时间。除非你打算把引擎做成高性能库,否则别在这里过度设计。

2.2 走法生成:先给每类棋子写伪走法,再统一过滤「送将」

走法生成是规则引擎的核心。我的做法是把每类棋子的走法写成独立函数,返回候选坐标数组,之后统一走一遍「走子 → 判断己方帅/将是否被攻击」的过滤。这样比每一步都单独判断规则更不容易漏,尤其是马的蹩马腿和炮的翻山这类特殊逻辑。

function rookMoves(board, r, c) { const moves = []; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (const [dr, dc] of dirs) { let nr = r + dr, nc = c + dc; while (nr >= 0 && nr < 10 && nc >= 0 && nc < 9 && board[nr][nc] === 0) { moves.push([nr, nc]); nr += dr; nc += dc; } if (nr >= 0 && nr < 10 && nc >= 0 && nc < 9 && board[nr][nc] * board[r][c] < 0) { moves.push([nr, nc]); // 吃子,只能吃到对方棋子 } } return moves; }

注意board[nr][nc] * board[r][c] < 0这个写法:它同时判断了「目标格是对方的子」和「目标格不是空」。如果同色就直接跳过,因为车不能跳子。炮的走法和吃法要分开:移动时走直线空位,吃子时必须隔一个炮架。这部分最容易写错,炮的「翻山」逻辑建议单独写cannonMoves,再准备几个典型局面做单测。马的蹩马腿则要检查马前进方向上的「马腿位」是否有子,相/象的塞象眼同理。

2.3 将军、困毙、长将与三次重复:胜负判定要闭环

伪走法生成完后,统一做合法性过滤,核心是「走完不能送将」:

function isLegalMove(board, from, to, color) { // 先复制棋盘,执行走子,再检查己方帅/将是否安全 const next = board.map(row => row.slice()); next[to[0]][to[1]] = next[from[0]][from[1]]; next[from[0]][from[1]] = 0; const king = color > 0 ? 5 : -5; return !isKingAttacked(next, king, -color); }

isKingAttacked的实现是遍历对方所有棋子,看它们能否攻击到帅/将的位置。这里有个隐藏规则:将帅不能直接照面,如果同列且中间无子,先被「飞将」的一方等效于被攻击。这个判断一定要写进isKingAttacked,不然后续会出现「帅对脸还合法」的翻车局面。

胜负判定围绕「当前玩家没有合法走法」来算:有合法走法但被将军,是被将死;没有合法走法且没被将军,是困毙,象棋里算对方胜。在此基础上加上将军时的「应将」提示,这套引擎已经能支撑本地双人对弈。长将和三次重复局面属于进阶规则,联机模式至少要把「长将判负」做成可配置项,不然会遇到无赖用户反复将军拖时间。

3. 联机对战核心:WebSocket 房间、协议设计与服务端权威校验

3.1 选型:为什么小程序实时联机优先选 WebSocket 而不是云数据库

微信小程序做实时联机,市面上有三条路:原生 WebSocket 自建服务、微信云开发的实时数据推送watch、第三方即时通信 SDK。棋类对弈对消息时序要求很高,每一步必须串行处理,「后到的落子覆盖先到的」这种错误不可接受。云开发的watch适合聊天和文档协作,但它的推送节奏和排序规则不适合当对弈总线;第三方 SDK 功能全,但会增大源码体积,还要额外学习封装。

常见做法是自建一个 WebSocket 房间服务,Node.js 或 Java 都行,前端用wss://接入。房间维度做状态隔离,每个房间一个状态对象:

// room 结构,字段是联机对弈的公共约定 const room = { id: 'room_1234', players: { red: { openid: 'u1', conn: ws }, black: { openid: 'u2', conn: ws } }, board: START_BOARD, // 当前棋盘 turn: 1, // 1 红先,-1 黑方 step: 0, // 步数序号,客户端重试去重用 undoReq: null, // 悔棋请求的发起方角色 status: 'playing', // playing / finished };

我把conn和openid分开存,是为了断线重连时只用openid找回玩家身份,conn可以直接替换成新连接。如果你把连接对象和身份信息绑死在同一个字段里,重连逻辑会非常难写。

3.2 消息协议:把「下子、悔棋、认输」定义成一张表

联机消息用 JSON 文本帧,type区分消息类型,data携带业务数据。移动消息必须带step序号,服务端发现序号小于等于已处理序号就丢弃,这是防重复提交的关键。

type发送方data 关键字段说明
create_room客户端——创建房间,返回 roomId
join_room客户端roomId加入房间,匹配对手
move客户端from, to, step上报落子,服务端校验后广播
undo_ask客户端——发起悔棋请求
undo_ack客户端agree对方同意或拒绝
resign客户端——认输
chat客户端text对局内文字消息
sync服务端board, turn, step, status全量状态推送,断线重连用

这里有个细节:悔棋不要直接生效,必须走「发起 → 对方确认 → 双方同步」的流程,否则手滑点错就能单方面悔棋,对局公平性没法保证。超时未确认的悔棋请求,我一般设 30 秒自动拒绝。

3.3 服务端代码骨架:收到 move 后先校验再广播

ws.on('message', (raw) => { const msg = JSON.parse(raw); if (msg.type === 'move') { const p = room.players[msg.data.openid]; // 校验轮次:当前该谁走 if ((room.turn > 0 && p.role !== 'red') || (room.turn < 0 && p.role !== 'black')) { return ws.send(JSON.stringify({ type: 'error', code: 'NOT_YOUR_TURN' })); } // 校验 step 序号,丢弃重复落子 if (msg.data.step <= room.step) return; // 服务端持有权威棋盘,用规则引擎校验走法 if (!isLegalMove(room.board, msg.data.from, msg.data.to, room.turn)) return; doMove(room, msg.data); room.step = msg.data.step; broadcast(room, { type: 'sync', data: snapshot(room) }); } });

这段代码的逻辑说明:服务端把第 2 章的规则引擎复制一份,以服务端棋盘为准,客户端上报的只是「意图」。step序号由客户端生成,从 1 开始递增,服务端只接受更大的序号。这样即使客户端网络重试导致同一帧发两次,服务端也只会执行一次。snapshot(room)负责把棋盘、轮次、步序序列化成可传输的对象,不要在广播时直接把 room 对象JSON.stringify,那会带上conn连接对象导致循环引用报错。

注意:千万不要只在客户端做合法性判断,然后直接把结果转发给对手。小程序代码很容易被逆向,把规则引擎放服务端是联机公平性的底线,也方便后面扩展观战和回放。

3.4 断线重连:心跳保活 + 房间保留窗口

小程序切后台,WebSocket 会被系统挂起甚至断开,这是联机象棋最影响体验的问题。做法是服务端每 30 秒向客户端发一个 ping,客户端回 pong;连续 3 次没回,认为该连接死掉,把玩家标记为「离线」但保留房间 60 秒。玩家回到前台后,小程序用roomId + openid + token重新连接,服务端比对身份后把players里的conn换成新连接,并推送一次全量sync。

心跳间隔和保留窗口这两个参数要配套:心跳 30 秒、三次超时约 90 秒判定离线,保留窗口 60 秒意味着玩家有 1 分钟时间切回来重连。窗口太短,切出去回个消息房间就没了;窗口太长,占着服务端内存不释放。

4. 微信小程序前端落地:canvas 棋盘、触摸落子与请求封装

4.1 渲染选型:canvas type 2d 更适合象棋,view 布局适合课程设计

先确认你手上的源码是小程序还是小游戏:小游戏走game.js和开放数据域,渲染方式完全不同。本文按小程序应用讲,用的是小程序里的 canvas 组件。小程序里画棋盘有两种方案:用 view 拼格子,或者用 canvas 画线画棋子。view 方案布局直观、适合交作业,但棋子拖拽和屏幕适配都要自己算,iPhone 和 Android 的 rpx 表现也不一致;canvas type="2d"是 Canvas 2D 接口,画 9 条竖线、10 条横线、河界和九宫斜线只需一次绘制,后续重绘成本也低。

const query = wx.createSelectorQuery(); query.select('#chessCanvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node; const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); drawBoard(ctx, cellSize); });

ctx.scale(dpr, dpr)必须写在所有绘制之前,否则画出来的坐标和触摸坐标对不上。棋盘格大小我按cell = res[0].width / 8计算,交叉点坐标固定为[col * cell, row * cell]。如果页面用了自定义导航栏,棋盘区域高度要用状态栏高度 + 胶囊按钮高度动态算,别写死 375,不然不同机型的棋盘会被顶出屏幕或压扁。

如果你计划同一套代码后续发 App 或鸿蒙端,可以评估用 uniapp 重写渲染层,但联机核心逻辑在服务端,迁移成本主要在 UI 这一层。

4.2 登录与请求封装:wx.login 换 openid,再换业务 token

微信小程序登录的标准链路是wx.login拿 code,发给自己的服务端,服务端调jscode2session换 openid,再签一个业务 token 返回。把请求封装成 Promise,统一带 token 和错误处理,后面所有联机请求都走这一层。

function request(url, data) { return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, method: 'POST', data, header: { Authorization: 'Bearer ' + getToken() }, success: (res) => { if (res.data && res.data.code === 0) resolve(res.data.data); else reject(new Error(res.data && res.data.msg)); }, fail: reject, }); }); }

这个封装把错误处理集中到了reject,业务代码里不用每个接口都写一遍res.data.code === 0的判断。注意小程序 WebSocket 的wx.connectSocket的 header 带不了自定义字段,所以 WebSocket 连接时 token 一般放在 query 参数里:wss://your.domain/ws?token=xxx&roomId=xxx。服务端校验 query 里的 token 即可。

4.3 触摸落子:touchstart 记起点,touchend 吸附交叉点

不要用touchmove实时移动棋子,那会让 canvas 每帧重绘,低端机上明显卡顿。我一般touchstart记录起点交叉点,touchend计算终点交叉点,两次都合法才发 move 消息。

canvas.addEventListener('touchstart', (e) => { const p = toCell(e.touches[0].x, e.touches[0].y); const piece = board[p.r][p.c]; if (piece * myColor > 0) selected = p; // 选自己的子 }); canvas.addEventListener('touchend', (e) => { if (!selected) return; const p = toCell(e.changedTouches[0].x, e.changedTouches[0].y); if (isLegalMove(board, selected, p, myColor)) { sendMove(selected, p, ++step); } selected = null; });

本地预校验最大的价值不是防作弊,而是省流量和降低服务端压力:非法走子直接提示「马腿被蹩」,不用等一个网络往返。收到服务端sync后再用服务端棋盘重绘,保证双方看到的一致。对刚入门微信小程序开发的读者来说,这套「本地即时反馈 + 服务端权威同步」的组合是最稳妥的联机交互模型。

5. 联机小程序避坑记录:从真机调试到审核的六个坎

5.1 合法域名报错:开发工具能跑,手机一扫码就懵

现象:开发工具里勾了「不校验合法域名」玩得欢,真机预览时 WebSocket 连接直接被拒,报url not in domain list。原因:小程序真机环境强制校验 socket 合法域名,而且要求域名必须 ICP 备案、必须走 WSS。解决:在 mp 后台的「开发管理 → 开发设置 → 服务器域名」里配置 socket 合法域名,开发阶段临时用「不校验」选项调试,上线前一定配上。域名配置生效有延迟,刚改完别急着测,等几分钟再扫码。

5.2 切后台回来,棋盘不动了

现象:玩家切出去回消息再切回来,对方已经走了好几步,自己这边界面还停在旧棋盘,点哪里都没反应。原因:小程序在后台会被挂起,WebSocket 连接被系统回收或静默断开,onSocketClose有时不触发。解决:页面onShow里检查连接readyState,不是OPEN就重建连接,用roomId + openid重连,服务端补发一次sync全量棋盘。只重连不心跳,掉线还是发现不了,两个机制要配套。

5.3 落子重复提交,一步走了两次

现象:网络抖动时客户端重发 move,服务端收到两条相同消息,两步棋变一步,棋盘错乱。原因:消息没有序号,服务端无法区分「重试」和「新走子」。解决:客户端每次落子生成单调递增的step,服务端只接受step > room.step的消息;同时在收到sync确认前锁住棋盘,不允许连续落子。这个坑在小程序里尤其常见,因为wx.sendSocketMessage失败后重试是常规操作。

5.4 iPhone 上棋盘发虚、点击错位

现象:同样的 canvas 代码,Android 正常,iPhone 上模糊,点击位置偏移。原因:canvas 的 CSS 尺寸和物理像素不一致,触摸坐标拿的是 CSS 像素,绘制坐标没做 dpr 换算。解决:canvas.width = cssWidth * dpr,绘制前ctx.scale(dpr, dpr),触摸事件里用原始坐标和 CSS 坐标比较,两套坐标系才不会打架。这里最容易踩的坑是在scale之后忘记改回绘制线宽,导致 iPhone 上线条粗细不一。

5.5 将帅照面判不出来

现象:双方帅将同列,中间空着,程序居然认为合法,走着走着局势很诡异。原因:实现将军检测时只查了「对方棋子能否攻击到帅」,没把「帅将同列且中间无子」当成攻击路径。解决:在isKingAttacked里加规则:帅和将同列时,如果中间没有其他棋子,则先「照面」的一方视为被攻击,此局面非法。这个 bug 藏得很深,单测里必须写一组「同列对峙」的用例。

5.6 上线审核被要求补充游戏类目

现象:提交审核提示类目与功能不符,要求补充游戏相关资质,小程序每年年审时同理会卡。原因:小程序平台对「实时对战」类功能审查较严,棋牌游戏类目需要对应主体和资质。解决:个人开发者做学习演示,尽量把联机做成「好友约战 + 异步落子」或加入人机模式;正式发布前先确认主体类型和类目要求,不要等审核被拒再改架构。

6. 上线前的进阶一步:用对局回放和房主迁移把房间做牢

象棋联机做到能玩只是第一步,真正耐用的房间还需要两个能力:对局可回放、房主可迁移。

对局回放的实现很轻:服务端收到每次合法 move 后,把{seq, from, to, fen, ts}追加到房间的只增记录里,落库时按room_id + seq追加,不更新已有记录。前端回放时用setInterval按固定间隔逐条应用落子,或者做一个 slider 拖动到指定seq重绘。这样赛后复盘、异常对局排查、版本问题定位都有据可查。我早期只存最终棋盘,用户说「我明明赢了」时完全没法核对,加了对局记录后这类纠纷基本消失。

房主迁移处理的是「创建房间的人中途退出」:房间对象里单独存一个hostId,房主掉线超过保活窗口时,把hostId转给当前仍在线的观战者或另一位玩家,并广播一条host_changed消息。不做这个,房主一走整个房间就散,四个人约好的棋局全泡汤。

另外建议在服务端按房间加一个串行队列:同房间的消息按到达顺序逐个处理,绝不并发修改同一份棋盘。Node 单进程事件循环天然满足这个约束;如果用 Java 实现,要避免多线程直接改 board,否则两个线程同时校验同一局面,步序就会乱。

我的习惯是,每改一个联机规则,先跑一遍「双客户端 + 断网重连 + 乱序重发」的模拟脚本,再上真机。联机程序的问题九成在边界状态,不在正常流程。希望你做中国象棋联机小程序时能少踩这些坑,一次跑通。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询