一、引言

记忆翻牌(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 交互流程

一次完整的游戏流程包含以下交互点:

  1. 选择难度(可选):点击难度按钮 → 立即重置游戏
  2. 翻牌(核心循环):点击卡片 → 翻转动画 → 配对检测 → 结果处理
  3. 观察记忆窗口:非配对卡片展示 800ms,玩家趁机记住位置
  4. 通关:所有卡片配对成功 → 显示通关横幅 + 成绩统计
  5. 新一局:点击按钮 → 重置所有状态,重新洗牌

翻牌是唯一的核心交互,但它的"密度"足够高——一局 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 是所有状态的核心——翻牌、配对、重置都围绕它展开。attemptselapsedSec 是游戏的成绩指标,用于通关后展示。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 是人类"扫一眼"的典型时间——足够看到图案并尝试记忆,又不至于让等待变得无聊。这个值来自对多款记忆翻牌游戏实测的总结。

lockInputsetTimeout 回调执行(卡片翻回)之前保持 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 变量、两个定时器、一个输入锁、三次数组替换,以及所有组件生命周期中最严格的定时器清理。

核心要点回顾:

  1. 三维卡片状态flipped(是否翻开)和 matched(是否配对)组合出三种视觉状态——紫色背面、白色正面、浅绿配对成功。matched 的优先级高于 flipped,简化了状态判断。

  2. 双层洗牌initGame() 中进行两次 Fisher-Yates 洗牌——第一次从素材池中随机选取 emoji(保证不同局面的图案多样性),第二次打乱牌组顺序(保证卡片位置无规律)。

  3. 翻牌四阶段flipCard() 方法包含输入校验 → 翻转渲染 → 分支(第一张/第二张)→ 配对检测四个阶段。复杂的状态机被拆解为四个层级的 if-return 守卫条件。

  4. 800ms 记忆窗口setTimeout 实现非配对卡片的延时翻转。800ms 是对人眼"扫一眼"时间的精心选择——太短来不及记忆,太长拖沓。lockInput 锁在此期间阻止所有点击,防止"三张卡片同时翻开"。

  5. 动态网格:卡片宽度用百分比公式 100/cols - 4 计算,在三档难度下(3/4/5 列)分别生成 29%/21%/16% 的宽度。配合 FlexWrap.Wrap,不同难度下自动形成 3×4、4×4、5×4 的网格。

  6. 双定时器管理setInterval(游戏计时)和 setTimeout(延时翻转)都需要在 aboutToDisappear() 中清理。前者防止页面销毁后继续计数,后者防止回调修改已销毁组件的状态。

  7. 紫色卡片隐喻#667eea(蓝紫色)被选为未翻开卡片的颜色——它不是纯蓝(过于冷静)也不是纯紫(过于戏剧化),介于两者之间的柔和色调在浅灰页面背景上形成了自然的"凸起"暗示,引导用户点击。

记忆翻牌游戏的乐趣在于发现——翻开两张卡片,看到它们恰好匹配的那一刻,大脑会释放一小股多巴胺。这个 200 行的 ArkUI 实现抓住了这个核心体验:用随机洗牌保证新鲜感,用 800ms 延时提供记忆机会,用绿色边框庆祝每一次成功的配对。

Logo

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

更多推荐