通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip
前言
上一篇讲了记忆翻牌的表情图案,这篇来聊聊配对消除的逻辑。
记忆翻牌的核心玩法是:翻开两张牌,相同就配对成功,不同就翻回去。这个看似简单的逻辑,实现起来需要考虑很多细节:点击时机的控制、状态的流转、延迟翻回的处理等等。
我在实现这个功能的时候,最开始没有加canTap锁定,结果玩家可以在等待期间疯狂点击,把游戏状态搞得一团糟。后来加上了这个限制,游戏体验才正常了。

状态变量定义
dart
List<bool> revealed = [];
这个列表记录每张卡片是否被翻开。revealed[i]为true表示第i张卡片正面朝上,玩家可以看到图案。初始时所有卡片都是false,背面朝上。
dart
List<bool> matched = [];
这个列表记录每张卡片是否已经配对成功。matched[i]为true表示第i张卡片已经找到了它的配对,不会再翻回去。配对成功的卡片会一直显示正面。
dart
int? firstIndex;
记录第一张翻开的卡片的索引。当玩家翻开第一张牌时,把索引存在这里;翻开第二张牌后,和firstIndex指向的牌比较,然后清空。
dart
bool canTap = true;
控制是否允许点击。当两张牌不匹配需要翻回去时,设为false禁止点击,800毫秒后恢复。这个变量是防止玩家作弊的关键。
dart
int moves = 0;
记录玩家的尝试次数。每翻开两张牌算一次尝试,不管是否配对成功。最后显示在胜利对话框里,让玩家知道自己的表现。
点击处理
dart
void _tap(int index) {
if (!canTap || revealed[index] || matched[index]) return;
setState(() {
revealed[index] = true;
if (firstIndex == null) {
firstIndex = index;
} else {
moves++;
if (cards[firstIndex!] == cards[index]) {
matched[firstIndex!] = matched[index] = true;
firstIndex = null;
if (matched.every((m) => m)) _showWinDialog();
} else {
canTap = false;
Timer(const Duration(milliseconds: 800), () {
setState(() {
revealed[firstIndex!] = revealed[index] = false;
firstIndex = null;
canTap = true;
});
});
}
}
});
}
这是整个游戏最核心的方法,处理玩家的每次点击。代码虽然不长,但逻辑很紧凑,每一行都有它的作用。
前置检查
dart
if (!canTap || revealed[index] || matched[index]) return;
三种情况不能点击:
- !canTap: 正在等待翻回去,禁止点击。这是最重要的检查,防止玩家在800毫秒等待期间乱点
- revealed[index]: 这张牌已经翻开了,不能重复翻
- matched[index]: 这张牌已经配对了,游戏规则不允许点击已配对的牌
这三个条件用||连接,任何一个满足就直接return,不执行后面的逻辑。这种"提前返回"的写法让代码更清晰,避免了深层嵌套。
翻开卡片
dart
revealed[index] = true;
把点击的卡片设为翻开状态。这行代码执行后,UI会立即更新,显示卡片的正面图案。setState包裹了整个逻辑,确保状态变化能触发重绘。
第一张牌
dart
if (firstIndex == null) {
firstIndex = index;
}
如果firstIndex是null,说明这是本轮的第一张牌。把当前索引存起来,等待玩家翻第二张。这时候不需要做任何判断,只是记录。
第二张牌
dart
} else {
moves++;
firstIndex不是null,说明已经有一张牌翻开了,这是第二张。尝试次数加1,不管接下来是否配对成功。
配对成功
dart
if (cards[firstIndex!] == cards[index]) {
matched[firstIndex!] = matched[index] = true;
firstIndex = null;
if (matched.every((m) => m)) _showWinDialog();
}
两张牌的图案相同,配对成功:
- 把两张牌都标记为已配对,它们会一直显示正面
- 清空firstIndex,准备下一轮配对
- 检查是否全部配对完成,如果是就显示胜利对话框
注意这里用了firstIndex!,感叹号是Dart的空断言操作符,告诉编译器"我确定这个值不是null"。因为我们在else分支里,firstIndex一定有值。
配对失败
dart
} else {
canTap = false;
Timer(const Duration(milliseconds: 800), () {
setState(() {
revealed[firstIndex!] = revealed[index] = false;
firstIndex = null;
canTap = true;
});
});
}
两张牌不同,配对失败:
- 禁止点击(canTap = false),防止玩家在等待期间继续点击
- 启动一个800毫秒的定时器
- 定时器触发后,把两张牌翻回去(revealed设为false)
- 清空firstIndex,准备下一轮
- 恢复点击(canTap = true)
这里的Timer是dart:async包提供的,用于延迟执行代码。800毫秒是一个经过测试的时间,足够玩家记住图案,又不会等太久影响游戏节奏。
canTap的作用
dart
bool canTap = true;
这个变量防止玩家在等待期间乱点。
如果不加这个限制,玩家可以在800毫秒内快速点击其他牌,打乱游戏状态。比如玩家翻开了A和B两张不同的牌,在等待翻回去的时候又点击了C,这时候firstIndex还指向A,但A马上要被翻回去,逻辑就乱了。
我最开始实现的时候没有这个变量,测试的时候发现快速点击会导致各种奇怪的bug:有时候牌翻不回去,有时候配对判断出错。加上canTap之后,这些问题都解决了。
这种"锁定"机制在游戏开发中很常见,任何需要等待的操作都应该考虑加锁,防止用户在等待期间触发新的操作。
Timer延迟
dart
Timer(const Duration(milliseconds: 800), () {
// 翻回去
});
用Timer实现延迟执行。Timer是dart:async包提供的类,第一个参数是延迟时间,第二个参数是回调函数。
800毫秒是个适中的时间,足够玩家记住图案,又不会等太久。我试过500毫秒,感觉太快了,玩家来不及看清;试过1000毫秒,又觉得节奏太慢。800毫秒刚刚好。
为什么不用Future.delayed
dart
// 也可以这样写
await Future.delayed(Duration(milliseconds: 800));
revealed[firstIndex!] = revealed[index] = false;
但这需要把方法改成async,而且setState的时机不好控制。async方法里的setState可能会在widget已经dispose之后执行,导致报错。
Timer更直接,而且可以在需要的时候取消(虽然这个游戏里不需要)。
Duration的写法
dart
const Duration(milliseconds: 800)
Duration是Dart的时间间隔类,可以用不同的单位创建:
Duration(milliseconds: 800)- 800毫秒Duration(seconds: 1)- 1秒Duration(minutes: 5)- 5分钟
加const是因为这个Duration是编译时常量,可以提高性能。
胜利检查
dart
if (matched.every((m) => m)) _showWinDialog();
every方法检查列表的每个元素是否都满足条件。
如果所有卡片都已配对(matched全是true),游戏胜利。every方法接收一个函数,对列表的每个元素调用这个函数,如果所有调用都返回true,every就返回true。
这里的(m) => m是一个简单的lambda,直接返回元素本身。因为matched是bool列表,元素本身就是true或false,所以这个写法等价于"检查是否所有元素都是true"。
胜利对话框
dart
void _showWinDialog() {
showDialog(context: context, builder: (_) => AlertDialog(
title: const Text('恭喜!'), content: Text('完成配对! 共$moves次尝试'),
actions: [TextButton(onPressed: () { Navigator.pop(context); setState(_initGame); }, child: const Text('再来一局'))],
));
}
显示尝试次数,8对卡片最少需要8次(每次都配对成功)。实际上很难做到,因为一开始不知道牌的位置,需要靠记忆。
对话框用showDialog显示,builder返回一个AlertDialog。AlertDialog有三个主要部分:
- title: 标题,显示"恭喜!"
- content: 内容,显示尝试次数
- actions: 按钮列表,这里只有一个"再来一局"按钮
点击按钮后,先用Navigator.pop关闭对话框,然后调用_initGame重新初始化游戏。setState(_initGame)是一个简写,等价于setState(() { _initGame(); })。
配对成功的视觉反馈
dart
color: matched[i] ? Colors.green[200] : ...
配对成功的卡片变成浅绿色,和其他卡片区分。
图案保持显示,玩家可以看到已经配对了哪些。这个视觉反馈很重要,让玩家知道哪些牌已经"安全"了,可以专注于剩下的牌。
绿色是一个积极的颜色,代表"成功"、"完成",用在这里很合适。Colors.green[200]是浅绿色,不会太刺眼。
卡片渲染逻辑
dart
Container(
decoration: BoxDecoration(
color: matched[i] ? Colors.green[200] : (revealed[i] ? Colors.white : Colors.blue[300]),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: revealed[i] || matched[i]
? Text(cards[i], style: const TextStyle(fontSize: 32))
: const Text('?', style: TextStyle(fontSize: 24, color: Colors.white)),
),
)
这段代码决定了卡片的外观。根据revealed和matched的状态,显示不同的颜色和内容。
颜色逻辑
dart
color: matched[i] ? Colors.green[200] : (revealed[i] ? Colors.white : Colors.blue[300])
这是一个嵌套的三元表达式:
- 如果已配对(matched[i]),显示浅绿色
- 否则,如果已翻开(revealed[i]),显示白色
- 否则,显示蓝色(背面)
内容逻辑
dart
child: revealed[i] || matched[i]
? Text(cards[i], style: const TextStyle(fontSize: 32))
: const Text('?', style: TextStyle(fontSize: 24, color: Colors.white))
如果卡片翻开或已配对,显示图案(表情符号);否则显示问号。
图案用32号字体,比较大,容易看清;问号用24号字体,稍微小一点,而且是白色的,和蓝色背景搭配。
状态流转
卡片有三种状态:
-
未翻开: revealed=false, matched=false
- 显示蓝色背面和问号
- 可以点击翻开
-
已翻开: revealed=true, matched=false
- 显示白色正面和图案
- 临时状态,可能翻回去
- 不能再次点击
-
已配对: matched=true
- 显示绿色正面和图案
- 永久状态,不会变
- 不能点击
状态只能按照特定的顺序流转:未翻开 → 已翻开 → 已配对(或翻回未翻开)。不可能从已配对变回其他状态。
理解这个状态流转对于理解游戏逻辑很重要。每次点击都会触发状态变化,而状态变化又会触发UI更新。
firstIndex的作用
dart
int? firstIndex;
记录第一张翻开的牌的索引。
- null: 还没翻开任何牌,或者刚完成一轮配对
- 有值: 已经翻开一张牌,等待第二张
这个变量是配对逻辑的核心。它让我们知道当前是在等待第一张牌还是第二张牌,以及第一张牌是哪一张。
用可空类型(int?)而不是用-1表示"没有",是Dart的推荐做法。null的语义更清晰,而且编译器会帮我们检查空值处理。
游戏初始化
dart
void _initGame() {
List<String> emojis = ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼'];
cards = [...emojis, ...emojis]..shuffle();
revealed = List.filled(16, false);
matched = List.filled(16, false);
firstIndex = null;
canTap = true;
moves = 0;
}
初始化游戏状态:
- 创建8对表情符号(16张牌)
- 打乱顺序
- 所有牌设为未翻开、未配对
- 清空firstIndex
- 允许点击
- 尝试次数归零
这个方法在游戏开始和"再来一局"时调用。
小结
这篇讲了记忆翻牌的配对消除,核心知识点:
- 状态变量: revealed、matched、firstIndex、canTap、moves各有作用
- firstIndex: 记录第一张牌,区分第一次和第二次点击
- 配对判断: 比较两张牌的图案是否相同
- Timer延迟: 800毫秒后翻回去,给玩家记忆时间
- canTap锁定: 防止等待期间乱点,保证游戏状态正确
- every方法: 检查是否全部配对完成
- 三种状态: 未翻开、已翻开、已配对,状态流转清晰
- 颜色反馈: 配对成功变绿色,给玩家积极反馈
- 嵌套三元: 根据多个条件决定显示内容
配对消除是记忆翻牌的核心逻辑,理解了状态流转和延迟处理,游戏就完成了。这个游戏虽然简单,但涉及到的状态管理、异步处理、用户交互等概念,在更复杂的应用中也会用到。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net