Day 23: 项目5-记忆翻牌游戏
📅 日期: 2026年03月18日
🎯 学习目标: 创建一个完整的记忆翻牌游戏
⏱️ 预计用时: 3-4 小时
📂 分类: 09-项目实战
🎯 学习目标
通过今天的实战项目,你将:
- 掌握游戏开发流程:从设计到实现完整游戏
- 深入理解 DOM 操作:动态创建、删除、修改元素
- 学会事件委托:高效处理大量元素的事件
- 实现游戏状态管理:开始、进行中、胜利、失败
- 应用动画效果:CSS3 翻转动画、过渡效果
💡 游戏需求
我们要创建一个 记忆翻牌游戏,具有以下功能:
核心功能
- 卡牌系统:4×4 网格,16 张卡牌(8 对图案)
- 翻转机制:点击卡牌翻转,显示图案
- 配对逻辑:两张相同配对成功,不同则翻回
- 计分系统:记录步数和时间
- 胜利条件:所有卡牌配对成功
界面设计
- 游戏区:4×4 卡牌网格
- 信息栏:步数、时间、重新开始按钮
- 胜利弹窗:显示最终成绩
📝 核心概念
1. 游戏状态管理
const gameState = {
isFlipping: false, // 是否正在翻转(防抖)
firstCard: null, // 第一张翻开的卡
secondCard: null, // 第二张翻开的卡
matchedPairs: 0, // 已配对数量
moves: 0, // 步数
time: 0, // 时间(秒)
timer: null, // 定时器ID
isPlaying: false // 是否在游戏中
};
2. 卡牌数据结构
// 卡牌图案(使用 emoji)
const cardIcons = ['🍎', '🍊', '🍋', '🍇', '🍓', '🍒', '🥝', '🍑'];
// 生成配对数据
function generateCards() {
const pairs = [...cardIcons, ...cardIcons]; // 复制一份,成对
return shuffleArray(pairs); // 随机打乱
}
3. 事件委托模式
// ✅ 使用事件委托:一个监听器处理所有卡牌
gameBoard.addEventListener('click', handleCardClick);
function handleCardClick(event) {
const card = event.target.closest('.card');
if (!card) return; // 只处理卡牌点击
// 处理翻转逻辑...
}
🛠️ DOM 操作方法
1. 动态创建元素
// 创建单个卡牌元素
function createCardElement(icon) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.icon = icon;
const front = document.createElement('div');
front.className = 'card-front';
const back = document.createElement('div');
back.className = 'card-back';
back.textContent = icon;
card.appendChild(front);
card.appendChild(back);
return card;
}
// 创建游戏面板
function createGameBoard(cards) {
const board = document.getElementById('game-board');
board.innerHTML = ''; // 清空
cards.forEach(icon => {
const card = createCardElement(icon);
board.appendChild(card);
});
}
2. 类名操作
// 添加类(翻转卡牌)
card.classList.add('flipped');
// 移除类(翻回卡牌)
card.classList.remove('flipped');
// 切换类
card.classList.toggle('selected');
// 检查类
if (card.classList.contains('matched')) {
// 已配对
}
// 替换类
card.classList.replace('flipped', 'matched');
3. 数据属性
// 设置数据
card.dataset.icon = '🍎';
card.dataset.index = 0;
// 读取数据
const icon = card.dataset.icon;
const index = parseInt(card.dataset.index);
🎮 完整项目代码
HTML 结构
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>记忆翻牌游戏</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>🎴 记忆翻牌游戏</h1>
<div class="stats">
<div class="stat">
<span class="stat-label">步数:</span>
<span id="moves">0</span>
</div>
<div class="stat">
<span class="stat-label">时间:</span>
<span id="time">00:00</span>
</div>
</div>
<button id="restart-btn" class="btn">重新开始</button>
</header>
<main id="game-board" class="game-board"></main>
</div>
<!-- 胜利弹窗 -->
<div id="win-modal" class="modal hidden">
<div class="modal-content">
<h2>🎉 恭喜胜利!</h2>
<div class="result">
<p>步数: <span id="final-moves">0</span></p>
<p>用时: <span id="final-time">00:00</span></p>
</div>
<button id="play-again-btn" class="btn">再玩一次</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
CSS 样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 600px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
color: #667eea;
margin-bottom: 20px;
font-size: 2em;
}
.stats {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 20px;
}
.stat {
font-size: 1.1em;
color: #555;
}
.stat-label {
font-weight: bold;
margin-right: 5px;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 30px;
border-radius: 25px;
font-size: 1em;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
/* 游戏面板 */
.game-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
perspective: 1000px; /* 3D 效果 */
}
/* 卡牌样式 */
.card {
aspect-ratio: 1;
position: relative;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.5s;
}
.card.flipped {
transform: rotateY(180deg);
}
.card.matched {
cursor: default;
}
.card-front,
.card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden; /* 隐藏背面 */
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
font-size: 2.5em;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
/* 卡牌正面(背面图案) */
.card-front {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 2em;
}
.card-front::after {
content: '?';
}
/* 卡牌背面(实际图案) */
.card-back {
background: white;
transform: rotateY(180deg);
border: 3px solid #667eea;
}
/* 胜利弹窗 */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
transition: opacity 0.3s;
}
.modal.hidden {
display: none;
}
.modal-content {
background: white;
padding: 40px;
border-radius: 20px;
text-align: center;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: popIn 0.3s ease-out;
}
@keyframes popIn {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.modal-content h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 2em;
}
.result {
margin: 20px 0;
font-size: 1.2em;
color: #555;
}
.result p {
margin: 10px 0;
}
/* 响应式设计 */
@media (max-width: 500px) {
.game-board {
gap: 10px;
}
.card-front,
.card-back {
font-size: 2em;
}
h1 {
font-size: 1.5em;
}
}
JavaScript 逻辑
// ==================== 配置 ====================
const cardIcons = ['🍎', '🍊', '🍋', '🍇', '🍓', '🍒', '🥝', '🍑'];
// ==================== 游戏状态 ====================
const state = {
cards: [],
flippedCards: [],
matchedPairs: 0,
moves: 0,
time: 0,
timer: null,
isPlaying: false
};
// ==================== DOM 元素 ====================
const gameBoard = document.getElementById('game-board');
const movesDisplay = document.getElementById('moves');
const timeDisplay = document.getElementById('time');
const restartBtn = document.getElementById('restart-btn');
const winModal = document.getElementById('win-modal');
const finalMoves = document.getElementById('final-moves');
const finalTime = document.getElementById('final-time');
const playAgainBtn = document.getElementById('play-again-btn');
// ==================== 工具函数 ====================
// Fisher-Yates 洗牌算法
function shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}
// 格式化时间
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// ==================== 游戏初始化 ====================
function initGame() {
// 重置状态
state.flippedCards = [];
state.matchedPairs = 0;
state.moves = 0;
state.time = 0;
state.isPlaying = true;
// 停止旧计时器
if (state.timer) {
clearInterval(state.timer);
}
// 更新显示
updateDisplay();
// 隐藏弹窗
winModal.classList.add('hidden');
// 生成并洗牌
const cards = generateCards();
// 创建游戏面板
createGameBoard(cards);
// 启动计时器
startTimer();
}
function generateCards() {
// 成对复制
const pairs = [...cardIcons, ...cardIcons];
// 随机打乱
return shuffleArray(pairs);
}
function createGameBoard(cards) {
gameBoard.innerHTML = '';
cards.forEach((icon, index) => {
const card = createCardElement(icon, index);
gameBoard.appendChild(card);
});
}
function createCardElement(icon, index) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.icon = icon;
card.dataset.index = index;
const front = document.createElement('div');
front.className = 'card-front';
const back = document.createElement('div');
back.className = 'card-back';
back.textContent = icon;
card.appendChild(front);
card.appendChild(back);
return card;
}
// ==================== 游戏逻辑 ====================
// 事件委托处理卡牌点击
gameBoard.addEventListener('click', handleCardClick);
function handleCardClick(event) {
const card = event.target.closest('.card');
// 忽略无效点击
if (!card) return;
if (!state.isPlaying) return;
if (card.classList.contains('flipped')) return;
if (card.classList.contains('matched')) return;
if (state.flippedCards.length >= 2) return;
// 翻转卡牌
flipCard(card);
}
function flipCard(card) {
// 添加翻转动画
card.classList.add('flipped');
// 加入已翻卡牌数组
state.flippedCards.push(card);
// 如果翻了两张,检查配对
if (state.flippedCards.length === 2) {
state.moves++;
updateDisplay();
checkMatch();
}
}
function checkMatch() {
const [card1, card2] = state.flippedCards;
const icon1 = card1.dataset.icon;
const icon2 = card2.dataset.icon;
if (icon1 === icon2) {
// 配对成功
handleMatch(card1, card2);
} else {
// 配对失败
handleMismatch(card1, card2);
}
}
function handleMatch(card1, card2) {
// 延迟一点,让玩家看到两张牌
setTimeout(() => {
// 标记为已配对
card1.classList.add('matched');
card2.classList.add('matched');
// 清空已翻卡牌
state.flippedCards = [];
// 增加配对计数
state.matchedPairs++;
// 检查胜利
if (state.matchedPairs === cardIcons.length) {
handleWin();
}
}, 500);
}
function handleMismatch(card1, card2) {
// 延迟后翻回
setTimeout(() => {
card1.classList.remove('flipped');
card2.classList.remove('flipped');
// 清空已翻卡牌
state.flippedCards = [];
}, 1000);
}
function handleWin() {
state.isPlaying = false;
clearInterval(state.timer);
// 显示胜利弹窗
finalMoves.textContent = state.moves;
finalTime.textContent = formatTime(state.time);
winModal.classList.remove('hidden');
}
// ==================== 计时器 ====================
function startTimer() {
state.timer = setInterval(() => {
state.time++;
updateDisplay();
}, 1000);
}
// ==================== 更新显示 ====================
function updateDisplay() {
movesDisplay.textContent = state.moves;
timeDisplay.textContent = formatTime(state.time);
}
// ==================== 事件监听 ====================
restartBtn.addEventListener('click', initGame);
playAgainBtn.addEventListener('click', initGame);
// ==================== 启动游戏 ====================
initGame();
⚠️ 重要注意事项
1. 事件委托的重要性
// ❌ 错误:每张卡牌都添加监听器
cards.forEach(card => {
card.addEventListener('click', handleClick); // 16 个监听器
});
// ✅ 正确:使用事件委托
gameBoard.addEventListener('click', (event) => {
const card = event.target.closest('.card');
if (card) handleClick(card); // 只有 1 个监听器
});
优点:
- 节省内存
- 动态添加的卡牌自动支持
- 性能更好
2. 防抖处理
// 防止快速点击多张卡牌
if (state.flippedCards.length >= 2) {
return; // 忽略点击
}
3. CSS 3D 翻转
.card {
transform-style: preserve-3d; /* 保持 3D 空间 */
transition: transform 0.5s; /* 平滑过渡 */
}
.card.flipped {
transform: rotateY(180deg); /* 翻转 180 度 */
}
4. 无障碍考虑
<!-- 添加 ARIA 标签 -->
<div class="card" role="button" tabindex="0" aria-label="卡牌">
🎓 实战应用场景
场景 1: 教育培训
- 儿童记忆训练:配对游戏
- 语言学习:单词-图片配对
- 历史学习:事件-年代配对
场景 2: 营销活动
- 品牌记忆:产品-配对
- 抽奖游戏:翻牌抽奖
- 知识竞赛:问答配对
场景 3: 社交游戏
- 多人对战:比谁配对更快
- 合作模式:团队合作配对
- 排行榜:记录最佳成绩
✍️ 练习任务
基础练习(必做)
-
代码实现:
- [ ] 创建 HTML、CSS、JS 文件
- [ ] 实现基本翻牌功能
- [ ] 添加计时和计分
-
功能验证:
- [ ] 卡牌随机打乱
- [ ] 配对成功保持翻开
- [ ] 配对失败自动翻回
- [ ] 胜利弹窗显示
进阶练习(推荐)
-
添加难度选择:
- 简单:4×3 网格(6 对)
- 中等:4×4 网格(8 对)
- 困难:5×4 网格(10 对)
-
音效增强:
- 翻牌音效
- 配对成功音效
- 胜利音乐
-
视觉效果:
- 配对成功动画
- 错误抖动效果
- 胜利彩带动画
-
数据持久化:
// 保存最佳成绩 localStorage.setItem('bestScore', JSON.stringify({ moves: state.moves, time: state.time }));
实战挑战(选做)
- 多人对战:WebSocket 实时对战
- 关卡系统:解锁新图案
- 成就系统:
- "神之手":10 步内完成
- "闪电战":30 秒内完成
- "完美主义":一次错误都没有
- 主题切换:动物、水果、运动等
💡 常见问题 FAQ
Q1: 为什么使用 dataset 而不是自定义属性?
A:
dataset是 HTML5 标准- 自动处理类型转换
- 更语义化,易于维护
Q2: 如何防止快速点击导致的问题?
A:
// 方案 1:状态检查
if (state.flippedCards.length >= 2) return;
// 方案 2:CSS pointer-events
.card.flipping {
pointer-events: none; /* 暂时禁用点击 */
}
Q3: 如何优化移动端体验?
A:
/* 触摸优化 */
.card {
touch-action: manipulation; /* 禁止双击缩放 */
-webkit-tap-highlight-color: transparent;
}
Q4: 如何实现撤销功能?
A:
const history = [];
function saveState() {
history.push({
cards: state.cards.map(c => c.cloneNode(true)),
moves: state.moves,
time: state.time
};
}
function undo() {
const prevState = history.pop();
// 恢复状态...
}
Q5: 如何生成真正的随机数?
A:
// Fisher-Yates 洗牌算法(已使用)
// 这是数学上证明最均匀的随机算法
📚 拓展阅读
MDN 文档
推荐资源
相关项目
🎉 总结
今天我们创建了一个 完整的记忆翻牌游戏,涵盖了:
✅ 游戏开发流程:从设计到实现
✅ DOM 操作技巧:动态创建、事件委托
✅ CSS3 动画:3D 翻转效果
✅ 游戏状态管理:开始、进行、胜利
✅ 用户体验优化:防抖、响应式设计
这是一个 真实可玩的游戏,你可以扩展成更复杂的项目。记住:游戏开发的核心是体验,让玩家玩得开心才是最重要的。
下一节课预告: Day 24 将学习 项目6 – 待办事项应用,实现一个完整的 CRUD 应用!
💪 加油!坚持就是胜利!