前言
Windows 3.1 时代,扫雷是办公室电脑里藏得最深的秘密。老板以为你在做报表,其实你在跟一堆数字和地雷较劲。三十年后,我在 DevEco Studio 6.1.1 Beta1 的模拟器里重新见到了那块灰色网格,只不过这次地雷是我自己埋的,规则也是我一行行代码写的。

起因很简单:朋友说"你整天用 HarmonyOS 写那些工具,能不能写个能玩的?"我想了三秒钟,脑海里蹦出来的第一个画面就是扫雷------简单、上瘾、而且把所有交互都浓缩在一块 Canvas 上。于是那个周末的深夜,我打开 Pura X Max 模拟器,从画网格开始,一点一点把扫雷的逻辑搬了进去。这篇文章就是那个晚上的产物。里面不止有代码,还有布雷算法的陷阱、递归展开时的性能考量、长按事件在 Canvas 上的实现,以及怎么让扫雷玩起来跟记忆里一模一样。
一、扫雷的骨架
扫雷的世界可以简化成一个二维数组 board[row][col]。每个格子存两个信息:是不是地雷、周围有多少颗地雷。我用两个数组来管:mine 数组存布尔值,neighbor 数组存数字。初始化的时候,先根据难度(简单 9×9/10 雷,中等 16×16/40 雷,专家 30×16/99 雷)铺开一块空棋盘,然后用 Math.random 随机挑位置埋雷。

这里有个容易踩的坑:随机埋雷可能埋到同一个格子里两次,最后地雷数量不够。所以要加一个去重逻辑------最简单的办法是生成一个从 0 到格子总数减一的索引数组,洗牌(Fisher-Yates,老朋友了),取前 N 个作为地雷位置。这是生成不重复随机数最干净的办法。
埋完雷,就该算数字了。对每个非雷格子,数周围 8 个方向有几个是雷,把数字写进 neighbor 数组。这一步叫"邻接计数",时间复杂度 O(N),9×9 的棋盘眨眼就算完。
游戏状态用一个 revealed 数组管:哪些格子已经翻开,哪些还盖着。另外还有个 flagged 数组管旗帜标记。用户点到一个格子,如果它是雷,游戏结束,显示所有雷;如果它周围数字是 0,就递归展开所有相邻的安全格------这个递归很关键,扫雷最爽的"哗啦一片展开"就是靠它实现的。
二、Canvas 点一下还是长按
HarmonyOS 的 Canvas 支持 onTouch 事件。我需要区分两种操作:轻点(揭示格子)和长按(标记旗帜)。因为模拟器没有真正的"右键",长按就成了标记旗帜的唯一入口。

思路是用 TouchType.Down 记录按下的时间和位置,TouchType.Up 时算一下时间差。如果小于 300 毫秒,就当轻点处理;大于等于 300 毫秒,就当长按处理。这个时间阈值是从 Android 系统抄来的,手感比较自然。
拿到触摸坐标后,得把它映射到棋盘的行列。Canvas 的 onTouch 回调里,touch.x 和 touch.y 是相对于画布的坐标。我提前算好了网格的左上角偏移量 offsetX 和 offsetY,以及格子边长 cellSize,所以 col = Math.floor((touchX - offsetX) / cellSize),row 同理。如果行列在有效范围内,就执行对应的操作。
轻点逻辑:
- 如果格子已经被揭开或者是旗子,忽略。
- 如果是地雷,游戏结束。
- 如果不是地雷,调用揭示函数
revealCell,它会把当前格子翻开,如果周围雷数为 0,递归翻开周围格子。
长按逻辑:
- 如果格子已被揭开,忽略。
- 切换旗子状态。
每次操作后重绘 Canvas。整个游戏状态全用 @State 变量管着:board(邻居数字)、mine(地雷布尔)、revealed、flagged、gameOver、minesRemaining。ArkUI 的声明式特性在这里特别顺手------状态一变,界面自动刷新。
三、递归展开
当用户点到一个周围雷数为 0 的格子,扫雷会自动展开一大片安全区域。这个效果靠递归实现,逻辑不复杂但写得不好会炸栈。
我的做法:revealCell(row, col) 先把当前格子标记为已揭示。如果它的邻居数字大于 0,就此打住;如果等于 0,就遍历周围 8 个格子,对每个还未揭示且不是旗子的格子递归调用自己。为了防止循环递归导致栈溢出,必须在递归前检查 revealed[row][col] 是否为 false,已经揭开的不再处理。
对于 9×9 的棋盘,最深递归也就几十层,完全没问题。但如果是 30×16 的大棋盘,理论上可能递归上百层。HarmonyOS 的 ArkTS 引擎对递归深度有限制,但几百层在可接受范围内。为了更安全,可以用显式的栈把递归改成迭代,但扫雷的规模不值得这么折腾,递归足够。
展开时 Canvas 要同步更新,每揭开一个格子就立刻重绘一次------这样用户能看到"水波扩散"的视觉效果。但 300 个格子在几十毫秒内全部绘制完,肉眼已经看不出延迟了,模拟器上跑着毫无压力。
四、输赢判定与界面设计
每揭开一个格子,都检查一下游戏是否胜利。胜利条件是:所有非雷格子都被揭开,并且没有点到地雷。实现上,每次揭示后遍历 revealed 数组,如果未揭示的格子数量等于地雷总数,就赢了。游戏结束(输或赢)时,设置 gameOver = true,同时揭示所有地雷(输了)或者显示祝贺(赢了)。
界面从上到下分三块:
- 顶部信息栏:剩余地雷数、一个笑脸按钮(点击重置游戏)。
- 中间棋盘:Canvas 绘制,格子用浅灰和深灰交替,已揭示的格子是白色,数字 1~8 分别用蓝、绿、红等颜色显示,地雷画成黑色圆点,旗子画成红色三角。
- 底部难度切换:三个按钮,"简单""中等""困难",点击重新初始化棋盘。
重置时,重新生成 board、mine、revealed、flagged 等数组,重置 gameOver 和 minesRemaining。所有逻辑都在 resetGame(rows, cols, mines) 函数里统一处理。

五、完整代码
下列代码适配 DevEco Studio 6.1.1 Beta1、SDK22 语法,模拟器 Pura X Max。新建 Empty Ability 项目,替换 entry/src/main/ets/pages/Index.ets 全部内容即可。不需要联网,不需要权限。

/*
* 扫雷游戏 --- Canvas 绘制,触摸点击/长按标记
* 环境:DevEco Studio 6.1.1 Beta1,Pura X Max 模拟器,SDK22
*/
import { CanvasRenderingContext2D } from '@ohos.graphics.canvas';
@Entry
@Component
struct Index {
@State board: number[][] = [];
@State mine: boolean[][] = [];
@State revealed: boolean[][] = [];
@State flagged: boolean[][] = [];
@State gameOver: boolean = false;
@State gameWin: boolean = false;
@State minesRemaining: number = 10;
private rows: number = 9;
private cols: number = 9;
private totalMines: number = 10;
private ctx: CanvasRenderingContext2D | null = null;
private canvasWidth: number = 0;
private canvasHeight: number = 0;
private cellSize: number = 0;
private offsetX: number = 0;
private offsetY: number = 0;
private touchStartTime: number = 0;
private touchStartRow: number = -1;
private touchStartCol: number = -1;
// 初始化所有数据
private resetGame(rows: number, cols: number, mines: number): void {
this.rows = rows;
this.cols = cols;
this.totalMines = mines;
this.minesRemaining = mines;
this.gameOver = false;
this.gameWin = false;
// 初始化数组
this.board = Array.from({ length: rows }, () => Array(cols).fill(0));
this.mine = Array.from({ length: rows }, () => Array(cols).fill(false));
this.revealed = Array.from({ length: rows }, () => Array(cols).fill(false));
this.flagged = Array.from({ length: rows }, () => Array(cols).fill(false));
// 随机布雷(Fisher-Yates 选索引)
let totalCells = rows * cols;
let indices = Array.from({ length: totalCells }, (_, i) => i);
for (let i = indices.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
for (let i = 0; i < mines; i++) {
let idx = indices[i];
let r = Math.floor(idx / cols);
let c = idx % cols;
this.mine[r][c] = true;
}
// 计算数字
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (this.mine[r][c]) continue;
let count = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
let nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && this.mine[nr][nc]) count++;
}
}
this.board[r][c] = count;
}
}
// 重新计算尺寸
this.calcCellSize();
this.drawAll();
}
// 计算格子大小
private calcCellSize(): void {
if (!this.ctx) return;
let w = this.canvasWidth;
let h = this.canvasHeight;
let maxW = w * 0.9;
let maxH = h * 0.7;
this.cellSize = Math.floor(Math.min(maxW / this.cols, maxH / this.rows));
this.offsetX = Math.floor((w - this.cellSize * this.cols) / 2);
this.offsetY = Math.floor((h - this.cellSize * this.rows) / 2);
}
// Canvas 就绪
private onCanvasReady(ctx: CanvasRenderingContext2D): void {
this.ctx = ctx;
this.canvasWidth = ctx.canvas.width;
this.canvasHeight = ctx.canvas.height;
this.resetGame(9, 9, 10);
}
// 绘制整个棋盘
private drawAll(): void {
if (!this.ctx) return;
let ctx = this.ctx;
let w = this.canvasWidth;
let h = this.canvasHeight;
ctx.clearRect(0, 0, w, h);
let size = this.cellSize;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
let x = this.offsetX + c * size;
let y = this.offsetY + r * size;
if (this.revealed[r][c]) {
ctx.fillStyle = '#E0E0E0';
ctx.fillRect(x, y, size, size);
if (this.mine[r][c]) {
// 地雷
ctx.fillStyle = '#333';
ctx.beginPath();
ctx.arc(x + size / 2, y + size / 2, size * 0.3, 0, 2 * Math.PI);
ctx.fill();
} else {
let num = this.board[r][c];
if (num > 0) {
let colors = ['#0000FF', '#008000', '#FF0000', '#000080', '#800000', '#008080', '#000000', '#808080'];
ctx.fillStyle = colors[num - 1] || '#333';
ctx.font = `${size * 0.6}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(num.toString(), x + size / 2, y + size / 2);
}
}
} else {
// 未揭示
ctx.fillStyle = (r + c) % 2 === 0 ? '#A0A0A0' : '#C0C0C0';
ctx.fillRect(x, y, size, size);
if (this.flagged[r][c]) {
// 旗子
ctx.fillStyle = '#FF0000';
ctx.beginPath();
ctx.moveTo(x + size * 0.2, y + size * 0.8);
ctx.lineTo(x + size * 0.5, y + size * 0.2);
ctx.lineTo(x + size * 0.8, y + size * 0.8);
ctx.fill();
}
}
// 格子边框
ctx.strokeStyle = '#666';
ctx.lineWidth = 0.5;
ctx.strokeRect(x, y, size, size);
}
}
// 游戏结束画面
if (this.gameOver) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, this.canvasHeight * 0.4, this.canvasWidth, 40);
ctx.fillStyle = '#FFFFFF';
ctx.font = '24px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.gameWin ? '你赢了!' : '游戏结束', this.canvasWidth / 2, this.canvasHeight * 0.4 + 30);
}
}
// 揭示格子(递归展开)
private revealCell(r: number, c: number): void {
if (r < 0 || r >= this.rows || c < 0 || c >= this.cols) return;
if (this.revealed[r][c] || this.flagged[r][c]) return;
this.revealed[r][c] = true;
if (this.board[r][c] === 0 && !this.mine[r][c]) {
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
this.revealCell(r + dr, c + dc);
}
}
}
}
// 检查胜利
private checkWin(): void {
let unrevealed = 0;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
if (!this.revealed[r][c]) unrevealed++;
}
}
if (unrevealed === this.totalMines) {
this.gameOver = true;
this.gameWin = true;
this.minesRemaining = 0;
this.drawAll();
}
}
// 触摸处理
private handleTouch(event: TouchEvent): void {
if (this.gameOver) return;
if (!this.ctx) return;
let touch = event.touches[0];
let col = Math.floor((touch.x - this.offsetX) / this.cellSize);
let row = Math.floor((touch.y - this.offsetY) / this.cellSize);
if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) return;
if (event.type === TouchType.Down) {
this.touchStartTime = Date.now();
this.touchStartRow = row;
this.touchStartCol = col;
} else if (event.type === TouchType.Up) {
let duration = Date.now() - this.touchStartTime;
if (row === this.touchStartRow && col === this.touchStartCol) {
if (duration < 300) {
// 轻点:揭示
if (this.revealed[row][col] || this.flagged[row][col]) return;
if (this.mine[row][col]) {
// 踩雷
this.gameOver = true;
this.gameWin = false;
// 揭示所有雷
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
if (this.mine[r][c]) this.revealed[r][c] = true;
}
}
this.drawAll();
return;
}
this.revealCell(row, col);
this.checkWin();
this.drawAll();
} else {
// 长按:标记/取消旗子
if (!this.revealed[row][col]) {
this.flagged[row][col] = !this.flagged[row][col];
this.minesRemaining += this.flagged[row][col] ? -1 : 1;
this.drawAll();
}
}
}
}
}
// 重置
private restart(): void {
this.resetGame(this.rows, this.cols, this.totalMines);
}
build() {
Column() {
// 信息栏
Row() {
Text(`💣 ${this.minesRemaining}`).fontSize(20).fontWeight(FontWeight.Bold)
Blank()
Button('😊')
.fontSize(28)
.backgroundColor('#00000000')
.onClick(() => { this.restart(); })
Blank()
Text('')
}
.width('90%')
.margin({ top: 16, bottom: 8 })
// 棋盘
Canvas()
.width('100%')
.height('65%')
.backgroundColor('#BBBBBB')
.onReady((event) => {
let ctx = event.context as CanvasRenderingContext2D;
this.onCanvasReady(ctx);
})
.onTouch((event: TouchEvent) => {
this.handleTouch(event);
})
// 难度选择
Row() {
Button('简单 9x9').fontSize(14).layoutWeight(1)
.onClick(() => { this.resetGame(9, 9, 10); })
Button('中等 16x16').fontSize(14).layoutWeight(1).margin({ left: 6 })
.onClick(() => { this.resetGame(16, 16, 40); })
Button('困难 30x16').fontSize(14).layoutWeight(1).margin({ left: 6 })
.onClick(() => { this.resetGame(30, 16, 99); })
}
.width('90%')
.margin({ top: 10 })
Text('轻点揭开,长按插旗')
.fontSize(12)
.fontColor('#888')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.backgroundColor('#CCCCCC')
}
}
运行效果
代码粘贴后 Run,Pura X Max 模拟器上出现一个灰色棋盘。轻点格子,数字逐个显现,点中空白区哗啦展开一大片。长按插红旗,剩余雷数同步减少。踩到雷则全盘翻开,笑脸变苦脸;找出所有安全格则弹出"你赢了"。底部难度按钮随时切换,简单到专家无缝衔接。Canvas 绘制流畅,触控响应灵敏,跟 Windows 上的老扫雷手感几无差别。


总结
这个扫雷小游戏虽然界面朴素,却把 HarmonyOS 应用开发里好几个关键点捏在了一起:
- Canvas 交互:触摸坐标映射、轻点与长按的识别,是自定义 UI 的核心技能。
- 二维数组与游戏状态管理 :
mine、revealed、flagged三个数组分层管理,逻辑清晰。 - 递归展开算法:从单个格子扩展到区域填充,体现了递归在图形问题中的威力。
- 随机布雷:Fisher-Yates 洗牌确保地雷分布真正随机,不给玩家留下规律。
- 声明式 UI 的重绘驱动 :
@State绑定游戏状态,Canvas 绘制随数据自动刷新。
扫雷之所以耐玩,是因为它把概率、推理和一点点运气搅在了一起。而把扫雷搬进 HarmonyOS 模拟器,就是把一段集体记忆变成了几 KB 的代码,这份乐趣比游戏本身还让人上瘾。