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

前言

请添加图片描述

扫雷游戏里,除了点击翻开格子,还有一个重要操作——插旗。当你确定某个格子是雷时,可以长按给它插上一面小旗,标记一下"这里有雷,别点"。

插旗功能看起来简单,但实现起来涉及到手势识别、状态切换、UI更新、计数统计等多个环节。这篇就来聊聊这个功能怎么做。

为什么需要插旗

有人可能觉得,我记住哪里有雷不就行了,干嘛要插旗?

实际玩起来就知道了。扫雷棋盘通常是10x10甚至更大,有十几二十颗雷。玩到后面,你推理出好几个位置是雷,但脑子记不住那么多,一不小心就点错了。

插旗的作用:

  1. 标记已确定的雷,防止误点
  2. 辅助推理,看到旗帜就知道这个位置已经排除了
  3. 统计进度,知道还剩几颗雷没找到

而且插了旗的格子是不能被点击翻开的,相当于一个保护机制。

手势识别:长按插旗

插旗用的是长按手势,和点击翻开区分开:

Widget _buildCell(int row, int col) {
  Cell cell = grid[row][col];
  return GestureDetector(
    onTap: () => _revealCell(row, col),
    onLongPress: () => _toggleFlag(row, col),

GestureDetector是Flutter的手势识别组件,可以检测各种手势:

  • onTap: 点击(按下后快速抬起)
  • onLongPress: 长按(按下后保持一段时间)
  • onDoubleTap: 双击
  • onPanUpdate: 拖动
  • 还有很多…

我们用onTap处理翻开格子,用onLongPress处理插旗。这样两个操作不会冲突。

💡 长按的触发时间:Flutter默认的长按触发时间是500毫秒。也就是说,手指按下后保持500毫秒不动,才会触发onLongPress。这个时间可以通过GestureDetectorlongPressTimeout参数调整,但一般不需要改。

_toggleFlag方法

长按触发后,调用_toggleFlag方法:

void _toggleFlag(int row, int col) {
  if (gameOver || gameWon) return;
  if (grid[row][col].isRevealed) return;

  setState(() {
    grid[row][col].isFlagged = !grid[row][col].isFlagged;
    flagCount += grid[row][col].isFlagged ? 1 : -1;
  });
}

方法名叫toggle,意思是切换。长按一次插旗,再长按一次取消旗帜,来回切换。

前置检查

方法开头有两个检查:

if (gameOver || gameWon) return;

如果游戏已经结束(踩雷了或者赢了),直接返回,不做任何操作。游戏结束后不应该还能插旗。

if (grid[row][col].isRevealed) return;

如果格子已经翻开了,也直接返回。已经翻开的格子不需要插旗,因为你已经知道它是什么了。

这两个检查很重要,防止无效操作

状态切换

setState(() {
  grid[row][col].isFlagged = !grid[row][col].isFlagged;

isFlagged是Cell的一个布尔属性,表示这个格子有没有被插旗。

!grid[row][col].isFlagged是取反操作:

  • 如果原来是false(没插旗),取反后变成true(插旗)
  • 如果原来是true(已插旗),取反后变成false(取消旗)

这就实现了切换效果。

计数更新

  flagCount += grid[row][col].isFlagged ? 1 : -1;
});

flagCount记录当前插了多少面旗。

这行代码用了三元运算符:

  • 如果isFlagged现在是true(刚插上旗),flagCount加1
  • 如果isFlagged现在是false(刚取消旗),flagCount减1

注意这行代码在isFlagged切换之后执行,所以判断的是切换后的状态。

为什么用setState

整个操作包在setState里,这是Flutter更新UI的标准方式。

setState做两件事:

  1. 执行里面的代码,修改状态
  2. 通知Flutter框架,这个Widget的状态变了,需要重新build

如果不用setState,直接修改isFlagged,数据是变了,但UI不会更新,用户看不到旗帜。

旗帜的显示

状态改了,UI怎么显示旗帜呢?在_getCellContent方法里:

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

这是方法的第一个判断,优先级最高。

条件是cell.isFlagged && !cell.isRevealed

  • isFlagged:格子被插了旗
  • !isRevealed:格子还没被翻开

两个条件都满足,才显示旗帜。

为什么要加!isRevealed?因为游戏结束时会翻开所有格子,如果某个插了旗的格子其实不是雷,翻开后应该显示它真正的内容(数字或空白),而不是继续显示旗帜。

旗帜图标

return const Icon(Icons.flag, color: Colors.red, size: 20);

用Material Icons的flag图标,红色,大小20像素。

红色旗帜很醒目,在蓝色的未翻开格子上一眼就能看到。

💡 为什么用const? Icon的所有参数都是编译时常量(Icons.flagColors.red20),所以整个Icon可以是const的。const Widget在Flutter里有性能优势,框架可以复用它们。

旗帜计数显示

页面顶部有一个信息卡片,显示当前插了多少旗:

_InfoCard(
  icon: Icons.flag,
  label: '旗帜',
  value: '$flagCount / $mineCount',
),

显示格式是"已插旗数 / 总雷数",比如"3 / 15"表示已经插了3面旗,总共有15颗雷。

这个信息帮助玩家判断进度。如果插的旗比雷还多,说明肯定有插错的。

_InfoCard组件

class _InfoCard extends StatelessWidget {
  final IconData icon;
  final String label;
  final String value;

  const _InfoCard({
    required this.icon,
    required this.label,
    required this.value,
  });

这是一个简单的信息展示组件,接收三个参数:图标、标签、值。

  
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
        child: Row(
          children: [
            Icon(icon, color: Theme.of(context).colorScheme.primary),
            const SizedBox(width: 8),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(label, style: const TextStyle(fontSize: 12)),
                Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

布局是Row里面放图标和文字,文字部分用Column垂直排列标签和值。

  • padding: 水平20像素,垂直12像素,让内容不会贴着卡片边缘
  • Icon: 使用主题色,和整体风格统一
  • SizedBox: 图标和文字之间空8像素
  • label: 小字号(12),灰色,次要信息
  • value: 加粗,主要信息

插旗与翻开的互斥

插了旗的格子是不能被翻开的,这个逻辑在_revealCell方法里:

void _revealCell(int row, int col) {
  if (gameOver || gameWon) return;
  if (row < 0 || row >= rows || col < 0 || col >= cols) return;
  if (grid[row][col].isRevealed || grid[row][col].isFlagged) return;

注意第三个检查:

if (grid[row][col].isRevealed || grid[row][col].isFlagged) return;

如果格子已经翻开(isRevealed或者已经插旗(isFlagged),直接返回,不执行翻开操作。

这就是插旗的保护作用。你确定某个格子是雷,插上旗,就不用担心手滑点到它了。

💡 想翻开插旗的格子怎么办? 先长按取消旗帜,再点击翻开。这是故意设计的,增加一步操作,防止误触。

游戏结束时的旗帜处理

游戏结束(踩雷)时,会翻开所有的雷:

void _revealAllMines() {
  for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
      if (grid[i][j].isMine) {
        grid[i][j].isRevealed = true;
      }
    }
  }
}

这里只设置了isRevealed = true,没有动isFlagged

结合_getCellContent的逻辑:

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

条件是!cell.isRevealed,翻开后这个条件不满足,就不会显示旗帜了,而是显示格子真正的内容。

所以游戏结束后:

  • 插对的旗(确实是雷):显示雷的图标
  • 插错的旗(其实不是雷):显示数字或空白

玩家可以看到自己哪些旗插对了,哪些插错了。

旗帜数量的限制

当前实现没有限制旗帜数量,玩家可以插任意多的旗。

但经典扫雷通常有个规则:旗帜数量不能超过雷的数量。如果要加这个限制:

void _toggleFlag(int row, int col) {
  if (gameOver || gameWon) return;
  if (grid[row][col].isRevealed) return;
  
  // 如果要插旗,检查是否已达上限
  if (!grid[row][col].isFlagged && flagCount >= mineCount) {
    // 可以弹个提示
    return;
  }

  setState(() {
    grid[row][col].isFlagged = !grid[row][col].isFlagged;
    flagCount += grid[row][col].isFlagged ? 1 : -1;
  });
}

加一个判断:如果当前没插旗(!isFlagged,意味着这次操作是要插旗)而且已经插满了(flagCount >= mineCount),就不让插了。

取消旗帜不受限制,所以只在插旗时检查。

触觉反馈

为了提升用户体验,可以在插旗时加一个震动反馈:

import 'package:flutter/services.dart';

void _toggleFlag(int row, int col) {
  // ... 检查代码 ...

  HapticFeedback.mediumImpact();  // 中等强度震动
  
  setState(() {
    // ... 状态更新代码 ...
  });
}

HapticFeedback是Flutter提供的触觉反馈API,mediumImpact()会让手机震动一下。

这样用户长按插旗时,除了看到旗帜出现,还能感受到震动,反馈更明确。

当前代码没有加这个功能,但加起来很简单。

旗帜的视觉反馈

插旗时可以加一些视觉反馈,让操作更有感觉。

动画效果

AnimatedScale(
  scale: cell.isFlagged ? 1.0 : 0.0,
  duration: const Duration(milliseconds: 200),
  child: const Icon(Icons.flag, color: Colors.red, size: 20),
)

旗帜出现时有一个从0到1的缩放动画,看起来像是"插上去"的。

颜色变化

AnimatedContainer(
  duration: const Duration(milliseconds: 200),
  decoration: BoxDecoration(
    color: cell.isFlagged ? Colors.orange[100] : Colors.grey[300],
    // ...
  ),
)

插旗的格子背景色变成浅橙色,和普通未翻开格子区分开。

当前实现简化了,没有这些动画效果。

旗帜与胜利条件

有些扫雷实现把"正确标记所有雷"作为胜利条件之一。

标准胜利条件

void _checkWin() {
  for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
      // 如果有非雷格子没翻开,还没赢
      if (!grid[i][j].isMine && !grid[i][j].isRevealed) return;
    }
  }
  gameWon = true;
}

标准胜利条件是:所有非雷格子都翻开了。

不要求必须插旗,只要不踩雷、翻开所有安全格子就赢了。

旗帜辅助胜利

有些实现允许"插满所有雷"也算赢:

void _checkWin() {
  // 方式1:所有非雷格子都翻开
  bool allSafeRevealed = true;
  for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
      if (!grid[i][j].isMine && !grid[i][j].isRevealed) {
        allSafeRevealed = false;
        break;
      }
    }
  }
  
  // 方式2:所有雷都被正确标记
  bool allMinesFlagged = true;
  for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
      if (grid[i][j].isMine && !grid[i][j].isFlagged) {
        allMinesFlagged = false;
        break;
      }
      if (!grid[i][j].isMine && grid[i][j].isFlagged) {
        allMinesFlagged = false; // 插错了
        break;
      }
    }
  }
  
  if (allSafeRevealed || allMinesFlagged) {
    gameWon = true;
  }
}

这样玩家可以选择翻开所有安全格子,或者标记所有雷,两种方式都能赢。

当前实现用的是标准胜利条件。

双击快速翻开

经典扫雷有个功能:双击一个已翻开的数字格子,如果周围的旗帜数等于数字,就自动翻开周围所有未标记的格子。

void _onDoubleTap(int row, int col) {
  Cell cell = grid[row][col];
  if (!cell.isRevealed || cell.isMine) return;
  
  // 统计周围旗帜数
  int flagCount = 0;
  for (int dr = -1; dr <= 1; dr++) {
    for (int dc = -1; dc <= 1; dc++) {
      int nr = row + dr, nc = col + dc;
      if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
        if (grid[nr][nc].isFlagged) flagCount++;
      }
    }
  }
  
  // 如果旗帜数等于数字,翻开周围未标记的格子
  if (flagCount == cell.adjacentMines) {
    for (int dr = -1; dr <= 1; dr++) {
      for (int dc = -1; dc <= 1; dc++) {
        int nr = row + dr, nc = col + dc;
        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
          if (!grid[nr][nc].isFlagged && !grid[nr][nc].isRevealed) {
            _revealCell(nr, nc);
          }
        }
      }
    }
  }
}

这个功能可以加快游戏速度,但如果旗帜插错了,会踩雷。

当前实现简化了,没有这个功能。

问号标记

有些扫雷实现支持三种状态:未标记 → 旗帜 → 问号 → 未标记。

enum CellMark { none, flag, question }

void _toggleMark(int row, int col) {
  if (gameOver || gameWon) return;
  if (grid[row][col].isRevealed) return;
  
  setState(() {
    switch (grid[row][col].mark) {
      case CellMark.none:
        grid[row][col].mark = CellMark.flag;
        flagCount++;
        break;
      case CellMark.flag:
        grid[row][col].mark = CellMark.question;
        flagCount--;
        break;
      case CellMark.question:
        grid[row][col].mark = CellMark.none;
        break;
    }
  });
}

问号表示"不确定",和旗帜的"确定是雷"区分开。

显示时:

Widget _getCellContent(Cell cell) {
  if (cell.mark == CellMark.flag && !cell.isRevealed) {
    return const Icon(Icons.flag, color: Colors.red, size: 20);
  }
  if (cell.mark == CellMark.question && !cell.isRevealed) {
    return const Icon(Icons.help, color: Colors.blue, size: 20);
  }
  // ... 其他情况
}

当前实现简化了,只有旗帜和未标记两种状态。

首次点击保护

经典扫雷有个规则:第一次点击不会踩雷。

bool firstClick = true;

void _revealCell(int row, int col) {
  // ... 前置检查 ...
  
  if (firstClick) {
    firstClick = false;
    // 如果第一次点到雷,把雷移到别处
    if (grid[row][col].isMine) {
      grid[row][col].isMine = false;
      // 找一个空位放雷
      for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
          if (!grid[i][j].isMine && (i != row || j != col)) {
            grid[i][j].isMine = true;
            _calculateAdjacentMines(); // 重新计算数字
            break;
          }
        }
      }
    }
  }
  
  // ... 正常翻开逻辑 ...
}

这样玩家第一次点击永远是安全的,不会一开始就踩雷。

当前实现简化了,没有这个保护。

小结

这篇讲了扫雷游戏的旗帜标记功能,核心知识点:

  • GestureDetectoronLongPress检测长按手势,和onTap点击区分开
  • toggle模式:长按切换旗帜状态,再按取消,简单直观
  • setState更新状态并触发UI重建,Flutter的标准模式
  • 前置检查:游戏结束、格子已翻开时不允许操作,防止无效操作
  • 互斥逻辑:插旗的格子不能被翻开,防止误触,保护玩家
  • 计数统计:实时显示已插旗数量,帮助玩家判断进度
  • _InfoCard组件:封装信息展示卡片,复用性好,代码整洁
  • 触觉反馈:HapticFeedback增强操作感,可选功能
  • 旗帜数量限制:可以限制不超过雷数,增加策略性
  • 双击快速翻开:高级功能,加快游戏速度
  • 问号标记:三态切换,表示不确定
  • 首次点击保护:第一次点击不会踩雷,对新手友好

插旗功能虽然不是扫雷的核心玩法,但它大大提升了游戏体验。一个好的游戏,细节功能也要做到位。


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

Logo

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

更多推荐