手把手用 Java Swing 从零实现黄金矿工小游戏:状态机与碰撞检测实战
简介黄金矿工小游戏Java是一份Java课程设计/大作业级别的完整工程面向初学Java与数据结构算法的学生兼顾GUI应用开发练手需求。项目基于Swing等Java GUI技术实现程序已测试可直接运行适合课程设计、实验报告或算法实践场景。压缩包共28个文件包含5个Java源码、7个编译后class文件、10个png与2个jpg图片素材以及.project、.classpath、.prefs等Eclipse工程配置文件和README说明整体体积1.35MB其中src目录存放未编译源码bin目录存放class运行产物便于对照学习。目前已有374人学习浏览说明该小游戏资源对同类学习者有不错的参考价值。读者可直接运行体验黄金矿工的核心玩法也能借助源码研究GUI布局、事件响应、计时计分、碰撞检测等基础算法与设计思路是一份轻量但完整的练手项目。1. 黄金矿工小游戏Java一个小项目为什么值得你亲手做一遍黄金矿工小游戏Java是我愿意安利给所有 Java 初学者的第一个图形项目它看起来只是钩子甩来甩去抓金子实际要把 Swing 绘图、事件绑定、碰撞检测、状态机四块知识串起来才能跑通。比起背一星期 Java 面试八股文花两个晚上调通这个游戏对 Java 基础、面向对象编程的理解会扎实得多。它既是 Java 课程设计案例源码里的常客也适合拿去验证你的 JDK 版本和 Java 环境变量配置是不是真的过关。这篇文章不评价某个网盘里那位作者的源码写得多好而是把它拆成几个可独立测试的模块让你能照着搭出自己的版本。如果你只是想要一份能交作业的源码包复制粘贴跑通就结束那网上一搜一大把但如果你想让老师追问时你答得上来“为什么用 Timer 不用 Thread”“钩子为什么不会穿模”这篇就是给你准备的。2. 先把游戏循环想清楚用 Swing 还是 JavaFXTimer为什么必须是主角2.1 选型对比Swing 在这个项目里比 JavaFX 更稳做黄金矿工这类 2D 小游戏常见选项是 Swing 和 JavaFX。我一般建议课程设计和自学第一版都选 Swing理由不是 Swing 比 JavaFX 强大而是它足够简单和“省事”。对比项SwingJavaFXJDK 自带是JDK 6 起就是标准组件JDK 11 起从 JDK 剥离需要额外引入模块课程机器兼容性高装了 JDK 就能跑要看对方 Java 环境和 IDE 是否配好插件绘图入门成本重写 paintComponent十几行能画图需要理解场景图、Stage、Scene 概念做黄金矿工够不够完全够帧率要求不高适合更复杂的 UI 程序先看一个现实问题你在自己电脑上把 JavaFX 配好了交作业时老师那台机器不一定有对应模块。Swing 只要 Java 环境变量配置没问题java -jar双击就能跑。尤其是很多学校机房还是 JDK 8JavaFX 配起来麻烦Swing 零额外依赖这几分钟时间省下来够你多调一个碰撞 bug 了。再谈性能。黄金矿工每帧要画的东西无非是背景、绳索、钩子、圆形矿物对象算上矿物数量最多几十个绘制调用Swing 的渲染能力完全撑得住。很多同学一上来就想用游戏引擎这是把简单问题复杂化。Swing 的短板在重绘闪烁和高分屏缩放前者用setDoubleBuffered(true)解决后者在你的课程设计场景里基本不用管。2.2 游戏主循环为什么必须用 javax.swing.Timer任何游戏都有一个主循环更新状态、渲染画面、等待一小段时间、再更新。黄金矿工也不例外。最容易犯的错误是写一个while(true)套Thread.sleep(16)然后在循环里直接调repaint()。// 错误示范自己 new 线程跑主循环 new Thread(() - { while (true) { updateGame(); repaint(); // 问题repaint 只是“请求”重绘不保证立即执行 Thread.sleep(16); // 问题sleep 不准且与 EDT 重绘不同步 } }).start();这段代码的问题在于Swing 的重绘是由事件分发线程EDT统一调度的。你在额外线程里调用repaint()只是往事件队列里塞了一个重绘请求而你的 update 循环并不会等真正画完再走下一步结果就是逻辑刷新和画面绘制各跑各的视觉上要么卡顿要么钩子瞬移。正确做法是使用javax.swing.Timerclass GamePanel extends JPanel { private javax.swing.Timer timer; private int tick 0; public void startGame() { // 每 16ms 触发一次约等于 60 FPS timer new javax.swing.Timer(16, e - { tick; updateGame(); // 先更新游戏逻辑钩子移动、碰撞判断、回收 repaint(); // 再请求重绘 }); timer.start(); } private void updateGame() { // 根据当前钩子状态做不同处理 } }这里的关键点是javax.swing.Timer的回调运行在 EDT 上updateGame和repaint是串行执行的天然避免了多线程竞态问题。你不需要关心锁不需要管线程安全这就是入门项目该有的复杂度。参数方面16ms 是经验值对应约 60 FPS。如果你想调慢节奏改成 20ms 甚至 33ms 都可以。但注意速度参数和帧率要解耦。比如钩子伸展速度定义为“每帧移动 3 像素”那么帧率从 60 变 30 时钩子就会变慢一倍。正确的做法是定义“每秒移动 180 像素”再在 updateGame 里用180 * (deltaMs / 1000.0)换算成当前帧的移动量。黄金矿工对帧率不敏感很多实现直接按帧移动也看不出问题但做这个设计能让你以后迁移到其他项目时少踩一个大坑。主循环定下来之后整个程序的结构就清晰了窗口是 JFrame画布是 JPanel逻辑更新放在 Timer 回调里。接下来做的所有功能都是往updateGame()和paintComponent()这两个方法里填内容。提示不要自己在 Timer 回调里再做new Thread开线程处理游戏逻辑。Swing 组件只能在 EDT 上访问你一旦开了新线程操作组件状态轻则绘制异常重则程序崩溃。3. 从空窗口到能玩拆成四个模块一步步搭出黄金矿工3.1 搭建骨架JFrame 与 JPanel 的分工窗口和画布要分开。JFrame 负责标题栏、关闭按钮、窗口大小JPanel 才是我们画游戏画面的地方。把两者职责分开后续加设置界面、暂停弹窗都会方便很多。public class GoldMinerFrame extends JFrame { public GoldMinerFrame() { setTitle(黄金矿工); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); // 锁定窗口大小避免坐标系混乱 setSize(800, 600); GamePanel gamePanel new GamePanel(); add(gamePanel); // JFrame 默认布局是 BorderLayout直接放中央 pack(); // 按面板首选尺寸调整窗口 setLocationRelativeTo(null); // 窗口居中 setVisible(true); gamePanel.startGame(); // 等界面显示后再启动主循环 } public static void main(String[] args) { new GoldMinerFrame(); } }对应地GamePanel 需要设置自己的首选尺寸并重写绘制方法class GamePanel extends JPanel { public GamePanel() { setPreferredSize(new Dimension(800, 600)); setDoubleBuffered(true); // 减少重绘闪烁Swing 绘图基本功 setFocusable(true); // 后面接收键盘事件需要 requestFocus(); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 先清背景否则画面会残影 Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 抗锯齿钩子和金子边缘更平滑 // 绘制顺序背景 - 矿物 - 绳索 - 钩子 drawBackground(g2); drawItems(g2); drawRope(g2); drawHook(g2); } }setPreferredSize配合 JFrame 的pack()使用比在 setSize 里写死尺寸更规范。至于setDoubleBuffered(true)Swing 的 JPanel 默认其实已经双缓冲了但显式写出来是一个好习惯提醒自己不要在这个游戏里用Thread.sleep去模拟动画。这一段代码跑起来你看到的是一块空白画布。别急着往下加功能先把“窗口能开、关闭不报错、画布能刷干净”验证到位再进入下一步。否则后面逻辑越来越多时你很难判断是绘制问题还是逻辑问题。3.2 钩爪状态机READY、EXTENDING、RETRACTING 三个状态的切换黄金矿工的核心玩法是按方向键调整钩子角度按空格发射钩子伸出抓取碰到矿物或到达最大长度后回收矿物被拖回来得分。整个过程可以用一个状态机描述这是我做这个项目时觉得收获最大的一块。enum HookState { READY, // 待发射可以调整角度 EXTENDING, // 正在伸出 RETRACTING // 正在收回 } class Hook { double x, y; // 钩尖坐标 double angle; // 与竖直方向的夹角弧度制 double length; // 当前绳索长度 double maxLength; // 绳索最大长度 HookState state HookState.READY; static final double EXTEND_SPEED 4.0; // 每帧伸展像素 static final double RETRACT_SPEED 6.0; // 每帧回收像素回收比伸展快手感更好 static final double TURN_SPEED 0.03; // 每帧角度变化量弧度 void update() { switch (state) { case READY: // 空闲时只需要等玩家按键角度由键盘事件修改 break; case EXTENDING: length EXTEND_SPEED; x anchorX() length * Math.sin(angle); y anchorY() length * Math.cos(angle); if (length maxLength) { state HookState.RETRACTING; // 伸到底没抓到东西原路返回 } break; case RETRACTING: length - RETRACT_SPEED; x anchorX() length * Math.sin(angle); y anchorY() length * Math.cos(angle); if (length 0) { state HookState.READY; // 完全收回回到待发射 } break; } } }注意坐标换算绳索长度是标量要转成画布上的 (x, y) 坐标就得用三角函数。这里angle是绳索与竖直向下方向的夹角所以x 锚点x length * sin(angle)y 锚点y length * cos(angle)。整个游戏里最容易出错的不是算法而是这种坐标约定不一致。我建议你从第一行代码起就统一用“锚点位于画面顶部中央、角度相对竖直方向”这个约定别换来换去。另外一个关键限制是READY 状态才能调角度EXTENDING / RETRACTING 状态下不能再换方向。这是很多实现做到一半变得像“蛇形走位”的原因——钩子明明已经伸出去了按键还能改变它的方向。真正黄金矿工的规则是发射后只管收放方向锁死。3.3 抓取判定与镜头深度圆-圆碰撞和坐标缩放矿物对象用圆形表示最方便圆心的 x、y半径 r价值 value。抓取判定就是判断钩尖点是否落在圆形范围内本质是点和圆的碰撞。class GoldItem { double x, y; double radius; int value; GoldItem(double x, double y, double radius, int value) { this.x x; this.y y; this.radius radius; this.value value; } // 判断钩尖 (hookX, hookY) 是否碰到本矿物 boolean contains(double hookX, double hookY) { double dx hookX - x; double dy hookY - y; return dx * dx dy * dy radius * radius; } }用平方距离而不是Math.sqrt比较是为了省一次开方运算。单帧对象数量少时性能差异不明显但这是一个值得养成的习惯以后做大量碰撞检测时它能帮你省出不少帧时间。抓到的矿物要“跟着钩子走”。常见做法是在钩子状态从 EXTENDING 切到 RETRACTING 时把抓到的矿物对象记录到 Hook 上然后在RETRACTING状态的回调里同步更新矿物的坐标。GoldItem carrying null; // 钩子当前抓到的矿物null 表示空手 // 在 EXTENDING 状态的 update 里加一段碰撞检查 for (GoldItem item : items) { if (item.contains(hook.x, hook.y)) { carrying item; hook.state HookState.RETRACTING; // 抓到东西立刻开始收回 break; } } // 在 RETRACTING 状态的 update 里让矿物跟着钩尖走 if (carrying ! null) { carrying.x hook.x; carrying.y hook.y; } // 长度归零回到 READY 时 if (carrying ! null) { score carrying.value; items.remove(carrying); carrying null; }这里有个体验细节钩子伸出去抓到金块后金块应该被拉向玩家同时石头、金块等矿物在画面中越变越大。这个“越来越大”的效果怎么实现最省事的办法是引入镜头深度的概念用一个depth变量代表当前镜头拉近的程度绘制矿物时把矿物坐标和半径统一乘上一个随 depth 增大的缩放系数。double depth 1.0; // 初始无缩放 double depthSpeed 0.002; // 每帧镜头拉近的增量 double maxDepth 2.2; // 最大缩放倍数超过就重置新一关 void updateDepth() { // 抓回矿物后镜头才拉近给玩家“东西正在被拖回来”的反馈 depth Math.min(maxDepth, depth depthSpeed); } // 绘制时先平移画布到锚点再缩放最后画在锚点偏移后的位置 void drawItems(Graphics2D g2) { for (GoldItem item : items) { double drawX anchorX() (item.x - anchorX()) * depth; double drawY anchorY() (item.y - anchorY()) * depth; double drawR item.radius * depth; g2.fillOval((int) (drawX - drawR), (int) (drawY - drawR), (int) (drawR * 2), (int) (drawR * 2)); } }需要提醒的是depth缩放只影响绘制不应该影响碰撞检测的坐标。碰撞判定始终用未缩放的原始坐标否则会出现“画面上钩子碰到了金子但逻辑上没碰到”的玄学 bug。这种“绘制坐标与逻辑坐标分离”的设计是这个项目里最值得学会的一条经验。4. 黄金矿工避坑指南线程、焦点与碰撞检测里的 5 个翻车点4.1 “键盘没反应”不是灵异事件JPanel 没拿到焦点现象窗口正常显示点鼠标也正常但方向键和空格键怎么按都没反应有时候点了画布好使点了某个按钮后又失灵。原因KeyListener 只对当前获得焦点的组件生效。如果界面上放了 JButton点击按钮后焦点停留在按钮上JPanel 的 KeyListener 就收不到按键了。这是 Swing 入门必踩的坑几乎每届课程设计都有人问。解决不要用 KeyListener改用 Key Bindings。它允许你设置监听范围是“整个窗口内”不受焦点按钮影响。InputMap im getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am getActionMap(); im.put(KeyStroke.getKeyStroke(LEFT), turnLeft); am.put(turnLeft, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { if (hook.state HookState.READY) { hook.angle - Hook.TURN_SPEED; } } }); im.put(KeyStroke.getKeyStroke(SPACE), fireHook); am.put(fireHook, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { if (hook.state HookState.READY) { hook.state HookState.EXTENDING; } } });WHEN_IN_FOCUSED_WINDOW表示只要当前窗口处于激活状态就接收按键这是游戏类 Swing 程序的标准做法。顺带一提注册按键时要顺手检查状态只有 READY 状态的钩子才允许转发或发射。4.2 画面一卡一卡钩子像在瞬移线程模型用错了现象Timer 确实启动了但画面不是平滑移动而是一顿一顿的极端情况下窗口直接白屏不动点击也失去响应。原因最常见的是自己在 Timer 回调里写了Thread.sleep或者在 updateGame 里做了耗时操作挡住了 EDT。Timer 的回调跑在 EDT 上它一旦被阻塞整个窗口的绘制、鼠标响应全部排队等待表现就是“卡死”。解决固定步长更新逻辑回调里只做轻量计算。private void startGame() { timer new javax.swing.Timer(16, e - { hook.update(); updateDepth(); repaint(); }); timer.start(); }如果你后面加了音效、图片加载记住资源加载放在初始化阶段不要在每一帧的回调里做 I/O 操作。如果某一步真的耗时超过几毫秒用SwingWorker或单独线程加载完再回到 EDT 更新状态。4.3 钩爪穿过了金块但没抓回来碰撞检测的步长问题现象钩子从矿物旁边划过肉眼看起来穿模了明明路径已经和圆形重叠但就是没触发抓取。原因钩子每帧移动 4 个像素而小矿物半径只有 8 像素单帧跨越的距离可能超过整个矿物直径碰撞判定点跳跃性地从矿物的一侧“跳”到了另一侧中间几次判定都落在圆外。解决限制单帧移动步长不大于最小碰撞对象半径的一半。这里的核心是让每帧位移和碰撞对象尺寸匹配static final double EXTEND_SPEED 3.0; // 调到小于最小矿物半径的一半 // 如果硬要加大速度就把钩子移动拆成子步骤 double step 0.5; for (double traveled 0; traveled EXTEND_SPEED; traveled step) { hook.length step; updateHookPosition(); if (checkCollision()) { break; } }第二种方案拆步子步骤钩子不变快但每 0.5 像素就做一次碰撞判定速度再大都不会漏判。代价是碰撞检查次数变多对这个项目几十个对象来说完全无所谓。4.4 金块抓回来一半钩子又突然伸出去状态机没有收口现象钩子带着金块往回拖拖到一半钩子突然又掉头向屏幕深处伸出去了或者回收过程中按空格键钩子立刻再次发射。原因update 里对 EXTENDING 和 RETRACTING 的处理没有互斥按键事件在任何状态都能把钩子切回 EXTENDING状态机被打断了。解决所有能改变钩子状态的地方都先查当前状态。收回期间不允许重新发射也不允许调整角度。我习惯在 Hook 类里加一个专门的方法来发起发射把合法性判断收敛在一处boolean fire() { if (state ! HookState.READY) { return false; // 只有待发射状态才允许发射收回过程中按空格无效 } state HookState.EXTENDING; return true; }这样即使以后加了“炸弹”“道具”等复杂逻辑也不会出现状态被外部代码随意改乱的情况。状态机的核心原则是状态的迁移只能通过定义好的方法发生不允许外部直接赋值。4.5 双击 jar 包没反应控制台也没有任何报错现象在 IDE 里运行一切正常但导出 jar 后双击窗口就是不出现也没有任何提示。原因双击 jar 用的是javaw这是一个没有控制台的 Java 进程System.out.println和异常堆栈你都看不见异常被静默吞掉了。解决在 main 方法里加全局异常兜底把错误弹窗显示出来public static void main(String[] args) { // 兜底所有未捕获异常避免双击后毫无反应 Thread.setDefaultUncaughtExceptionHandler((t, e) - { e.printStackTrace(); JOptionPane.showMessageDialog(null, 程序运行出错 e.getMessage(), 错误, JOptionPane.ERROR_MESSAGE); }); SwingUtilities.invokeLater(GoldMinerFrame::new); }以后无论是 ClassNotFoundException 还是图片路径不对双击 jar 都会弹窗告诉你错在哪而不是让你坐在屏幕前发呆。调试时建议用java -jar yourgame.jar在命令行里跑控制台会直接打印异常堆栈比 IDE 里跑更接近真实运行环境。5. 从“能跑”到“能交”难度曲线、随机矿藏与调试开关课程设计的评分差异往往不在“能不能玩”而在“好不好玩、代码好不好维护”。最后这一步我建议你做三件事。第一把难度参数集中到一块常量区。黄金矿工可调参数很多钩子伸展速度、回收速度、最大长度、矿物数量、矿物价值区间、每关镜头缩放上限。我把它们统一收敛到GameConfig类里配成按关卡号返回的难度档位而不是散落在各个类里的魔法数字。参数第一关第三关第五关矿物数量81420石头数量258钩子伸展速度3.03.64.2镜头缩放上限1.82.02.2这样调难度只需要改表不需要动游戏逻辑代码老师让你“加一关”时你五分钟就能改完。第二矿物随机生成要做重叠避让。一个常见的低级问题是金子生成时和石头重叠在一起钩子抓一个带出来另一个分数逻辑全乱。简单做法是拒绝采样随机生成位置后遍历已有对象如果距离小于两者半径之和就重新生成。GoldItem createItem(Random random) { while (true) { double x 100 random.nextDouble() * 600; double y 150 random.nextDouble() * 350; if (items.stream().noneMatch(it - Math.hypot(it.x - x, it.y - y) it.radius 30)) { return new GoldItem(x, y, 15 random.nextDouble() * 10, value); } } }第三加一个调试开关。我在 GamePanel 里留了一个boolean debug false按键 F3 切换。开启后把所有矿物的圆心、半径用红圈画出来同时把钩子当前状态和坐标打印在画布左上角。这个开关帮我解决过至少三次“看起来碰到了但没抓取”的争议先看到判定圆在哪才能判断是碰撞逻辑错还是绘制坐标错。写这个小游戏让我养成的最重要习惯就是任何图形程序都要逻辑坐标与绘制坐标分离任何动画都要走固定步长更新。这两个习惯后来做 Android 自定义 View 和游戏原型时都直接复用上了。希望这篇能帮你少走几个我当年踩过的弯路也希望你做完之后能对 Swing 事件模型和状态机这两个 Java 基础硬骨头有更踏实的体感。希望帮到你。本文还有配套的精品资源点击获取