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

本项目从零开始,在鸿蒙 6.0 设备上构建一款单人记忆翻牌游戏。你将学习 Flutter 页面路由传参、数据模型、计时器与动画的基础用法,并了解如何将三方库(如 audioplayersshared\_preferences)无缝扩展进你的项目。所有代码均带详细注释,可直接在鸿蒙平台运行。

一、你将构建什么

一款单人记忆翻牌游戏,核心功能如下:

  • 屏幕上排列若干对 emoji 卡片,所有卡片默认背面朝上;

  • 玩家每次翻开两张卡片,图案相同则配对成功并保持翻开状态,否则短暂显示后自动翻回;

  • 游戏目标:在最短时间内找出所有配对卡片;

  • 游戏结束后返回主页,本地记录各难度最佳时间(本文采用内存存储,可轻松扩展至 shared\_preferences 实现持久化);

  • 支持多难度选择,界面配备细腻的缩放动画,提升交互体验。

二、环境与工具

搭建项目需准备以下环境与工具,避免后续编译报错:

  • 鸿蒙 6.0 真机或模拟器;

  • DevEco Studio(内置 OpenHarmony SDK);

  • Flutter 3.22 及以上版本(已集成鸿蒙平台支持);

  • 基本网络环境(用于下载项目依赖库)。

2.1 检查 Flutter 与鸿蒙环境

在终端依次执行以下命令,确保每一步输出符合预期:

1. 确认 Flutter 版本

flutter --version

输出示例:Flutter 3\.22\.0 • channel stable …,要求版本 ≥ 3.22,建议使用稳定版。

2. 启用鸿蒙平台支持

flutter config --enable-ohos
flutter config --list | grep enable-ohos

应显示 enable\-ohos: true,表示鸿蒙平台已启用。

3. 检查 DevEco Studio 与 OHOS SDK 路径

flutter doctor -v

输出中需找到以下类似段落,确认 SDK 配置正确:

[] OpenHarmony toolchain - develop for OpenHarmony devices
    • DevEco Studio at /Applications/DevEco-Studio.app
    • OpenHarmony SDK at /Users/xxx/ohos-sdk
    • OpenHarmony SDK version 6.0.0

若出现 提示,需按以下方式修复:

  • 在 DevEco Studio 中下载对应 API 6.0 的系统镜像或 SDK;

  • 设置环境变量 OHOS\_SDK\_HOME,指向你的 SDK 路径。

4. 检查已连接的鸿蒙设备

flutter devices

列表中需至少有一个 ohos 设备,示例如下:

ohos (mobile) • 1234567890ABCDEF • ohos • OpenHarmony 6.0

若无设备,需确认:

  • 真机已开启“开发者模式”并信任连接的电脑;

  • 模拟器已在 DevEco Studio 中启动,且能被 hdc list targets 识别。

三、创建项目并配置鸿蒙平台

打开终端,依次执行以下命令,完成项目创建与鸿蒙平台配置:

mkdir -p ~/HarmonyProjects && cd ~/HarmonyProjects
flutter create memory_flip_ohos
cd memory_flip_ohos

若项目未自动生成鸿蒙平台支持文件,执行以下命令添加:

flutter create --platforms ohos .

配置完成后,项目根目录下会出现 ohos/ 文件夹,说明鸿蒙平台配置成功。

四、项目结构

为保证代码清晰可维护,所有逻辑文件均放在 lib/ 目录下,具体结构如下:

lib/
├── main.dart               # 应用入口,初始化根组件
├── models/
│   └── game_state.dart     # 游戏数据模型与全局状态管理
├── pages/
│   ├── home_page.dart      # 主页,负责难度选择与最佳时间展示
│   └── game_page.dart      # 游戏页面,实现核心翻牌、计时、匹配逻辑
└── widgets/
    └── memory_card.dart    # 单张卡片组件,包含翻开动画效果

在终端执行以下命令,创建对应目录:

mkdir -p lib/models lib/pages lib/widgets

创建完成后,用编辑器依次创建并写入以下 Dart 文件(具体代码见下一节)。

五、代码实现详解

以下所有文件均包含详细注释,可按顺序复制到项目对应目录中,直接使用。

5.1 应用入口 —— lib/main\.dart

import 'package:flutter/material.dart';
import 'pages/home_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MemoryFlipApp());
}

/// 应用根组件,设定全局主题样式
class MemoryFlipApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '记忆翻牌大冒险',
      debugShowCheckedModeBanner: false, // 隐藏调试横幅
      theme: ThemeData(
        brightness: Brightness.light,
        primarySwatch: Colors.teal,
        scaffoldBackgroundColor: Colors.teal.shade50,
      ),
      home: HomePage(), // 应用启动后直接跳转主页
    );
  }
}

说明:采用纯白天蓝色调主题,简洁清爽;直接跳转至 HomePage,未添加主题切换逻辑,可自行扩展为日/夜间模式。

5.2 游戏数据模型 —— lib/models/game\_state\.dart

import 'dart:math';

/// 单张卡片的数据模型,存储卡片核心信息
class CardModel {
  final int id;        // 配对唯一标识,相同id为一对卡片
  final String emoji; // 卡片显示的图案
  bool isFlipped;      // 卡片是否已翻开
  bool isMatched;      // 卡片是否已匹配成功

  CardModel({
    required this.id,
    required this.emoji,
    this.isFlipped = false,
    this.isMatched = false,
  });
}

/// 游戏全局状态管理,控制游戏流程与数据
class GameState {
  late List<CardModel> cards;          // 所有卡片列表
  int flippedCardCount = 0;            // 当前已翻开但未匹配的卡片数
  int matchedPairs = 0;                // 已成功配对的对数
  bool isGameOver = false;             // 游戏是否结束
  int moveCount = 0;                   // 尝试次数(翻开两张卡片算一次)
  Stopwatch stopwatch = Stopwatch();   // 游戏计时器

  // 卡片图案库,可自由扩展更多图案
  static const List<String> emojiSet = [
    '🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
    '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔',
    '🐧', '🐦', '🐤', '🦆', '🦅', '🦉', '🦇', '🐺',
  ];

  /// 初始化一局游戏,pairCount 为当前难度的配对对数
  void initGame(int pairCount) {
    // 1. 从图案库随机选取 pairCount 个图案,每种图案生成两张卡片
    final shuffled = emojiSet.toList()..shuffle();
    final selected = shuffled.take(pairCount).toList();
    final cardList = <CardModel>[];
    for (int i = 0; i < selected.length; i++) {
      cardList.add(CardModel(id: i, emoji: selected[i]));
      cardList.add(CardModel(id: i, emoji: selected[i])); // 生成成对卡片
    }
    // 2. 打乱卡片顺序,保证每次游戏布局不同
    cardList.shuffle(Random());
    cards = cardList;

    // 3. 重置游戏状态,准备新一局游戏
    flippedCardCount = 0;
    matchedPairs = 0;
    isGameOver = false;
    moveCount = 0;
    stopwatch.reset();
    stopwatch.start(); // 启动计时器
  }
}

要点

  • id 是卡片配对的唯一标识,匹配逻辑基于 id 对比,比直接比对 emoji 更可靠;

  • emojiSet 提供 24 种不同图案,可支撑最高 10 对卡片的难度需求。

5.3 卡片组件 —— lib/widgets/memory\_card\.dart

import 'package:flutter/material.dart';
import '../models/game_state.dart';

class MemoryCard extends StatelessWidget {
  final CardModel card;
  final VoidCallback onTap; // 卡片点击回调函数

  const MemoryCard({super.key, required this.card, required this.onTap});

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        // 已翻开或已匹配的卡片,不响应点击事件
        if (card.isFlipped || card.isMatched) return;
        onTap();
      },
      child: AnimatedScale(
        scale: 1.0,
        duration: Duration(milliseconds: 300),
        curve: Curves.easeOutBack, // 弹性动画效果,提升交互质感
        child: AnimatedSwitcher(
          duration: Duration(milliseconds: 300),
          // 根据卡片状态,切换显示正面或背面
          child: card.isFlipped || card.isMatched
              ? _buildFront()   // 显示卡片正面(图案)
              : _buildBack(),   // 显示卡片背面(问号)
        ),
      ),
    );
  }

  // 构建卡片背面(默认显示状态)
  Widget _buildBack() {
    return Container(
      key: ValueKey('back_${card.id}'),
      decoration: BoxDecoration(
        color: Colors.teal,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(blurRadius: 4, color: Colors.black26, offset: Offset(2, 2)),
        ],
      ),
      child: Center(
        child: Text('?', style: TextStyle(fontSize: 32, color: Colors.white70)),
      ),
    );
  }

  // 构建卡片正面(翻开后显示状态)
  Widget _buildFront() {
    return Container(
      key: ValueKey('front_${card.id}'),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(blurRadius: 4, color: Colors.black26, offset: Offset(2, 2)),
        ],
        // 匹配成功的卡片,添加金色边框标识
        border: card.isMatched
            ? Border.all(color: Colors.amber, width: 3)
            : null,
      ),
      child: Center(
        child: Text(card.emoji, style: TextStyle(fontSize: 36)),
      ),
    );
  }
}

动画说明

  • AnimatedScale 提供缩放动画,curve: Curves\.easeOutBack 让卡片翻开时有弹性效果,提升交互体验;

  • AnimatedSwitcher 实现卡片正面与背面的淡入淡出切换,两个组件的 duration 均设为 300 毫秒,反馈及时。

5.4 主页 —— lib/pages/home\_page\.dart

import 'package:flutter/material.dart';
import 'game_page.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  // 三个难度的最佳时间(单位:秒),初始值 999 表示“暂无记录”
  int _best6 = 999, _best8 = 999, _best10 = 999;

  /// 将秒数格式化为 mm:ss 格式,用于显示最佳时间
  String _formatSeconds(int seconds) {
    if (seconds == 999) return '--:--';
    final m = seconds ~/ 60; // 分钟数
    final s = seconds % 60;  // 秒数
    return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
  }

  /// 根据游戏页面返回的结果,更新对应难度的最佳时间
  void _updateBestTime(int pairs, int seconds) {
    setState(() {
      if (pairs == 6 && seconds < _best6) _best6 = seconds;
      if (pairs == 8 && seconds < _best8) _best8 = seconds;
      if (pairs == 10 && seconds < _best10) _best10 = seconds;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('记忆翻牌大冒险'),
      ),
      body: Center(
        child: SingleChildScrollView(
          padding: EdgeInsets.all(20),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('选择难度', style: Theme.of(context).textTheme.headlineSmall),
              SizedBox(height: 30),
              _buildDifficultyCard('简单', 6, _best6),
              SizedBox(height: 12),
              _buildDifficultyCard('中等', 8, _best8),
              SizedBox(height: 12),
              _buildDifficultyCard('困难', 10, _best10),
            ],
          ),
        ),
      ),
    );
  }

  /// 构建单个难度选择卡片,点击跳转至对应难度的游戏页面
  Widget _buildDifficultyCard(String label, int pairs, int bestSeconds) {
    return Card(
      elevation: 4,
      child: ListTile(
        title: Text(label, style: TextStyle(fontSize: 18)),
        subtitle: Text('翻开 $pairs 对卡片'),
        trailing: Text('最佳 ${_formatSeconds(bestSeconds)}'),
        onTap: () async {
          // 跳转游戏页面,并等待游戏结束后返回的结果
          final result = await Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => GamePage(pairCount: pairs),
            ),
          );
          // 若返回有效结果,更新对应难度的最佳时间
          if (result != null && result is Map) {
            _updateBestTime(result['pairs'], result['seconds']);
          }
        },
      ),
    );
  }
}

关键设计

  • 最佳时间仅保存在内存中,应用退出后数据会丢失;若需持久化,可引入 shared\_preferences 将数据写入本地;

  • 通过 Navigator\.push 跳转游戏页面,并等待 GamePage 通过 Navigator\.pop 返回游戏结果,实现页面间数据传递。

5.5 游戏主页面 —— lib/pages/game\_page\.dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../models/game_state.dart';
import '../widgets/memory_card.dart';

class GamePage extends StatefulWidget {
  final int pairCount; // 从主页传入的配对数量(决定游戏难度)

  const GamePage({super.key, required this.pairCount});

  
  State<GamePage> createState() => _GamePageState();
}

class _GamePageState extends State<GamePage> {
  final GameState _gameState = GameState();
  List<CardModel?> _selectedCards = []; // 暂存当前翻开的两张未匹配卡片
  Timer? _timer; // 用于每秒刷新计时显示
  late Duration _elapsedTime = Duration.zero; // 已流逝的游戏时间

  
  void initState() {
    super.initState();
    _gameState.initGame(widget.pairCount); // 初始化游戏
    // 每秒刷新一次计时显示
    _timer = Timer.periodic(Duration(seconds: 1), _updateTimer);
  }

  /// 每秒更新一次已流逝时间,刷新界面显示
  void _updateTimer(Timer timer) {
    if (!_gameState.isGameOver) {
      setState(() {
        _elapsedTime = _gameState.stopwatch.elapsed;
      });
    }
  }

  /// 卡片点击处理逻辑,控制卡片翻开、匹配判断
  void _onCardTap(CardModel card) async {
    if (_gameState.isGameOver) return; // 游戏结束后,不响应点击
    if (_selectedCards.length == 2) return; // 已有两张未处理卡片,不允许再翻开

    // 翻开当前卡片,更新游戏状态
    setState(() {
      card.isFlipped = true;
      _gameState.flippedCardCount++;
      _selectedCards.add(card);
    });

    // 当翻开两张卡片时,进行匹配判断
    if (_selectedCards.length == 2) {
      _gameState.moveCount++; // 增加尝试次数
      final first = _selectedCards[0]!;
      final second = _selectedCards[1]!;

      if (first.id == second.id) {
        // 配对成功:标记卡片为已匹配,重置暂存列表
        setState(() {
          first.isMatched = true;
          second.isMatched = true;
          _gameState.matchedPairs++;
          _gameState.flippedCardCount -= 2;
          _selectedCards.clear();

          // 所有卡片配对完成,游戏结束
          if (_gameState.matchedPairs == widget.pairCount) {
            _gameState.isGameOver = true;
            _gameState.stopwatch.stop();
            _returnBestTime(); // 向主页返回游戏结果
          }
        });
      } else {
        // 配对失败:延迟 800ms 后,将两张卡片翻回背面
        await Future.delayed(Duration(milliseconds: 800));
        setState(() {
          first.isFlipped = false;
          second.isFlipped = false;
          _gameState.flippedCardCount -= 2;
          _selectedCards.clear();
        });
      }
    }
  }

  /// 游戏结束后,向主页返回当前难度和用时,用于更新最佳时间
  void _returnBestTime() {
    Navigator.pop(context, {
      'pairs': widget.pairCount,
      'seconds': _elapsedTime.inSeconds,
    });
  }

  /// 将 Duration 格式化为 mm:ss,用于显示游戏用时
  String formatDuration(Duration d) {
    String twoDigits(int n) => n.toString().padLeft(2, '0');
    final minutes = twoDigits(d.inMinutes.remainder(60));
    final seconds = twoDigits(d.inSeconds.remainder(60));
    return '$minutes:$seconds';
  }

  
  void dispose() {
    _timer?.cancel(); // 页面销毁时,取消计时器,避免内存泄漏
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    final cardCount = _gameState.cards.length;
    // 根据卡片总数,自动计算网格列数,优化显示效果
    final crossAxisCount = (cardCount <= 8) ? 4 : (cardCount <= 15 ? 5 : 6);

    return Scaffold(
      appBar: AppBar(
        title: Text('记忆翻牌'),
        actions: [
          Center(
            child: Padding(
              padding: EdgeInsets.only(right: 16),
              child: Text(
                '⏱ ${formatDuration(_elapsedTime)}',
                style: TextStyle(fontSize: 18),
              ),
            ),
          ),
        ],
      ),
      // 游戏结束时显示结束界面,否则显示卡片网格
      body: _gameState.isGameOver
          ? _buildGameOverScreen()
          : GridView.builder(
              padding: const EdgeInsets.all(12),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: crossAxisCount,
                mainAxisSpacing: 8,
                crossAxisSpacing: 8,
              ),
              itemCount: cardCount,
              itemBuilder: (context, index) {
                final card = _gameState.cards[index];
                return MemoryCard(
                  card: card,
                  onTap: () => _onCardTap(card),
                );
              },
            ),
    );
  }

  /// 游戏结束界面,显示用时、尝试次数,提供重新游戏和返回主页选项
  Widget _buildGameOverScreen() {
    return AnimatedOpacity(
      opacity: 1.0,
      duration: Duration(milliseconds: 600),
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text('🎉 恭喜完成!', style: TextStyle(fontSize: 32)),
            SizedBox(height: 16),
            Text('用时:${formatDuration(_elapsedTime)}'),
            Text('尝试次数:${_gameState.moveCount}'),
            SizedBox(height: 24),
            ElevatedButton.icon(
              icon: Icon(Icons.replay),
              label: Text('再来一局'),
              onPressed: () {
                // 重置游戏状态,开始新一局
                setState(() {
                  _gameState.initGame(widget.pairCount);
                  _selectedCards.clear();
                });
              },
            ),
            SizedBox(height: 8),
            TextButton(
              onPressed: () => Navigator.pop(context), // 返回主页
              child: Text('返回首页'),
            ),
          ],
        ),
      ),
    );
  }
}

核心逻辑

  • \_selectedCards 暂存当前翻开的两张未匹配卡片,当数量达到 2 时,立即进行匹配判断;

  • 匹配成功:两张卡片标记为 isMatched = true,保持翻开状态;当所有配对完成,游戏结束并停止计时;

  • 匹配失败:延迟 0.8 秒后,将两张卡片翻回背面,清空暂存列表;

  • 计时器每秒刷新一次 AppBar 上的时间显示,确保用时实时更新;

  • 游戏结束后,通过 Navigator\.pop 将当前难度和用时传回主页,主页据此更新对应难度的最佳时间。

六、运行与测试

按以下步骤运行项目,验证功能是否正常:

  1. 确保所有 Dart 文件已按上述代码保存至对应目录;

  2. 在项目根目录(memory\_flip\_ohos)执行以下命令,下载依赖并运行项目:
    flutter pub get flutter run \-d ohos

  3. 应用启动后,进行功能测试:

    • 点击任一难度卡片,进入游戏页面;

    • 卡片默认显示背面(问号),点击可翻开,显示 emoji 图案;

    • 翻出两张相同图案的卡片,即配对成功,卡片会锁定并显示金色边框;

    • 全部卡片配对完成后,弹出游戏结束界面,显示用时和尝试次数;

    • 点击“返回首页”,若当前成绩优于该难度的最佳时间,主页会自动更新最佳时间显示。
      在这里插入图片描述
      在这里插入图片描述

七、引入三方库的扩展建议

当前项目为简化新手入门流程,未强制使用第三方插件。可根据需求,添加以下三方库丰富功能,扩展方式如下:

7.1 音效反馈 —— audioplayers

为翻牌、配对成功等操作添加音效,提升交互体验,步骤如下:

  1. pubspec\.yaml 中添加依赖:
    audioplayers: ^6\.1\.0 \# 或使用鸿蒙适配版 audioplayers\_ohos

  2. 在项目根目录创建 assets/audio/ 文件夹,放置flip\.mp3(翻牌音效)和 match\.mp3(配对成功音效);

  3. game\_page\.dart 中初始化 AudioPlayer,并在翻牌(\_onCardTap)和配对成功时,调用 play\(AssetSource\(\.\.\.\)\) 播放对应音效。

提示:若鸿蒙平台无官方版 audioplayers,可查找 audioplayers\_ohos 或通过 git 依赖接入适配版本。

7.2 最佳时间持久化 —— shared\_preferences

将各难度最佳时间保存至本地,避免应用退出后数据丢失,步骤如下:

  1. pubspec\.yaml 中添加依赖:
    shared\_preferences: ^2\.3\.3 \# 或使用鸿蒙适配版 shared\_preferences\_ohos

  2. 在主页 \_updateBestTime 方法中,将更新后的最佳时间写入 SharedPreferences

  3. 在主页 initState 方法中,读取本地保存的最佳时间,赋值给 \_best6\_best8\_best10,实现数据持久化。

八、总结

通过本项目,你已掌握在鸿蒙 6.0 上使用 Flutter 构建完整交互式应用的核心流程,具体包括:

  • 鸿蒙开发环境的搭建与核心配置检查;

  • Flutter 项目的合理结构组织、数据模型定义、页面路由与数据回传;

  • 动画组件(AnimatedScaleAnimatedSwitcher)的实际运用,提升界面交互质感;

  • 计时器 TimerStopwatch 的组合使用,实现游戏计时功能;

  • 三方库的集成思路,为后续功能扩展打下基础。

后续可自行扩展功能,例如:替换卡片图案为本地图片、增加更多难度档位、接入云端排行榜、添加日/夜间模式等,让这款“记忆翻牌大冒险”更加完善出彩。

Logo

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

更多推荐