简介:这是一款利用Java编写的打字训练与游戏结合型教育软件,面向希望提升盲打速度与准确率的办公人员、学生及Java编程爱好者。资源压缩包共51个文件,大小仅2.02MB,内含10个java源码、22个编译后的class字节码文件、可直接运行的WritingGame.exe与WritingGame.jar,以及数张jpg、bmp界面图片素材,既能直接启动体验,也可通过源码学习Java桌面应用开发。已有458人学习下载。软件提供字母、数字、符号及单词句子等分类训练,实时显示打字速度与正确率,帮助用户定位薄弱环节;还内置追逐、消除等趣味打字游戏,让练习不再枯燥。通读源码可掌握Java事件监听、Swing/JavaFX图形界面搭建、键盘事件处理等实用技能,适合作为Java入门阶段的综合小项目反复研读。
1. Java打字软件游戏:别急着画界面,先想清楚这三件事
要落地一个 Java打字软件游戏,很多人第一反应是拖个窗口、放上单词就开始敲键盘。实际拆这种源码包时我发现,界面大概只占三成工作量,真正决定能不能玩的是另外三件事:键盘事件能不能稳定进入游戏逻辑,单词命中判定用哪种规则,以及计时计分会不会被界面线程拖垮。这份资源解决的就是 AWT/Swing 下的完整打字练习链路:打开窗口,随机出词,实时比对,计时计分,难度递增。适合刚学完 Java 语法想做第一个完整 Swing 项目的学生,也适合要在自己小工具里加一段键盘录入反馈的开发者。后面几章按拆包顺序,把事件链路、单词库、计时计分和四个高频坑一次讲透。
2. 先立骨架:从键盘事件到单词命中的最短路径
2.1 为什么是 Swing:自带事件分发,不用引第三方包
打字游戏这种轻交互场景,Swing 是最常见的选择,也最省事。JDK 自带的 javax.swing 和 java.awt 不需要额外依赖,不用在构建工具里加一堆配置;JavaFX 虽然渲染更现代,但模块化配置和启动类处理对新手反而多一层门槛。控制台方案用 Scanner 也能做“输入一行对比一行”,但它拿不到单键事件,没法做到“按下一个字母立刻判断对错”,所以打字软件游戏基本都落在 Swing 上。
事件从按下到反馈,可以拆成一张链路表,后面写代码时也按这条线分模块:
| 环节 | 组件/接口 | 作用 |
|---|---|---|
| 事件源 | JFrame / JPanel | 持有焦点,接收键盘输入 |
| 监听器 | KeyListener | 把底层按键事件转给游戏逻辑 |
| 游戏逻辑 | 独立的 Controller 类 | 维护当前单词、光标位置、分数 |
| UI 刷新 | JLabel / repaint | 把命中状态变成文字颜色等反馈 |
这样拆的好处是每一环都能单独替换:把英文单词换成代码片段库,把 KeyListener 换成软键盘输入源,游戏逻辑本身不用动。后面我所有代码都按这个分层写,窗口类里不堆业务判断。
2.2 最小可跑版本:JFrame、JLabel、KeyListener
先给一个能直接编译的最小 Demo,不涉及计分,只管“出词、按键、命中换词”。这段代码是我拆模拟项目 X 时还原出来的最小骨架。
// TypingDemo.java - 最小可运行的打字游戏骨架 import javax.swing.*; import java.awt.*; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; public class TypingDemo extends JFrame implements KeyListener { private final JLabel wordLabel = new JLabel("hello", SwingConstants.CENTER); private final String[] words = {"hello", "world", "java", "typing", "swing"}; public TypingDemo() { setTitle("Java打字软件游戏-最小骨架"); setDefaultCloseOperation(EXIT_ON_CLOSE); setSize(400, 240); setLayout(new BorderLayout()); wordLabel.setFont(new Font("Monospaced", Font.BOLD, 28)); add(wordLabel, BorderLayout.CENTER); // 窗口获得焦点后才能收到按键事件 addKeyListener(this); setFocusable(true); setLocationRelativeTo(null); setVisible(true); } @Override public void keyTyped(KeyEvent e) { char in = e.getKeyChar(); String current = wordLabel.getText(); // 只判断当前目标位置的首字母,命中就换词 if (!current.isEmpty() && Character.toLowerCase(in) == Character.toLowerCase(current.charAt(0))) { wordLabel.setText(nextWord()); } } private String nextWord() { int index = (int) (Math.random() * words.length); return words[index]; } @Override public void keyPressed(KeyEvent e) { /* 功能键逻辑放这里 */ } @Override public void keyReleased(KeyEvent e) { /* 一般用不到 */ } public static void main(String[] args) { SwingUtilities.invokeLater(TypingDemo::new); } }逻辑说明:窗口构造里把当前类注册成 KeyListener;keyTyped 拿到的是“字符输入”事件,最适合做文本比对。这里用了一个简化规则:把当前单词第一个字符当成期望值,命中就随机换下一个词。虽然粗糙,但把“事件 → 判断 → 刷新”闭环跑通了。
参数说明:字体用 Monospaced 是为了等宽对齐,避免字母宽度变化导致视觉抖动;setFocusable(true) 很关键,否则窗口默认拿不到键盘焦点;setSize 的 400×240 只是初始尺寸,正式项目里会按内容做自适应布局。
接下来把核心判断从窗口类里抽出来,这是后边所有难度和计分功能的基础:
// TypingGame.java - 抽取核心控制器 public class TypingGame { private String word; private int cursor; public static final int MAX_ERRORS = 3; public void loadWord(String newWord) { this.word = newWord; this.cursor = 0; } /** 用一个字符去匹配当前目标位置,返回是否命中。 */ public boolean type(char c) { if (cursor >= word.length()) { return false; } char target = Character.toLowerCase(word.charAt(cursor)); if (Character.toLowerCase(c) == target) { cursor++; return true; } return false; } public boolean isFinished() { return cursor == word.length(); } public int getCursor() { return cursor; } }逻辑说明:TypingGame 把“打到哪里”用 cursor 记录,每命中一个字符 cursor 加 1;isFinished 判断一个单词是否打完。这样界面层只负责把键盘字符交给 type(char),完全不关心单词内容。MAX_ERRORS 是后面容错参数占位,当前写法里还没用上。
这里有个容易被忽略的点:为什么要把判断逻辑单独拆类?因为后面加入输入法过滤、错误计数、连击加时等功能时,改动都落在 TypingGame 内,窗口类保持稳定,调试时只需要盯着一个类看,不用在 Swing 回调里翻找业务代码。
2.3 keyPressed 与 keyTyped 怎么分工
很多同学把所有判断塞进 keyPressed,结果发现大写锁定和输入法状态会影响字符值。我的做法是:keyTyped 只处理可打印字符,比如字母、数字、标点;keyPressed 处理功能键,比如 Enter 开始、Esc 暂停。原因在于 keyPressed 拿到的 keyCode 是物理按键码,不经过字符映射,小键盘数字和大键盘数字是两套 keyCode;而 keyTyped 拿到的是已经映射好的字符,遇到功能键时 getKeyChar 会返回 CHAR_UNDEFINED。
所以判字母一定用 keyTyped,判快捷键用 keyPressed。贴一段回车开始的改动,直接接在 2.2 的窗口上:
@Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_ENTER && !started) { started = true; statusLabel.setText("计时开始"); startTimer(); } }说明:这里用 VK_ENTER 而不是 '\n',因为 keyPressed 只认 keyCode。started 是窗口类的成员布尔变量,防止回车触发多次开始;startTimer 放到第 3 章的计时器部分实现。这样“按回车开始”和“打字母算分”两条事件路径完全分开,后边排查时思路清晰很多。
3. 把游戏做厚:单词库、计时与计分、难度三参数
3.1 单词库设计:从硬编码数组到外部 TXT 加载
2.2 里单词写在数组里,做 Demo 可以,做完整的软件游戏不行。常见做法是把 words.txt 放资源目录,每行一个单词,启动时一次性读入、按长度分组。下面这个加载方法可以直接用:
// WordLoader.java import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; public class WordLoader { /** 读取单词库,忽略空行和长度不足的词 */ public static List<String> load(String path, int minLength) throws IOException { List<String> result = new ArrayList<>(); List<String> lines = Files.readAllLines(Paths.get(path), StandardCharsets.UTF_8); for (String line : lines) { String word = line.trim().toLowerCase(); if (word.isEmpty() || word.length() < minLength) { continue; } result.add(word); } return result; } }逻辑说明:Files.readAllLines 会把整个文件读进内存,几万词量级完全够用;trim 加 toLowerCase 是为了去掉行尾回车和大小写干扰。参数 minLength 用来过滤太短的词,比如两三个字母的组合高手一眼打完,没有练习意义,默认值通常设成 4。
这里最容易翻车的是编码问题。TXT 文件如果是 UTF-8 编码,Windows 记事本另存为 ANSI 后,Java 这边读到非 ASCII 字符就会乱码。英文单词库用 UTF-8 没问题,但如果以后要加中文词库,我建议统一强制 UTF-8,界面里也别让用户选编码,否则排错成本非常高。这个坑是从之前一个图像处理 Demo 里带出来的血泪经验。
接着把加载到的词随机打乱:
// 打乱词库,seed 固定则顺序固定,方便复现测试 Collections.shuffle(wordList, new Random(seed));Random 带 seed 是个好习惯。seed 固定时,每次启动打乱顺序一致,测试可以用同一组词复现“上一局为什么卡死”;seed = -1 时表示随机,给正常玩家使用。正式版本里我习惯在配置里放一个 debugSeed 隐藏参数,默认 -1,调试时手动改成固定值,跑完再改回来。
3.2 计时与计分:javax.swing.Timer 与正确率计算
计时有两种常见写法:Thread.sleep 加循环更新 UI,或者 javax.swing.Timer。前者在独立线程里改 UI 会撞上 Swing 的单线程规则,轻则闪烁,重则抛异常;后者是 Swing 自带的事件驱动计时器,回调直接在 EDT 上执行,改 UI 是安全的。所以这里几乎必选 javax.swing.Timer。
// GameTimer.java 片段,展示计时器骨架 import javax.swing.*; public class GameTimer { private Timer timer; private int remainSeconds; private final JLabel label; public GameTimer(JLabel label, int totalSeconds) { this.label = label; this.remainSeconds = totalSeconds; this.timer = new Timer(1000, e -> tick()); } private void tick() { remainSeconds--; label.setText("剩余: " + remainSeconds + "s"); if (remainSeconds <= 0) { timer.stop(); label.setText("时间到"); } } public void start() { timer.start(); } }逻辑说明:Timer 构造器的第一个参数 1000 是触发间隔毫秒,第二个参数是 ActionListener,每秒执行一次 tick;tick 里改 label 不需要额外同步,因为它在 EDT 线程里执行。参数 totalSeconds 从外部传入,对应单局时长,常见值是 60 或 120 秒。
不过这个写法有一个隐患,我在第 4 章会专门讲:tick 次数不能当精准计时用,它只适合做 UI 刷新。真正算剩余时间要用 System.currentTimeMillis()。
计分这块更容易失衡。最简单的计分是每词固定 10 分,问题是长词和短词同样分,玩家会专挑短词打。我会按字符数算基础分,再加错误罚分和快速完成奖励:
public int scoreWord(String word, int errorCount, long costMs) { int base = word.length() * 10; int errorPenalty = errorCount * 5; int speedBonus = costMs <= 1000L ? 20 : 0; return Math.max(base - errorPenalty + speedBonus, 1); }逻辑说明:base 按字符数给分,长词分高;errorPenalty 对打错次数做罚分,目的是阻止“乱按撞运气”;speedBonus 对一秒内完成的单词额外奖励,拉高手速区分度。参数值是我在实际试玩里调的,不建议照抄,最好用第 5 章的日志回放去校验。
还需要统计整局正确率,正确率一般用“命中字符数 / 总输入字符数”:
double accuracy = hitCount / (double) (hitCount + missCount) * 100.0;说明:hitCount 是 feedChar 返回命中次数,missCount 是错误次数。用 double 运算避免整数除法直接变成 0,这是新手经常踩的算术细节。
3.3 难度梯度:速度、长度、容错率三参数
难度不是“加快速度”一个维度就够的。我用三个可调参数共同控制难度:单词长度、容错次数、单局时长。再配一个 speedLevel 做跨局递增。
| 参数名 | 取值范围 | 作用 | 默认值 |
|---|---|---|---|
| wordMinLength | 3 ~ 8 | 单词最短长度,越大越难 | 4 |
| maxErrorPerWord | 0 ~ 5 | 一个词允许打错次数,超限判失败 | 3 |
| timeLimitSeconds | 30 ~ 300 | 单局时长,影响紧张感 | 60 |
| speedLevel | 1 ~ 5 | 控制长度与罚分的倍率 | 2 |
把这些参数写进 properties 文件,启动时读取,比每次改代码重编译方便得多:
// Application.properties 示例 wordMinLength=4 maxErrorPerWord=3 timeLimitSeconds=60 speedLevel=2 debugSeed=-1加载代码:
Properties props = new Properties(); try (InputStream in = Files.newInputStream(Paths.get("Application.properties"))) { props.load(in); } int minLength = Integer.parseInt(props.getProperty("wordMinLength", "4")); int maxError = Integer.parseInt(props.getProperty("maxErrorPerWord", "3")); int timeLimit = Integer.parseInt(props.getProperty("timeLimitSeconds", "60"));说明:props.load 默认读取 ISO-8859-1 编码,所以 properties 文件里的中文注释会有兼容风险,正式变量名不受影响。getProperty 的第二个参数是默认值,防止配置文件缺行导致启动崩溃——这是所有配置类代码里最值得养成的习惯。
难度递增逻辑可以放在每局结束后判断:如果完成时间低于当前关卡限定值,speedLevel 加 1,同时 wordMinLength 加 1。注意封顶,speedLevel 到 5 就不要再加,否则单词长度超过 10 后练习体验会变成背单词而不是练打字。
4. 避坑排查:焦点丢失、中文输入法、计时漂移、计分虚高
4.1 键盘没反应:焦点被按钮或弹窗抢走
现象:单词正常显示,但敲键盘没有任何反应,手动点一下窗口又恢复。
原因:Swing 的 KeyListener 只对持有焦点的组件生效。JFrame 默认能拿焦点,但窗口里多放一个 JButton 或 JTextField 后,焦点就会落到那个组件上,按键被按钮消费掉,窗口收不到任何事件。
解决:给窗口设置 setFocusable(true),游戏开始时主动调 requestFocusInWindow()。更稳的做法是不依赖窗口焦点,给主面板设置 setFocusTraversalKeysEnabled(false),避免 Tab 键把焦点带到按钮上。参考代码:
this.setFocusable(true); // 在“开始游戏”按钮的回调里重新要回焦点 startButton.addActionListener(e -> { gamePanel.requestFocusInWindow(); });说明:requestFocusInWindow 必须等窗口可见后调用才有效,所以放在按钮回调里比放在构造器里稳。另一个经验是只给一个面板做焦点目标,不要多个面板同时 setFocusable,否则会出现“焦点在 A,事件却在 B”的诡异现象。
4.2 中文输入法吃掉字母
现象:用户开着中文输入法,屏幕出现候选框,按回车后才会上屏,游戏完全无法实时判断。
原因:keyTyped 拿到的是输入法组合后的字符序列,中间态不可控,甚至可能拿到全角字符。
解决:跨平台强制切换英文输入法做不到,最实用的方案是过滤非英文字母,同时在界面上提示“请关闭中文输入法”。过滤代码:
@Override public void keyTyped(KeyEvent e) { char c = e.getKeyChar(); if (!Character.isLetter(c) || Character.UnicodeBlock.of(c) != Character.UnicodeBlock.BASIC_LATIN) { return; // 非英文字母一律忽略 } game.type(Character.toLowerCase(c)); }说明:Character.UnicodeBlock.BASIC_LATIN 会把全角字母、中文、其他文字全部排除。虽然粗暴,但符合打字练习场景。这也是为什么第 2 章强调用 keyTyped 而不是 keyPressed——keyPressed 在中文输入法组合阶段拿到的是中间态 keyCode,过滤逻辑根本不知道用户到底按了什么。
4.3 计时器漂移:一分钟的游戏实际跑了七十秒
现象:javax.swing.Timer 设置了 1000 毫秒间隔,剩余时间却减少得比真实时间慢,窗口拖拽时更明显。
原因:Swing Timer 的精度受 EDT 事件队列影响,某一个绘制回调耗时过长时,后续 tick 会延迟排队;Timer 本身是“尽量准”而非“绝对准”,长时间运行会累积偏差。
解决:不要靠 tick 次数算总时间,改用 System.currentTimeMillis() 计算真实剩余时间,Timer 只做刷新控制:
long deadline = System.currentTimeMillis() + timeLimit * 1000L; timer = new Timer(200, e -> { long remain = deadline - System.currentTimeMillis(); if (remain <= 0) { finishGame(); } else { label.setText("剩余: " + (remain / 1000) + "s"); } });说明:这里 Timer 间隔改成 200 毫秒,只负责刷新显示,时间判定完全看时间戳。即使某次刷新延迟了 300 毫秒,最终结束时间也以 deadline 为准,不会越玩越长。如果要暂停游戏,正确做法是记下暂停时刻的剩余毫秒,恢复时用“暂停时剩余值”重新计算 deadline,而不是去改 Timer 本身。
4.4 计分虚高:乱按也能刷到高分
现象:玩家不按顺序乱敲,分数反而涨;或一个单词只打对两个字母就跳到下一词。
原因:计分逻辑只统计“命中数”,没有强制按顺序比对,也没有失败判定。
解决:强制从光标位置顺序匹配,并引入每词容错上限。修改后的控制器逻辑:
public int feedChar(char c) { if (type(c)) { return 1; // 命中当前光标位 } errorCount++; if (errorCount >= maxErrorPerWord) { return -1; // 本词失败,跳过 } return 0; // 打错但未超限 }说明:返回值只有 1、0、-1 三种状态,界面层根据返回值刷颜色、加计时或扣分。这样乱按最多在第一个字符上得一次分,后续必须按顺序命中当前光标位置,策略上没有空子可钻。这里 maxErrorPerWord 来自 3.3 的配置文件,前后完全联动。实际玩下来,容错 3 次比较合适,容错 0 会让新手产生挫败感。
5. 手感校准:用时间戳日志回放每一局
做完一个能玩的版本之后,最容易忽视的是“手感”问题:单词切换太快、容错太宽松、计分曲线不对。这些靠感觉调不靠谱,我习惯在控制器里加一个输入日志类,把每一局按键事件按时间戳记下来,结束后导出来做回放分析。
// InputLog.java - 记录每次按键的时刻与光标位置 import java.util.ArrayList; import java.util.List; public class InputLog { private final List<long[]> events = new ArrayList<>(); public void log(char c, int cursor, long nanoTime) { // 格式:字符ASCII、当前光标位置、nanoTime时间戳 events.add(new long[]{c, cursor, nanoTime}); } public List<long[]> getEvents() { return events; } }在 feedChar 被调用时插入一行日志:log(c, game.getCursor(), System.nanoTime())。为什么用 nanoTime:它适合测量间隔,不受系统时间调整影响。结束一局后把 events 逐行打印到控制台或写入 CSV,我一般会写进一个 result.csv,用脚本分析按键间隔分布:
# 简易回放统计:按行读取三列,计算相邻时间差 awk '{print $3 - prev; prev = $3}' result.csv | sort -n | tail -20说明:这一行 awk 命令把相邻两个键的事件间隔算出来,排序后看最大间隔和分布。如果最大间隔经常超过 2000 毫秒,说明单词切换节奏太慢;如果大量间隔集中在 50 毫秒以下,说明玩家在乱敲。
再配合 3.2 的计分公式,可以做一个简单校验:把回放日志喂给它,统计平均每词耗时和错误率,再决定要不要调整 speedBonus 的阈值。我会先在配置里把 speedBonus 阈值从 1000 毫秒改成 800 毫秒,回放同一份日志,看分数变化是否合理,而不是拿真人反复试玩。从那以后,我每次调难度参数都强制先走一遍日志回放,手里有数据再改代码,手感比瞎调稳太多了。希望帮到你。
本文还有配套的精品资源,点击获取