一、应用概述
记忆翻牌(Memory Card Matching)是一款经典的益智游戏:玩家在网格中翻开卡片,记忆图案位置,配对两张相同的卡片即可得分。本项目基于 HarmonyOS ArkTS 框架实现了一个完整的记忆翻牌应用,涵盖卡片洗牌(Fisher-Yates 算法)、翻牌动画、匹配判定、锁定机制、计时与步数统计等核心功能。本文将从架构设计、算法实现、UI/UX 和 HarmonyOS 特性等角度进行全面剖析。
二、架构设计
2.1 系统架构
┌──────────────────────────────────────────┐ │ Index.ets │ │ (主页面:持有游戏状态 @State) │ ├──────────────────────────────────────────┤ │ MemoryGameView.ets │ │ (游戏视图:网格布局、状态面板) │ ├──────────────────────────────────────────┤ │ CardComponent.ets │ │ (卡片组件:正面/背面、翻转动画) │ ├──────────────────────────────────────────┤ │ GameModel.ets │ │ (游戏模型:卡片数据、状态管理) │ ├──────────────────────────────────────────┤ │ GameController.ets │ │ (游戏控制器:洗牌、翻转、匹配逻辑) │ └──────────────────────────────────────────┘2.2 数据流设计
用户点击卡片 → CardComponent.onClick() → GameController.flipCard(index) → 检查锁定状态 → 翻牌 → 两张已翻开?→ 执行匹配检查 → 匹配成功:保持翻开,加分 → 匹配失败:短暂展示后翻回 → 检查是否全部匹配 → 游戏结束三、核心技术实现
3.1 卡片数据结构
// GameModel.ets export enum CardState { Hidden = 0, // 背面朝上(未翻开) Revealed = 1, // 正面朝上(已翻开) Matched = 2 // 已配对成功 } export class Card { id: number; // 唯一标识 emoji: string; // 图案内容(使用 Emoji) state: CardState; // 当前状态 constructor(id: number, emoji: string) { this.id = id; this.emoji = emoji; this.state = CardState.Hidden; } } export class GameModel { cards: Card[] = []; firstFlippedIndex: number = -1; // 第一张翻开的卡片索引 secondFlippedIndex: number = -1; // 第二张翻开的卡片索引 isLocked: boolean = false; // 锁定标志(防止快速连点) matchedPairs: number = 0; // 已匹配对数 totalPairs: number = 0; // 总对数 attempts: number = 0; // 尝试次数 isGameOver: boolean = false; // 游戏是否结束 timer: number = 0; // 计时(秒) }3.2 Fisher-Yates 洗牌算法
Fisher-Yates(也称为 Knuth 洗牌算法)是生成数组随机排列的最优算法。它的核心思想是从数组尾部开始,每次随机选取一个未处理的元素与当前位置交换,逐步向前推进。
// GameController.ets export function fisherYatesShuffle<T>(array: T[]): T[] { const result: T[] = [...array]; // 创建副本,避免修改原数组 for (let i = result.length - 1; i > 0; i--) { // 生成 [0, i] 范围内的随机整数 const j = Math.floor(Math.random() * (i + 1)); // 交换 i 和 j 位置的元素 [result[i], result[j]] = [result[j], result[i]]; } return result; }算法原理详解:
- 从后向前遍历:指针 i 从 n-1 递减到 1
- 随机选取:在 [0, i] 范围内选一个随机索引 j
- 交换元素:将 result[i] 与 result[j] 交换
- 不重复选择:位置 i 在交换后不会再参与后续随机
概率分析:
- 总排列数:n! 种等可能性排列
- 第 1 步(i=n-1):n 种选择,概率 1/n
- 第 2 步(i=n-2):n-1 种选择
- 最终每个排列出现的概率 = 1/n × 1/(n-1) × … × 1/1 = 1/n!
时间复杂度:O(n),仅需一次遍历
空间复杂度:O(n),需要副本数组
3.3 翻牌与匹配逻辑
// GameController.ets export function flipCard(model: GameModel, index: number): boolean { // 1. 边界检查:锁定中、已匹配、已翻开、游戏结束 if (model.isLocked) return false; if (model.isGameOver) return false; if (model.cards[index].state !== CardState.Hidden) return false; // 2. 翻开卡片 model.cards[index].state = CardState.Revealed; // 3. 第一张翻开 if (model.firstFlippedIndex === -1) { model.firstFlippedIndex = index; return true; } // 4. 第二张翻开 — 执行匹配检查 model.secondFlippedIndex = index; model.attempts++; model.isLocked = true; // 锁定,防止额外操作 const card1 = model.cards[model.firstFlippedIndex]; const card2 = model.cards[model.secondFlippedIndex]; if (card1.emoji === card2.emoji) { // 匹配成功 handleMatchSuccess(model); } else { // 匹配失败 — 延时翻回 handleMatchFailure(model); } return true; } function handleMatchSuccess(model: GameModel): void { model.cards[model.firstFlippedIndex].state = CardState.Matched; model.cards[model.secondFlippedIndex].state = CardState.Matched; model.matchedPairs++; model.isLocked = false; model.firstFlippedIndex = -1; model.secondFlippedIndex = -1; // 检查游戏是否结束 if (model.matchedPairs === model.totalPairs) { model.isGameOver = true; } } function handleMatchFailure(model: GameModel): void { // 使用 setTimeout 延迟 1 秒后翻回 setTimeout(() => { // 翻回前再次检查状态(防止在延时期间被其他操作修改) if (model.cards[model.firstFlippedIndex].state === CardState.Revealed) { model.cards[model.firstFlippedIndex].state = CardState.Hidden; } if (model.cards[model.secondFlippedIndex].state === CardState.Revealed) { model.cards[model.secondFlippedIndex].state = CardState.Hidden; } model.isLocked = false; model.firstFlippedIndex = -1; model.secondFlippedIndex = -1; }, 1000); }3.4 锁定机制详解
锁定机制是防止玩家在动画播放期间快速连点导致状态错乱的关键设计:
场景:玩家点击了两张不同卡片 → 两张卡片翻开 → isLocked = true → 启动 1 秒定时器 → 在此 1 秒内,所有点击被忽略 → 1 秒后卡片翻回,isLocked = false如果没有锁定机制,玩家可能在卡片翻回前点击第三张卡片,导致firstFlippedIndex和secondFlippedIndex的逻辑混乱。
3.5 卡片初始化
export function initializeGame(model: GameModel, pairCount: number): void { const emojis: string[] = [ '🍎', '🍊', '🍋', '🍇', '🍓', '🍒', '🍑', '🥝', '🍌', '🍉', '🍍', '🥭', '🍈', '🫐', '🥥', '🍅' ]; // 选择需要的图案对 const selectedEmojis = emojis.slice(0, pairCount); // 为每个图案创建两张卡片 const cardPairs: Card[] = []; for (let i = 0; i < selectedEmojis.length; i++) { cardPairs.push(new Card(i * 2, selectedEmojis[i])); cardPairs.push(new Card(i * 2 + 1, selectedEmojis[i])); } // Fisher-Yates 洗牌 model.cards = fisherYatesShuffle(cardPairs); model.totalPairs = pairCount; model.matchedPairs = 0; model.attempts = 0; model.firstFlippedIndex = -1; model.secondFlippedIndex = -1; model.isLocked = false; model.isGameOver = false; model.timer = 0; }四、卡片组件与翻转动画
4.1 卡片组件实现
// CardComponent.ets @Component struct CardComponent { @Prop card: Card; onClick?: () => void; build() { Stack() { // 卡片背面(默认显示) if (this.card.state === CardState.Hidden) { Column() { Text('?') .fontSize(36) .fontWeight(FontWeight.Bold) .fontColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#2B6CB0') .borderRadius(12) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } // 卡片正面(已翻开或已匹配) if (this.card.state !== CardState.Hidden) { Column() { Text(this.card.emoji) .fontSize(40) } .width('100%') .height('100%') .backgroundColor( this.card.state === CardState.Matched ? '#C6F6D5' : '#FFFFFF' ) .borderRadius(12) .border({ width: this.card.state === CardState.Matched ? 3 : 1, color: this.card.state === CardState.Matched ? '#38A169' : '#CBD5E0' }) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } } .width(80) .height(100) .onClick(() => { if (this.onClick) this.onClick(); }) } }4.2 翻牌动画实现
HarmonyOS ArkTS 提供了animateTo函数来实现流畅的翻转动画:
// 翻牌动画 function flipAnimation(target: Component, duration: number): void { animateTo({ duration: duration, curve: Curve.EaseInOut }, () => { // 在动画块内修改引起重绘的属性 target.scale({ x: 0, y: 1 }); // 沿 X 轴缩扁 }); // 中间状态切换卡片内容 setTimeout(() => { animateTo({ duration: duration, curve: Curve.EaseInOut }, () => { target.scale({ x: 1, y: 1 }); // 还原 }); }, duration); }实际开发中可以使用transition结合opacity和rotate实现更逼真的 3D 翻转效果:
// 使用 rotate 实现 3D 翻转 .rotate({ x: 0, y: 1, z: 0, angle: this.rotationAngle })五、计时器实现
// TimerManager.ets export class TimerManager { private intervalId: number = -1; private callback: (seconds: number) => void; private seconds: number = 0; constructor(callback: (seconds: number) => void) { this.callback = callback; } start(): void { this.seconds = 0; this.intervalId = setInterval(() => { this.seconds++; this.callback(this.seconds); }, 1000); } stop(): void { if (this.intervalId !== -1) { clearInterval(this.intervalId); this.intervalId = -1; } } reset(): void { this.stop(); this.seconds = 0; this.callback(0); } getSeconds(): number { return this.seconds; } }六、主页面完整代码
// Index.ets import { GameModel, Card, CardState } from './GameModel'; import { initializeGame, flipCard } from './GameController'; import { TimerManager } from './TimerManager'; @Entry @Component struct Index { @State model: GameModel = new GameModel(); private timerManager: TimerManager = new TimerManager( (seconds: number) => { this.model.timer = seconds; } ); aboutToAppear(): void { initializeGame(this.model, 6); // 6 对(12 张卡片) this.timerManager.start(); } build() { Column() { // 标题 Text('记忆翻牌 🧠') .fontSize(28) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 10 }) // 状态面板 Row() { Column() { Text('计时') .fontSize(14) .fontColor(Color.Gray) Text(`${Math.floor(this.model.timer / 60)}:${String(this.model.timer % 60).padStart(2, '0')}`) .fontSize(20) .fontWeight(FontWeight.Bold) } .margin({ horizontal: 20 }) Column() { Text('步数') .fontSize(14) .fontColor(Color.Gray) Text(`${this.model.attempts}`) .fontSize(20) .fontWeight(FontWeight.Bold) } .margin({ horizontal: 20 }) Column() { Text('配对') .fontSize(14) .fontColor(Color.Gray) Text(`${this.model.matchedPairs}/${this.model.totalPairs}`) .fontSize(20) .fontWeight(FontWeight.Bold) } .margin({ horizontal: 20 }) } .width('100%') .justifyContent(FlexAlign.Center) .padding(10) // 游戏结束提示 if (this.model.isGameOver) { Text('🎉 恭喜通关!🎉') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#38A169') .margin({ bottom: 15 }) } // 卡片网格 Grid() { ForEach(this.model.cards, (card: Card, index: number) => { CardComponent({ card: card, onClick: () => { if (!this.model.isGameOver) { flipCard(this.model, index); } } }) }, (card: Card) => card.id.toString()) } .columnsTemplate('1fr 1fr 1fr 1fr') .rowsTemplate('1fr 1fr 1fr') .width(360) .height(340) .padding(10) // 操作按钮 Button(this.model.isGameOver ? '再来一局' : '重新洗牌') .fontSize(18) .backgroundColor('#2B6CB0') .fontColor(Color.White) .borderRadius(8) .width(200) .height(48) .margin({ top: 15 }) .onClick(() => { this.timerManager.reset(); initializeGame(this.model, 6); this.timerManager.start(); }) } .width('100%') .height('100%') .backgroundColor('#F7FAFC') } }七、HarmonyOS 特性分析
7.1 @State 与响应式编程
在记忆翻牌应用中,@State装饰器被应用于GameModel对象。当卡片状态、计时器、匹配数等属性发生变化时,ArkTS 框架自动追踪依赖并触发局部重渲染。
@State model: GameModel = new GameModel();关键在于:只有被 @State 标记的变量变化才会触发 UI 更新。因此 model 内部的 cards 数组变化、timer 变化都需要显式赋值以触发响应。
7.2 Grid 网格布局
Grid() { // 卡片组件 } .columnsTemplate('1fr 1fr 1fr 1fr') // 4 列等宽 .rowsTemplate('1fr 1fr 1fr') // 3 行等高对于 6 对(12 张)卡片,4×3 网格是最佳布局。1fr单位自动均分可用空间。
7.3 条件渲染
if (this.card.state === CardState.Hidden) { // 显示背面 } if (this.card.state !== CardState.Hidden) { // 显示正面 }ArkTS 的if条件渲染语法可以轻松实现卡片不同状态的 UI 切换。框架会智能管理组件的创建和销毁。
7.4 生命周期函数
aboutToAppear(): void { initializeGame(this.model, 6); this.timerManager.start(); }aboutToAppear是组件的生命周期回调,在组件即将显示时调用。适合执行初始化逻辑。
八、算法深入探讨
8.1 Fisher-Yates vs 其他洗牌算法
| 算法 | 时间复杂度 | 空间复杂度 | 均匀性 |
|---|---|---|---|
| Fisher-Yates | O(n) | O(1) (原地) | 完美均匀 |
| 排序洗牌 (Sort + Random) | O(n log n) | O(n) | 取决于排序稳定性 |
| 随机抽取 (Random Pick) | O(n²) | O(n) | 均匀 |
Fisher-Yates 在效率和均匀性上都是最优选择。
8.2 匹配判定优化
对于大规模匹配游戏(如 20 对以上),可以使用哈希表预计算卡片位置,将匹配判定优化到 O(1):
// 预计算:图案 → [索引列表] const emojiMap: Map<string, number[]> = new Map(); model.cards.forEach((card, index) => { if (!emojiMap.has(card.emoji)) { emojiMap.set(card.emoji, []); } emojiMap.get(card.emoji)!.push(index); });8.3 难度动态调整
export function calculateGridSize(pairCount: number): { cols: number, rows: number } { const totalCards = pairCount * 2; // 尽可能接近正方形 let cols = Math.ceil(Math.sqrt(totalCards)); while (totalCards % cols !== 0) { cols++; } const rows = totalCards / cols; return { cols, rows }; }九、UI/UX 设计细节
9.1 交互反馈
- 点击反馈:卡片点击时有缩放动画
- 状态区分:
- 背面:蓝色背景 + 问号
- 翻开:白色背景 + 图案
- 配对成功:绿色边框 + 浅绿色背景
- 游戏结束:弹窗或横幅庆祝信息
- 锁定状态:视觉上不可见,但用户能感受到"点不动"
9.2 无障碍设计
- Emoji 图案提供视觉辨识,对色盲用户友好
- 卡片尺寸足够大(80×100dp),易于点击
- 清晰的状态面板显示进度
9.3 视觉风格
- 使用柔和的灰色背景(#F7FAFC)
- 卡片圆角设计(12dp)
- 统一的蓝色主色调
- 匹配成功后使用绿色进行正面反馈
十、最佳实践总结
10.1 代码组织
- MVC 模式:Model-View-Controller 分离
- 模型只负责数据存储,控制器处理业务逻辑
- 视图组件保持无状态(或最小状态)
10.2 状态管理
- 集中管理游戏状态,避免分散
- 使用锁定机制防止并发操作问题
- 定期重置状态,避免内存泄漏
10.3 性能优化
- 卡片数量较少时无需特殊优化
- 对于大量卡片,考虑使用 LazyForEach 按需渲染
- 动画使用硬件加速属性(transform/opacity)
10.4 可扩展性
- 支持自定义图案集(可扩展为图片)
- 支持不同难度等级(4×3、4×4、6×6)
- 支持多人对战模式
十一、总结
记忆翻牌游戏是一个完美的技术实践案例,涵盖了:
- Fisher-Yates 洗牌算法—— O(n) 时间复杂度、完美均匀的随机排列
- 翻牌与匹配逻辑—— 状态机设计、锁定机制
- 声明式 UI—— 条件渲染、网格布局、动画
- HarmonyOS 特性—— @State 响应式编程、生命周期、计时器
通过本文的详细分析,开发者可以掌握记忆翻牌游戏从设计到实现的完整流程,并将这些技术应用到更复杂的游戏和应用开发项目中。记忆翻牌虽小,五脏俱全,是学习 ArkTS 游戏开发的绝佳入门项目。