通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip

前言

请添加图片描述

玩过Windows扫雷的都知道,点开一个格子后,如果周围有雷,格子里会显示一个数字,告诉你周围8个格子里有几颗雷。而且这个数字是有颜色的:1是蓝色,2是绿色,3是红色……

这个彩色数字是扫雷游戏的灵魂,玩家就是靠这些数字来推理哪里有雷。这篇就来聊聊怎么实现这个数字显示功能。

数字的含义

先搞清楚这个数字是怎么来的。

扫雷的棋盘是一个二维网格,每个格子要么是雷,要么不是雷。当玩家点开一个非雷格子时,需要显示这个格子周围8个方向有多少颗雷。

所谓周围8个方向,就是上、下、左、右、左上、右上、左下、右下这8个相邻的格子。如果某个方向超出了棋盘边界,就不算。

比如一个格子周围有2颗雷,就显示数字2;周围没有雷,就什么都不显示(空白)。

Cell数据结构

每个格子用一个Cell对象表示:

class Cell {
  bool isMine = false;
  bool isRevealed = false;
  bool isFlagged = false;
  int adjacentMines = 0;
}

四个属性各有用途:

  • isMine: 这个格子是不是雷
  • isRevealed: 这个格子有没有被翻开
  • isFlagged: 这个格子有没有被插旗标记
  • adjacentMines: 周围有几颗雷(这就是要显示的数字)

adjacentMines在游戏初始化时就计算好了,不是每次显示时才算。这样点击格子时直接读取就行,不用重复计算。

计算周围雷数

游戏初始化时,随机放置完地雷后,要遍历每个非雷格子,计算它周围的雷数:

for (int i = 0; i < rows; i++) {
  for (int j = 0; j < cols; j++) {
    if (!grid[i][j].isMine) {
      grid[i][j].adjacentMines = _countAdjacentMines(i, j);
    }
  }
}

两层循环遍历所有格子。如果这个格子不是雷(!grid[i][j].isMine),就调用_countAdjacentMines计算周围雷数,存到adjacentMines属性里。

💡 为什么雷格子不用算? 因为雷格子被点开后游戏就结束了,不需要显示数字。所以只有非雷格子才需要计算。

_countAdjacentMines方法

这个方法是计算的核心:

int _countAdjacentMines(int row, int col) {
  int count = 0;
  for (int i = -1; i <= 1; i++) {
    for (int j = -1; j <= 1; j++) {
      int r = row + i;
      int c = col + j;
      if (r >= 0 && r < rows && c >= 0 && c < cols && grid[r][c].isMine) {
        count++;
      }
    }
  }
  return count;
}

逻辑拆解一下:

外层两个循环ij都是从-1到1,组合起来就是9种情况:

  • i=-1, j=-1:左上
  • i=-1, j=0:上
  • i=-1, j=1:右上
  • i=0, j=-1:左
  • i=0, j=0:自己(会被跳过,因为自己不是雷的话不会计数)
  • i=0, j=1:右
  • i=1, j=-1:左下
  • i=1, j=0:下
  • i=1, j=1:右下

r = row + ic = col + j计算出相邻格子的坐标。

然后是边界检查:

if (r >= 0 && r < rows && c >= 0 && c < cols && grid[r][c].isMine) {
  • r >= 0:行号不能小于0
  • r < rows:行号不能超出棋盘
  • c >= 0:列号不能小于0
  • c < cols:列号不能超出棋盘
  • grid[r][c].isMine:这个相邻格子是雷

只有同时满足这5个条件,才把计数器加1。

💡 边界检查很重要。 如果格子在棋盘边缘或角落,它的某些方向是没有相邻格子的。比如左上角的格子,它的左边、上边、左上都超出了棋盘。不做边界检查会导致数组越界崩溃。

显示格子内容

格子的内容由_getCellContent方法决定:

Widget _getCellContent(Cell cell) {
  if (cell.isFlagged && !cell.isRevealed) {
    return const Icon(Icons.flag, color: Colors.red, size: 20);
  }
  if (!cell.isRevealed) {
    return const SizedBox();
  }
  if (cell.isMine) {
    return const Icon(Icons.brightness_7, color: Colors.black, size: 20);
  }
  if (cell.adjacentMines > 0) {
    return Text(
      '${cell.adjacentMines}',
      style: TextStyle(
        fontWeight: FontWeight.bold,
        color: _getNumberColor(cell.adjacentMines),
      ),
    );
  }
  return const SizedBox();
}

这个方法根据格子的状态返回不同的Widget,逻辑是从上到下依次判断

第一个判断:插旗状态

if (cell.isFlagged && !cell.isRevealed) {
  return const Icon(Icons.flag, color: Colors.red, size: 20);
}

如果格子被插了旗(isFlagged)而且还没被翻开(!isRevealed),显示一个红色的旗帜图标。

第二个判断:未翻开状态

if (!cell.isRevealed) {
  return const SizedBox();
}

如果格子还没翻开,返回一个空的SizedBox,什么都不显示。格子的背景色会显示它是未翻开的状态。

第三个判断:是雷

if (cell.isMine) {
  return const Icon(Icons.brightness_7, color: Colors.black, size: 20);
}

如果是雷,显示一个黑色的太阳图标(Icons.brightness_7看起来像爆炸的雷)。这种情况只有游戏结束时才会出现,因为点到雷游戏就结束了,会把所有雷都翻开。

第四个判断:有相邻雷

if (cell.adjacentMines > 0) {
  return Text(
    '${cell.adjacentMines}',
    style: TextStyle(
      fontWeight: FontWeight.bold,
      color: _getNumberColor(cell.adjacentMines),
    ),
  );
}

如果周围有雷(adjacentMines > 0),显示数字。数字用Text组件,样式是加粗的,颜色由_getNumberColor方法根据数字决定。

最后:周围没雷

return const SizedBox();

如果以上都不满足,说明这是一个翻开的、周围没有雷的空白格子,返回空的SizedBox。

数字颜色映射

经典扫雷的数字颜色是有讲究的,每个数字对应固定的颜色:

Color _getNumberColor(int number) {
  switch (number) {
    case 1: return Colors.blue;
    case 2: return Colors.green;
    case 3: return Colors.red;
    case 4: return Colors.purple;
    case 5: return Colors.brown;
    case 6: return Colors.cyan;
    case 7: return Colors.black;
    case 8: return Colors.grey;
    default: return Colors.black;
  }
}

这个配色方案是还原Windows扫雷的经典配色:

  • 1 - 蓝色:最常见的数字,用冷静的蓝色
  • 2 - 绿色:比较安全的感觉
  • 3 - 红色:开始有点危险了,用警示的红色
  • 4 - 深紫色:更危险
  • 5 - 棕色:很少见,周围一半都是雷
  • 6 - 青色:非常少见
  • 7 - 黑色:极其罕见
  • 8 - 灰色:理论上的最大值,周围全是雷

💡 为什么要用不同颜色? 一是为了好看,二是为了快速识别。玩家扫一眼就能知道这是几,不用仔细看数字。颜色比数字更容易被大脑识别。

default分支理论上不会执行,因为adjacentMines的值只可能是0-8。但加上它是个好习惯,防止意外情况。

数字的样式

再看一下数字的TextStyle:

style: TextStyle(
  fontWeight: FontWeight.bold,
  color: _getNumberColor(cell.adjacentMines),
),
  • fontWeight: FontWeight.bold:加粗显示,让数字更醒目
  • color:根据数字获取对应的颜色

没有设置fontSize,用的是默认字号。因为格子大小是根据屏幕自适应的,默认字号在大多数情况下都合适。如果格子特别小,可能需要调小字号。

格子的构建

数字显示是格子的一部分,看看完整的格子构建:

Widget _buildCell(int row, int col) {
  Cell cell = grid[row][col];
  return GestureDetector(
    onTap: () => _revealCell(row, col),
    onLongPress: () => _toggleFlag(row, col),
    child: Container(
      margin: const EdgeInsets.all(1),
      decoration: BoxDecoration(
        color: cell.isRevealed
            ? (cell.isMine ? Colors.red : Colors.grey[300])
            : Colors.blue[400],
        borderRadius: BorderRadius.circular(4),

格子用GestureDetector包裹,处理点击(翻开)和长按(插旗)事件。

Container设置了格子的外观:

  • margin: 格子之间留1像素间隙,不会挤在一起
  • color: 根据状态显示不同颜色
    • 未翻开:蓝色(Colors.blue[400]
    • 翻开且是雷:红色
    • 翻开且不是雷:浅灰色(Colors.grey[300]
  • borderRadius: 圆角4像素,看起来更柔和

格子的内容用Center包裹,让数字/图标居中显示:

      child: Center(
        child: _getCellContent(cell),
      ),

未翻开格子的阴影

未翻开的格子还有一个细节——阴影效果:

        boxShadow: cell.isRevealed
            ? null
            : [
                BoxShadow(
                  color: Colors.black.withOpacity(0.2),
                  offset: const Offset(1, 1),
                  blurRadius: 2,
                ),
              ],

只有未翻开的格子才有阴影(cell.isRevealed ? null : [...])。

阴影的参数:

  • color: 黑色,20%透明度,不会太重
  • offset: 向右下偏移1像素,模拟光源在左上方
  • blurRadius: 模糊半径2像素,阴影边缘柔和

这个阴影让未翻开的格子看起来像是凸起的按钮,翻开后阴影消失,变成平的,视觉上有"按下去"的感觉。

为什么数字要预计算

有人可能会问:为什么要在初始化时就算好所有格子的adjacentMines,而不是显示时再算?

两个原因:

1. 性能考虑

每次setState后,所有格子都会重新build。如果每次build都要算一遍周围雷数,100个格子就要算100次,每次算要检查8个方向,总共800次检查。虽然现代手机算这点东西不费劲,但能省则省。

预计算的话,初始化时算一次就够了,后面显示时直接读取。

2. 逻辑清晰

把"计算雷数"和"显示数字"分开,代码更清晰。初始化负责准备数据,显示负责渲染UI,各司其职。

如果混在一起,_getCellContent方法会变得很复杂,又要判断状态,又要计算雷数,不好维护。

空白格子的连锁翻开

还有一个相关的功能:当翻开一个周围没有雷的格子(adjacentMines == 0)时,会自动翻开周围的格子,如果周围也有空白格子,继续连锁翻开。

if (grid[row][col].adjacentMines == 0) {
  for (int i = -1; i <= 1; i++) {
    for (int j = -1; j <= 1; j++) {
      _revealCell(row + i, col + j);
    }
  }
}

这是一个递归调用。翻开当前格子后,如果是空白,就对周围8个格子也调用_revealCell。如果周围的格子也是空白,会继续递归,直到遇到有数字的格子为止。

这就是为什么扫雷里点一个空白区域,会"哗"地一下翻开一大片。

小结

这篇讲了扫雷游戏中数字显示的实现,核心知识点:

  • Cell数据结构存储格子状态,adjacentMines记录周围雷数
  • _countAdjacentMines遍历周围8个方向,统计雷的数量,注意边界检查
  • _getCellContent根据格子状态返回不同的Widget:旗帜、空白、雷、数字
  • _getNumberColor用switch语句映射数字到颜色,还原经典扫雷配色
  • 预计算雷数而不是实时计算,性能更好,逻辑更清晰
  • 阴影效果让未翻开的格子有立体感

数字显示看起来简单,但涉及到数据结构设计、算法实现、UI渲染等多个方面。把这些搞清楚,扫雷游戏的核心就完成一大半了。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐