每日算法 — 使用java实现打字游戏:falling words物理掉落与WPM实时统计算法

打字游戏是提升键盘熟练度的经典工具,核心玩法是单词从屏幕上方落下,玩家在单词触底前输入完整内容即可消除。本文用Java实现一个完整的打字游戏,重点讲解单词掉落物理模拟、WPM(Words Per Minute)实时统计算法以及动态难度调节机制。

一、游戏机制与算法目标

打字游戏的核心规则:
– 单词从屏幕顶部随机水平位置生成,以恒定速度下落
– 玩家输入与某个下落单词前缀匹配时,该单词高亮显示
– 完整输入单词后,单词消除并得分
– 单词落底则扣除生命值,生命归零游戏结束
– 随着分数增加,单词下落速度和生成频率逐渐提升

需要解决的三个算法问题:
1. 如何实现单词下落动画与输入状态的实时同步
2. 如何准确计算并平滑显示WPM打字速度
3. 如何设计难度曲线,让游戏体验既不过于简单也不至于挫败

二、核心算法原理

2.1 单词掉落与输入匹配

每个单词对象维护以下状态:
– 位置坐标 $(x, y)$、下落速度 $v_y$
– 完整文本 word、当前已匹配长度 matchedLen

每帧更新时:
1. $y = y + v_y$
2. 将玩家输入缓冲区与所有单词前缀逐一比较
3. 找到最长前缀匹配,更新对应单词的 matchedLen
4. 若 matchedLen == word.length(),标记为消除

2.2 WPM实时计算

WPM的国际标准定义为每5个字符算一个”词”:

$$
\text{WPM} = \frac{\text{已输入字符总数} / 5}{\text{耗时分钟数}}
$$

为避免早期数据波动过大,采用滑动窗口法:只统计最近60秒内的输入数据。

2.3 准确率计算

$$
\text{准确率} = \frac{\text{正确输入字符数}}{\text{正确输入字符数} + \text{退格/错误字符数}} \times 100\%
$$

三、完整Java实现

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.*;
import java.util.List;

/**
 * 打字游戏:实现单词掉落物理、WPM实时统计与动态难度调节
 */
public class TypingGame extends JPanel implements ActionListener, KeyListener {

    private static final int WIDTH = 800;
    private static final int HEIGHT = 600;
    private static final int BASE_FALL_SPEED = 1; // 基础下落速度(像素/帧)
    private static final int SPAWN_INTERVAL_BASE = 120; // 基础生成间隔(帧)
    private static final int LIVES_MAX = 10;

    // 词库(按难度分级)
    private static final String[] WORD_POOL_EASY = {
        "cat", "dog", "sun", "run", "fun", "big", "red", "box", "pen", "cup",
        "java", "code", "type", "fast", "word", "game", "play", "list", "tree", "sort"
    };
    private static final String[] WORD_POOL_MEDIUM = {
        "algorithm", "variable", "function", "keyboard", "monitor",
        "compiler", "debugger", "network", "database", "framework",
        "interface", "abstract", "override", "inherit", "polymorph"
    };
    private static final String[] WORD_POOL_HARD = {
        "synchronization", "serialization", "multithreading", "encapsulation",
        "cryptography", "microservices", "containerization", "parallelization",
        "denormalization", "idempotency", "refactoring", "optimization"
    };

    // 下落的单词列表
    private List<FallingWord> words = new ArrayList<>();
    private Random random = new Random();

    // 玩家输入缓冲区
    private StringBuilder inputBuffer = new StringBuilder();

    // 游戏状态
    private int score = 0;
    private int lives = LIVES_MAX;
    private int frameCount = 0;
    private boolean gameOver = false;
    private boolean paused = false;

    // WPM与准确率统计
    private int totalCharsTyped = 0;
    private int correctCharsTyped = 0;
    private int backspaceCount = 0;
    private long gameStartTime = System.currentTimeMillis();

    // 滑动窗口:记录最近60秒内每次正确输入的时间戳和字符数
    private LinkedList<TypeEvent> recentEvents = new LinkedList<>();

    private Timer timer;

    /**
     * 打字事件:用于滑动窗口WPM计算
     */
    static class TypeEvent {
        long timestamp;
        int charCount;
        TypeEvent(long ts, int c) { timestamp = ts; charCount = c; }
    }

    /**
     * 下落单词对象
     */
    static class FallingWord {
        String text;
        double x, y;
        double speed;
        int matchedLen = 0; // 当前已匹配的字符数
        boolean isTarget = false; // 是否被当前输入锁定

        FallingWord(String text, double x, double y, double speed) {
            this.text = text;
            this.x = x;
            this.y = y;
            this.speed = speed;
        }
    }

    public TypingGame() {
        setPreferredSize(new Dimension(WIDTH, HEIGHT));
        setBackground(new Color(25, 25, 40));
        setFocusable(true);
        addKeyListener(this);
        timer = new Timer(16, this); // ~60 FPS
        timer.start();
    }

    /**
     * 根据当前分数选择难度并随机生成单词
     */
    private void spawnWord() {
        String[] pool;
        if (score < 500) {
            pool = WORD_POOL_EASY;
        } else if (score < 1500) {
            pool = (random.nextDouble() < 0.7) ? WORD_POOL_EASY : WORD_POOL_MEDIUM;
        } else {
            double r = random.nextDouble();
            if (r < 0.3) pool = WORD_POOL_EASY;
            else if (r < 0.7) pool = WORD_POOL_MEDIUM;
            else pool = WORD_POOL_HARD;
        }

        String word = pool[random.nextInt(pool.length)];
        // 避免与当前下落单词重复
        for (FallingWord fw : words) {
            if (fw.text.equals(word)) {
                word = pool[random.nextInt(pool.length)];
                break;
            }
        }

        int wordWidth = word.length() * 14;
        double x = 30 + random.nextInt(WIDTH - wordWidth - 60);

        // 难度曲线:分数越高,下落越快
        double speedMultiplier = 1.0 + score / 800.0;
        double speed = BASE_FALL_SPEED * speedMultiplier * (0.8 + random.nextDouble() * 0.4);

        words.add(new FallingWord(word, x, 0, speed));
    }

    /**
     * 核心游戏循环
     */
    @Override
    public void actionPerformed(ActionEvent e) {
        if (gameOver || paused) return;

        frameCount++;

        // 1. 动态生成间隔:分数越高,生成越频繁
        double spawnMultiplier = Math.max(0.3, 1.0 - score / 3000.0);
        int currentInterval = (int)(SPAWN_INTERVAL_BASE * spawnMultiplier);
        if (frameCount % currentInterval == 0) {
            spawnWord();
        }

        // 2. 更新所有单词位置
        for (FallingWord fw : words) {
            fw.y += fw.speed;
        }

        // 3. 检测落底单词,扣除生命
        Iterator<FallingWord> it = words.iterator();
        while (it.hasNext()) {
            FallingWord fw = it.next();
            if (fw.y > HEIGHT - 30) {
                it.remove();
                lives--;
                if (lives <= 0) gameOver = true;
            }
        }

        // 4. 清理过期的滑动窗口事件(超过60秒)
        long now = System.currentTimeMillis();
        while (!recentEvents.isEmpty() && now - recentEvents.peekFirst().timestamp > 60000) {
            recentEvents.pollFirst();
        }

        repaint();
    }

    /**
     * 处理键盘输入,实现前缀匹配与消除逻辑
     */
    @Override
    public void keyTyped(KeyEvent e) {
        if (gameOver || paused) return;

        char c = e.getKeyChar();

        // 只接受字母输入
        if (Character.isLetter(c)) {
            c = Character.toLowerCase(c);
            inputBuffer.append(c);
            totalCharsTyped++;

            // 寻找最佳匹配单词
            String input = inputBuffer.toString();
            FallingWord bestMatch = null;
            int maxMatchLen = -1;

            for (FallingWord fw : words) {
                if (fw.text.startsWith(input)) {
                    if (fw.text.length() > maxMatchLen) {
                        maxMatchLen = fw.text.length();
                        bestMatch = fw;
                    }
                }
            }

            // 重置所有单词的匹配状态
            for (FallingWord fw : words) {
                fw.isTarget = false;
                fw.matchedLen = 0;
            }

            if (bestMatch != null) {
                bestMatch.isTarget = true;
                bestMatch.matchedLen = input.length();
                correctCharsTyped++;

                // 完整输入,消除单词
                if (input.length() >= bestMatch.text.length()) {
                    words.remove(bestMatch);
                    score += bestMatch.text.length() * 10;
                    inputBuffer.setLength(0);

                    // 记录打字事件用于WPM计算
                    recentEvents.addLast(new TypeEvent(
                        System.currentTimeMillis(), bestMatch.text.length()));
                }
            } else {
                // 无匹配,清空输入(惩罚机制)
                backspaceCount += inputBuffer.length();
                inputBuffer.setLength(0);
            }
        }
    }

    @Override
    public void keyPressed(KeyEvent e) {
        // ESC暂停
        if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
            paused = !paused;
        }
        // 游戏结束后按R重启
        if (e.getKeyCode() == KeyEvent.VK_R && gameOver) {
            resetGame();
        }
        // Backspace支持(可选辅助功能)
        if (e.getKeyCode() == KeyEvent.VK_BACK_SPACE && !gameOver && !paused) {
            if (inputBuffer.length() > 0) {
                inputBuffer.deleteCharAt(inputBuffer.length() - 1);
                backspaceCount++;
            }
        }
    }

    @Override public void keyReleased(KeyEvent e) {}

    /**
     * 计算滑动窗口WPM(最近60秒)
     */
    private double calculateWPM() {
        if (recentEvents.isEmpty()) return 0.0;

        int totalChars = 0;
        for (TypeEvent event : recentEvents) {
            totalChars += event.charCount;
        }

        // 计算实际时间窗口(秒),至少为1秒避免除零
        long windowStart = recentEvents.peekFirst().timestamp;
        long windowEnd = recentEvents.peekLast().timestamp;
        double windowSeconds = Math.max(1.0, (windowEnd - windowStart) / 1000.0);

        // WPM = (字符数 / 5) / (分钟数)
        return (totalChars / 5.0) / (windowSeconds / 60.0);
    }

    /**
     * 计算准确率
     */
    private double calculateAccuracy() {
        int total = correctCharsTyped + backspaceCount;
        if (total == 0) return 100.0;
        return (correctCharsTyped * 100.0) / total;
    }

    private void resetGame() {
        words.clear();
        inputBuffer.setLength(0);
        recentEvents.clear();
        score = 0;
        lives = LIVES_MAX;
        frameCount = 0;
        totalCharsTyped = 0;
        correctCharsTyped = 0;
        backspaceCount = 0;
        gameStartTime = System.currentTimeMillis();
        gameOver = false;
        paused = false;
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        Graphics2D g2d = (Graphics2D) g;
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB);

        // 绘制单词
        g2d.setFont(new Font("Consolas", Font.BOLD, 20));
        FontMetrics fm = g2d.getFontMetrics();

        for (FallingWord fw : words) {
            int wordWidth = fm.stringWidth(fw.text);

            if (fw.isTarget) {
                // 被锁定的单词:已匹配部分绿色,未匹配部分高亮
                String matched = fw.text.substring(0, fw.matchedLen);
                String remaining = fw.text.substring(fw.matchedLen);

                int mx = (int)fw.x;
                g2d.setColor(new Color(100, 255, 100));
                g2d.drawString(matched, mx, (int)fw.y);

                int mx2 = mx + fm.stringWidth(matched);
                g2d.setColor(new Color(255, 255, 100));
                g2d.drawString(remaining, mx2, (int)fw.y);

                // 绘制下划线
                g2d.setColor(new Color(100, 255, 100, 150));
                g2d.drawLine(mx, (int)fw.y + 4, mx + wordWidth, (int)fw.y + 4);
            } else {
                // 普通单词
                g2d.setColor(new Color(200, 200, 220));
                g2d.drawString(fw.text, (int)fw.x, (int)fw.y);
            }

            // 绘制危险警告(接近底部变红)
            if (fw.y > HEIGHT - 100) {
                g2d.setColor(new Color(255, 50, 50, (int)(100 * (fw.y - (HEIGHT - 100)) / 100)));
                g2d.drawString(fw.text, (int)fw.x, (int)fw.y);
            }
        }

        // 绘制顶部UI栏
        g2d.setColor(new Color(40, 40, 60, 220));
        g2d.fillRect(0, 0, WIDTH, 60);
        g2d.setColor(Color.WHITE);
        g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, 16));

        double wpm = calculateWPM();
        double accuracy = calculateAccuracy();
        long elapsed = (System.currentTimeMillis() - gameStartTime) / 1000;

        g2d.drawString(String.format("Score: %d", score), 20, 25);
        g2d.drawString(String.format("WPM: %.1f", wpm), 180, 25);
        g2d.drawString(String.format("Accuracy: %.1f%%", accuracy), 320, 25);
        g2d.drawString(String.format("Time: %d:%02d", elapsed / 60, elapsed % 60), 500, 25);

        // 绘制生命值
        g2d.drawString("Lives: ", 650, 25);
        for (int i = 0; i < LIVES_MAX; i++) {
            if (i < lives) {
                g2d.setColor(new Color(255, 80, 80));
                g2d.fillRect(720 + i * 18, 12, 12, 18);
            } else {
                g2d.setColor(new Color(80, 80, 80));
                g2d.drawRect(720 + i * 18, 12, 12, 18);
            }
        }

        // 绘制输入缓冲区
        if (inputBuffer.length() > 0) {
            g2d.setColor(new Color(100, 255, 100, 200));
            g2d.setFont(new Font("Consolas", Font.BOLD, 24));
            g2d.drawString("Input: " + inputBuffer.toString(), 20, HEIGHT - 30);
        }

        // 绘制暂停/结束遮罩
        if (paused) {
            g2d.setColor(new Color(0, 0, 0, 150));
            g2d.fillRect(0, 0, WIDTH, HEIGHT);
            g2d.setColor(Color.WHITE);
            g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, 40));
            g2d.drawString("PAUSED", WIDTH / 2 - 70, HEIGHT / 2);
        }
        if (gameOver) {
            g2d.setColor(new Color(80, 0, 0, 200));
            g2d.fillRect(0, 0, WIDTH, HEIGHT);
            g2d.setColor(Color.WHITE);
            g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, 48));
            g2d.drawString("GAME OVER", WIDTH / 2 - 130, HEIGHT / 2 - 20);
            g2d.setFont(new Font("Microsoft YaHei", Font.PLAIN, 20));
            g2d.drawString(String.format("Final Score: %d  |  Peak WPM: %.1f", score, wpm), 
                          WIDTH / 2 - 160, HEIGHT / 2 + 20);
            g2d.drawString("Press R to Restart", WIDTH / 2 - 90, HEIGHT / 2 + 55);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("打字游戏 - WPM统计与动态难度");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.add(new TypingGame());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });
    }
}

四、关键算法详解

4.1 前缀匹配的贪心策略

当玩家输入一个字符后,系统在所有下落单词中寻找以当前输入为前缀的单词。采用最长单词优先策略:

if (fw.text.startsWith(input)) {
    if (fw.text.length() > maxMatchLen) {
        bestMatch = fw;
    }
}

这种策略避免了”abandon”和”about”同时出现时,输入”ab”系统锁定错误单词的问题。

4.2 滑动窗口WPM的平滑性

传统WPM从游戏开始累计计算,导致早期数值极不稳定(如输入3个字符就显示60WPM)。滑动窗口法只统计最近60秒的数据,使得:
– 游戏初期WPM自然为0,随输入增加平稳上升
– 打字速度变化能实时反映在WPM数值上
– 长时间停顿后WPM会自然下降

4.3 动态难度三要素

本游戏同时调整三个难度参数:
1. 下落速度:speed = base * (1 + score / 800)
2. 生成频率:interval = base * max(0.3, 1 - score / 3000)
3. 词库升级:分数达到阈值后引入更长、更复杂的单词

三者协同作用,使游戏难度呈平滑的S型曲线上升。

五、复杂度分析

操作 时间复杂度 说明
前缀匹配 $O(N \cdot L)$ $N$ 为单词数,$L$ 为平均单词长度
WPM计算 $O(M)$ $M$ 为60秒内事件数,实际有界
单词生成 $O(N)$ 检查重复需要遍历当前单词
帧渲染 $O(N)$ 绘制所有下落单词

由于屏幕同时存在的单词数被物理空间限制(通常不超过15个),所有操作的实际时间复杂度均可视为 $O(1)$。

六、扩展思路

  1. 组合词挑战:引入短语和短句,要求按空格分隔依次输入多个单词
  2. Boss模式:周期性出现超长单词,击败后获得大量分数和生命回复
  3. 在线排行榜:将WPM和准确率上传到服务器,与全球玩家排名
  4. 个性化词库:根据用户职业领域(编程、医学、法律)动态加载专业词汇

打字游戏将字符串处理、实时统计和物理动画融为一体,是练习Java事件驱动编程和UI刷新的理想项目。