Flutter for OpenHarmony游戏集合App实战之扫雷旗帜标记
扫雷游戏中的插旗功能实现涉及多个关键环节:通过长按手势识别触发_toggleFlag方法切换格子标记状态,使用setState更新UI显示旗帜图标,并统计已插旗数。该方法包含游戏状态检查、旗帜状态切换和计数更新逻辑。插旗格子禁止点击翻开,提供防误触保护。游戏结束时自动取消错误标记的旗帜,显示真实雷区布局。顶部信息卡片实时显示插旗进度,辅助玩家决策。整个功能通过Flutter的手势检测和状态管理实现
通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip
前言

扫雷游戏里,除了点击翻开格子,还有一个重要操作——插旗。当你确定某个格子是雷时,可以长按给它插上一面小旗,标记一下"这里有雷,别点"。
插旗功能看起来简单,但实现起来涉及到手势识别、状态切换、UI更新、计数统计等多个环节。这篇就来聊聊这个功能怎么做。
为什么需要插旗
有人可能觉得,我记住哪里有雷不就行了,干嘛要插旗?
实际玩起来就知道了。扫雷棋盘通常是10x10甚至更大,有十几二十颗雷。玩到后面,你推理出好几个位置是雷,但脑子记不住那么多,一不小心就点错了。
插旗的作用:
- 标记已确定的雷,防止误点
- 辅助推理,看到旗帜就知道这个位置已经排除了
- 统计进度,知道还剩几颗雷没找到
而且插了旗的格子是不能被点击翻开的,相当于一个保护机制。
手势识别:长按插旗
插旗用的是长按手势,和点击翻开区分开:
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。这个时间可以通过GestureDetector的longPressTimeout参数调整,但一般不需要改。
_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做两件事:
- 执行里面的代码,修改状态
- 通知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.flag、Colors.red、20),所以整个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;
}
}
}
}
}
// ... 正常翻开逻辑 ...
}
这样玩家第一次点击永远是安全的,不会一开始就踩雷。
当前实现简化了,没有这个保护。
小结
这篇讲了扫雷游戏的旗帜标记功能,核心知识点:
- GestureDetector的
onLongPress检测长按手势,和onTap点击区分开 - toggle模式:长按切换旗帜状态,再按取消,简单直观
- setState更新状态并触发UI重建,Flutter的标准模式
- 前置检查:游戏结束、格子已翻开时不允许操作,防止无效操作
- 互斥逻辑:插旗的格子不能被翻开,防止误触,保护玩家
- 计数统计:实时显示已插旗数量,帮助玩家判断进度
- _InfoCard组件:封装信息展示卡片,复用性好,代码整洁
- 触觉反馈:HapticFeedback增强操作感,可选功能
- 旗帜数量限制:可以限制不超过雷数,增加策略性
- 双击快速翻开:高级功能,加快游戏速度
- 问号标记:三态切换,表示不确定
- 首次点击保护:第一次点击不会踩雷,对新手友好
插旗功能虽然不是扫雷的核心玩法,但它大大提升了游戏体验。一个好的游戏,细节功能也要做到位。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)