记忆翻牌游戏
自从上次看到朋友在聚会上拿出一款小巧却富有趣味的翻牌游戏后,我就萌生了想亲手实现一款“记忆翻牌(拼图找对)”的念头。它看似简单——一组被打乱的图片卡牌,通过点击翻牌、记忆位置再配对,但在功能实现、动画交互、性能优化、UI 设计等方面,却包含了足够多的小坑与学习点。于是,我在一个周末的清晨,给自己定下了“把它做得精致且功能完善”的小目标——包括定时、步数统计、卡牌翻转动画、配对成功消失、响应式布局、多难度可扩展,甚至日后加上排行榜和移动端适配。
下面,就跟随我的叙事脚步,一起拆解需求、搭建架构、写代码、调样式、解疑惑,带你从零到一完整剖析这款小型翻牌游戏。
一、需求与整体设计
回望最初的白板,最核心的玩法无非六点:
- 随机生成若干对卡牌,初始背面朝上;
- 点击后翻转,露出正面;
- 任意时刻只能同时翻开两张;
- 翻出的两张若图案一致,则从视图中消失,否则在短暂延迟后恢复背面;
- 统计用户翻牌步数与用时;
- UI 需有流畅的翻转动画,并在配对、失败时给出提示反馈。
围绕这几条,我画了下面这张逻辑流程图,理清主流程与分支:
@startuml
start
:生成 N 对随机卡牌数组;
:渲染卡牌(背面);
fork:用户点击卡牌;if (翻牌中?) then (是)stopelse (否):翻转点击卡牌;:已翻开列表 push 卡牌;if (已翻开列表 长度 == 2) then (是):步数+1;if (两图相同?) then (是):配对成功,动画后移除;else (否):延迟后翻回;endif:已翻开列表 清空;endifendif
end fork
if (剩余卡牌 == 0) then (是):停止计时,展示结果;stop
endif
@enduml
从这幅图可以看出,核心逻辑并不复杂,但如何将它优雅地用现代前端封装起来,就需要细细打磨:状态管理、定时器控制、翻转动画、交互锁定、布局响应、样式美化……下面我们逐一展开。
二、项目环境与目录结构
我选用了最轻量的前端工具链:直接在本地用 vite
初始化一个带 React + TypeScript 的项目,附带基本的 CSS Module。目录大致如此:
memory-puzzle/
├── index.html
├── src/
│ ├── App.tsx // 入口组件
│ ├── components/
│ │ ├── Card.tsx // 卡牌组件
│ │ ├── Timer.tsx // 计时器组件
│ │ └── Stats.tsx // 步数与时间展示
│ ├── hooks/
│ │ └── useTimer.ts // 封装定时器
│ ├── styles/
│ │ ├── App.module.css
│ │ └── Card.module.css
│ ├── assets/ // 图片素材
│ └── utils/
│ └── shuffle.ts // Fisher–Yates 随机打乱
└── package.json
- App.tsx:负责全局状态管理(未翻牌列表、已翻牌列表、步数、计时状态等)和主布局。
- Card.tsx:具体的单张卡牌,包括翻转动画和点击回调。
- useTimer.ts:Hook 方式管理计时,调用
setTimeout
或setInterval
并提供start / stop / reset
。 - shuffle.ts:Fisher–Yates 算法实现数组乱序。
这样分工确保了职责单一、易于维护。下面,我们先从最底层的小工具说起。
三、工具函数:打乱数组
在 src/utils/shuffle.ts
中,我写下这样一句话:要让 N 对卡牌随机出现,就得先把长度为 2N 的数组乱序。Fisher–Yates 算法是业界标准,性能和公平性都在线。代码并不算长:
export function shuffle<T>(array: T[]): T[] {const arr = array.slice();for (let i = arr.length - 1; i > 0; i--) {const j = Math.floor(Math.random() * (i + 1));[arr[i], arr[j]] = [arr[j], arr[i]];}return arr;
}
这段代码里,array.slice()
确保原数组不被修改;每次随机选一个索引 j
与当前 i
交换,时间复杂度 O(N),空间复杂度 O(N)。
四、卡牌组件的翻转动画
从视觉上看,翻牌游戏最打动人的就是那种仿真的卡牌旋转效果:背面朝天,一转身出现图片;若要复位,则反向旋转。实现思路是 CSS3 的 transform: rotateY
与过渡动画。下面是 Card.module.css
的精简样式:
.card {width: 100px;height: 100px;perspective: 600px;cursor: pointer;
}
.inner {position: relative;width: 100%;height: 100%;transition: transform 0.4s;transform-style: preserve-3d;
}
.flipped .inner {transform: rotateY(180deg);
}
.face, .back {position: absolute;width: 100%;height: 100%;backface-visibility: hidden;border-radius: 8px;
}
.back {background: #4a90e2;
}
.face {transform: rotateY(180deg);background-size: cover;
}
这里的关键点有:
.card
的perspective
给子元素一个 3D 深度;.inner
上设置preserve-3d
,确保子面板能在三维空间内翻转;.flipped
控制是否旋转 180 度;.backface-visibility: hidden
避免背面内容透出。
在 Card.tsx
中,我用一个布尔状态 isFlipped
来决定是否加上 .flipped
类,同时给父组件回调一个点击事件:
type CardProps = {id: number;img: string;isFlipped: boolean;onClick: (id: number) => void;isMatched: boolean;
};export default function Card({ id, img, isFlipped, onClick, isMatched }: CardProps) {return (<div className={styles.card} onClick={() => !isFlipped && !isMatched && onClick(id)}><div className={`${styles.inner} ${isFlipped ? styles.flipped : ''}`}><div className={styles.back}></div><div className={styles.face} style={{ backgroundImage: `url(${img})` }}></div></div></div>);
}
这样,就能在点击卡牌时,通过父组件传入的 isFlipped
与 isMatched
来决定交互是否生效及视觉呈现。
五、定时器 Hook:精准掌控游戏时长
在 src/hooks/useTimer.ts
中,我封装了一个简单的计时器,支持 start()
、stop()
、reset()
,并通过 useEffect
保证定时器引用的最新回调。核心代码大致长这样:
import { useState, useRef, useCallback, useEffect } from 'react';export function useTimer() {const [seconds, setSeconds] = useState(0);const timerRef = useRef<number | null>(null);const start = useCallback(() => {if (timerRef.current !== null) return;timerRef.current = window.setInterval(() => {setSeconds(sec => sec + 1);}, 1000);}, []);const stop = useCallback(() => {if (timerRef.current !== null) {clearInterval(timerRef.current);timerRef.current = null;}}, []);const reset = useCallback(() => {stop();setSeconds(0);}, [stop]);useEffect(() => {return () => {if (timerRef.current) clearInterval(timerRef.current);};}, []);return { seconds, start, stop, reset };
}
因为我们只需要秒级精度,所以用 setInterval
足矣;若对性能敏感,未来也能改成 requestAnimationFrame
。
六、主逻辑:App 组件
6.1 状态管理
在 App.tsx
,我用 React 的 useState
承担所有游戏状态,包括:
const [cards, setCards] = useState<CardType[]>([]);
const [flippedIds, setFlippedIds] = useState<number[]>([]);
const [matchedIds, setMatchedIds] = useState<Set<number>>(new Set());
const [steps, setSteps] = useState(0);
const { seconds, start, stop, reset } = useTimer();
- cards:长度 2N 的数组,每项
{ id, img }
; - flippedIds:当前翻开的卡牌
id
列表,最多两个; - matchedIds:已配对成功的
id
集合; - steps:配对尝试次数;
- seconds:游戏已用时。
一切初始化都在一个 initGame()
函数里:
function initGame() {const imgs = shuffle([...原始图片列表, ...原始图片列表]);setCards(imgs.map((src, idx) => ({ id: idx, img: src })));setFlippedIds([]);setMatchedIds(new Set());setSteps(0);reset();
}
页面首次渲染与“重新开始”按钮都调用它,并在 initGame()
之后立刻 start()
。
6.2 点击翻牌逻辑
核心的点击回调 handleCardClick(id: number)
:
-
如果已翻开 ≥2 张,或该卡已匹配、已翻开,则忽略;
-
将新
id
加入flippedIds
; -
如果此时长度为 2:
-
steps + 1
; -
若两者对应的
img
相同:- 延迟 300ms 后把它们加入
matchedIds
,并清空flippedIds
;
- 延迟 300ms 后把它们加入
-
否则延迟 800ms 后清空
flippedIds
;
-
-
每次成功消除一对,若
matchedIds.size === cards.length
,则stop()
计时并弹出胜利提示。
这其中最大的“坑”在于用 setState
更新数组/集合时,需要传入新的引用:
setMatchedIds(prev => new Set(prev).add(id1).add(id2));
否则 React 难以察觉变化,不会重渲染。
接着说完点击翻牌的逻辑细节后,我把整个游戏的“心脏”——App.tsx
的核心流程继续讲完。
我先把 handleCardClick
的大致实现贴出来,代码里穿插了注释,方便理解:
const handleCardClick = (id: number) => {// 如果当前正在翻两张,或者点的是已匹配的卡,就直接 returnif (flippedIds.length >= 2 || matchedIds.has(id)) return;// 翻开新卡const newFlipped = [...flippedIds, id];setFlippedIds(newFlipped);// 如果这是第一次翻,说明游戏刚开始,要启动计时if (steps === 0 && newFlipped.length === 1) {start();}// 如果正好翻开了两张,开始判断if (newFlipped.length === 2) {setSteps(prev => prev + 1);const [firstId, secondId] = newFlipped;const firstCard = cards.find(c => c.id === firstId)!;const secondCard = cards.find(c => c.id === secondId)!;if (firstCard.img === secondCard.img) {// 成功配对,给一点小延迟让“嗒”一下动画完整setTimeout(() => {setMatchedIds(prev => new Set(prev).add(firstId).add(secondId));setFlippedIds([]);// 如果全都配完了,就结束游戏if (matchedIds.size + 2 === cards.length) {stop();// 我简单用 window.alert,实际可改成 Modalalert(`恭喜!你用了 ${steps + 1} 步,耗时 ${seconds} 秒完成挑战!`);}}, 300);} else {// 配对失败,稍微停顿再翻回去setTimeout(() => {setFlippedIds([]);}, 800);}}
};
这里要特别留意两处“坑”:
- 步骤更新与计时启动时机:我在用户翻第一张牌时就
start()
,避免用户不动游戏却让计时器跑了; - 判断胜利条件时机:
matchedIds
是异步更新的,上面直接用matchedIds.size + 2 === cards.length
来判断,更保险;否则有时会因为闭包里拿到的旧matchedIds
而漏判。
6.3 统计组件与布局
在游戏界面上方,我用了一个小横条展示当前步数和用时,也放了一个“重置”按钮。对应组件 Stats.tsx
:
export default function Stats({ steps, seconds, onReset }: StatsProps) {const formatTime = (s: number) => {const m = Math.floor(s / 60);const sec = s % 60;return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;};return (<div className={styles.stats}><div>步数:<strong>{steps}</strong></div><div>用时:<strong>{formatTime(seconds)}</strong></div><button onClick={onReset}>重新开始</button></div>);
}
我用 CSS Flex 做横向布局,配合少量阴影和圆角,整体看上去轻盈又简洁。下面是一段示意的样式:
.stats {display: flex;align-items: center;justify-content: space-between;padding: 8px 16px;background: #ffffff;box-shadow: 0 2px 6px rgba(0,0,0,0.1);border-radius: 8px;margin-bottom: 16px;
}
.stats div, .stats button {font-size: 14px;
}
.stats button {padding: 4px 12px;background: #4a90e2;color: #fff;border: none;border-radius: 4px;cursor: pointer;
}
.stats button:hover {background: #357ab8;
}
七、响应式布局与难度扩展
七·一 多难度支持
在真正发布前,我想让它既能满足新手,也能满足“高手挑战”。最直接的方式就是在开始时让用户选个难度:4×4、6×6、8×8……对应配对数从 8 对到 32 对。流程图如下:
@startuml
start
:用户选择难度 Level;
fork:根据 Level 决定 N;:从图片池随机挑选 N 种图片;:复制、合并、shuffle,生成 cards;:渲染界面;
fork again:重置按钮或通关后,可再选难度或重玩当前;
end fork
stop
@enduml
实现上,我在 App.tsx
最顶部加入了一个 select
下拉:
<select value={gridSize} onChange={e => setGridSize(Number(e.target.value))}><option value={4}>4×4</option><option value={6}>6×6</option><option value={8}>8×8</option>
</select>
每次 gridSize
变化,就重新调用 initGame()
并把 cards
数组长度对应更新。为了避免页面卡顿,图片资源我都预先在 assets 里做了 20+ 种,并在初始化时用 JS 动态按需导入(import.meta.glob
)。
七·二 响应式适配
考虑到在手机或平板上也能愉快地玩游戏,我用 CSS Grid + media queries 做了简易响应:
.game-board {display: grid;grid-template-columns: repeat(var(--cols), 1fr);gap: 12px;
}
@media (max-width: 600px) {.card { width: 70px; height: 70px; }.game-board { gap: 8px; }
}
并在 React 里,动态把 --cols
设成 gridSize
,这样无论是 4 列还是 8 列,都能自动撑满容器。
八、动画细节与用户体验
为了让用户在配对成功时有更直观的反馈,我给配对的两张牌加了一个“缩放消失”效果:在它们进入 matchedIds
后,先给 .matched
类,触发 CSS 动画:
.matched .inner {animation: disappear 0.5s forwards;
}
@keyframes disappear {0% { transform: scale(1) rotateY(180deg); }100% { transform: scale(0) rotateY(180deg); opacity: 0; }
}
如此一来,当两张配对后,会先“啪”地对折,再“咻”地缩小消失,增强了成就感。
此外,失败翻回时,我还在配对失败延迟前,给两张牌短暂抖动:
@keyframes shake {0%,100% { transform: rotateY(180deg) translateX(0); }25% { transform: rotateY(180deg) translateX(-5px); }75% { transform: rotateY(180deg) translateX(5px); }
}
.flipped.wrong .inner {animation: shake 0.4s;
}
在 JS 里,判断失败后,我临时把失败的两个 id
存到一个 wrongIds
数组里,加上 wrong
类,动画完毕后再清空,就能让失败的两个卡片“生气”地抖一会儿再翻回去。
九、小结与后续扩展
至此,一款拥有现代感翻转动画、步数与时间统计、多难度、响应式、配对反馈、精美 UI 的“记忆翻牌”小游戏初步完成。你可以在桌面、手机上畅玩 4×4、6×6 甚至更大格子的挑战。下面是未来可以继续打磨的方向:
- 排行榜:接入后端或使用 LocalStorage,把每个难度下最少步数、最短用时存储并展示;
- 主题皮肤:可换成卡通人物、风景、图标等不同主题;
- 关卡模式:按难度逐步解锁关卡,增强粘性;
- 社交分享:把战绩生成海报、一键分享到朋友圈;
- 离线打包:用 PWA 或 Electron 支持离线、桌面应用。
做这个项目让我真正体会到,把一个简单的游戏机制落到页面上,每一步都要考虑代码可维护性、动画交互细节、状态边界条件和用户体验的方方面面。希望这篇从头到尾的拆解,对你动手实践前端小项目有所启发——因为真正的乐趣,不仅在游戏本身,更在于打造游戏的全过程。