Flutter for OpenHarmony游戏集合App实战之记忆翻牌配对消除

通过网盘分享的文件:game_flutter_openharmony.zip

链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip

前言

上一篇讲了记忆翻牌的表情图案,这篇来聊聊配对消除的逻辑。

记忆翻牌的核心玩法是:翻开两张牌,相同就配对成功,不同就翻回去。这个看似简单的逻辑,实现起来需要考虑很多细节:点击时机的控制、状态的流转、延迟翻回的处理等等。

我在实现这个功能的时候,最开始没有加canTap锁定,结果玩家可以在等待期间疯狂点击,把游戏状态搞得一团糟。后来加上了这个限制,游戏体验才正常了。

状态变量定义

dart 复制代码
List<bool> revealed = [];

这个列表记录每张卡片是否被翻开。revealedi为true表示第i张卡片正面朝上,玩家可以看到图案。初始时所有卡片都是false,背面朝上。

dart 复制代码
List<bool> matched = [];

这个列表记录每张卡片是否已经配对成功。matchedi为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毫秒等待期间乱点
  • revealedindex: 这张牌已经翻开了,不能重复翻
  • matchedindex: 这张牌已经配对了,游戏规则不允许点击已配对的牌

这三个条件用||连接,任何一个满足就直接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();
}

两张牌的图案相同,配对成功:

  1. 把两张牌都标记为已配对,它们会一直显示正面
  2. 清空firstIndex,准备下一轮配对
  3. 检查是否全部配对完成,如果是就显示胜利对话框

注意这里用了firstIndex!,感叹号是Dart的空断言操作符,告诉编译器"我确定这个值不是null"。因为我们在else分支里,firstIndex一定有值。

配对失败

dart 复制代码
} else {
  canTap = false;
  Timer(const Duration(milliseconds: 800), () {
    setState(() {
      revealed[firstIndex!] = revealed[index] = false;
      firstIndex = null;
      canTap = true;
    });
  });
}

两张牌不同,配对失败:

  1. 禁止点击(canTap = false),防止玩家在等待期间继续点击
  2. 启动一个800毫秒的定时器
  3. 定时器触发后,把两张牌翻回去(revealed设为false)
  4. 清空firstIndex,准备下一轮
  5. 恢复点击(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.green200是浅绿色,不会太刺眼。

卡片渲染逻辑

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])

这是一个嵌套的三元表达式:

  1. 如果已配对(matchedi),显示浅绿色
  2. 否则,如果已翻开(revealedi),显示白色
  3. 否则,显示蓝色(背面)

内容逻辑

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号字体,稍微小一点,而且是白色的,和蓝色背景搭配。

状态流转

卡片有三种状态:

  1. 未翻开: revealed=false, matched=false

    • 显示蓝色背面和问号
    • 可以点击翻开
  2. 已翻开: revealed=true, matched=false

    • 显示白色正面和图案
    • 临时状态,可能翻回去
    • 不能再次点击
  3. 已配对: 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;
}

初始化游戏状态:

  1. 创建8对表情符号(16张牌)
  2. 打乱顺序
  3. 所有牌设为未翻开、未配对
  4. 清空firstIndex
  5. 允许点击
  6. 尝试次数归零

这个方法在游戏开始和"再来一局"时调用。

小结

这篇讲了记忆翻牌的配对消除,核心知识点:

  • 状态变量: revealed、matched、firstIndex、canTap、moves各有作用
  • firstIndex: 记录第一张牌,区分第一次和第二次点击
  • 配对判断: 比较两张牌的图案是否相同
  • Timer延迟: 800毫秒后翻回去,给玩家记忆时间
  • canTap锁定: 防止等待期间乱点,保证游戏状态正确
  • every方法: 检查是否全部配对完成
  • 三种状态: 未翻开、已翻开、已配对,状态流转清晰
  • 颜色反馈: 配对成功变绿色,给玩家积极反馈
  • 嵌套三元: 根据多个条件决定显示内容

配对消除是记忆翻牌的核心逻辑,理解了状态流转和延迟处理,游戏就完成了。这个游戏虽然简单,但涉及到的状态管理、异步处理、用户交互等概念,在更复杂的应用中也会用到。


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

相关推荐
一 乐1 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
tiankongdeyige1 小时前
游戏战斗系统学习第2篇:有限状态机 (FSM)
学习·游戏·typescript
剑客的茶馆1 小时前
在影视、动画和游戏特效(VFX/Animation Pipeline)行业中Python 调用RV Play
游戏·rv play·rv
卓怡学长1 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn2 小时前
Spring 注解使用详解
java·spring
admin and root2 小时前
「AI安全篇」实战AntiDebug自动化JS逆向加解密MCP
javascript·人工智能·网络安全·自动化·漏洞挖掘·cnvd·src赏金
其实防守也摸鱼2 小时前
Fastjson 反序列化漏洞(JNDI注入)
android·数据库·安全·oracle·自动化·fastjson·反序列化
一条小小yu2 小时前
Spring IoC的理解
java·后端·spring
落魄实习生2 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈2 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统