ARTICLE DETAIL

资讯详情

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

微信小程序五子棋实战:Canvas绘图、WebSocket通信与游戏逻辑全解析

微信小程序五子棋实战:Canvas绘图、WebSocket通信与游戏逻辑全解析 1. 项目概述与核心价值最近在整理过往项目时翻到了一个挺有意思的练手作品——一个基于微信小程序平台开发的双人五子棋游戏。这个项目乍一看似乎很简单不就是画个棋盘、落个棋子、判断输赢嘛。但真正动手做起来你会发现它几乎涵盖了微信小程序前端开发中大部分核心知识点从基础的页面布局与样式WXML/WXSS到复杂的数据绑定与状态管理再到Canvas绘图、事件交互、实时通信WebSocket以及简单的AI逻辑。对于想系统学习小程序开发或者想找一个综合性实战项目练手的朋友来说这是一个绝佳的“麻雀虽小五脏俱全”的案例。这个双人五子棋小程序的核心目标是模拟一个完整的、可交互的对局环境。它不仅要实现本地双人对弈两人共用一部手机轮流下棋更要挑战通过网络实现远程实时对战。在这个过程中你会遇到如何高效绘制棋盘与棋子、如何精准处理用户的触摸落子事件、如何设计一套清晰的数据结构来记录棋盘状态并判断复杂的五子连珠胜负、以及如何利用WebSocket实现低延迟的双向通信等一系列实际问题。通过拆解和实现这个项目你能获得的远不止一个游戏而是一套解决前端交互、状态同步和网络通信问题的通用方法论。2. 项目整体设计与架构思路2.1 技术栈选型与考量为什么选择微信小程序原生开发首先它的生态成熟文档详尽对于学习移动端开发路径清晰。其次它提供了丰富的API如Canvas用于绘图WebSocket用于网络通信完全能满足我们这个项目的需求。最后小程序“即用即走”的特性也方便我们将成品分享给朋友进行实际对战测试。我们没有选择uniapp或多端框架是为了更深入地理解小程序原生的运行机制和性能特点避免框架抽象层带来的认知模糊。在数据管理上我们采用了小程序原生的Page数据对象和setData方法。对于五子棋这种状态相对集中一个15x15的二维数组足以描述全局的应用引入像Vuex或Redux这类状态管理库会显得过于重型。我们只需在Page的data中定义一个chessBoard数组和一个currentPlayer标识当前执子方所有视图的更新都通过setData驱动逻辑清晰且高效。网络通信方面双人远程对战是核心亮点。我们选择了WebSocket协议而非传统的HTTP轮询。原因很简单五子棋对战的交互是典型的“你一步我一步”的回合制且要求极低的延迟和实时状态同步。WebSocket提供了全双工通信通道一旦连接建立服务器可以主动向客户端推送落子信息避免了HTTP短连接反复建立的开销和延迟体验上更接近实时。2.2 核心模块划分根据功能我们将项目划分为以下几个核心模块视图渲染模块负责使用Canvas绘制棋盘网格、黑白棋子以及高亮显示最后落子位置或获胜连线。交互逻辑模块监听Canvas的触摸事件将触摸点坐标转换为棋盘网格索引处理落子合法性校验如位置是否已有棋子。游戏规则引擎模块这是大脑。它维护当前的棋盘状态数组执行落子操作并在每次落子后从横、竖、左斜、右斜四个方向扫描判断是否有连续五子以此裁定胜负。网络通信模块封装WebSocket连接负责创建房间、加入房间、发送落子坐标、接收对手落子信息以及处理断线重连等。状态与页面管理模块管理整个应用的生命周期和页面流包括首页模式选择、房间页、对战页、结果页等之间的跳转与数据传递。注意在项目初期务必先实现本地双人对战的所有逻辑并测试通过再去集成网络模块。这符合“分而治之”的原则能有效降低调试复杂度。本地逻辑是基础网络层只是传输层。3. 核心细节解析与实操要点3.1 Canvas绘图性能与精度的平衡棋盘和棋子的绘制是整个游戏的视觉基础。微信小程序的Canvas API与H5标准基本一致但有一些自己的优化和限制。初始化与适配 首先需要在WXML中定义Canvas组件并获取其上下文。这里有个关键点Canvas的尺寸最好使用px单位明确指定而不是依赖百分比因为绘图API是基于物理像素工作的。我们可以根据屏幕宽度动态计算一个合适的棋盘大小保证在不同尺寸手机上都能有良好的显示效果。// pages/game/game.js Page({ data: { boardSize: 600, // 动态计算得出例如屏幕宽度-边距 gridSize: 40, // 每个格子的像素大小 boardRows: 15, }, onReady: function () { const query wx.createSelectorQuery(); query.select(#chessBoard) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node; const ctx canvas.getContext(2d); this.ctx ctx; this.drawChessBoard(); // 初始化绘制棋盘 }); }, })绘制棋盘网格 绘制15x15的网格线。这里要注意线宽和颜色的选择。线宽太细在视网膜屏上可能看不清太粗又显得笨重。通常1px或2px的#b59a6d仿木色是不错的选择。绘制时采用循环画线的方式注意计算好起始点和结束点的坐标。绘制棋子 棋子是圆使用arc方法绘制。黑白棋分别用纯黑和纯白填充。这里有一个提升视觉质感的技巧为白棋添加一个淡淡的灰色阴影shadowBlur和shadowColor为黑棋添加一个高光点通过绘制一个更小的径向渐变白色圆点模拟可以立刻让棋子看起来立体。落子时我们需要将棋子的中心点精准对齐到网格交叉点上。实操心得频繁调用drawImage绘制棋子图片如PNG可能会比用arc绘制性能差且引入资源管理问题。对于五子棋这种固定样式的图形用代码绘制是更优解。但记得在drawChessBoard这种重绘整个棋盘的函数中要先用clearRect清空画布否则会层层叠加。3.2 触摸事件与坐标转换用户触摸屏幕落子我们需要将触摸点的坐标(clientX, clientY)转换为棋盘上的行列索引(row, col)。handleTap(e) { // 获取触摸点相对于Canvas的位置 const x e.touches[0].clientX - this.canvasOffsetLeft; const y e.touches[0].clientY - this.canvasOffsetTop; // 转换为棋盘网格索引 const gridSize this.data.gridSize; // 加上gridSize/2是为了实现“吸附”效果点击格子附近也能落到交叉点 let col Math.round(x / gridSize); let row Math.round(y / gridSize); // 边界检查 if (row 0 row this.data.boardRows col 0 col this.data.boardRows) { this.placeChess(row, col); // 尝试落子 } }这里的关键是获取Canvas元素距离屏幕顶部和左侧的距离this.canvasOffsetLeft/Top。由于小程序页面布局可能复杂直接使用e.touches[0].clientX/Y得到的是相对于屏幕的坐标。我们需要通过wx.createSelectorQuery()获取Canvas节点的位置信息并在每次页面渲染后更新这个偏移量确保坐标转换的准确性。注意事项触摸事件在快速连续点击时可能会被多次触发。为了避免连点导致重复落子可以在handleTap函数开始时设置一个锁标志isPlacing在落子逻辑处理和视图更新完成后再释放。或者使用WXML中catch:tap并配合数据驱动的视图更新利用setData的异步合并特性来避免。3.3 游戏规则引擎胜负判定算法这是项目的算法核心。我们用一个二维数组chessBoard来记录棋盘状态例如0代表空1代表黑子2代表白子。每次落子后我们需要以该子为中心向四个方向水平、垂直、左上-右下、右上-左下进行扫描统计连续的同色棋子数量。算法实现要点方向向量定义四个方向向量dirs [[1,0], [0,1], [1,1], [1,-1]]分别代表右、下、右下、左下。双向计数对于每个方向需要向正反两个方向计数。例如判断水平方向先向右数连续的同色棋子再向左数总和1当前子本身即为该方向上的连续数。终止条件向某个方向遍历时遇到棋盘边界、空位或异色棋子则停止。checkWin(row, col, player) { const board this.data.chessBoard; const dirs [[1, 0], [0, 1], [1, 1], [1, -1]]; // 四个方向 for (let [dx, dy] of dirs) { let count 1; // 当前落下的子 // 正向计数 for (let step 1; step 5; step) { const newRow row dx * step; const newCol col dy * step; if (this.isValidPosition(newRow, newCol) board[newRow][newCol] player) { count; } else { break; } } // 反向计数 for (let step 1; step 5; step) { const newRow row - dx * step; const newCol col - dy * step; if (this.isValidPosition(newRow, newCol) board[newRow][newCol] player) { count; } else { break; } } // 如果任意方向连续数达到5则获胜 if (count 5) { return true; } } return false; }这个算法的时间复杂度是O(1)因为每次只检查固定步长4步内的格子效率非常高。实操心得在实现时可以将获胜棋子的坐标记录下来用于在UI上高亮显示获胜连线这能极大提升用户体验。只需在count的同时将坐标存入一个临时数组即可。4. 实操过程与核心环节实现4.1 本地双人对战完整流程我们首先构建不依赖网络的本地对战版本这是所有功能的基础。步骤一初始化游戏状态在Page的data中初始化棋盘和游戏状态。data: { chessBoard: [], // 15x15的二维数组 currentPlayer: 1, // 1为黑方2为白方 gameOver: false, winner: 0, winLine: [], // 存储获胜连线的坐标 }, onLoad() { this.initBoard(); }, initBoard() { const rows 15; let board []; for (let i 0; i rows; i) { board.push(new Array(rows).fill(0)); } this.setData({ chessBoard: board, currentPlayer: 1, gameOver: false, winner: 0, winLine: [] }); this.drawChessBoard(); // 重绘画布 }步骤二落子逻辑整合将触摸事件、坐标转换、规则校验、胜负判定串联起来。placeChess(row, col) { if (this.data.gameOver) { wx.showToast({ title: 游戏已结束, icon: none }); return; } if (this.data.chessBoard[row][col] ! 0) { wx.showToast({ title: 此处已有棋子, icon: none }); return; } // 1. 更新数据层 const newBoard this.data.chessBoard; const currentPlayer this.data.currentPlayer; newBoard[row][col] currentPlayer; // 2. 绘制棋子到Canvas this.drawChess(row, col, currentPlayer); // 3. 检查胜负 if (this.checkWin(row, col, currentPlayer)) { this.setData({ gameOver: true, winner: currentPlayer, winLine: this.getWinLine(row, col, currentPlayer) // 获取获胜连线 }); this.highlightWinLine(); // 高亮显示连线 wx.showModal({ title: 游戏结束, content: ${currentPlayer 1 ? 黑方 : 白方}获胜, showCancel: false }); return; } // 4. 检查平局棋盘下满 if (this.isBoardFull(newBoard)) { this.setData({ gameOver: true }); wx.showModal({ title: 游戏结束, content: 棋盘已满平局, showCancel: false }); return; } // 5. 交换执子方 this.setData({ chessBoard: newBoard, currentPlayer: currentPlayer 1 ? 2 : 1 }); }步骤三视图更新drawChess函数根据行列索引和玩家标识在Canvas对应位置绘制棋子。highlightWinLine函数则遍历winLine数组在获胜棋子上或之间绘制高亮线条。至此一个功能完整的本地双人五子棋就实现了。你可以和身边的朋友在同一台设备上轮流点击屏幕进行对战。4.2 网络对战模块集成WebSocket本地对战跑通后我们引入WebSocket来实现远程对战。这里需要一个后端服务来处理房间管理和消息转发。为了简化我们可以使用Node.js ws库快速搭建或者使用云开发等BaaS服务。前端小程序WebSocket管理连接建立在进入房间页面时连接后端WebSocket服务器。connectSocket(roomId) { const socket wx.connectSocket({ url: wss://your-server.com/ws?roomId${roomId}userId${this.userId}, }); socket.onOpen(() { console.log(连接成功); wx.showToast({ title: 已连接对手, icon: success }); }); socket.onMessage((res) { const msg JSON.parse(res.data); this.handleSocketMessage(msg); // 处理服务器消息 }); socket.onClose(() { /* 处理断开 */ }); socket.onError(() { /* 处理错误 */ }); this.socket socket; }消息协议设计定义简单的JSON消息格式。// 客户端发送落子消息 { type: MOVE, row: 5, col: 5, player: 1 } // 服务器广播落子消息 { type: MOVE, row: 5, col: 5, player: 1, from: opponent } // 服务器发送游戏开始、玩家加入、游戏结束等消息 { type: GAME_START, blackPlayer: userA, whitePlayer: userB } { type: PLAYER_JOIN, userId: userB } { type: GAME_OVER, winner: 1 }落子同步在placeChess函数中落子成功后除了更新本地UI还要通过WebSocket发送MOVE消息给服务器由服务器转发给对手。同时在handleSocketMessage中监听MOVE消息当收到对手落子时调用同一个placeChess函数但可能需跳过某些本地校验来更新棋盘。handleSocketMessage(msg) { switch(msg.type) { case MOVE: // 注意这里需要区分消息来源避免自己下棋又处理一遍自己的广播 if (msg.from opponent) { this.placeChessFromRemote(msg.row, msg.col, msg.player); } break; case GAME_OVER: this.setData({ gameOver: true, winner: msg.winner }); break; // ... 处理其他类型消息 } } placeChessFromRemote(row, col, player) { // 一个简化版的落子函数只更新数据和视图不发送网络消息 const newBoard this.data.chessBoard; newBoard[row][col] player; this.drawChess(row, col, player); this.setData({ chessBoard: newBoard, currentPlayer: player 1 ? 2 : 1 // 切换当前玩家指示 }); // 同样需要检查胜负 if (this.checkWin(row, col, player)) { // ... 处理游戏结束 } }关键点状态同步。必须保证双方客户端的棋盘状态完全一致。任何一方的落子操作都必须经过服务器确认并广播另一方才能执行。所有游戏规则如胜负判定理论上应在服务端也做一次校验防止客户端被篡改作弊但对于练手项目可以暂在客户端进行以简化后端逻辑。4.3 房间管理与匹配逻辑一个完整的对战系统需要房间。我们可以设计一个简单的房间管理逻辑创建房间用户点击“创建房间”后端生成一个唯一的房间ID并返回给创建者同时将该用户加入房间。加入房间另一用户输入房间ID请求加入。后端检查房间是否存在且未满员然后允许加入并通过WebSocket通知房间内所有玩家“游戏开始”。状态同步房间内所有玩家的WebSocket连接都与这个房间ID绑定。服务器维护一个房间映射表MaproomId, Arraysocket当收到某个房间内一个客户端的MOVE消息时就遍历该房间的所有socket连接进行广播。注意事项网络延迟和断线重连是网络对战必须考虑的问题。对于五子棋这种回合制游戏可以在客户端加入一个“等待对手落子...”的提示状态。断线后应尝试自动重连并在重连成功后向服务器请求当前完整的棋盘状态chessBoard数组进行同步而不是只同步最后一步。5. 常见问题与排查技巧实录在开发过程中我遇到了不少坑这里总结几个典型问题和解决方法。5.1 Canvas绘图模糊或变形问题描述在部分高分辨率屏幕上Canvas绘制的线条和圆看起来模糊或者棋盘格子大小不一致。原因分析微信小程序的Canvas底层是原生组件其绘图像素与CSS像素存在一个pixelRatio设备像素比的映射关系。如果不做处理1px的线在Retina屏上会用2个物理像素绘制但可能因为缩放导致模糊。解决方案在获取Canvas节点后显式设置其width和height属性为实际CSS像素 * pixelRatio然后通过scale方法将上下文缩放回来。const dpr wx.getSystemInfoSync().pixelRatio; const canvas res[0].node; const ctx canvas.getContext(2d); canvas.width this.data.boardSize * dpr; canvas.height this.data.boardSize * dpr; ctx.scale(dpr, dpr); // 后续所有的绘图坐标都使用CSS像素值这样你在代码中仍然使用逻辑像素如gridSize40进行计算和绘制但Canvas内部是以更高的物理分辨率在渲染从而获得清晰的图像。5.2 触摸事件坐标不准问题描述点击屏幕时落子位置总是有偏移特别是在有滚动或复杂布局的页面上。原因分析e.touches[0].clientX/Y获取的是触点相对于屏幕左上角的坐标。而Canvas的绘图坐标系是相对于Canvas画布自身的左上角。你需要减去Canvas画布距离屏幕顶部和左侧的距离。解决方案使用wx.createSelectorQuery()动态获取Canvas节点的布局位置信息并存储起来用于坐标转换。注意这个位置可能在页面滚动或窗口变化时改变最好在onReady和onPageScroll等生命周期里更新它。updateCanvasPosition() { const query wx.createSelectorQuery(); query.select(#chessBoard).boundingClientRect(rect { this.canvasOffsetLeft rect.left; this.canvasOffsetTop rect.top; }).exec(); }5.3 setData频繁调用导致性能问题问题描述在快速落子或网络消息频繁时界面响应变慢。原因分析setData是小程序视图层和逻辑层通信的桥梁频繁调用或一次性设置大量数据会引起线程间通信开销和视图层不必要的重绘。优化策略数据合并将一次操作中需要更新的多个字段放在一次setData调用中。// 不佳 this.setData({ chessBoard: newBoard }); this.setData({ currentPlayer: nextPlayer }); // 更佳 this.setData({ chessBoard: newBoard, currentPlayer: nextPlayer });局部更新对于大型数组或对象如果只修改其中一小部分尽量只设置变化的部分。小程序基础库2.8.2支持使用数据路径。// 只更新棋盘数组的某一个位置 this.setData({ [chessBoard[${row}][${col}]]: player });与Canvas绘制解耦棋盘状态用setData更新但Canvas绘图直接调用ctx接口。两者独立避免因数据更新触发不必要的WXML节点渲染。5.4 WebSocket连接不稳定与重连问题描述在网络切换或服务器短暂波动时WebSocket连接断开游戏中断。解决方案实现一个带指数退避的重连机制。let reconnectAttempts 0; const maxReconnectDelay 10000; // 最大重连间隔10秒 function reconnect() { if (this.socket this.socket.readyState wx.WebSocket.OPEN) { return; } reconnectAttempts; const delay Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay); console.log(将在 ${delay}ms 后尝试第 ${reconnectAttempts} 次重连); setTimeout(() { this.connectSocket(this.data.roomId); }, delay); } // 在 onClose 事件中调用 socket.onClose(() { console.log(连接断开); if (!this.data.gameOver) { // 游戏未结束时才重连 this.reconnect(); } }); // 连接成功后重置重连计数 socket.onOpen(() { reconnectAttempts 0; });5.5 游戏状态同步冲突问题描述在网络对战中可能出现双方几乎同时落子或者收到乱序消息导致棋盘状态不一致。解决思路引入操作序列号或时间戳。每个合法的落子操作都由服务器分配一个递增的序列号。客户端必须按序列号顺序应用落子操作。如果收到序列号不连续的消息如收到了#5但#4还没到则将其缓存直到收到#4后再一起处理。这保证了即使在网络波动下所有客户端最终看到的操作顺序都是一致的。对于练手项目可以简化处理在客户端设计一个“锁定”状态当本地玩家点击落子后立即禁用棋盘点击或显示loading直到收到服务器对该操作的广播确认后才解除锁定并更新对手的棋盘。这样避免了本地和远程操作的即时冲突。6. 项目扩展与优化方向完成基础版本后这个项目还有很大的打磨和扩展空间能让它从一个“玩具”变得更像一个“产品”。1. 人机对战模式 实现一个不同难度的AI对手。最简单的可以是随机落子。中级难度可以使用“评分表”法为每个空位根据其周围棋子形势打分选择最高分位置落子。高级难度则可能需要实现 Minimax 搜索算法配合Alpha-Beta剪枝甚至接入一些开源的五子棋AI引擎。这能让你深入学习游戏AI的基本原理。2. 观战模式 允许其他用户通过房间号进入观战。观战者只接收服务器广播的落子消息并同步更新棋盘但不能进行操作。这需要后端区分玩家Socket和观众Socket并在广播消息时做区分。3. 游戏回放与复盘 记录每一局棋的每一步落子序列包括时间戳。结束后可以生成一个棋谱文件支持随时回放甚至进行棋局分析。这个功能对棋类小程序很有价值。4. 动画与音效增强体验 为落子、获胜连线添加平滑的动画如棋子掉落、连线绘制。添加触感反馈wx.vibrateShort和音效能极大提升游戏的沉浸感和操作爽感。音效文件要注意大小可以使用工具压缩。5. 引入云开发与排行榜 使用微信小程序云开发可以轻松实现后端逻辑云函数、数据库存储用户信息、战绩和存储存储棋谱。在此基础上实现一个基于ELO等级分或胜率的排行榜系统增加用户的粘性和竞争乐趣。我个人在完成这个项目后的体会是它就像一把瑞士军刀几乎用到了小程序开发中所有重要的基础能力。从Canvas绘图踩坑到WebSocket调试从触摸事件处理到状态同步设计每一个问题的解决过程都是实实在在的经验积累。尤其是网络对战部分它让你不得不思考客户端与服务器之间数据的一致性、消息的时序以及异常处理这些都是构建更复杂实时应用的基础。如果你能独立完成并处理好上述大部分细节那么你对微信小程序开发的理解就已经超越了入门级别具备了解决实际问题的能力。最后一个小建议开发过程中多使用微信开发者工具的调试器、Network和Storage面板它们是你定位问题最好的帮手。
返回列表