HarmonyOS——模拟器上写个扫雷的夜晚

前言

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.xtouch.y 是相对于画布的坐标。我提前算好了网格的左上角偏移量 offsetXoffsetY,以及格子边长 cellSize,所以 col = Math.floor((touchX - offsetX) / cellSize)row 同理。如果行列在有效范围内,就执行对应的操作。

轻点逻辑:

  • 如果格子已经被揭开或者是旗子,忽略。
  • 如果是地雷,游戏结束。
  • 如果不是地雷,调用揭示函数 revealCell,它会把当前格子翻开,如果周围雷数为 0,递归翻开周围格子。

长按逻辑:

  • 如果格子已被揭开,忽略。
  • 切换旗子状态。

每次操作后重绘 Canvas。整个游戏状态全用 @State 变量管着:board(邻居数字)、mine(地雷布尔)、revealedflaggedgameOverminesRemaining。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 分别用蓝、绿、红等颜色显示,地雷画成黑色圆点,旗子画成红色三角。
  • 底部难度切换:三个按钮,"简单""中等""困难",点击重新初始化棋盘。

重置时,重新生成 boardminerevealedflagged 等数组,重置 gameOverminesRemaining。所有逻辑都在 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 的核心技能。
  • 二维数组与游戏状态管理minerevealedflagged 三个数组分层管理,逻辑清晰。
  • 递归展开算法:从单个格子扩展到区域填充,体现了递归在图形问题中的威力。
  • 随机布雷:Fisher-Yates 洗牌确保地雷分布真正随机,不给玩家留下规律。
  • 声明式 UI 的重绘驱动@State 绑定游戏状态,Canvas 绘制随数据自动刷新。

扫雷之所以耐玩,是因为它把概率、推理和一点点运气搅在了一起。而把扫雷搬进 HarmonyOS 模拟器,就是把一段集体记忆变成了几 KB 的代码,这份乐趣比游戏本身还让人上瘾。

相关推荐
G_dou_2 小时前
Flutter三方库适配OpenHarmony【bmi_calculator】BMI 计算器项目完整实战
flutter·harmonyos
大雷神2 小时前
第41篇|补光与水印:效果选项如何参与最终照片记录
harmonyos
大雷神3 小时前
第39篇|拍摄模式切换:单拍、双拍、顺序拍的 UI 逻辑
harmonyos
yuegu7774 小时前
HarmonyOS应用<节气通>开发第10篇:测验记录与错题本
华为·harmonyos
G_dou_4 小时前
Flutter三方库适配OpenHarmony【tip_calculator】小费计算器项目完整实战
flutter·harmonyos
yuegu7774 小时前
HarmonyOS应用<节气通>开发第6篇:节气详情页(下)——诗词与养生
华为·harmonyos
慧海灵舟5 小时前
鸿蒙南向开发教程Day 2:创建自己的 Hello World 工程
华为·harmonyos·写文章,赢小鸿ai
颜淡慕潇5 小时前
鸿蒙 PC的 vcpkg 交叉编译库在x86_64宿主环境下的AI自动化验证方案
人工智能·自动化·harmonyos