当前位置: 首页 > news >正文

记忆翻牌游戏

自从上次看到朋友在聚会上拿出一款小巧却富有趣味的翻牌游戏后,我就萌生了想亲手实现一款“记忆翻牌(拼图找对)”的念头。它看似简单——一组被打乱的图片卡牌,通过点击翻牌、记忆位置再配对,但在功能实现、动画交互、性能优化、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 方式管理计时,调用 setTimeoutsetInterval 并提供 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;
}

这里的关键点有:

  • .cardperspective 给子元素一个 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>);
}

这样,就能在点击卡牌时,通过父组件传入的 isFlippedisMatched 来决定交互是否生效及视觉呈现。


五、定时器 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)

  1. 如果已翻开 ≥2 张,或该卡已匹配、已翻开,则忽略;

  2. 将新 id 加入 flippedIds

  3. 如果此时长度为 2:

    • steps + 1

    • 若两者对应的 img 相同:

      • 延迟 300ms 后把它们加入 matchedIds,并清空 flippedIds
    • 否则延迟 800ms 后清空 flippedIds

  4. 每次成功消除一对,若 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 支持离线、桌面应用。

做这个项目让我真正体会到,把一个简单的游戏机制落到页面上,每一步都要考虑代码可维护性、动画交互细节、状态边界条件和用户体验的方方面面。希望这篇从头到尾的拆解,对你动手实践前端小项目有所启发——因为真正的乐趣,不仅在游戏本身,更在于打造游戏的全过程。

http://www.dtcms.com/a/473932.html

相关文章:

  • 自己做的网站如何让别人访问织梦帝国wordpress
  • Linux -程序地址空间
  • (Spring)@PathVariable 与 @RequestParam 区别与应用
  • SpringAI从入门到精通 (2)
  • Linux 12mybash的实现
  • K8s YAML 文件详解:从语法到实战编写指南
  • 社区版Idea怎么创建Spring Boot项目?Selected Java version 17 is not supported. 问题解决
  • 益阳市 网站建设电子商务网站建设的主要风险
  • SpringBootRemotePowershellAdmin:开箱即用的 Windows远程运维开源工具
  • 插槽vue/react
  • 对vue生命周期的理解
  • 2017民非单位年检那个网站做黄山旅游攻略景点必去
  • [笔记 自用]CAN总线通信配置
  • HTML 教程
  • 用自己服务器做网站用备案怎样在亚马逊网上开店
  • PHP操作elasticsearch7.8
  • 学校网站建设需求分析哪个小说网站可以做封面
  • 网站制作类软件推荐莆田网站格在哪里做
  • TypeScript 面试题及详细答案 100题 (21-30)-- 接口(Interface)
  • 承德网站新手怎么做网络推广
  • 6. 从0到上线:.NET 8 + ML.NET LTR 智能类目匹配实战--渐进式学习闭环:从反馈到再训练
  • 2.c++面向对象(五)
  • python中的一些运算符
  • 【嵌入式面试题】boss收集的11道,持续更新中
  • 保证样式稿高度还原
  • 网站建设 源码怎么注册公司名
  • [xboard] 34 buildroot 的overlay机制
  • 某公司站点的挖掘实战分享
  • 第三方和审核场景回调还是主动查询
  • Git基本命令的使用(超详细)