最近做小程序练手项目时很多同学问我“能不能不借助游戏引擎只靠小程序原生能力做一个消除类小游戏”答案是肯定的。“彩色消除”属于经典的三消玩法核心是棋盘数据、消除判定、下落填充和交互反馈这些逻辑用原生 JavaScript 就能实现甚至不需要后端参与。本文就用微信小程序从零实现一个 8×8 的彩色消除小游戏包含完整代码、算法拆解、运行效果和常见报错排查。新手可以跟着一步一步搭有基础的开发者也可以直接拿去改造成自己的项目。1. 彩色消除小游戏的玩法与实现思路1.1 彩色消除是什么彩色消除可以理解为“三消”游戏的一种在一个正方形的棋盘里随机分布着多种颜色的方块。玩家每次交换相邻的两个方块如果横排或竖排出现三个或以上相同颜色的方块这些方块就会被消除消除后上方方块下落棋盘顶部再补充新的随机方块如果新方块又形成了三连就继续消除形成连锁反应。这个玩法看起来简单但涉及的核心逻辑并不少如何生成一个“开局没有三连”的棋盘如何判断横排和竖排是否存在三个及以上同色方块如何删除被消除的方块如何让上方方块下落并在顶部补足新方块如何判断一次交换是否合法如何判断游戏是否结束如何设计分数、步数、最高分等状态。在微信小程序里实现这套逻辑可以完全不依赖后端所有数据都存在前端数组里。项目结构清晰代码量适中非常适合用来练习小程序开发。1.2 小程序页面方案与小游戏方案这里要先区分两个概念微信小程序和微信小游戏。微信小游戏属于“小游戏”类目通常使用 Canvas、WebGL 或游戏引擎开发包体限制、API 和审核规则都与普通小程序不同。而“彩色消除”用普通的 WXML WXSS JavaScript 页面渲染也能做本质上是“带游戏玩法的普通小程序”。本文选择普通小程序页面方案原因有三不需要额外注册小游戏类目个人开发者也方便创建体验版WXML 声明式渲染适合展示网格数据逻辑容易理解核心算法与引擎无关以后迁移到小游戏或 Web 端都很方便。如果你的目标是发布到微信“小游戏”类目那需要另外注册小游戏 AppID并使用小游戏 API。本文重点是玩法算法和小程序交互可以认为是一份“三消游戏逻辑 小程序页面渲染”的通用教程。1.3 核心技术点概览在动手前先明确要掌握的技术点技术点说明二维数组用board[row][col]表示棋盘格子颜色随机生成生成棋盘时避免出现初始三连匹配检测遍历行和列找连续三个及以上同色消除重置将匹配到的格子标记为空重力下落每列从下往上压缩非空格子顶部补随机颜色连锁检测消除后再次检测直到没有三连点击交互用dataset获取格子坐标完成选中和交换状态存储用本地缓存保存历史最高分下面从环境准备开始逐步实现。2. 环境准备与项目结构2.1 开发环境说明微信小程序开发需要以下基础环境微信开发者工具建议使用最新稳定版一个微信小程序 AppID。个人开发者可以注册个人主体小程序也可以先用测试号体验基础 CSS / JavaScript 知识。版本说明本文的代码不依赖某个特定基础库的新特性使用常规的 WXML、WXSS、JavaScript 和小程序 API。如果你的基础库版本过低建议在开发者工具中把调试基础库调到 2.x 以上具体版本可根据项目实际情况调整重点是理解配置和代码思路。2.2 创建项目与目录结构打开微信开发者工具选择“小程序”项目填入 AppID 或使用测试号创建空白项目。项目目录结构如下color-match/ ├── app.js ├── app.json ├── app.wxss ├── sitemap.json └── pages/ └── index/ ├── index.js ├── index.json ├── index.wxml └── index.wxss如果创建模板时自动生成了其他文件可以删除只保留上面这些。本文示例都以这个目录为基础。2.3 页面配置与整体布局在app.json中配置页面路径和窗口标题{ pages: [ pages/index/index ], window: { navigationBarTitleText: 彩色消除, navigationBarBackgroundColor: #2f3542, navigationBarTextStyle: white, backgroundColor: #f1f2f6 }, style: v2, sitemapLocation: sitemap.json }sitemap.json使用微信开发者工具默认内容即可{ desc: 关于本文件的更多信息请参考微信官方文档, rules: [ { action: allow, page: * } ] }页面的整体 UI 分成三块顶部工具栏显示当前分数、剩余步数、重新开始按钮中间棋盘8×8 的彩色方块区域游戏结束弹层显示本局得分和历史最高分。这样在体验上更接近完整的小游戏又不至于太复杂。3. 数据模型设计与棋盘生成3.1 用二维数组描述棋盘三消游戏最核心的数据结构是二维数组。约定棋盘大小ROWS 8COLS 8颜色数量COLOR_COUNT 5对应 5 种颜色每个格子的值是一个数字例如 0 到 4被消除的格子用EMPTY -1表示。在index.js顶部定义常量const ROWS 8; const COLS 8; const COLOR_COUNT 5; const EMPTY -1;这样棋盘数据就是一个二维数组board[0] [3, 1, 0, 2, 4, 1, 0, 3] board[1] [2, 0, 3, 4, 1, 2, 3, 0]渲染时颜色值 0~4 对应不同的 CSS 背景类。3.2 随机生成棋盘并避免初始三连如果每一步都完全随机生成开局很可能直接出现好几个三连玩家还没开始操作就自动消除体验不好。所以生成棋盘时要额外处理每放入一个颜色先检查它左边两个格子和上边两个格子如果会形成横向或竖向三连就换一个颜色。生成逻辑function randomCellColor(board, row, col) { const exclude new Set(); // 横向检查左边连续两个格子 if (col 2 board[row][col - 1] board[row][col - 2]) { exclude.add(board[row][col - 1]); } // 纵向检查上边连续两个格子 if (row 2 board[row - 1][col] board[row - 2][col]) { exclude.add(board[row - 1][col]); } const colors []; for (let c 0; c COLOR_COUNT; c) { if (!exclude.has(c)) { colors.push(c); } } return colors[Math.floor(Math.random() * colors.length)]; } function createBoard() { const board []; for (let row 0; row ROWS; row) { board[row] []; for (let col 0; col COLS; col) { board[row][col] randomCellColor(board, row, col); } } return board; }这段代码的关键点Set用来存储当前格子“不能取的颜色”只有左边两个相同且和当前位置相邻时左边颜色才被排除只有上边两个相同且和当前位置相邻时上边颜色才被排除由于生成顺序是从左到右、从上到下检查依赖的格子一定已经生成。如果棋盘玩到后期出现死局也就是所有格子填满但没有任何可交换操作通常的处理方式是重开或洗牌。本文在“游戏结束判断”里会处理这种情况。3.3 WXML 渲染彩色棋盘为了让 WXML 渲染时有稳定的wx:key页面数据里除了保存二维数组board还需要保存一份带唯一 key 的viewBoard。转换函数function toViewBoard(board) { return board.map((row, r) ({ rowKey: row- r, cells: row.map((color, c) ({ key: r - c, color })) })); }在index.wxml中使用两层循环渲染view classboard view classrow wx:for{{viewBoard}} wx:for-itemrow wx:keyrowKey view classcell color-{{item.color}} {{selected selected.row index selected.col idx ? selected : }} wx:for{{row.cells}} wx:for-itemitem wx:for-indexidx wx:keykey >.page { padding: 30rpx; background: #f1f2f6; min-height: 100vh; } .board { display: flex; flex-direction: column; background: #dfe4ea; border-radius: 16rpx; padding: 10rpx; } .row { display: flex; } .cell { width: 80rpx; height: 80rpx; margin: 6rpx; border-radius: 12rpx; transition: transform 0.15s ease; } .color-0 { background: #ff6b6b; } .color-1 { background: #ffa502; } .color-2 { background: #2ed573; } .color-3 { background: #1e90ff; } .color-4 { background: #a55eea; } .selected { transform: scale(0.9); box-shadow: 0 0 12rpx rgba(0, 0, 0, 0.35); }如果棋盘格数比较大建议使用rpx或vw动态计算格子大小这里 8×8 的固定尺寸在多数手机上都能正常显示。4. 核心算法消除、下落与连锁4.1 查找可消除的三连三消的匹配规则是逐行扫描连续三个及以上同色逐列扫描连续三个及以上同色。findMatches返回所有需要消除的格子坐标数组function findMatches(board) { const matches []; // 横向检测 for (let row 0; row ROWS; row) { let col 0; while (col COLS) { const value board[row][col]; if (value EMPTY) { col; continue; } let end col; while (end 1 COLS board[row][end 1] value) { end; } if (end - col 1 3) { for (let c col; c end; c) { matches.push({ row, col: c }); } } col end 1; } } // 纵向检测 for (let col 0; col COLS; col) { let row 0; while (row ROWS) { const value board[row][col]; if (value EMPTY) { row; continue; } let end row; while (end 1 ROWS board[end 1][col] value) { end; } if (end - row 1 3) { for (let r row; r end; r) { matches.push({ row: r, col }); } } row end 1; } } return matches; }注意同一个格子如果既在横向三连中又在纵向三连中可能会被多次加入matches。消除时重复赋值不影响结果但统计得分时要先做去重。4.2 标记并消除当拿到matches数组后把所有对应格子置为EMPTYfunction clearMatches(board, matches) { for (const item of matches) { board[item.row][item.col] EMPTY; } }这里假设已经存在一个函数applyGravity清除后马上让上方方块下落。4.3 下落与顶部补充下落的本质是按列压缩从每一列的最底部开始往上遍历遇到非空方块就移动到当前可写入的最下方位置遍历结束后列顶部空出来的位置填入新的随机颜色。function applyGravity(board) { for (let col 0; col COLS; col) { let writeRow ROWS - 1; for (let row ROWS - 1; row 0; row--) { if (board[row][col] ! EMPTY) { board[writeRow][col] board[row][col]; if (writeRow ! row) { board[row][col] EMPTY; } writeRow--; } } for (let row writeRow; row 0; row--) { board[row][col] Math.floor(Math.random() * COLOR_COUNT); } } }这个函数的关键在于writeRow指针。它从底部开始始终指向“下一个非空方块应该放到的位置”。从下往上遍历时底部的方块会先落到最下面上面的方块依次往下压最后顶部空出的位置统一填充新颜色。4.4 连锁结算与得分消除后补充新方块新方块可能再次形成三连所以需要一个循环函数直到棋盘稳定。function resolveBoard(board) { let totalCount 0; let chainCount 0; let matches findMatches(board); while (matches.length 0) { chainCount; const uniqueMatches new Set( matches.map((item) item.row - item.col) ); totalCount uniqueMatches.size; clearMatches(board, matches); applyGravity(board); matches findMatches(board); } return { count: totalCount, chains: chainCount }; }返回的count表示本回合总共消除的方块数chains表示连锁次数。分数可以简单用count * 10来计算也可以在连锁出现时增加额外倍率。5. 交互逻辑选中、交换与游戏结束5.1 点击选中与相邻判断用户点击第一个格子时把它标记为选中点击第二个格子时判断两个格子是否相邻。相邻的定义是曼哈顿距离等于 1也就是上下左右相邻不包括斜对角function isAdjacent(a, b) { return Math.abs(a.row - b.row) Math.abs(a.col - b.col) 1; }index.js中的页面方法onCellTap(e) { if (this.data.gameOver) return; const row Number(e.currentTarget.dataset.row); const col Number(e.currentTarget.dataset.col); const current { row, col }; const selected this.data.selected; if (!selected) { this.setData({ selected: current }); return; } // 点击同一个格子取消选中 if (selected.row row selected.col col) { this.setData({ selected: null }); return; } // 点击了不相邻的格子切换选中到新格子 if (!isAdjacent(selected, current)) { this.setData({ selected: current }); return; } // 尝试交换 this.trySwap(selected, current); }5.2 交换并检测可消除性交换前要复制一份棋盘避免直接修改this.data.board。如果交换后没有产生任何三连就把交换撤销如果产生了消除就保留交换后的棋盘并更新分数和步数。trySwap(a, b) { const copy this.data.board.map((row) [...row]); swap(copy, a, b); const result resolveBoard(copy); if (result.count 0) { const newScore this.data.score result.count * 10; const newSteps this.data.steps - 1; this.setData({ board: copy, viewBoard: toViewBoard(copy), selected: null, score: newScore, steps: newSteps }); // 轻触震动反馈模拟器不生效时可忽略 wx.vibrateShort({ type: light, fail: () {} }); this.checkGameOver(); } else { // 交换无效取消选中棋盘不变 this.setData({ selected: null }); } }swap函数function swap(board, a, b) { const temp board[a.row][a.col]; board[a.row][a.col] board[b.row][b.col]; board[b.row][b.col] temp; }这里的优化点如果不想在每次消除时都整体setData整个棋盘可以只更新变化的部分但对 8×8 的小棋盘来说整体刷新完全够用代码也更好维护。5.3 步数与最高分步数在每次有效交换后减少 1。这里要注意“有效交换”的定义是交换后真的产生了消除。如果玩家点击相邻方块但没有消除步数不应该减少。最高分使用本地缓存保存saveBestScore() { const best wx.getStorageSync(bestScore) || 0; if (this.data.score best) { wx.setStorageSync(bestScore, this.data.score); this.setData({ bestScore: this.data.score }); } }页面读取最高分onLoad() { this.initGame(); const best wx.getStorageSync(bestScore) || 0; this.setData({ bestScore: best }); }5.4 判断是否还有可行操作当步数还有剩余但棋盘已经找不到任何可以交换出三连的操作时游戏也应该结束。这里用一个搜索函数枚举所有相邻交换只要有一次交换后能产生三连就说明游戏还能继续function hasValidMove(board) { for (let row 0; row ROWS; row) { for (let col 0; col COLS; col) { const neighbors [ { row: row 1, col }, { row, col: col 1 } ]; for (const nb of neighbors) { if (nb.row ROWS || nb.col COLS) continue; const copy board.map((r) [...r]); swap(copy, { row, col }, nb); if (findMatches(copy).length 0) { return true; } } } } return false; }检查游戏结束checkGameOver() { const noStep this.data.steps 0; const noMove !hasValidMove(this.data.board); if (noStep || noMove) { this.saveBestScore(); this.setData({ gameOver: true }); } }6. 完整小程序代码下面把前面拆开的代码合并到对应文件里。你可以直接建立一个空白小程序项目然后把文件内容复制进去运行。6.1 app.js 与 app.jsonApp({ onLaunch() {} });app.json前面已经给出这里再完整贴一遍{ pages: [ pages/index/index ], window: { navigationBarTitleText: 彩色消除, navigationBarBackgroundColor: #2f3542, navigationBarTextStyle: white, backgroundColor: #f1f2f6 }, style: v2, sitemapLocation: sitemap.json }6.2 pages/index/index.jsconst ROWS 8; const COLS 8; const COLOR_COUNT 5; const EMPTY -1; function randomCellColor(board, row, col) { const exclude new Set(); if (col 2 board[row][col - 1] board[row][col - 2]) { exclude.add(board[row][col - 1]); } if (row 2 board[row - 1][col] board[row - 2][col]) { exclude.add(board[row - 1][col]); } const colors []; for (let c 0; c COLOR_COUNT; c) { if (!exclude.has(c)) { colors.push(c); } } return colors[Math.floor(Math.random() * colors.length)]; } function createBoard() { const board []; for (let row 0; row ROWS; row) { board[row] []; for (let col 0; col COLS; col) { board[row][col] randomCellColor(board, row, col); } } return board; } function toViewBoard(board) { return board.map((row, r) ({ rowKey: row- r, cells: row.map((color, c) ({ key: r - c, color })) })); } function findMatches(board) { const matches []; for (let row 0; row ROWS; row) { let col 0; while (col COLS) { const value board[row][col]; if (value EMPTY) { col; continue; } let end col; while (end 1 COLS board[row][end 1] value) { end; } if (end - col 1 3) { for (let c col; c end; c) { matches.push({ row, col: c }); } } col end 1; } } for (let col 0; col COLS; col) { let row 0; while (row ROWS) { const value board[row][col]; if (value EMPTY) { row; continue; } let end row; while (end 1 ROWS board[end 1][col] value) { end; } if (end - row 1 3) { for (let r row; r end; r) { matches.push({ row: r, col }); } } row end 1; } } return matches; } function clearMatches(board, matches) { for (const item of matches) { board[item.row][item.col] EMPTY; } } function applyGravity(board) { for (let col 0; col COLS; col) { let writeRow ROWS - 1; for (let row ROWS - 1; row 0; row--) { if (board[row][col] ! EMPTY) { board[writeRow][col] board[row][col]; if (writeRow ! row) { board[row][col] EMPTY; } writeRow--; } } for (let row writeRow; row 0; row--) { board[row][col] Math.floor(Math.random() * COLOR_COUNT); } } } function resolveBoard(board) { let totalCount 0; let chainCount 0; let matches findMatches(board); while (matches.length 0) { chainCount; const uniqueMatches new Set( matches.map((item) item.row - item.col) ); totalCount uniqueMatches.size; clearMatches(board, matches); applyGravity(board); matches findMatches(board); } return { count: totalCount, chains: chainCount }; } function isAdjacent(a, b) { return Math.abs(a.row - b.row) Math.abs(a.col - b.col) 1; } function swap(board, a, b) { const temp board[a.row][a.col]; board[a.row][a.col] board[b.row][b.col]; board[b.row][b.col] temp; } function hasValidMove(board) { for (let row 0; row ROWS; row) { for (let col 0; col COLS; col) { const neighbors [ { row: row 1, col }, { row, col: col 1 } ]; for (const nb of neighbors) { if (nb.row ROWS || nb.col COLS) continue; const copy board.map((r