☰
微信小程序原生实现彩色消除游戏:三消算法与完整代码解析
2026/10/10 22:39:18 网站建设 项目流程

最近做小程序练手项目时,很多同学问我:“能不能不借助游戏引擎,只靠小程序原生能力做一个消除类小游戏?”答案是肯定的。“彩色消除”属于经典的三消玩法,核心是棋盘数据、消除判定、下落填充和交互反馈,这些逻辑用原生 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 = 8,COLS = 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 class="board"> <view class="row" wx:for="{{viewBoard}}" wx:for-item="row" wx:key="rowKey" > <view class="cell color-{{item.color}} {{selected && selected.row === index && selected.col === idx ? 'selected' : ''}}" wx:for="{{row.cells}}" wx:for-item="item" wx:for-index="idx" wx:key="key" >.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数组后,把所有对应格子置为EMPTY:

function 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.json

App({ 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.js

const 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

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

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

立即咨询