打字游戏是提升键盘熟练度的经典工具,核心玩法是单词从屏幕上方落下,玩家在单词触底前输入完整内容即可消除。本文用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)$。
六、扩展思路
- 组合词挑战:引入短语和短句,要求按空格分隔依次输入多个单词
- Boss模式:周期性出现超长单词,击败后获得大量分数和生命回复
- 在线排行榜:将WPM和准确率上传到服务器,与全球玩家排名
- 个性化词库:根据用户职业领域(编程、医学、法律)动态加载专业词汇
打字游戏将字符串处理、实时统计和物理动画融为一体,是练习Java事件驱动编程和UI刷新的理想项目。