Flutter 实战:memory_match_game 翻牌记忆游戏的双卡匹配、延迟翻回与鸿蒙适配解析

前言

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

memory_match_game 是一个基于 Flutter 编写的本地翻牌记忆游戏。应用把 8 个图标复制成 16 张牌并随机打乱,玩家每次点击两张卡片进行配对;如果两张牌图标一致,则保持翻开,否则在 800 毫秒后翻回。页面同时统计尝试次数和已匹配对数,直到所有配对完成后显示胜利提示。

这个项目没有联网、没有排行榜、没有计时器,也没有复杂动画控制。它的价值集中在 随机洗牌、状态驱动翻牌、双选配对、延迟回收、自定义卡片样式和胜利统计 上。对于 Flutter 入门、游戏交互和鸿蒙适配来说,这是一份很清晰的练习样例。

翻牌类小游戏最适合拿来理解“点击一次改变局部状态,点击两次触发判断,异步延迟恢复界面”这一整套 Flutter 交互模式。

在这里插入图片描述

图示说明:本文围绕 Flutter 本地翻牌记忆游戏展开,重点分析随机卡牌、配对判断、延迟翻回、胜利统计和鸿蒙端适配要点。

一、项目定位与源码概览

1.1 应用目标

memory_match_game 的目标是实现一个经典的记忆翻牌游戏。玩家需要通过记住已翻开的牌面,尽可能少的步数完成所有配对。源码中的游戏流程非常清晰:

  1. 启动后生成 16 张牌。
  2. 牌面隐藏,只显示问号图标。
  3. 点击一张牌后翻开。
  4. 再点击第二张牌后触发匹配判断。
  5. 若匹配成功,两张牌保持翻开。
  6. 若不匹配,800 毫秒后自动翻回。
  7. 当所有对数匹配完成时显示胜利提示。

1.2 功能边界

当前源码是 本地单机小游戏,没有实现以下功能:

  • 不接入网络排行榜。
  • 不记录最高分或历史记录。
  • 不提供计时器。
  • 不支持难度切换。
  • 不支持关卡系统。
  • 不支持自定义牌面素材包。

因此,文章分析应围绕源码真实具备的能力展开,不把它写成完整商业游戏。

1.3 核心文件

文件 作用 说明
pubspec.yaml 依赖声明 使用 Flutter SDK 和基础图标依赖
lib/main.dart 游戏核心代码 包含入口、状态、翻牌逻辑、胜利判断和页面
test/widget_test.dart Widget 测试入口 当前仍是默认计数器测试,需要按实际游戏改造
ohos 鸿蒙工程目录 用于跨端构建与平台适配

1.4 技术关键词

技术点 项目体现 学习价值
StatefulWidget 保存牌面、翻开状态和胜负 理解状态驱动 UI
Random 洗牌 掌握本地随机化
List.filled 初始化翻开状态 学习固定长度状态列表
Future.delayed 不匹配时延迟翻回 掌握异步 UI 处理
GridView.builder 生成 4 列卡片网格 理解网格布局
CustomPainter 本项目未使用自定义绘制

二、运行环境与依赖结构

2.1 SDK 声明

项目在 pubspec.yaml 中声明 Dart SDK:

environment:
  sdk: ^3.9.2

这表示项目运行在较新的 Dart 环境中,可以使用空安全、集合字面量、级联操作和现代 Flutter API。

2.2 项目依赖

依赖保持轻量:

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
依赖 用途 说明
flutter UI、渲染、手势和状态管理 核心能力来源
cupertino_icons 图标资源 当前页面主要使用 Material 图标
flutter_test Widget 测试 可验证页面状态和交互
flutter_lints 静态规则 约束代码风格

2.3 常用命令

开发和验证常用命令如下:

flutter pub get
flutter analyze
flutter test
flutter run

这些命令分别用于依赖获取、静态检查、测试执行和本地运行。对于鸿蒙适配,建议先把 Flutter 层逻辑跑通,再进入平台构建。

2.4 适配复杂度

memory_match_game 没有使用平台插件,因此跨端适配重点主要集中在:

  • 网格布局是否稳定。
  • 点击手势是否准确。
  • 异步翻回是否按预期执行。
  • 胜利状态卡片是否正确显示。
  • AppBar 刷新是否可用。

三、应用入口与主题配置

3.1 main 函数

应用入口如下:

void main() {
  runApp(const MyApp());
}

main() 只负责启动根组件,具体游戏逻辑都在页面状态中完成。

3.2 MyApp 根组件

根组件负责配置应用标题、主题和首页:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Memory Match',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber),
      ),
      home: const MyHomePage(title: 'Memory Match'),
    );
  }
}

这里选择琥珀色作为主题种子色,和翻牌游戏中常见的暖色卡片视觉比较契合。

3.3 首页组件

MyHomePage 是一个 StatefulWidget

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

翻牌游戏需要维护大量动态状态,因此使用 StatefulWidget 很合理。

3.4 入口关系表

层级 类或函数 职责
启动层 main() 启动 Flutter 应用
应用层 MyApp 配置标题、主题和首页
页面层 MyHomePage 接收标题并创建状态
状态层 _MyHomePageState 管理牌面、翻开状态、步数和匹配结果

四、游戏状态设计

4.1 状态字段总览

源码中的状态字段如下:

final List<IconData> _icons = [
  Icons.star,
  Icons.favorite,
  Icons.home,
  Icons.pets,
  Icons.music_note,
  Icons.wb_sunny,
  Icons.cake,
  Icons.diamond,
];

late List<IconData> _cards;
late List<bool> _revealed;
int? _firstIndex;
int? _secondIndex;
int _moves = 0;
int _matchedPairs = 0;
bool _isProcessing = false;

4.2 字段职责

字段 类型 作用
_icons List<IconData> 8 个基础图标
_cards List<IconData> 复制并打乱后的 16 张牌
_revealed List<bool> 每张牌是否翻开
_firstIndex int? 第一张已翻牌索引
_secondIndex int? 第二张已翻牌索引
_moves int 尝试次数
_matchedPairs int 已匹配对数
_isProcessing bool 是否正在处理翻牌判断

4.3 状态组合关系

这些字段共同决定游戏进程:

  • _cards 决定牌面内容。
  • _revealed 决定哪些牌可见。
  • _firstIndex_secondIndex 决定当前轮次。
  • _moves 记录玩家尝试次数。
  • _matchedPairs 记录成功配对。
  • _isProcessing 防止异步期间重复点击。

4.4 late 字段的使用

late List<IconData> _cards;
late List<bool> _revealed;

这两个字段会在 _initGame() 中初始化,因此使用 late 很合适。它们在页面首次构建前就会被赋值。

五、初始化与重新开局

5.1 initState 生命周期


void initState() {
  super.initState();
  _initGame();
}

initState() 在状态对象创建后调用,适合执行游戏初始化。

5.2 初始化函数

void _initGame() {
  _cards = [..._icons, ..._icons]..shuffle();
  _revealed = List.filled(_cards.length, false);
  _firstIndex = null;
  _secondIndex = null;
  _moves = 0;
  _matchedPairs = 0;
  _isProcessing = false;
}

这个函数会重建全部游戏初始状态。

5.3 牌组生成

_cards = [..._icons, ..._icons]..shuffle();

这行代码先把 8 个图标复制一份,形成 16 张牌,再调用 shuffle() 打乱顺序。

5.4 翻开状态初始化

_revealed = List.filled(_cards.length, false);

List.filled 会创建与牌数相同长度的布尔列表,初始值全为 false,表示所有牌都处于隐藏状态。

5.5 重开入口

页面提供两个重开入口:

IconButton(
  icon: const Icon(Icons.refresh),
  onPressed: _initGame,
)

以及胜利后的 Play Again 按钮,它们都调用 _initGame()

六、翻牌点击逻辑

6.1 方法入口

玩家点击卡片时会调用 _onCardTap()

void _onCardTap(int index) {
  if (_isProcessing || _revealed[index] || _secondIndex != null) return;

  setState(() {
    _revealed[index] = true;

    if (_firstIndex == null) {
      _firstIndex = index;
    } else {
      _secondIndex = index;
      _moves++;
      _checkMatch();
    }
  });
}

6.2 点击拦截

if (_isProcessing || _revealed[index] || _secondIndex != null) return;

这行代码拦截三类非法点击:

  1. 正在处理上一轮翻牌时不允许继续点击。
  2. 已翻开的牌不允许再次点击。
  3. 同一轮次已经翻出两张牌时不允许继续点第三张。

6.3 第一张牌

如果 _firstIndex 为空,说明当前是第一张牌:

_firstIndex = index;

此时只翻开一张牌,等待玩家选择第二张。

6.4 第二张牌

第二次点击后:

_secondIndex = index;
_moves++;
_checkMatch();

系统记录第二张牌、增加一次尝试次数,并立即进入配对判断。

七、配对判断逻辑

7.1 判断入口

void _checkMatch() {
  _isProcessing = true;

  if (_cards[_firstIndex!] == _cards[_secondIndex!]) {
    setState(() {
      _matchedPairs++;
      _firstIndex = null;
      _secondIndex = null;
      _isProcessing = false;
    });
  } else {
    Future.delayed(const Duration(milliseconds: 800), () {
      setState(() {
        _revealed[_firstIndex!] = false;
        _revealed[_secondIndex!] = false;
        _firstIndex = null;
        _secondIndex = null;
        _isProcessing = false;
      });
    });
  }
}

7.2 匹配成功

当两张牌图标相同:

_matchedPairs++;
_firstIndex = null;
_secondIndex = null;
_isProcessing = false;

两张牌会一直保持翻开状态,下一轮直接开始。

7.3 匹配失败

如果两张牌不同,系统不会立即翻回,而是延迟 800 毫秒:

Future.delayed(const Duration(milliseconds: 800), () {
  setState(() {
    _revealed[_firstIndex!] = false;
    _revealed[_secondIndex!] = false;
    _firstIndex = null;
    _secondIndex = null;
    _isProcessing = false;
  });
});

这个短暂延迟给玩家留出记忆和确认时间。

7.4 异步处理意义

_isProcessing 的存在非常关键。它避免了延迟翻回期间玩家继续点击其他牌,从而破坏当前轮次状态。

记忆翻牌这类游戏最容易出问题的地方,不是“判断是否相同”,而是“异步等待期间如何锁住输入”。_isProcessing 就是在解决这个问题。

7.5 状态轮次表

阶段 _firstIndex _secondIndex _isProcessing
未开始 null null false
选中第一张 有值 null false
选中第二张并判断 有值 有值 true
匹配成功后 null null false
匹配失败等待翻回 有值 有值 true

八、胜利统计与提示

8.1 胜利条件

build() 中,源码通过以下条件判断是否赢了:

final isWon = _matchedPairs == _icons.length;

_icons.length 是 8,所以当匹配对数达到 8 时即游戏胜利。

8.2 统计卡片

顶部状态卡片展示两个关键指标:

Text('$_moves')
Text('$_matchedPairs/${_icons.length}')
指标 含义
Moves 当前尝试次数
Matched 已匹配对数 / 总对数

8.3 胜利提示卡

isWontrue 时,页面会额外显示胜利卡片:

Card(
  color: Colors.green.shade100,
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Icon(Icons.emoji_events, size: 32, color: Colors.green),
        const SizedBox(width: 8),
        Text(
          'You won in $_moves moves!',
        ),
      ],
    ),
  ),
)

8.4 胜负反馈层次

状态 反馈方式
进行中 统计卡片显示 Moves 和 Matched
胜利 增加绿色奖杯提示卡
重开 AppBar 刷新按钮和 Play Again 按钮

九、卡片网格布局

9.1 GridView.builder

卡片区使用 GridView.builder

GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 4,
    crossAxisSpacing: 8,
    mainAxisSpacing: 8,
  ),
  itemCount: _cards.length,
  itemBuilder: (context, index) { ... },
)

9.2 4 列布局

配置 作用
crossAxisCount: 4 一行显示 4 张牌
crossAxisSpacing: 8 横向间距
mainAxisSpacing: 8 纵向间距

16 张牌以 4x4 方式布局,适合大多数手机屏幕。

9.3 卡片交互

每张牌使用 GestureDetector 包裹 AnimatedContainer

GestureDetector(
  onTap: () => _onCardTap(index),
  child: AnimatedContainer(
    duration: const Duration(milliseconds: 300),
    decoration: BoxDecoration(
      color: isRevealed ? _getCardColor(index) : Colors.amber.shade300,
      borderRadius: BorderRadius.circular(12),
    ),
  ),
)

9.4 动画容器

AnimatedContainer 会在颜色或样式变化时自动插值,提升翻牌的视觉流畅度。

十、卡片视觉状态

10.1 颜色判断

源码通过 _getCardColor() 控制翻开卡片的背景色:

Color _getCardColor(int index) {
  if (!_revealed[index]) {
    return Colors.amber;
  }
  return _cards[index] == _cards[_firstIndex!] && _secondIndex != null
      ? Colors.green.shade100
      : Colors.white;
}

10.2 颜色含义

状态 背景色
未翻开 琥珀色
翻开且当前轮次匹配成功 浅绿色
翻开但非当前轮次匹配状态 白色

10.3 问号图标

未翻开的卡片显示问号:

Icon(
  Icons.question_mark,
  size: 32,
  color: Colors.amber.shade700,
)

10.4 牌面图标

翻开后直接显示目标图标:

Icon(
  _cards[index],
  size: 32,
  color: Colors.amber.shade700,
)

这种设计让游戏规则非常直观:玩家记住的是图标位置,而不是抽象数字。

十一、页面布局结构

11.1 Scaffold 骨架

页面主体如下:

return Scaffold(
  appBar: AppBar(
    title: Text(widget.title),
    backgroundColor: Theme.of(context).colorScheme.inversePrimary,
    actions: [
      IconButton(
        icon: const Icon(Icons.refresh),
        onPressed: _initGame,
      ),
    ],
  ),
  body: Column(
    children: [
      // 状态卡片
      // 胜利提示
      // 牌面网格
    ],
  ),
);

11.2 三层主体

区域 作用
顶部卡片 显示 Moves 和 Matched
胜利提示 游戏完成后展示
卡片网格 实际翻牌交互区域

11.3 Expanded 网格区域

Expanded(
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: GridView.builder(...),
  ),
)

Expanded 让卡片网格占据剩余空间,避免上方状态区和下方网格互相挤压。

11.4 小屏关注点

如果屏幕较小,4 列网格中的卡片尺寸会变得较紧凑。鸿蒙端适配时需要重点确认:

  • 卡片是否仍可点击。
  • 图标是否清晰。
  • 网格是否发生溢出。

十二、鸿蒙适配关注点

12.1 适配优势

memory_match_game 的跨端适配难度较低,因为它:

  • 没有平台插件。
  • 没有网络请求。
  • 没有音效和文件访问。
  • 没有复杂动画控制器。
  • 主要依赖 Flutter 基础组件和 Dart 状态。

12.2 网格布局验证

鸿蒙端需要重点验证:

项目 验证内容
GridView.builder 4 列网格是否稳定
AnimatedContainer 翻牌过渡是否正常
GestureDetector 单击是否准确触发
Future.delayed 延迟翻回是否稳定
胜利提示 绿色卡片是否正常显示

12.3 异步流程验证

尤其要确认 _isProcessing 是否能在鸿蒙端正确阻止重复点击。翻牌游戏很容易在异步等待期间出现状态冲突,这一项一定要验证。

12.4 刷新行为验证

点击 AppBar 刷新后需要检查:

  1. 卡片是否重新洗牌。
  2. 所有牌是否重新隐藏。
  3. Moves 是否回到 0。
  4. Matched 是否回到 0。
  5. 胜利提示是否消失。

十三、测试设计与现有测试问题

13.1 当前测试状态

test/widget_test.dart 目前仍是 Flutter 默认计数器测试,会查找 01 和加号按钮。但实际页面是翻牌游戏,没有计数器文本,也没有加号按钮。

所以测试文件必须按真实页面改造。

13.2 初始页面测试

可以先验证标题和初始统计:

testWidgets('shows memory match game initially', (WidgetTester tester) async {
  await tester.pumpWidget(const MyApp());

  expect(find.text('Memory Match'), findsOneWidget);
  expect(find.text('0'), findsWidgets);
});

13.3 游戏板渲染测试

testWidgets('renders 16 cards', (WidgetTester tester) async {
  await tester.pumpWidget(const MyApp());

  expect(find.byType(GestureDetector), findsWidgets);
});

13.4 刷新按钮测试

testWidgets('refresh button resets the game', (WidgetTester tester) async {
  await tester.pumpWidget(const MyApp());

  await tester.tap(find.byIcon(Icons.refresh));
  await tester.pump();

  expect(find.textContaining('Moves'), findsOneWidget);
});

13.5 游戏胜利测试思路

由于牌面是随机洗牌的,完整胜利测试更适合拆成纯逻辑单元测试。例如测试匹配判断、计数增长和重置逻辑,而不是强依赖具体图标顺序。

十四、代码质量与可维护性

14.1 当前实现优点

memory_match_game 的源码有几个明显优点:

  • 游戏逻辑集中,阅读顺序清晰。
  • 状态字段数量不多。
  • 配对判断直观。
  • 延迟翻回逻辑简洁。
  • 网格布局天然适合卡片游戏。
  • 重开流程统一。

14.2 可抽离的牌组模型

如果继续扩展,可以把牌面抽象成模型:

class MemoryCard {
  const MemoryCard({
    required this.icon,
    required this.revealed,
    required this.matched,
  });

  final IconData icon;
  final bool revealed;
  final bool matched;
}

14.3 可抽离的配对函数

配对逻辑也可以做成纯函数:

bool isMatch(IconData first, IconData second) => first == second;

这样可以更容易做单元测试。

14.4 状态管理可演进

当前项目用本地 State 已经够用。如果未来要增加难度、计时、排行榜或音效,可以考虑把游戏状态抽到独立控制器或状态管理层。

十五、性能与体验优化

15.1 算法复杂度

翻牌游戏的主要操作都是常数级或线性级,整体性能压力很小:

操作 复杂度 说明
随机洗牌 O(n) 对 16 张牌进行打乱
点击判断 O(1) 单张卡片状态变更
匹配检查 O(1) 比较两张 IconData
胜利判断 O(1) 对已匹配对数进行比较

15.2 动画时长

duration: const Duration(milliseconds: 300)

300 毫秒的动画配合 800 毫秒的翻回延迟,整体节奏比较适中。

15.3 点击区域

当前卡片是整个 GestureDetector 容器可点击。这个设计比只点图标更友好,尤其在移动设备上更容易命中。

15.4 体验可优化点

可以继续增强:

  • 增加开局倒计时。
  • 增加配对音效。
  • 增加难度模式。
  • 增加关卡关卡词库。
  • 增加翻牌失败动画。

十六、常见问题与优化建议

16.1 为什么牌面一开始都是问号

因为 _revealed 初始全是 false,卡片构建时会显示问号图标。

16.2 为什么同一轮只能点两张牌

_onCardTap() 通过 _secondIndex != null 拦截第三次点击,确保一轮只处理两张牌。

16.3 为什么不匹配后不会立刻翻回

源码用 Future.delayed(const Duration(milliseconds: 800)) 给玩家留出短暂观察时间。

16.4 为什么赢了之后还能点刷新

刷新按钮始终保留在 AppBar 中,可以随时重新开始一局。

16.5 为什么测试文件会失败

默认测试仍在查找计数器页面的文本和按钮,而实际页面是翻牌游戏,所以测试目标不匹配。

16.6 能不能加入计时器

可以,但当前源码没有实现。若要加计时器,需要在状态中维护开始时间和结束时间,并在胜利后计算总耗时。

十七、核心知识点速查

17.1 Widget 速查

Widget 使用位置 作用
MaterialApp 根组件 应用配置
Scaffold 页面骨架 AppBar 和 Body
Card 状态和提示区域 信息分区
GridView.builder 卡片网格 动态生成 16 张牌
GestureDetector 卡片点击 捕获翻牌动作
AnimatedContainer 卡片外观 提供翻牌过渡
IconButton AppBar 刷新游戏
Expanded 网格区域 占据剩余空间

17.2 方法速查

方法 作用
_initGame() 随机生成牌组并重置状态
_onCardTap() 处理卡片点击
_checkMatch() 判断是否配对成功
_getCardColor() 返回卡片背景色

17.3 状态速查

状态 变化来源 影响 UI
_cards 开局洗牌 决定牌面图标
_revealed 点击卡片后更新 决定是否显示图标
_firstIndex 第一次点击 记录第一张牌
_secondIndex 第二次点击 记录第二张牌
_moves 每轮第二次点击后增加 顶部统计
_matchedPairs 匹配成功时增加 胜利判断
_isProcessing 异步翻回期间为 true 防止乱点

十八、扩展方向

18.1 功能扩展

可以在当前项目上继续增加:

  • 计时器。
  • 最高分记录。
  • 难度模式。
  • 关卡系统。
  • 主题切换。

18.2 UI 扩展

界面层可以进一步增强:

  • 加入翻牌动画。
  • 加入匹配成功特效。
  • 加入失败抖动效果。
  • 加入开始页。
  • 加入重新开始弹窗。

18.3 工程扩展

工程层可以继续演进:

  • 抽离状态模型。
  • 抽离纯逻辑函数。
  • 增加更贴合页面的测试。
  • 增加鸿蒙端专项验证。

18.4 跨端实践价值

memory_match_game 适合作为 Flutter 适配鸿蒙的小游戏样例。它覆盖了随机洗牌、网格布局、异步判断、状态卡片和胜利提示,能帮助开发者验证普通 Flutter 游戏交互在鸿蒙端的表现。

总结

memory_match_game 用一份简洁的 Flutter 源码实现了本地翻牌记忆游戏。它通过 _icons 提供基础牌面,通过 _cards 保存洗牌后的 16 张牌,通过 _revealed 控制翻开状态,通过 _firstIndex_secondIndex_isProcessing 管理一轮翻牌流程,并通过 _moves_matchedPairs 完成胜利统计。

从工程角度看,这个项目最值得学习的是 双选配对状态管理异步延迟翻回。它没有复杂依赖,适合用于 Flutter 小游戏入门、网格交互练习和鸿蒙端 UI 验证。需要注意的是,当前源码是本地单机演示,不包含计时器、排行榜、难度模式或网络功能。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐