鸿蒙原生开发——从零构建记忆翻牌游戏
一、引言
记忆翻牌(Memory Match)是一款经典的桌面益智游戏——若干对相同的卡片正面朝下随机排列,玩家每次翻开两张,如果图案相同则配对成功,不同则翻回背面。游戏的目标是用最少的尝试次数找出所有配对。
从技术角度看,记忆翻牌是一个状态管理密集型应用。每张卡片有三种状态(背面朝上、正面朝上、已配对),游戏运行时还需要维护全局锁(防止快速连点)、计时器(记录用时)和延时翻转(非配对卡片的800ms展示窗口)。这些状态之间的转换构成了一个典型的状态机。
本文用 ArkUI 从零构建一个记忆翻牌游戏,包含三档难度(3×4、4×4、5×4 网格)、翻牌配对、计时统计和通关判定。每个卡片都有视觉上的状态区分——紫色背面、白色正面、绿色配对成功——让玩家能直观感知游戏状态。
阅读完本文,你将能够:
- 用
class定义卡片数据模型并管理多维状态 - 实现 Fisher-Yates 洗牌算法对卡片随机排列
- 用
setTimeout实现延时翻转(记忆窗口) - 用
setInterval实现游戏计时 - 使用
FlexWrap.Wrap构建自适应列数的卡片网格
二、游戏设计
2.1 核心规则
游戏的核心规则可以用三句话描述:
- 初始状态:所有卡片正面朝下(显示
?),每次翻开两张 - 配对成功:两张卡片图案相同 → 保持正面朝上,标记为已配对(绿色背景)
- 配对失败:两张卡片图案不同 → 展示 800ms 后翻回背面
玩家需要记住已翻过但未配对的卡片位置,在后续尝试中利用这些记忆找出配对。这就是"记忆翻牌"名称的由来——游戏在训练短期视觉记忆。
2.2 难度设计
三档难度通过调整网格列数和配对数量来实现:
| 难度 | 网格 | 卡片数 | 配对数 | 说明 |
|---|---|---|---|---|
| 简单 | 3×4 | 12 | 6 | 每行 3 张,4 行,适合新手 |
| 普通 | 4×4 | 16 | 8 | 标准尺寸,适中的记忆挑战 |
| 困难 | 5×4 | 20 | 10 | 每行 5 张,记忆负荷最大 |
三档难度共用一个 10 对(20 个 emoji)的素材池。每次新游戏开始时,从池中随机抽取当前难度所需数量的 emoji,然后各复制一份形成配对,最后对整副牌进行洗牌。
选择三档而非两档,是因为两档会产生"新手 vs 专家"的二元对立感,而三档(简单/普通/困难)是一种更自然的梯度——大多数玩家会从普通开始,感到轻松就挑战困难,感到吃力就降到简单。
2.3 交互流程
一次完整的游戏流程包含以下交互点:
- 选择难度(可选):点击难度按钮 → 立即重置游戏
- 翻牌(核心循环):点击卡片 → 翻转动画 → 配对检测 → 结果处理
- 观察记忆窗口:非配对卡片展示 800ms,玩家趁机记住位置
- 通关:所有卡片配对成功 → 显示通关横幅 + 成绩统计
- 新一局:点击按钮 → 重置所有状态,重新洗牌
翻牌是唯一的核心交互,但它的"密度"足够高——一局 16 张卡片的标准游戏通常需要 15-25 次尝试才能完成,每次尝试都涉及两次点击和一次观察决策。这比单个按钮的交互要丰富得多。
三、数据模型与状态管理
3.1 CardData 类
每张卡片用 CardData 类描述其三种属性:
class CardData {
emoji: string = ''; // 卡片图案(翻到正面才可见)
flipped: boolean = false; // 是否正面朝上(临时状态)
matched: boolean = false; // 是否已配对(永久状态)
}
三种属性组合出三种视觉状态:
| flipped | matched | 显示内容 | 背景色 | 含义 |
|---|---|---|---|---|
| false | false | ? |
#667eea(紫) |
未翻开 |
| true | false | emoji | #FFFFFF(白) |
已翻开,尚未配对 |
| — | true | emoji | #C8E6C9(浅绿) |
配对成功 |
matched 的优先级高于 flipped——一旦 matched=true,无论 flipped 是什么值,卡片都显示为配对成功状态。这种状态分层简化了逻辑:配对后不需要关心 flipped 的值。
3.2 状态变量
页面的 @State 变量分为三类:
@State cards: CardData[] = []; // 卡片数组(核心数据)
@State attempts: number = 0; // 尝试次数
@State elapsedSec: number = 0; // 游戏用时(秒)
@State gameStarted: boolean = false; // 是否已开始
@State gameWon: boolean = false; // 是否通关
@State difficulty: number = 1; // 难度档位(0/1/2)
cards 是所有状态的核心——翻牌、配对、重置都围绕它展开。attempts 和 elapsedSec 是游戏的成绩指标,用于通关后展示。gameStarted 控制计时器的启动时机(首次翻牌才启动),gameWon 控制通关横幅的显示。
除了 @State 变量,还有四个私有变量不参与 UI 渲染,因此不需要 @State 装饰:
private firstFlipIdx: number = -1; // 第一张翻开的卡片索引(-1 表示等待第一张)
private lockInput: boolean = false; // 输入锁(延时翻转期间阻止点击)
private timerId: number = -1; // 计时器 ID
private flipTimeoutId: number = -1; // 延时翻转的定时器 ID
3.3 状态更新模式
与前面所有 Demo 一致,cards 数组的更新必须通过"修改元素 → 整体替换"来触发 UI 刷新:
// 翻转卡片
this.cards[idx].flipped = true;
this.cards = [...this.cards]; // 替换数组引用,触发 ForEach 重渲染
// 配对成功
this.cards[firstIdx].matched = true;
this.cards[idx].matched = true;
this.cards = [...this.cards];
如果只修改 this.cards[idx].flipped = true 而不用 [...this.cards] 替换引用,ArkUI 的 @State 不会检测到变化,UI 不会刷新。这是 ArkUI 状态管理的基本规则,也是前面所有 Demo 反复验证过的模式。
四、洗牌算法与游戏初始化
4.1 两次 Fisher-Yates 洗牌
initGame() 中进行了两次洗牌——第一次洗 emoji 池,第二次洗牌组:
initGame(): void {
this.stopAll();
const cfg = DIFFICULTIES[this.difficulty];
// 第一次洗牌:从 10 个 emoji 中随机选出当前难度所需数量
const pool = [...ALL_EMOJIS];
this.shuffle(pool);
// 生成配对的牌组:每个入选 emoji 出现两次
const deck: CardData[] = [];
for (let i = 0; i < cfg.pairs; i++) {
deck.push({ emoji: pool[i], flipped: false, matched: false });
deck.push({ emoji: pool[i], flipped: false, matched: false });
}
// 第二次洗牌:打乱牌组顺序
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = deck[i];
deck[i] = deck[j];
deck[j] = tmp;
}
this.cards = deck;
this.attempts = 0;
this.elapsedSec = 0;
this.gameStarted = false;
this.gameWon = false;
this.firstFlipIdx = -1;
this.lockInput = false;
}
两次洗牌的必要性:第一次洗牌保证"这一局用到哪些 emoji"是随机的——如果直接取 ALL_EMOJIS 的前 N 个,不同局面的图案变化性会大打折扣。第二次洗牌保证"同一对两张卡片的位置"是随机的——如果生成了 [🌟, 🌟, 🔥, 🔥, ...] 就直接用,卡片位置有规律可循。
4.2 shuffle 辅助方法
Fisher-Yates 洗牌算法提取为独立方法:
shuffle(arr: string[]): void {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
Fisher-Yates 的时间复杂度为 O(n),空间复杂度为 O(1)。它保证了每种排列出现的概率相等(均匀分布),是所有洗牌场景的首选算法。在密码生成器的文章中我们用它来随机排列字符,这里用来随机排列卡片——同一个算法,不同的应用场景。
4.3 难度切换
切换难度时调用 initGame() 完全重置:
selectDifficulty(di: number): void {
this.difficulty = di;
this.initGame();
}
切换难度会立即重置当前游戏——包括已配对的卡片、计时和尝试次数。这是有意为之:难度切换意味着"开始一局新游戏",玩家不会期待保留旧进度。
五、翻牌交互逻辑
5.1 flipCard 核心方法
翻牌是游戏的核心操作,每次点击都经过多层判断:
flipCard(idx: number): void {
// 第一层:输入锁(延时翻转期间禁止点击)
if (this.lockInput) return;
// 第二层:已配对 / 已翻开的卡片不可重复点击
if (this.cards[idx].matched || this.cards[idx].flipped) return;
// 首次翻牌时启动计时器
if (!this.gameStarted) {
this.gameStarted = true;
this.timerId = setInterval(() => { this.elapsedSec++; }, 1000);
}
// 翻转当前卡片
this.cards[idx].flipped = true;
this.cards = [...this.cards];
// 第一张牌:记录索引,等待第二张
if (this.firstFlipIdx === -1) {
this.firstFlipIdx = idx;
return;
}
// 第二张牌:进行配对判断
this.attempts++;
const firstIdx = this.firstFlipIdx;
this.firstFlipIdx = -1;
// ...配对检测逻辑
}
整个翻牌逻辑可以分为四个阶段:
阶段一(输入校验):检查 lockInput(延时翻转期间)、matched(已配对)、flipped(已翻开)三个条件,任一为真则忽略点击。这是状态机中的"守卫条件"——只有在"游戏进行中 + 卡片可翻"的状态下才能翻牌。
阶段二(翻转):将当前卡片的 flipped 设为 true,替换数组触发渲染。
阶段三(分支):如果这是"本轮第一张",记录索引后返回,等待玩家点击第二张。如果这是"本轮第二张",进入配对检测。
阶段四(配对检测):比较两张卡片的 emoji 属性。相同 → 配对成功,不同 → 延时翻转。
5.2 配对成功和通关检测
if (this.cards[firstIdx].emoji === this.cards[idx].emoji) {
// 两张卡片标记为已配对
this.cards[firstIdx].matched = true;
this.cards[idx].matched = true;
this.cards = [...this.cards];
// 全部配对的通关检测
if (this.cards.every((c: CardData) => c.matched)) {
this.stopAll();
this.gameWon = true;
}
}
Array.every() 遍历所有卡片检查 matched 是否全为 true。16 张卡片(普通难度)的全遍历代价为 O(n),对性能无影响。每次配对成功后都执行一次通关检测,确保通关横幅及时出现。
5.3 配对失败与延时翻转
else {
this.lockInput = true; // 加锁
this.flipTimeoutId = setTimeout(() => {
this.cards[firstIdx].flipped = false; // 翻回第一张
this.cards[idx].flipped = false; // 翻回第二张
this.cards = [...this.cards];
this.lockInput = false; // 解锁
this.flipTimeoutId = -1;
}, 800);
}
800ms 的延时是一个微妙的设计决策。如果设为 300ms,玩家来不及记住卡片位置;如果设为 1500ms,游戏节奏过于拖沓。800ms 是人类"扫一眼"的典型时间——足够看到图案并尝试记忆,又不至于让等待变得无聊。这个值来自对多款记忆翻牌游戏实测的总结。
lockInput 在 setTimeout 回调执行(卡片翻回)之前保持 true,阻止玩家在此期间点击任何卡片。如果没有这个锁,玩家可能在延时期间点击第三张卡片,造成"三张卡片同时翻开"的状态混乱。
六、计时与定时器管理
6.1 计时器
this.timerId = setInterval(() => { this.elapsedSec++; }, 1000);
1 秒间隔的向上计时器,与秒表计时器(100ms 间隔、精确到 0.01 秒)形成对比。记忆翻牌不需要亚秒级精度,1 秒间隔对 UI 线程的影响几乎为零。
计时器在首次翻牌(!this.gameStarted)时启动,而非页面加载时启动。这样设计有两个好处:(1) 玩家可以先观察卡牌布局再开始,(2) 计时器不在后台空转。
6.2 定时器清理
所有定时器在两个地方被清理:
// 新一局 / 切难度 / 通关时
stopAll(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1;
}
if (this.flipTimeoutId !== -1) {
clearTimeout(this.flipTimeoutId);
this.flipTimeoutId = -1;
}
}
// 页面离开时(防止内存泄漏)
aboutToDisappear(): void {
this.stopAll();
}
aboutToDisappear() 中的清理是必须的——如果玩家在延时翻转的 800ms 内离开页面,setTimeout 的回调仍然会触发并修改已销毁组件的状态,导致运行时错误。这个模式与前面所有使用定时器的 Demo 保持一致。
七、UI 设计
7.1 信息架构
页面从上到下分为四个区域:
┌────────────────────────────┐
│ 🧠 记忆翻牌(深色标题栏) │
├────────────────────────────┤
│ [简单] [普通] [困难] │ ← 难度选择
├────────────────────────────┤
│ 尝试 4 用时 00:38 配对 3/8 │ ← 统计栏
├────────────────────────────┤
│ [?] [?] [🌟] [?] │
│ [?] [❤️] [?] [?] │ ← 4×4 卡片网格
│ [?] [?] [?] [🌟] │
│ [❤️] [?] [?] [?] │
├────────────────────────────┤
│ 🔄 新一局 │ ← 重新开始
├────────────────────────────┤
│ 🎉 恭喜通关!(通关时显示) │ ← 通关横幅
└────────────────────────────┘
统计栏只在游戏开始后显示有效数据——gameStarted=false 时显示初始值(尝试 0、用时 00:00、配对 0/N),通关横幅只在 gameWon=true 时渲染:
if (this.gameWon) {
Column() {
Text('🎉 恭喜通关!')
.fontSize(20)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
Text(`尝试 ${this.attempts} 次 · 用时 ${this.formatTime(this.elapsedSec)}`)
.fontSize(FontSize.CAPTION)
.fontColor('#FFFFFFAA')
}
.width('100%')
.padding(Spacing.LG)
.backgroundColor('#52C41A')
.borderRadius(BorderRadius.LG)
// ...
}
7.2 卡片网格
卡片使用 Flex({ wrap: FlexWrap.Wrap }) 实现自适应网格布局:
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(this.cards, (card: CardData, ci: number) => {
Column() {
Text(card.flipped || card.matched ? card.emoji : '?')
.fontSize(this.emojiSize())
.fontColor(card.flipped || card.matched ? '#1a1a2e' : '#FFFFFF')
}
.width(this.cardWidth())
.height(this.cardHeight())
.backgroundColor(card.matched ? '#C8E6C9' :
(card.flipped ? '#FFFFFF' : '#667eea'))
.borderRadius(BorderRadius.MD)
.justifyContent(FlexAlign.Center)
// ...
.onClick(() => { this.flipCard(ci); })
})
}
.width('100%')
卡片的宽高、emoji 字号根据当前难度动态计算:
cardWidth(): string {
const cols = DIFFICULTIES[this.difficulty].cols;
return (100 / cols - 4) + '%'; // 3列→29%, 4列→21%, 5列→16%
}
cardHeight(): number {
const cols = DIFFICULTIES[this.difficulty].cols;
if (cols === 3) return 90; // 3列→大卡片
if (cols === 4) return 72; // 4列→标准尺寸
return 58; // 5列→小卡片
}
emojiSize(): number {
const cols = DIFFICULTIES[this.difficulty].cols;
if (cols === 3) return 40;
if (cols === 4) return 36;
return 28;
}
百分比宽度的公式 100/cols - 4 为每个卡片预留了约 2% 的间距。三档难度下卡片宽度分别为 29%、21%、16%,在 360dp 宽屏幕上对应的物理尺寸约为 104dp、76dp、58dp——所有尺寸下 emoji 都清晰可辨。
7.3 卡片颜色语义
三种卡片状态使用三种不同的背景色:
- 未翻开(
#667eea蓝紫色):带阴影效果,暗示"可以点击" - 已翻开(
#FFFFFF白色):高亮状态,暂时展示或等待配对 - 已配对(
#C8E6C9浅绿 +#4CAF50绿色边框):成功状态,视觉上温和且清晰
蓝紫色(#667eea)的选择并非偶然。它不是纯蓝也不是纯紫,而是介于两者之间的柔和色调,既不过于冷静(纯蓝)也不过於戏剧化(纯紫)。在白色和浅灰的页面背景下,蓝紫色的卡片有了自然的"凸起"暗示,符合卡片"可以被翻开"的物理隐喻。
7.4 难度选择按钮
难度按钮使用填充/轮廓的对比来区分选中态:
ForEach(DIFFICULTIES, (d: DifficultyCfg, di: number) => {
Text(d.label)
.fontColor(this.difficulty === di ? '#FFFFFF' : '#667eea')
.backgroundColor(this.difficulty === di ? '#667eea' : '#667eea18')
.borderRadius(BorderRadius.FULL)
.onClick(() => { this.selectDifficulty(di); })
})
未选中的按钮是浅紫背景 + 紫色文字(10% 不透明度背景 = #667eea18),选中的按钮是实心紫底 + 白字。这个视觉模式与列表模式下的 tab 切换一致,保持了整个项目 UI 的连贯性。
八、完整代码结构
MemoryGamePage (~210 行)
├── 常量定义
│ ├── ALL_EMOJIS[] — 10 个 emoji 素材
│ └── DIFFICULTIES[] — 三档难度配置
├── 数据模型
│ └── class CardData — 卡片属性(emoji / flipped / matched)
├── 状态变量
│ ├── @State cards[] — 卡片数组
│ ├── @State attempts / elapsedSec — 成绩指标
│ ├── @State gameStarted / gameWon — 游戏阶段
│ └── @State difficulty — 当前难度
├── 私有变量
│ ├── firstFlipIdx — 第一张牌索引
│ ├── lockInput — 输入锁
│ └── timerId / flipTimeoutId — 定时器句柄
├── 游戏逻辑
│ ├── initGame() — 初始化(两次洗牌)
│ ├── shuffle() — Fisher-Yates 洗牌
│ ├── flipCard() — 翻牌处理(四阶段)
│ └── selectDifficulty() — 切换难度
├── 辅助方法
│ ├── formatTime() — 时间格式化(MM:SS)
│ ├── matchedPairs() — 已配对数量
│ ├── cardWidth() / cardHeight() / emojiSize() — 动态尺寸
│ └── stopAll() — 定时器清理
├── 视图
│ ├── 标题栏 — 🧠 记忆翻牌
│ ├── 难度选择 — 三按钮行
│ ├── 统计栏 — 尝试 / 用时 / 配对
│ ├── 通关横幅(条件渲染)
│ ├── 卡片网格 — Flex wrap + ForEach
│ └── 新一局按钮
└── 生命周期
└── aboutToDisappear() — 清理所有定时器
九、总结
本文从零构建了一个记忆翻牌游戏。与前面十二篇的数据记录和工具应用不同,记忆翻牌是一个纯交互驱动的游戏——它的核心不是用户输入了什么,而是用户在翻开-观察-记忆-决策循环中体验到的游戏乐趣。从技术角度看,它也是状态管理最具挑战性的 Demo——六种 @State 变量、两个定时器、一个输入锁、三次数组替换,以及所有组件生命周期中最严格的定时器清理。
核心要点回顾:
-
三维卡片状态:
flipped(是否翻开)和matched(是否配对)组合出三种视觉状态——紫色背面、白色正面、浅绿配对成功。matched的优先级高于flipped,简化了状态判断。 -
双层洗牌:
initGame()中进行两次 Fisher-Yates 洗牌——第一次从素材池中随机选取 emoji(保证不同局面的图案多样性),第二次打乱牌组顺序(保证卡片位置无规律)。 -
翻牌四阶段:
flipCard()方法包含输入校验 → 翻转渲染 → 分支(第一张/第二张)→ 配对检测四个阶段。复杂的状态机被拆解为四个层级的if-return守卫条件。 -
800ms 记忆窗口:
setTimeout实现非配对卡片的延时翻转。800ms 是对人眼"扫一眼"时间的精心选择——太短来不及记忆,太长拖沓。lockInput锁在此期间阻止所有点击,防止"三张卡片同时翻开"。 -
动态网格:卡片宽度用百分比公式
100/cols - 4计算,在三档难度下(3/4/5 列)分别生成 29%/21%/16% 的宽度。配合FlexWrap.Wrap,不同难度下自动形成 3×4、4×4、5×4 的网格。 -
双定时器管理:
setInterval(游戏计时)和setTimeout(延时翻转)都需要在aboutToDisappear()中清理。前者防止页面销毁后继续计数,后者防止回调修改已销毁组件的状态。 -
紫色卡片隐喻:
#667eea(蓝紫色)被选为未翻开卡片的颜色——它不是纯蓝(过于冷静)也不是纯紫(过于戏剧化),介于两者之间的柔和色调在浅灰页面背景上形成了自然的"凸起"暗示,引导用户点击。
记忆翻牌游戏的乐趣在于发现——翻开两张卡片,看到它们恰好匹配的那一刻,大脑会释放一小股多巴胺。这个 200 行的 ArkUI 实现抓住了这个核心体验:用随机洗牌保证新鲜感,用 800ms 延时提供记忆机会,用绿色边框庆祝每一次成功的配对。
更多推荐


所有评论(0)