Day-22-项目4-打字练习游戏

Day 22: 项目4-打字练习游戏

🎯 Day 22 – 打字练习游戏
📂 分类: 09-项目实战
🔥 难度: ⭐⭐⭐⭐☆


🎯 学习目标

  • ✅ 掌握键盘事件处理(keydown、keyup)
  • ✅ 学会实时输入验证和反馈
  • ✅ 理解游戏计时和WPM计算
  • ✅ 掌握打字准确率统计
  • ✅ 学会使用CSS动画增强体验

💡 项目概述

项目简介

打字练习游戏是学习键盘事件和实时交互的经典项目,它包含:

  • ⌨️ 键盘事件:keydown、keyup、keypress 的深入应用
  • ⏱️ 实时统计:WPM(每分钟字数)、准确率
  • 🎯 目标文本:随机或预设的练习文本
  • 📊 进度追踪:实时显示输入进度
  • 🏆 成绩记录:保存最佳成绩

核心功能

  • [ ] 显示目标文本供用户输入
  • [ ] 实时检测键盘输入
  • [ ] 高亮显示当前字符位置
  • [ ] 计算打字速度(WPM)
  • [ ] 统计错误率
  • [ ] 倒计时模式
  • [ ] 历史最佳成绩

技术重点

  • 键盘事件: addEventListener、key、keyCode
  • 字符串操作: substring、charAt、split
  • 定时器: setInterval、performance.now()
  • 实时反馈: DOM 动态更新
  • CSS 动画: 过渡效果、错误提示

📝 项目结构

typing-game/
├── index.html          # 主页面
├── style.css           # 样式文件
└── script.js           # JavaScript逻辑

🎮 完整代码

1. HTML (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>
            <p class="subtitle">提高你的打字速度和准确率</p>
        </header>

        <!-- 游戏模式选择 -->
        <div class="mode-selection" id="modeSelection">
            <button class="mode-btn" data-mode="time">⏱️ 计时模式</button>
            <button class="mode-btn" data-mode="practice">📝 练习模式</button>
        </div>

        <!-- 统计信息 -->
        <div class="stats">
            <div class="stat-item">
                <span class="stat-label">时间</span>
                <span class="stat-value" id="timer">60</span>
                <span class="stat-unit">秒</span>
            </div>
            <div class="stat-item">
                <span class="stat-label">WPM</span>
                <span class="stat-value" id="wpm">0</span>
                <span class="stat-unit">字/分</span>
            </div>
            <div class="stat-item">
                <span class="stat-label">准确率</span>
                <span class="stat-value" id="accuracy">100</span>
                <span class="stat-unit">%</span>
            </div>
            <div class="stat-item">
                <span class="stat-label">错误</span>
                <span class="stat-value" id="errors">0</span>
            </div>
        </div>

        <!-- 游戏区域 -->
        <div class="game-area">
            <!-- 目标文本显示 -->
            <div class="text-display" id="textDisplay">
                <span class="target-text" id="targetText"></span>
                <span class="cursor" id="cursor"></span>
            </div>

            <!-- 输入提示 -->
            <div class="input-hint">
                <p id="currentChar">按下任意键开始...</p>
            </div>

            <!-- 进度条 -->
            <div class="progress-bar">
                <div class="progress-fill" id="progress"></div>
            </div>
        </div>

        <!-- 控制按钮 -->
        <div class="controls">
            <button id="restartBtn" class="btn btn-primary">🔄 重新开始</button>
            <button id="newTextBtn" class="btn btn-secondary">📄 换一段文本</button>
        </div>

        <!-- 历史最佳 -->
        <div class="best-score">
            <span>最佳 WPM: </span>
            <span id="bestWPM">0</span>
        </div>

        <!-- 结果弹窗 -->
        <div class="modal" id="resultModal" style="display: none;">
            <div class="modal-content">
                <h2>🎉 练习完成!</h2>
                <div class="result-stats">
                    <div class="result-item">
                        <span class="result-label">WPM</span>
                        <span class="result-value" id="finalWPM">0</span>
                    </div>
                    <div class="result-item">
                        <span class="result-label">准确率</span>
                        <span class="result-value" id="finalAccuracy">0%</span>
                    </div>
                    <div class="result-item">
                        <span class="result-label">错误数</span>
                        <span class="result-value" id="finalErrors">0</span>
                    </div>
                </div>
                <button id="closeModal" class="btn btn-primary">继续练习</button>
            </div>
        </div>
    </div>

    <script src="script.js"></script>
</body>
</html>

2. CSS (style.css)

/* 全局样式 */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Courier New', monospace;
    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;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
    max-width: 900px;
    width: 100%;
    padding: 30px;
}

/* 头部 */
header {
    text-align: center;
    margin-bottom: 30px;
}

header h1 {
    color: #333;
    font-size: 32px;
    margin-bottom: 10px;
}

.subtitle {
    color: #666;
    font-size: 14px;
}

/* 模式选择 */
.mode-selection {
    display: flex;
    justify-content: center;
    gap: 15px;
    margin-bottom: 30px;
}

.mode-btn {
    padding: 12px 24px;
    border: 2px solid #e0e0e0;
    background: white;
    border-radius: 10px;
    cursor: pointer;
    font-size: 14px;
    transition: all 0.3s;
}

.mode-btn:hover {
    border-color: #667eea;
    background: #f7f7f7;
}

.mode-btn.active {
    background: #667eea;
    color: white;
    border-color: #667eea;
}

/* 统计信息 */
.stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 20px;
    margin-bottom: 30px;
}

.stat-item {
    background: #f7f7f7;
    border-radius: 12px;
    padding: 20px;
    text-align: center;
}

.stat-label {
    display: block;
    color: #666;
    font-size: 12px;
    margin-bottom: 10px;
}

.stat-value {
    display: block;
    color: #667eea;
    font-size: 32px;
    font-weight: bold;
}

.stat-unit {
    color: #999;
    font-size: 12px;
}

/* 游戏区域 */
.game-area {
    margin-bottom: 30px;
}

/* 文本显示 */
.text-display {
    background: #f7f7f7;
    border-radius: 12px;
    padding: 30px;
    margin-bottom: 20px;
    font-size: 24px;
    line-height: 1.8;
    min-height: 200px;
    position: relative;
}

.target-text {
    color: #999;
}

.char {
    display: inline;
    padding: 2px 1px;
    border-radius: 3px;
    transition: all 0.1s;
}

.char.correct {
    color: #48bb78;
    background: #c6f6d5;
}

.char.incorrect {
    color: #f56565;
    background: #fed7d7;
    text-decoration: underline;
}

.char.current {
    background: #667eea;
    color: white;
    animation: blink 1s infinite;
}

@keyframes blink {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
}

/* 光标 */
.cursor {
    display: inline-block;
    width: 3px;
    height: 24px;
    background: #667eea;
    animation: blink 1s infinite;
    margin-left: -1px;
}

/* 输入提示 */
.input-hint {
    text-align: center;
    margin-bottom: 20px;
}

#currentChar {
    color: #666;
    font-size: 14px;
}

/* 进度条 */
.progress-bar {
    width: 100%;
    height: 8px;
    background: #e0e0e0;
    border-radius: 4px;
    overflow: hidden;
}

.progress-fill {
    height: 100%;
    background: #667eea;
    transition: width 0.3s;
    width: 0%;
}

/* 控制按钮 */
.controls {
    display: flex;
    gap: 15px;
    justify-content: center;
}

.btn {
    padding: 15px 30px;
    border: none;
    border-radius: 10px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s;
}

.btn-primary {
    background: #667eea;
    color: white;
}

.btn-primary:hover {
    background: #5568d3;
    transform: scale(1.05);
}

.btn-secondary {
    background: #f7f7f7;
    color: #333;
}

.btn-secondary:hover {
    background: #e0e0e0;
}

/* 最佳成绩 */
.best-score {
    text-align: center;
    margin-top: 20px;
    color: #666;
    font-size: 14px;
}

.best-score span:last-child {
    color: #667eea;
    font-weight: bold;
    font-size: 18px;
}

/* 结果弹窗 */
.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;
}

.modal-content {
    background: white;
    border-radius: 20px;
    padding: 40px;
    text-align: center;
    animation: slideIn 0.3s ease;
}

@keyframes slideIn {
    from {
        opacity: 0;
        transform: translateY(-50px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.modal-content h2 {
    color: #333;
    margin-bottom: 30px;
}

.result-stats {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 20px;
    margin-bottom: 30px;
}

.result-item {
    background: #f7f7f7;
    border-radius: 12px;
    padding: 20px;
}

.result-label {
    display: block;
    color: #666;
    font-size: 12px;
    margin-bottom: 10px;
}

.result-value {
    display: block;
    color: #667eea;
    font-size: 32px;
    font-weight: bold;
}

/* 响应式 */
@media (max-width: 768px) {
    .container {
        padding: 20px;
    }
    
    .stats {
        grid-template-columns: repeat(2, 1fr);
    }
    
    .text-display {
        font-size: 18px;
        padding: 20px;
    }
    
    .result-stats {
        grid-template-columns: 1fr;
    }
}

3. JavaScript (script.js)

// ========== 练习文本库 ==========
const TEXTS = [
    "The quick brown fox jumps over the lazy dog. This sentence contains every letter of the alphabet.",
    "Programming is the art of telling another human what one wants the computer to do.",
    "In software engineering, we often face complex problems that require creative solutions.",
    "JavaScript is a versatile programming language that powers the modern web.",
    "Practice makes perfect. Keep typing and you will see improvement over time.",
    "The best way to learn programming is by building real projects and solving problems.",
    "Technology changes rapidly, but the fundamentals remain the same.",
    "Debugging is twice as hard as writing the code in the first place.",
    "Any fool can write code that a computer can understand. Good programmers write code that humans can understand.",
    "First, solve the problem. Then, write the code."
];

// ========== 游戏状态管理 ==========
class TypingGame {
    constructor() {
        // 游戏状态
        this.gameStarted = false;
        this.gameEnded = false;
        this.gameMode = 'time';  // 'time' or 'practice'
        
        // 文本状态
        this.targetText = '';
        this.currentIndex = 0;
        this.errors = 0;
        
        // 时间和统计
        this.timeLeft = 60;
        this.timer = null;
        this.startTime = null;
        
        // 成绩
        this.bestWPM = this.loadBestWPM();
        
        // 初始化
        this.initializeElements();
        this.attachEventListeners();
        this.loadNewText();
        this.updateBestScore();
    }

    // 初始化 DOM 元素
    initializeElements() {
        this.elements = {
            targetText: document.getElementById('targetText'),
            timer: document.getElementById('timer'),
            wpm: document.getElementById('wpm'),
            accuracy: document.getElementById('accuracy'),
            errors: document.getElementById('errors'),
            progress: document.getElementById('progress'),
            currentChar: document.getElementById('currentChar'),
            bestWPM: document.getElementById('bestWPM'),
            resultModal: document.getElementById('resultModal'),
            finalWPM: document.getElementById('finalWPM'),
            finalAccuracy: document.getElementById('finalAccuracy'),
            finalErrors: document.getElementById('finalErrors')
        };
    }

    // 附加事件监听器
    attachEventListeners() {
        // 键盘输入
        document.addEventListener('keydown', (e) => this.handleKeyDown(e));
        
        // 模式选择
        document.querySelectorAll('.mode-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                this.switchMode(btn.dataset.mode);
            });
        });
        
        // 控制按钮
        document.getElementById('restartBtn').addEventListener('click', () => {
            this.restart();
        });
        
        document.getElementById('newTextBtn').addEventListener('click', () => {
            this.loadNewText();
        });
        
        document.getElementById('closeModal').addEventListener('click', () => {
            this.closeModal();
        });
    }

    // 切换游戏模式
    switchMode(mode) {
        this.gameMode = mode;
        
        // 更新按钮状态
        document.querySelectorAll('.mode-btn').forEach(btn => {
            btn.classList.remove('active');
            if (btn.dataset.mode === mode) {
                btn.classList.add('active');
            }
        });
        
        // 重新开始
        this.restart();
    }

    // 加载新文本
    loadNewText() {
        const randomIndex = Math.floor(Math.random() * TEXTS.length);
        this.targetText = TEXTS[randomIndex];
        this.renderText();
        this.resetGame();
    }

    // 渲染文本
    renderText() {
        let html = '';
        for (let i = 0; i < this.targetText.length; i++) {
            html += `<span class="char" data-index="${i}">${this.targetText[i]}</span>`;
        }
        this.elements.targetText.innerHTML = html;
        this.updateCursor();
    }

    // 重置游戏
    resetGame() {
        this.gameStarted = false;
        this.gameEnded = false;
        this.currentIndex = 0;
        this.errors = 0;
        this.timeLeft = 60;
        
        // 停止计时器
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = null;
        }
        
        // 重置显示
        this.elements.timer.textContent = this.timeLeft;
        this.elements.wpm.textContent = '0';
        this.elements.accuracy.textContent = '100';
        this.elements.errors.textContent = '0';
        this.elements.progress.style.width = '0%';
        this.elements.currentChar.textContent = '按下任意键开始...';
        
        // 重置字符样式
        document.querySelectorAll('.char').forEach(char => {
            char.classList.remove('correct', 'incorrect', 'current');
        });
        
        // 标记第一个字符
        if (document.querySelector('.char[data-index="0"]')) {
            document.querySelector('.char[data-index="0"]').classList.add('current');
        }
    }

    // 处理键盘输入
    handleKeyDown(e) {
        if (this.gameEnded) {
            return;
        }
        
        // 忽略功能键
        if (e.key.length > 1) {
            return;
        }
        
        // 开始游戏
        if (!this.gameStarted) {
            this.startGame();
        }
        
        const currentChar = this.targetText[this.currentIndex];
        const charElement = document.querySelector(`.char[data-index="${this.currentIndex}"]`);
        
        // 检查字符是否正确
        if (e.key === currentChar) {
            // 正确
            charElement.classList.add('correct');
            charElement.classList.remove('current', 'incorrect');
        } else {
            // 错误
            charElement.classList.add('incorrect');
            this.errors++;
            this.elements.errors.textContent = this.errors;
            
            // 震动效果
            this.shakeElement(charElement);
        }
        
        // 移动到下一个字符
        this.currentIndex++;
        this.updateCursor();
        this.updateStats();
        
        // 检查是否完成
        if (this.currentIndex >= this.targetText.length) {
            this.endGame();
        }
    }

    // 开始游戏
    startGame() {
        this.gameStarted = true;
        this.startTime = Date.now();
        
        // 启动计时器
        this.timer = setInterval(() => {
            this.timeLeft--;
            this.elements.timer.textContent = this.timeLeft;
            
            if (this.timeLeft <= 0) {
                this.endGame();
            }
        }, 1000);
        
        this.elements.currentChar.textContent = '开始打字!';
    }

    // 更新光标位置
    updateCursor() {
        // 移除所有 current 类
        document.querySelectorAll('.char').forEach(char => {
            char.classList.remove('current');
        });
        
        // 添加 current 类到当前字符
        if (this.currentIndex < this.targetText.length) {
            const currentChar = document.querySelector(`.char[data-index="${this.currentIndex}"]`);
            if (currentChar) {
                currentChar.classList.add('current');
            }
            
            // 显示当前字符提示
            const char = this.targetText[this.currentIndex];
            if (char === ' ') {
                this.elements.currentChar.textContent = '当前字符: 空格';
            } else {
                this.elements.currentChar.textContent = `当前字符: ${char}`;
            }
        }
    }

    // 更新统计信息
    updateStats() {
        // 计算进度
        const progress = (this.currentIndex / this.targetText.length) * 100;
        this.elements.progress.style.width = `${progress}%`;
        
        // 计算 WPM
        const timeElapsed = (Date.now() - this.startTime) / 1000 / 60;  // 分钟
        const wordsTyped = this.currentIndex / 5;  // 标准计算:5个字符=1个单词
        const wpm = Math.round(wordsTyped / timeElapsed) || 0;
        this.elements.wpm.textContent = wpm;
        
        // 计算准确率
        const totalChars = this.currentIndex + this.errors;
        const accuracy = totalChars > 0 
            ? Math.round((this.currentIndex / totalChars) * 100) 
            : 100;
        this.elements.accuracy.textContent = accuracy;
    }

    // 震动元素
    shakeElement(element) {
        element.style.animation = 'none';
        element.offsetHeight;  // 触发重绘
        element.style.animation = 'shake 0.3s ease';
    }

    // 结束游戏
    endGame() {
        this.gameEnded = true;
        
        // 停止计时器
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = null;
        }
        
        // 计算最终成绩
        const timeElapsed = (Date.now() - this.startTime) / 1000 / 60;
        const wordsTyped = this.currentIndex / 5;
        const finalWPM = Math.round(wordsTyped / timeElapsed) || 0;
        const totalChars = this.currentIndex + this.errors;
        const finalAccuracy = totalChars > 0 
            ? Math.round((this.currentIndex / totalChars) * 100) 
            : 100;
        
        // 更新最佳成绩
        if (finalWPM > this.bestWPM) {
            this.bestWPM = finalWPM;
            this.saveBestWPM();
            this.updateBestScore();
        }
        
        // 显示结果
        this.elements.finalWPM.textContent = finalWPM;
        this.elements.finalAccuracy.textContent = `${finalAccuracy}%`;
        this.elements.finalErrors.textContent = this.errors;
        this.elements.resultModal.style.display = 'flex';
    }

    // 重新开始
    restart() {
        this.closeModal();
        this.loadNewText();
    }

    // 关闭弹窗
    closeModal() {
        this.elements.resultModal.style.display = 'none';
    }

    // 加载最佳成绩
    loadBestWPM() {
        const saved = localStorage.getItem('typingGameBestWPM');
        return saved ? parseInt(saved) : 0;
    }

    // 保存最佳成绩
    saveBestWPM() {
        localStorage.setItem('typingGameBestWPM', this.bestWPM);
    }

    // 更新最佳成绩显示
    updateBestScore() {
        this.elements.bestWPM.textContent = this.bestWPM;
    }
}

// 添加震动动画
const style = document.createElement('style');
style.textContent = `
    @keyframes shake {
        0%, 100% { transform: translateX(0); }
        25% { transform: translateX(-5px); }
        75% { transform: translateX(5px); }
    }
`;
document.head.appendChild(style);

// 初始化游戏
document.addEventListener('DOMContentLoaded', () => {
    const game = new TypingGame();
    console.log('⌨️ 打字游戏已启动!');
    console.log('💡 提示:选择计时模式或练习模式开始');
});

🎯 项目亮点

1. 键盘事件处理

document.addEventListener('keydown', (e) => {
    // e.key: 按键字符
    // e.keyCode: 按键代码
    // e.code: 物理键位
});

2. 实时统计计算

// WPM 计算
const wpm = Math.round((charsTyped / 5) / timeInMinutes);

// 准确率计算
const accuracy = Math.round((correctChars / totalChars) * 100);

3. 视觉反馈系统

// 正确: 绿色
charElement.classList.add('correct');

// 错误: 红色 + 下划线
charElement.classList.add('incorrect');

// 当前: 蓝色 + 闪烁
charElement.classList.add('current');

✍️ 练习任务

练习1: 基础功能(45分钟)

  • [ ] 实现文本显示
  • [ ] 实现键盘输入检测
  • [ ] 实现字符高亮
  • [ ] 实现光标移动
  • [ ] 测试基本功能

练习2: 统计功能(60分钟)

  • [ ] 实现 WPM 计算
  • [ ] 实现准确率统计
  • [ ] 实现错误计数
  • [ ] 实现进度条
  • [ ] 测试统计准确性

练习3: 游戏模式(45分钟)

  • [ ] 实现计时模式
  • [ ] 实现练习模式
  • [ ] 添加结果弹窗
  • [ ] 实现重新开始
  • [ ] 测试所有模式

练习4: 优化扩展(60分钟)

  • [ ] 添加难度选择
  • [ ] 添加更多练习文本
  • [ ] 实现音效反馈
  • [ ] 优化移动端体验
  • [ ] 添加成就系统

💡 常见问题 FAQ

Q1: WPM 是如何计算的?

A: 标准公式:(字符数 / 5) / 时间(分钟)

const wpm = Math.round((charsTyped / 5) / timeInMinutes);

Q2: 如何处理特殊字符?

A: 使用 e.key 而不是 e.keyCode

// ✅ 好
const char = e.key;

// ❌ 不好
const char = String.fromCharCode(e.keyCode);

Q3: 如何提高游戏难度?

A: 可以添加:

  • 更长的文本
  • 包含特殊符号
  • 减少时间限制
  • 增加错误惩罚

📚 拓展学习

推荐项目

  • 键盘快捷键训练: 学习组合键
  • 代码打字练习: 特定编程语言
  • 盲打训练: 不看键盘

相关主题

  • 键盘事件详解
  • 正则表达式
  • 定时器应用
  • 性能优化

🎓 总结

今天我们学到了

  • ✅ 键盘事件处理
  • ✅ 实时统计计算
  • ✅ WPM 和准确率算法
  • ✅ 游戏模式设计
  • ✅ 视觉反馈系统

关键概念

  • ⌨️ 键盘事件: keydown、keyup
  • 📊 WPM: (字符数 / 5) / 分钟
  • 🎯 实时反馈: 即时高亮和提示
  • 💾 本地存储: 保存最佳成绩

学习时间: 2026-03-17
难度: ⭐⭐⭐⭐☆
预计用时: 4-5 小时
关键词: 打字游戏、键盘事件、实时统计、WPM计算
相关标签: #09-项目实战


📌 下一步: 学习 Day 23: 项目5-记忆翻牌游戏,继续游戏开发之旅!