鸿蒙原生开发——从零构建记忆翻牌游戏
一、引言
记忆翻牌(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类描述其三种属性:
classCardData{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变量分为三类:
@Statecards:CardData[]=[];// 卡片数组(核心数据)@Stateattempts:number=0;// 尝试次数@StateelapsedSec:number=0;// 游戏用时(秒)@StategameStarted:boolean=false;// 是否已开始@StategameWon:boolean=false;// 是否通关@Statedifficulty:number=1;// 难度档位(0/1/2)cards是所有状态的核心——翻牌、配对、重置都围绕它展开。attempts和elapsedSec是游戏的成绩指标,用于通关后展示。gameStarted控制计时器的启动时机(首次翻牌才启动),gameWon控制通关横幅的显示。
除了@State变量,还有四个私有变量不参与 UI 渲染,因此不需要@State装饰:
privatefirstFlipIdx:number=-1;// 第一张翻开的卡片索引(-1 表示等待第一张)privatelockInput:boolean=false;// 输入锁(延时翻转期间阻止点击)privatetimerId:number=-1;// 计时器 IDprivateflipTimeoutId:number=-1;// 延时翻转的定时器 ID3.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();constcfg=DIFFICULTIES[this.difficulty];// 第一次洗牌:从 10 个 emoji 中随机选出当前难度所需数量constpool=[...ALL_EMOJIS];this.shuffle(pool);// 生成配对的牌组:每个入选 emoji 出现两次constdeck:CardData[]=[];for(leti=0;i<cfg.pairs;i++){deck.push({emoji:pool[i],flipped:false,matched:false});deck.push({emoji:pool[i],flipped:false,matched:false});}// 第二次洗牌:打乱牌组顺序for(leti=deck.length-1;i>0;i--){constj=Math.floor(Math.random()*(i+1));consttmp=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(leti=arr.length-1;i>0;i--){constj=Math.floor(Math.random()*(i+1));consttmp=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++;constfirstIdx=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{constcols=DIFFICULTIES[this.difficulty].cols;return(100/cols-4)+'%';// 3列→29%, 4列→21%, 5列→16%}cardHeight():number{constcols=DIFFICULTIES[this.difficulty].cols;if(cols===3)return90;// 3列→大卡片if(cols===4)return72;// 4列→标准尺寸return58;// 5列→小卡片}emojiSize():number{constcols=DIFFICULTIES[this.difficulty].cols;if(cols===3)return40;if(cols===4)return36;return28;}百分比宽度的公式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 延时提供记忆机会,用绿色边框庆祝每一次成功的配对。
