Day-23-项目5-记忆翻牌游戏

Day 23: 项目5-记忆翻牌游戏

📅 日期: 2026年03月18日
🎯 学习目标: 创建一个完整的记忆翻牌游戏
⏱️ 预计用时: 3-4 小时
📂 分类: 09-项目实战


🎯 学习目标

通过今天的实战项目,你将:

  1. 掌握游戏开发流程:从设计到实现完整游戏
  2. 深入理解 DOM 操作:动态创建、删除、修改元素
  3. 学会事件委托:高效处理大量元素的事件
  4. 实现游戏状态管理:开始、进行中、胜利、失败
  5. 应用动画效果:CSS3 翻转动画、过渡效果

💡 游戏需求

我们要创建一个 记忆翻牌游戏,具有以下功能:

核心功能

  1. 卡牌系统:4×4 网格,16 张卡牌(8 对图案)
  2. 翻转机制:点击卡牌翻转,显示图案
  3. 配对逻辑:两张相同配对成功,不同则翻回
  4. 计分系统:记录步数和时间
  5. 胜利条件:所有卡牌配对成功

界面设计

  • 游戏区: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: 社交游戏

  • 多人对战:比谁配对更快
  • 合作模式:团队合作配对
  • 排行榜:记录最佳成绩

✍️ 练习任务

基础练习(必做)

  1. 代码实现

    • [ ] 创建 HTML、CSS、JS 文件
    • [ ] 实现基本翻牌功能
    • [ ] 添加计时和计分
  2. 功能验证

    • [ ] 卡牌随机打乱
    • [ ] 配对成功保持翻开
    • [ ] 配对失败自动翻回
    • [ ] 胜利弹窗显示

进阶练习(推荐)

  1. 添加难度选择

    • 简单:4×3 网格(6 对)
    • 中等:4×4 网格(8 对)
    • 困难:5×4 网格(10 对)
  2. 音效增强

    • 翻牌音效
    • 配对成功音效
    • 胜利音乐
  3. 视觉效果

    • 配对成功动画
    • 错误抖动效果
    • 胜利彩带动画
  4. 数据持久化

    // 保存最佳成绩
    localStorage.setItem('bestScore', JSON.stringify({
        moves: state.moves,
        time: state.time
    }));
    

实战挑战(选做)

  1. 多人对战:WebSocket 实时对战
  2. 关卡系统:解锁新图案
  3. 成就系统
    • "神之手":10 步内完成
    • "闪电战":30 秒内完成
    • "完美主义":一次错误都没有
  4. 主题切换:动物、水果、运动等

💡 常见问题 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 文档

推荐资源

  1. CSS Tricks – 3D Transforms
  2. JavaScript Game Development
  3. Designing Games

相关项目


🎉 总结

今天我们创建了一个 完整的记忆翻牌游戏,涵盖了:

游戏开发流程:从设计到实现
DOM 操作技巧:动态创建、事件委托
CSS3 动画:3D 翻转效果
游戏状态管理:开始、进行、胜利
用户体验优化:防抖、响应式设计

这是一个 真实可玩的游戏,你可以扩展成更复杂的项目。记住:游戏开发的核心是体验,让玩家玩得开心才是最重要的。


下一节课预告: Day 24 将学习 项目6 – 待办事项应用,实现一个完整的 CRUD 应用!

💪 加油!坚持就是胜利!