十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

微信小游戏象棋源码解析:从规则引擎到Canvas渲染

微信小游戏象棋源码解析:从规则引擎到Canvas渲染 简介一份基于微信小程序平台的全民象棋完整游戏源码面向希望快速上手小程序游戏开发、同时理解棋类规则算法实现的开发者与前端初学者可直接用于实战学习与环境复现。项目围绕棋盘布局、棋子走法、吃子与将军判定、胜负逻辑等核心算法展开涵盖棋盘数据表示、走法合法性校验、被将军状态检测、胜负判断等模块同时覆盖WXML/WXSS页面搭建、JavaScript业务处理、云数据库存储及WebSocket多玩家同步等典型技术点。压缩包共二十一个文件包含十二个JSON配置、两个JS逻辑脚本、四个JPG与两个PNG图像素材、一个TXT说明文档整体体积仅1.16MB便于快速下载与导入微信开发者工具预览调试。已有202人浏览学习从中可以获得一套可运行的象棋小游戏工程熟悉从工程配置、资源目录到核心玩法实现的完整链路。同时可参考其中网络请求、身份验证和性能优化写法适合作为课程设计或小程序实战练手的起步模板。1. 小程序游戏源码全民象棋一份可导入微信开发者工具的小游戏对战工程解压全民象棋后第一眼就会注意到它和普通小程序项目长得不一样入口是 game.js 与 game.json而不是 app.js / app.json根目录同时存在 project.config.json 和 project.config(1).json带 (1) 的是系统重复下载生成的副本真正起作用的是不带括号的那个。项目以微信小游戏形态运行界面渲染走 Canvas不依赖 WXML 组件树对习惯了 Page/Component 的开发者来说光把项目跑起来就有不少小坑。整套源码覆盖棋盘布局、走子规则、胜负判断和触控交互适合想研究棋类算法在移动端落地的开发者也适合拿完整小游戏工程做二次开发的人。内容按工程配置、算法引擎、渲染交互、发布验证四条线逐层拆解每部分都给出可抄走的代码和参数。2. 配置文件拆解game.json、project.config.json 与资源版本表的用途2.1 三个配置文件的分工直接看文件列表app-config.json、project.config.json、game.json、version.json 各出现两遍其中带 (1) 后缀的都是操作系统在重复下载时生成的副本删掉不影响工程反而能让目录更干净。真正决定小游戏行为的是 game.json它声明设备方向、状态栏和网络超时project.config.json 决定开发者工具如何编译上传包含 appid 与 libVersionapp-config.json 在不少 Laya/Cocos 导出的工程里是引擎附加产物微信开发者工具识别的是 project.config.json两者冲突时以工具读到的为准。配置文件运行时开发者工具典型字段game.json读取决定渲染模式与横竖屏读取决定工具模拟方式deviceOrientation、showStatusBar、networkTimeoutproject.config.json不参与读取决定编译与上传appid、compileType、libVersion、miniprogramRootapp-config.json一般不参与部分引擎读取引擎版本、启动场景version.json资源加载时读取用于拼接 CDN 路径version 字段、资源 hashgame.json 里最实用的配置是 deviceOrientation。象棋是横版桌面对局写成 landscape 后手机上会自动横屏状态栏一并隐藏沉浸感也更接近原生小游戏{ deviceOrientation: landscape, showStatusBar: false, networkTimeout: { request: 10000, connectSocket: 15000 } }这里把 request 超时设置为 10 秒。单人本地对局不依赖服务器超时长一点没有感知如果后续接入云开发或 WebSocket 联机connectSocket 保持 15 秒能减少弱网环境下握手失败的概率。showStatusBar 置为 false 后状态栏消失但绘制棋盘时依然要用 wx.getSystemInfoSync() 读取 safeArea 再做顶部留白否则全面屏机型的刘海区域会遮挡轮次信息。2.2 导入开发者工具的正确姿势用微信开发者工具新建“小游戏”项目目录指到解压根目录AppID 先用测试号。测试号无需在小程序后台注册本地编译和模拟器都能跑想在手机真机预览再把 project.config.json 中的 appid 替换成自己的。很多导入失败的现象并不是代码问题而是 miniprogramRoot 指向错误。{ appid: touristappid, compileType: game, libVersion: 2.30.4, miniprogramRoot: ./ }compileType 必须为game而不是miniprogram否则工具会按 Page/Component 的编译链路处理直接报找不到 app.json。libVersion 建议不低于 2.20Canvas 2D 接口在低版本上表现不稳定真机上会出现 drawImage 不绘制的问题。工具导入后如果提示“找不到入口文件”优先检查根目录是否真的有 game.js以及 project.config.json 里的路径是否多写了一层嵌套目录。2.3 res 资源目录与 version.json 的缓存机制小游戏主包限制 4MB棋盘背景、棋子图标、音效都放进 res 后很容易超限。version.json 常见做法是记录每个资源的版本号发布时由构建脚本生成 hash运行时代码加载时拼接版本参数避免 CDN 缓存雪崩// 加载资源时拼接版本号避免旧的 CDN 缓存 const versionMap require(./version.json); function assetUrl(name) { return res/${name}?v${versionMap[name] || 1}; }这样每次后端更新资源后只要改动 version.json客户端就能拉到新资源不必发版。项目里 ZhongGuoXiangQi 目录一般在导出工具中对应工程名是否参与编译要看 project.config.json 的 packOptions.ignore 配置如果工具没报错不建议手动改它。到这里应该理解整个项目并不只是 game.js 一个文件配置与资源版本管理占了相当比重这也是从 demo 走向可维护工程的分水岭。3. 象棋规则引擎棋盘建模、走子检测与将军判断的实现路径3.1 棋盘数据结构与棋子编码象棋棋盘是 9 列 10 行基于小游戏 Canvas 的渲染逻辑最直接的数据结构是二维数组 board[row][col]。用行优先而不是列优先是为了渲染时按行遍历自然对应屏幕 y 坐标避免频繁行列互换。棋子用 1 到 7 表示红方11 到 17 表示黑方这样通过整数大小即可判断阵营也方便输出日志时直接打印数字const ROWS 10; const COLS 9; // 0 空红方1帅 2仕 3相 4马 5车 6炮 7兵 // 黑方11将 12士 13象 14马 15车 16炮 17卒 function isRed(p) { return p 1 p 7; } function isBlack(p) { return p 11 p 17; } function sideOf(p) { return p 0 ? : (isRed(p) ? red : black); }这里把红黑用 10 的差值分开优势是类型判断和转换都很快副作用是如果后续做悔棋、拆解棋谱需要额外记录棋子原始编码而不是当前位置。初始化时把红方放在 row 0-4黑方放在 row 5-9对应的字形资源用 res 目录里的图片按 type 索引。3.2 走子规则纯几何可达性与吃子判定每个棋子的合法走法可以抽象成两个层次第一步是纯几何可达即不考虑“走后是否被将军”第二步是合法性过滤把会让己方将帅暴露的走法剔除。几何层用一个按棋子类型分发的函数表后续要扩展新棋种或规则变体时只需要增删映射不需要改动触摸和渲染逻辑function getRawMoves(r, c, board) { const p board[r][c]; if (p 0) return []; const side sideOf(p); const type p 10 ? p - 10 : p; switch (type) { case 1: return generalMoves(r, c, board, side); case 2: return adviserMoves(r, c, board, side); case 3: return elephantMoves(r, c, board, side); case 4: return horseMoves(r, c, board, side); case 5: return rookMoves(r, c, board, side); case 6: return cannonMoves(r, c, board, side); case 7: return soldierMoves(r, c, board, side); default: return []; } }函数的入参固定为 (r, c, board, side)方便单元测试直接喂边界数据。以马为例蹩马腿的实现是把八个方向的目标偏移与腿偏移一一对应function horseMoves(r, c, board, side) { const targets [[-2,-1],[-2,1],[2,-1],[2,1],[-1,-2],[-1,2],[1,-2],[1,2]]; const legs [[-1,0],[-1,0],[1,0],[1,0],[0,-1],[0,1],[0,-1],[0,1]]; const result []; for (let i 0; i 8; i) { const nr r targets[i][0], nc c targets[i][1]; if (nr 0 || nr ROWS || nc 0 || nc COLS) continue; if (board[r legs[i][0]][c legs[i][1]] ! 0) continue; const tp board[nr][nc]; if (tp 0 || (isRed(tp) ! isRed(board[r][c]))) result.push([nr, nc]); } return result; }说明targets 是马跳动的八个终点偏移legs 是八个方向对应的马腿坐标偏移二者下标一一对应顺序错了就会出现“横跳时蹩竖腿”的怪异现象。判断目标点时用 isRed 是否相同来排除己方棋子比单独比较 side 字符串更省事。炮的走法要单独处理因为它吃子时必须隔一个“炮架”function cannonMoves(r, c, board, side) { const result []; const dirs [[0,1],[0,-1],[1,0],[-1,0]]; for (const [dr, dc] of dirs) { let nr r dr, nc c dc, jumped false; while (nr 0 nr ROWS nc 0 nc COLS) { const tp board[nr][nc]; if (!jumped) { if (tp 0) { result.push([nr, nc]); } else { jumped true; } } else { if (tp ! 0) { if (isRed(tp) ! isRed(board[r][c])) result.push([nr, nc]); break; } } nr dr; nc dc; } } return result; }jumped 标志位区分“翻山前”与“翻山后”两个阶段。容易踩的坑是炮在跳过一个棋子后继续向后走不能再跳过第二个棋子所以碰到第一个非空子才置位碰到第二个非空子要么吃子要么中断循环。车和帅的直线走法用同一套方向数组区别只在边界条件与九宫限制不在这里展开。3.3 将军检测与合法着法过滤isInCheck 需要先定位将帅位置再遍历对方全部棋子的原始可达位置如果直接把 getRawMoves 用于每步合法过滤会产生“将军检测又调走法”的递归因此关键是把“几何可达”和“合法性”分层function getLegalMoves(r, c, board) { const p board[r][c]; const side sideOf(p); const raw getRawMoves(r, c, board); const legal []; for (const [tr, tc] of raw) { const next board.map(row row.slice()); next[tr][tc] p; next[r][c] 0; if (!isInCheck(next, side)) legal.push([tr, tc]); } return legal; }board.map(row row.slice()) 是浅拷贝二维数组的常用写法因为一维元素是数字不存在引用共享问题。每步克隆棋盘的开销对 10x9 的规模完全可接受如果未来把搜索层数推到三层以上再改成增量式 move/unmove。isInCheck 的实现里还需要补一条“将帅照面”规则双方将帅在同一列且中间没有任何棋子时视为被将军哪怕帅旁边没有其他攻击子function isInCheck(board, side) { const general side red ? 1 : 11; let gr -1, gc -1; for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { if (board[r][c] general) { gr r; gc c; } } } if (gr -1) return true; // 将帅消失视为输 const enemy side red ? black : red; for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { const tp board[r][c]; if (tp ! 0 sideOf(tp) enemy) { if (getRawMoves(r, c, board).some(([tr, tc]) tr gr tc gc)) return true; } } } // 将帅照面 const oppGeneral side red ? 11 : 1; for (let r 0; r ROWS; r) { if (board[r][gc] oppGeneral) { let blocked false; for (let k Math.min(gr, r) 1; k Math.max(gr, r); k) { if (board[k][gc] ! 0) { blocked true; break; } } if (!blocked) return true; } } return false; }两个值得注意的边界一是找不到本方的将帅直接返回 true避免搜索时空指针二是照面检测按列扫描时初始位置 gr 和对方将帅的 r 之间必须无子一旦有子则不构成将军。到此rules 层与 render 层解耦后续无论是加电脑 AI 还是联机同步都只需要调度 getLegalMoves规则引擎可以在 Node 环境里单独跑单元测试不需要拉起整个小程序。4. Canvas 渲染与触控交互象棋棋盘的 selection 状态机实现4.1 坐标换算从 clientX 到棋盘行列小游戏没有 WXML/WXSS 组件树所有界面绘制都在 Canvas 中进行这是它和普通小程序最本质的差异。触摸事件拿到的坐标是屏幕像素而走子逻辑需要的是 row/col。换算公式依赖棋盘在屏幕上的绘制起点和单格边长实际项目里棋盘画在 Canvas 居中位置所以先缓存棋盘矩形const ORIGIN { x: 24, y: 48 }; // 棋盘左上角在 canvas 中的像素坐标 const CELL_W 32; // 每格宽 const CELL_H 32; // 每格高 function toBoardCoord(clientX, clientY) { const col Math.floor((clientX - ORIGIN.x) / CELL_W); const row Math.floor((clientY - ORIGIN.y) / CELL_H); return { row, col }; }Math.floor 之后坐标系原点在棋盘左上角col 和 row 的取值范围分别是 0-8 与 0-9。竖屏模式下 ORIGIN.y 一般比 ORIGIN.x 大因为顶部需要给状态栏和轮次信息留位置。换到不同尺寸屏幕时CELL_W 和 CELL_H 要按 Canvas 实际宽高等比缩放否则在 iPhone 与 Android 上点击位置会整体偏移。4.2 选中、落子、切换一个简单的状态机对局交互本质是有限状态机空闲态到选中态再到落子或换选。用变量 selected 保存当前选中的棋子合法目标列表 legalTargets 由 getLegalMoves 计算。触摸结束事件里只做两件事判断当前格子是否可落子可落子就移动否则重新选中let selected null; let legalTargets []; wx.onTouchStart(() { const { row, col } toBoardCoord(...); if (selected legalTargets.some(t t[0] row t[1] col)) { movePiece(selected, row, col); selected null; legalTargets []; } else { selected { row, col }; legalTargets getLegalMoves(row, col, board); } render(); });这段代码有个隐含策略点击空白或敌方棋子时直接进入新选中态。如果在选中态点自己另一个棋子应该切换选中对象而不是执行移动上面代码在 else 里统一重新选中正是覆盖了这种情况。movePiece 内部完成棋盘数据更新和回合切换function movePiece(from, row, col) { board[row][col] board[from.row][from.col]; board[from.row][from.col] 0; currentSide currentSide red ? black : red; if (isInCheck(board, currentSide)) { showCheckToast(currentSide); } }movePiece 没有做二次合法性校验因为调用前已经用 legalTargets 过滤过。如果后续要接网络对战必须在服务端也执行一遍 getLegalMoves防止恶意客户端直接提交非法着法。4.3 渲染循环与重绘调度小游戏里可以依赖 requestAnimationFrame 驱动渲染但象棋这种回合制场景不必每帧重绘在 touch 事件或 movePiece 之后手动 render 一次即可省电且帧率稳定。真正需要持续渲染的是后期加“思考中”动画或走子补间时再单独开一个动画循环。渲染函数按“棋盘线到棋子到高亮到轮次信息”的顺序画避免后画的元素被覆盖function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); drawBoardLines(); drawHighlights(); // 选中格与合法目标点 drawPieces(); drawRoundInfo(); // 当前回合与将军提示 }drawHighlights 用半透明色块标注可落子位置玩家会先看到提示再落子反馈比单纯拖动更明确。真机上如果出现闪烁多半是 clearRect 与 draw 之间没有关闭抗锯齿或者每次 draw 前忘记设置 fillStyle。性能瓶颈通常不在绘制本身而在 drawImage 高频解码大图棋盘背景压缩到 512 宽以内真机渲染耗时会明显下降。5. 从模拟器到真机小游戏包体控制与调试发布技巧5.1 用 vConsole 和经验日志定位规则 bug小游戏里 console.log 默认不显示开发者工具模拟器里能看到真机预览时只有打开调试面板才能看到。常见做法是在开发版里手动引入 vConsole 逻辑或者在关键函数里把日志写入一个内存数组滑动屏幕时查看最近 N 条const logs []; function log(msg) { logs.push([${Date.now()}] ${msg}); if (logs.length 100) logs.shift(); console.log(msg); }日志数组在规则引擎里尤其好用因为走子合法性的 bug 往往要回溯到前几步才能定位只靠 console 打点很难复现点击序列。5.2 包体审计与资源瘦身上传前先在开发者工具里看“代码依赖分析”主包体积超过 4MB 时优先压缩 res 目录。一个实际可落地的压缩基线如下资源类型建议格式体积预期棋盘背景JPEG 80% 质量100-200KB棋子图片PNG 索引色单张 5-15KB音效MP3 64kbps单段 10-30KB字体仅保留常用字不超过 500KB如果 res 还是太大把音效和棋子图拆到 CDN通过 version.json 管理 hash。注意小游戏主包 4MB 限制包含代码和所有本地资源没有任何豁免路径真机上报“分包加载失败”时先看是不是触发了主包体积上限。5.3 体验版上传与联机扩展上传前在 project.config.json 填入正式 appid开发者工具左上角“上传”按钮会生成体验版二维码。多人对局如果要保存棋谱可以用微信云开发的数据库每走一步写入一条带对局 id 的记录再通过实时数据推送做同步。云开发集合授权默认只允许创建者读写体验版测试时要给集合添加所有人可读的权限规则否则第二个测试账号登录后看不到棋局记录。本文还有配套的精品资源点击获取
返回列表