news 2026/9/15 19:49:42

微信小程序五子棋开发:从棋盘渲染到对局状态管理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微信小程序五子棋开发:从棋盘渲染到对局状态管理

简介:微信小程序双人五子棋项目实例,适合具备基础前端知识、想进阶小程序游戏开发的初学者与移动端爱好者。资源为完整可运行工程,解压后导入微信开发者工具即可直接体验双人对局。压缩包共10个文件,包含4个json配置文件(项目与页面配置)、3个js逻辑脚本(核心算法与工具函数)、2个wxss样式表和1个wxml页面结构,整体仅6KB,轻量清晰。该项目已有1895人学习使用。通过它可掌握小程序的目录结构、数据绑定与事件处理,重点理解15×15棋盘的绘制与点击落子、五子连珠胜负判断、悔棋记录以及双人轮流对弈的状态管理思路,项目模块划分清楚,为独立开发互动型小程序提供了可直接参考的样板。

1. 从双人五子棋看小程序棋盘渲染的选型边界

双人五子棋在小程序里看起来是个入门级项目,但真要把它写得顺手,第一个坑就出在棋盘渲染上:15×15 的交叉点,用 Canvas 逐帧重绘和用 view 组件堆格子,走的完全是两条技术路线。Canvas 的绘图 API 对小棋盘来说有点大材小用,而且在小程序里 canvas 的触摸事件坐标换算要额外处理,反而容易在 iOS 真机上出现点击偏移。这个项目实例正好拿来做对照组——它用纯 WXML + WXSS 铺棋盘、用 JavaScript 管状态,把博弈逻辑和渲染层彻底分开。适合刚接触微信小程序但已经写过一点前端页面的开发者,通过它你能同时看清小程序的视图层结构、事件冒泡机制、以及一局棋从落子到终局的完整状态机该怎么组织。理解了这个边界,后面换 Canvas 或者接人机对战算法时,才知道哪些能力是白送的、哪些得自己补。

2. 棋盘数据结构与 WXML 布局:先把格子铺对

2.1 用二维数组表示棋盘的依据

五子棋盘本质是一个二维坐标系统,15×15 的交叉点可以抽象成 15×15 的二维数组,每个元素只有三种状态:0(空)、1(黑子)、2(白子)。选数组而不是邻接表或者对象 Map,是因为后续做胜利判定时要频繁按坐标访问邻居节点,数组按下标访问是 O(1) 的,而且 JSON 序列化方便,存进 storage 做对局续传也容易。

这个项目里utils/util.js承担了棋盘初始化的职责,下面是一个标准的 15×15 初始化函数。

// utils/util.js function createBoard(size = 15) { const board = new Array(size); for (let i = 0; i < size; i++) { board[i] = new Array(size).fill(0); } return board; } module.exports = { createBoard };

createBoard接收棋盘边长size,默认 15。这里必须用new Array(size)先建外层,再对每个内层执行new Array(size).fill(0),不能图省事写new Array(size).fill(new Array(size).fill(0)),否则所有行会指向同一个数组引用,改一个点全盘跟着变。这是新手最容易踩的 JavaScript 引用陷阱。

2.2 页面数据与 WXML 双层循环渲染

棋盘放到页面里有两种常见做法:把整个二维数组塞进data,由 WXML 用wx:for双层遍历生成格子;或者只维护落子记录列表,渲染时动态计算格子坐标。前者直观,但每次setData要传整块二维数组,数据量在 15×15 场景下约 225 个数字,体积可控;后者数据少但渲染逻辑复杂,不适合这个项目的教学定位。

pages/index/index.wxml里,棋盘部分这样组织。

<view class="board"> <view class="board-row" wx:for="{{board}}" wx:for-item="row" wx:for-index="rowIndex" wx:key="rowIndex" > <view class="cell {{item ? 'cell-black' : (colValue === 0 ? '' : 'cell-white')}}" wx:for="{{row}}" wx:for-item="colValue" wx:for-index="colIndex" wx:key="colIndex" >/* pages/index/index.wxss */ .board { width: 690rpx; margin: 20rpx auto; background: #dcb35c; border-radius: 8rpx; padding: 12rpx; box-sizing: border-box; display: flex; flex-wrap: wrap; } .board-row { display: flex; width: 100%; } .cell { width: 44rpx; height: 44rpx; box-sizing: border-box; border-right: 2rpx solid #8b6914; border-bottom: 2rpx solid #8b6914; position: relative; } .cell:nth-child(15) { border-right: none; }

这里棋盘总宽 690rpx 减去 padding 24rpx,剩余 666rpx 除以 15 个格子约等于 44.4rpx,WXSS 不支持小数位精确计算,所以直接写 44rpx,最后一行格子会略微多出一点——视觉上看不出来,但如果你后续要给棋盘加星位标记,坐标定位就要用百分比或者calc(),别依赖累加式布局。

提示:棋盘底色用深色木纹色(#dcb35c),棋子用黑白圆点,这是五子棋项目的通用视觉方案,可读性最好。

2.4 落子事件与坐标换算

onCellTap是页面逻辑层的入口函数,负责把 WXML 传来的行列号落到数组并渲染棋子。

// pages/index/index.js Page({ data: { board: [], currentPlayer: 1, gameOver: false }, onLoad() { this.setData({ board: util.createBoard(15) }); }, onCellTap(e) { const row = e.currentTarget.dataset.row; const col = e.currentTarget.dataset.col; if (this.data.gameOver) return; if (this.data.board[row][col] !== 0) return; const board = this.data.board; board[row][col] = this.data.currentPlayer; this.setData({ board: board, currentPlayer: this.data.currentPlayer === 1 ? 2 : 1 }); } });

e.currentTarget.dataset.row取到的是字符串还是数字取决于 WXML 中的写法,这里我们在模板里硬编码了整数,但小程序的事件对象里 dataset 统一按字符串透传。严谨的做法是parseInt(e.currentTarget.dataset.row, 10),否则后续拿board[row]时 JavaScript 会隐式转换,能跑但容易埋藏类型隐患。

当前落子方用currentPlayer字段维护,1 为黑棋、2 为白棋。每次合法落子后切换,这就是最简单的回合状态机。到这里棋盘已经能下棋了,但缺了胜负判定,这一步留到下一章。

3. 胜利条件判定:从暴力四方向扫描到提前终止

3.1 为什么需要检查八个方向而不是四个

五子棋的连珠判定规则是:任一棋子在横、竖、左斜、右斜四个线性方向上连成五个及以上即为胜。注意「五个及以上」这个表述,实战中棋子可能形成六连甚至长连。标准五子棋规则下长连是否算赢在各地玩法里有差异,小程序项目里一般统一定为「大于等于五即胜」,避免规则分支。

四个线性方向的正反两侧加起来是八个方向,但写代码时只需要扫描四个基向量,每个基向量向正反双向延伸计数。四个基向量分别是:右方向(0, 1)、下方向(1, 0)、右下斜向(1, 1)、右上斜向(1, -1)

为什么不写成八个方向分别扫描?因为双向扫描需要做两次边界判断,代码重复度高,合并成一次单方向扩散更紧凑。

3.2 逐方向计数的胜利判定函数

下面是utils/util.js里的胜利判定实现,接收棋盘、落子行、落子列、当前玩家作为入参。

// utils/util.js function checkWin(board, row, col, player) { const directions = [ [0, 1], // 水平 [1, 0], // 垂直 [1, 1], // 右下斜 [1, -1] // 右上斜 ]; for (const [dx, dy] of directions) { let count = 1; // 正方向 for (let i = 1; i < 5; i++) { const nr = row + dx * i; const nc = col + dy * i; if (!isInBoard(nr, nc)) break; if (board[nr][nc] !== player) break; count++; } // 反方向 for (let i = 1; i < 5; i++) { const nr = row - dx * i; const nc = col - dy * i; if (!isInBoard(nr, nc)) break; if (board[nr][nc] !== player) break; count++; } if (count >= 5) return true; } return false; } function isInBoard(row, col) { return row >= 0 && row < 15 && col >= 0 && col < 15; }

这段代码的关键是循环变量i从 1 到 4,因为落子点本身的 count 已初始化为 1;向正方向最多延伸四格就足以覆盖「以当前点为中心形成五连」的全部可能,反方向同理。每次延伸前先做边界检查,再比对棋子颜色,一旦遇到空格或对方棋子就break掉当前方向。

每步落子都调用checkWin的代价是 O(4×8) 常数时间,在 225 格的棋盘上几乎可以忽略。但如果棋盘升级到 19 路或需要支持 AI 搜索,就要改成「增量更新胜利判定」——只维护四个方向上的连续棋子长度数组,每落一子更新受影响的坐标串,复杂度从 O(常数) 变成 O(1),这是后面要讲的进阶方向。

3.3 把判定接入落子流程

checkWin返回值是布尔类型,接入onCellTap时要在切换currentPlayer之前判断胜负。

// pages/index/index.js const util = require('../../utils/util.js'); Page({ // 省略 data 与 onLoad onCellTap(e) { const row = parseInt(e.currentTarget.dataset.row, 10); const col = parseInt(e.currentTarget.dataset.col, 10); if (this.data.gameOver) return; if (this.data.board[row][col] !== 0) return; const board = this.data.board; const player = this.data.currentPlayer; board[row][col] = player; if (util.checkWin(board, row, col, player)) { this.setData({ board: board, gameOver: true, winner: player }); wx.showModal({ title: '本局结束', content: (player === 1 ? '黑棋' : '白棋') + '胜', showCancel: false }); return; } this.setData({ board: board, currentPlayer: player === 1 ? 2 : 1 }); } });

这里注意一个时序问题:checkWin必须在currentPlayer切换前调用,否则拿到的player已经是对方。我习惯在函数开头先const player = this.data.currentPlayer保存当前值,后面所有逻辑共用这个局部变量,避免多次this.data读取之间状态被意外修改。

3.4 平局判定与对局状态机

平局条件是棋盘被下满(225 手)且无人胜出。需要维护一个moveCount字段,每次合法落子moveCount++,当计数达到 225 时触发平局弹窗。这里有一个隐含问题:是否需要在每步都遍历全盘检查剩余空位?不需要,moveCount维护的是已落子数,与棋盘剩余空位一一对应,O(1) 可得。

游戏状态机的完整流转是:playing(对局中)→blackWin/whiteWin/draw(终局)。页面里用gameOver布尔量加winner字段表达终局,状态维度拆成两个变量比维护一个枚举更直观,但代价是可能出现「gameOver为 true 但winner未赋值」的中间态——实际开发中可以在进入终局时统一用一个setData同时更新两个字段,保证数据一致。

4. 悔棋与重开:历史记录栈的设计时机

4.1 为什么悔棋要单独建栈而不是重新渲染棋盘

悔棋的朴素实现是「把当前落子点清空」,但这样只能悔一步,而且无法处理「悔两步后玩家改变路线」的持续性需求。正确做法是维护一个历史记录栈,栈里存每一步的坐标(行、列、玩家),悔棋时弹栈并清空对应格子。

从数据结构角度,历史记录栈本质是「操作序列」的持久化。它和棋盘二维数组构成互为备份的关系:棋盘数组代表当前快照,历史栈代表到达快照的路径。这一步区分很关键——如果你只存棋盘数组不存历史,悔棋时只能倒退一个状态,回退到任意历史点就要额外做整盘重放,O(n²) 开销下写起来又容易出错。

// pages/index/index.js Page({ data: { board: [], moveHistory: [], currentPlayer: 1, gameOver: false }, onCellTap(e) { // 落子逻辑,成功后执行追加 const move = { row: row, col: col, player: player }; const history = this.data.moveHistory.concat([move]); this.setData({ moveHistory: history }); }, onUndo() { if (this.data.moveHistory.length === 0) return; if (this.data.gameOver) return; const history = this.data.moveHistory.slice(0, -1); const lastMove = this.data.moveHistory[this.data.moveHistory.length - 1]; const board = this.data.board; board[lastMove.row][lastMove.col] = 0; this.setData({ board: board, moveHistory: history, currentPlayer: lastMove.player }); } });

悔棋操作回退currentPlayerlastMove.player,这意味着轮到刚悔棋的那一方重新落子。有些棋类 App 会做成「悔棋后轮到对方」,两种规则各有拥趸。项目里我建议采用lastMove.player,逻辑更自然——你悔的是自己刚下的那手,重新下的还是你。

4.2 悔棋边界:终局后是否允许

代码里在onUndo开头加了gameOver判断。实际项目里这里有两种产品决策:终局后禁止悔棋,或者终局后允许悔棋并回到对局中。前者实现简单,但玩家输棋想复盘会很憋屈;后者更友好,但需要同步重置gameOverwinner字段,并且currentPlayer回退到正确的一方。

我一般这样处理:终局弹窗里放「再来一局」和「回顾棋谱」两个按钮,回顾棋谱不靠悔棋实现,而是按历史重放。悔棋只存在于对局中,这样状态机边界清晰,不会出现「gameOver true 但棋盘能继续改」的脏状态。

4.3 重开一局的三种实现方式对比

重开功能有三种常见做法,按性能从劣到优排列。

方案实现适用场景
整页重启wx.reLaunch到当前页,清空全部 data最省事,但会白屏闪跳,观感差
重置数据重新调用onLoad里的初始化逻辑需要把初始化代码抽成公共函数,避免逻辑重复
增量清空遍历历史栈把已有棋子逐个清空数据量大时性能好,但实现复杂度最高

15×15 棋盘只有 225 格,选「重置数据」最合适。在onLoad里把initGame()拆出来,重开时直接调用它。

// pages/index/index.js Page({ initGame() { this.setData({ board: util.createBoard(15), moveHistory: [], currentPlayer: 1, gameOver: false, winner: 0 }); }, onLoad() { this.initGame(); }, onRestart() { this.initGame(); } });

initGame把所有对局相关字段统一重置,比散落各处的setData更好维护。注意这里currentPlayer永远从 1(黑棋)开始,如果项目要支持「黑白任选」,初始化参数要改成可配置。

5. 回放与导出:用 moveHistory 做棋谱的数据变现

5.1 按历史栈顺序重放对局

moveHistory除了悔棋,还能直接转化为「复盘回放」功能。回放的思路是:把历史栈逐条读出来,每读取一条就更新棋盘数组上对应坐标,配合setTimeoutwx.nextTick做时间间隔控制。

// pages/index/index.js Page({ onReplay() { if (this.data.gameOver === false) { wx.showToast({ title: '对局结束后才能回放', icon: 'none' }); return; } const history = this.data.moveHistory; const emptyBoard = util.createBoard(15); let step = 0; this.setData({ board: emptyBoard, replaying: true }); const timer = setInterval(() => { if (step >= history.length) { clearInterval(timer); this.setData({ replaying: false }); return; } const move = history[step]; const board = this.data.board; board[move.row][move.col] = move.player; this.setData({ board: board }); step++; }, 600); } });

这个实现里setInterval间隔固定 600ms,约等于每秒两步,适合人眼跟谱。参数可调:复盘用 300ms 快速浏览,教学用 1000ms 慢速分解。注意回放过程中不能让玩家点击棋盘落子,要在onCellTap开头加if (this.data.replaying) return;拦截。

5.2 导出对局记录的 JSON 结构

moveHistory序列化成 JSON 存到本地或发给朋友,是这个小程序项目加上「分享棋谱」功能的最短路径。微信小程序里写入本地文件的接口是wx.getFileSystemManager().writeFile,保存路径可以选wx.env.USER_DATA_PATH,这是小程序用户私有目录,不会污染缓存。

// pages/index/index.js onExport() { const history = this.data.moveHistory; const record = { version: 'gomoku-1.0', boardSize: 15, moves: history }; const filePath = wx.env.USER_DATA_PATH + '/replay-' + Date.now() + '.json'; const fs = wx.getFileSystemManager(); fs.writeFile({ filePath: filePath, data: JSON.stringify(record), encoding: 'utf8', success() { wx.showToast({ title: '棋谱已保存', icon: 'success' }); }, fail(err) { console.error('导出失败:', err); } }); }

version字段很重要,棋盘规则如果从 15 路扩到 19 路,老棋谱的boardSize与新版本不一致,回放前要先做校验。moves数组元素是包含rowcolplayer的对象,和moveHistory里的结构完全一致,实现了写入读出的双向兼容。

5.3 复盘中的一个隐蔽 Bug:定时器泄漏

代码里的setIntervalclearInterval(timer)前一直挂载。如果用户在回放中途点了返回上一页或直接退出小程序,定时器不会被释放,页面销毁后还在跑,轻则数据空转,重则触发setData警告。正确做法是页面onUnload里清除定时器。

// pages/index/index.js Page({ onUnload() { if (this.replayTimer) { clearInterval(this.replayTimer); } }, onReplay() { // 先清理上次残留定时器 if (this.replayTimer) clearInterval(this.replayTimer); let step = 0; this.replayTimer = setInterval(() => { // 回放逻辑 }, 600); } });

把定时器 ID 存到this.replayTimer而不是data里,因为定时器 ID 不需要驱动视图更新,放进data反而会多一次无谓的渲染。这是小程序性能优化里一个常被忽视的细分点:data里只放需要渲染到界面的数据,内部状态一律挂到this上。

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

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

湖南关键词优化排名推广避坑指南:新手建站不踩雷

湖南关键词优化排名推广避坑指南:新手建站不踩雷 不会代码想做网站?别急着找外包。很多湖南的中小企业老板或创业者,卡在第一步:想做个官网或商城,但不懂技术,怕被坑。这篇避坑指南,不讲虚的,只讲湖南本地做关键词优化排名推广时,新手最容易交智商税的地方。 一、 明确目标:别被“全站收录”忽悠…

作者头像 李华
网站建设 2026/9/15 19:45:31

用分数阶傅里叶变换(FRFT)实现chirp信号检测与参数估计

在雷达目标检测、水声通信、甚至是生物医学信号分析里&#xff0c;我经常碰到一类“频率随时间线性变化”的信号。这类信号叫chirp&#xff0c;也叫线性调频信号。直观说&#xff0c;它的瞬时频率是一条直线&#xff0c;要么往上扫、要么往下扫。问题在于&#xff0c;常规FFT一…

作者头像 李华
网站建设 2026/9/15 19:43:39

Linux磁盘幽灵空间排查:df满du没满的真相与处理

凌晨两点四十&#xff0c;监控平台的告警把值班手机震醒了。登录服务器一看&#xff0c;根分区或者说某个数据分区使用率已经冲到95%以上&#xff0c;df -h 红得刺眼。但等我跑了一遍 du&#xff0c;整个人都愣住了&#xff1a;系统里所有能看到的文件加起来&#xff0c;离 df …

作者头像 李华