ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java Swing游戏开发实战:用状态机与碰撞检测实现黄金矿工

2026/10/4 14:18:54 拓冰建站 浏览量
Java Swing游戏开发实战:用状态机与碰撞检测实现黄金矿工 简介基于Java Swing的黄金矿工小游戏完整项目配套视频教程适合Java初学者、Swing技术爱好者与毕业设计选题学生。整个压缩包包含664个文件除Java源码与编译后的class文件外还有121个xml配置用于工程与界面布局92张png、69张gif和46张jpg图片素材覆盖游戏场景与角色动画26个mp4教学视频演示开发全过程整体约258MB便于边看边练、按步骤理解桌面游戏开发流程。已有111人学习下载。资源按开发步骤组织可跟着视频从零编写代码也可直接阅读工程结构掌握窗口绘制、碰撞检测、游戏循环等Swing核心知识点为后续Java桌面应用开发打下扎实基础。1. 一个压缩包能教会你的比“Java基础”多得多如果只给你一个写着“Java开发黄金矿工游戏.rar”的压缩包你能从这个文件名里读出什么可能是一份课程设计、一套毕业设计源码或者一个刚学完 Java 基础的人攒出来的第一个完整项目。但在我眼里黄金矿工这个游戏的代码量不大却刚好把 Swing 绘图、多线程调度、碰撞检测、状态机四件事全揉在一起了是典型的“基础都会、一动手就卡”的训练场。你需要注意这游戏真正的难点不在画面多花哨而在钩爪的运动模型什么时候伸、什么时候收、勾到金子后回收速度怎么衰减这些细节直接决定游戏手感。我见过很多人照着教程敲完结果勾到钻石直接穿过去或者回收快得像开了加速器问题全出在对“钩爪状态”的理解上。本文就用一套能跑起来的最小实现把这个状态模型拆给你看先立住原理再给可复现代码最后是调试经验。适合拿它练手的是学过 Java 语法和面向对象、但还没独立写完过两百行以上交互程序的人以及想在手头留一个干净的小项目应对面试提问的开发者。2. 钩爪的运动机制与多线程模型动手前先拆清三个状态黄金矿工的玩法一句话就能说清一根挂在锚点上的钩爪左右摆动你按一次发射键钩爪就沿当前角度射出去碰倒金子、石头或边界后自动收回把抓到的东西拖回锚点换钱。所有的趣味都来自“能不能勾准”和“勾到什么后值不值得收”。如果你直接开始写代码第一版多半是把钩爪位置当普通坐标来算鼠标点一下就直线射出去——那已经不是黄金矿工了。我一般会在动笔之前先把钩爪的运动拆成状态机把界面绘图和逻辑更新分开这一章就按这个顺序讲原理。2.1 钩爪的三态机旋转、射出、回收钩爪的任何时刻只属于三种状态ROTATING未发射绕锚点左右摆动、EXTENDING发射后持续向前伸出、RETRACTING碰到目标或边界后往回拖。游戏循环每走一帧只需要根据当前状态做一次更新旋转态改角度伸长态加长度回收态减长度并顺带把抓到的物品拖回来。这三个状态之间的切换条件值得单独捋一遍因为后续所有 bug 几乎都出在切换条件的遗漏上。常见做法是用一个 Java 枚举定义状态Hook 类内部持有当前状态和切换方法而不是用散落的布尔变量互斥。public enum HookState { ROTATING, EXTENDING, RETRACTING }旋转态到伸长态的切换由玩家按键触发这也是整个游戏里唯一的主动操作入口。伸长态到回收态有两个触发点一是末端碰到任意物品二是钩爪长度超过最大值或末端越界。后一个触发点很容易被漏掉漏掉的典型症状是钩爪会在屏幕边缘越伸越远永不回头。回收态回到旋转态也有两个触发点物品被拖回锚点并结算分数或者空手完全收回。注意单帧内最多只处理一次状态切换否则会出现“刚碰到钻石瞬间就判定为回收完成”的穿透现象。这些边界情况我会在第 5 章结合实际现象展开排查。2.2 为什么选 Swing Timer 而不是 new Thread很多新手教程会把游戏循环写成“while (true) { update(); repaint(); Thread.sleep(16); }”然后告诉你这是标准做法。这在大项目里是灾难因为 Swing 组件不是线程安全的直接在非事件分发线程里调用 repaint 会间歇性地抛NullPointerException或界面卡死。我的建议是用javax.swing.Timer它会在事件分发线程EDT上按固定间隔触发actionPerformed你在这个回调里做位置更新和重绘天然规避了并发问题。Timer timer new Timer(16, this); // 约 60 帧/秒 timer.start();这里的 16 是毫秒间隔换算下来大约 60 FPS。要区分两个容易混淆的“Timer”javax.swing.Timer用于界面层适合本项目的游戏主循环java.util.Timer是普通后台定时器回调跑在独立线程里适合做存档自动保存这类与界面无关的任务。如果是带 Spring 的 Web 项目你会接触 Quartz、XXL-Job 这类 Java 定时任务框架但 Swing 小游戏完全用不上。黄金矿工只有一个主循环没必要引入额外调度框架Swing Timer 的精度对帧率已经足够。如果后面你发现计时不准那就是用了普通线程去提前更新逻辑而不是 Timer 本身的问题。2.3 碰撞检测的两种判定方式钩爪的碰撞判定对象只有“末端点”和“物品矩形”。旋转和伸长过程中钩爪画成一条从锚点连到末端点的线段线段本身不做碰撞只有末端点参与了交互判定这是街机版黄金矿工的常见简化处理也是手感的关键钩爪头部的“牙”碰到东西才算抓到。实现上用Rectangle.contains(int x, int y)就能完成判定。第二种判定方式是把末端点当成一个小圆与物品矩形做膨胀矩形碰撞适合你想让爪子“抓得宽一点”的时候原理不变只是矩形外扩几个像素。Rectangle hitBox new Rectangle(item.x, item.y, item.width, item.height); boolean hit hitBox.contains(hookEndX, hookEndY);但这里有个很实际的连环坑如果钩爪每帧前进的像素大于物品的边长末端点可能从物品矩形的一侧直接跳到另一侧造成“穿过钻石”的现象这叫隧道效应。我给的参数是钩爪每帧伸出 3 像素而最小物品钻石宽 12 像素通常不会穿透。如果你调大了速度或者物品尺寸很小就要用细分步进把这一帧的位移拆成 N 小段每段都做一次碰撞判定。这个留到第 5 章排错部分细说。3. 落地一个可玩的黄金矿工核心类设计与关键代码原理讲清楚了这一章直接落到能复现的工程结构。这个项目我建议只拆三个核心类Hook管钩爪三态与位置推进Item管地图上的矿物数据GamePanel管游戏循环、碰撞与绘制。不要一上来就加 MVC、框架、配置文件两百行能跑通的最小骨架才是第一步。下面的代码就是可抄作业的版本我基于 640×480 的面板尺寸来写锚点放在(320, 60)钩爪初始朝正下方左右各摆 60 度。3.1 Hook 类角度、长度与速度的推进逻辑Hook 类应该是整个项目里最干净的一个类它不需要知道 GamePanel 的存在只维护自己的状态和数值。我把角度定义为“与垂直向下方向的夹角”用弧度存储这样Math.cos(angle)控制 y 方向位移Math.sin(angle)控制 x 方向位移角度为 0 时正好朝下射。旋转速度设计为每帧 0.02 弧度约每秒 68 度摆完正负 60 度一个来回大约 1.77 秒手感比较适中。public class Hook { private int anchorX, anchorY; // 锚点坐标 private double angle 0; // 当前角度弧度 private double length 0; // 钩爪当前长度 private HookState state HookState.ROTATING; private static final double MAX_ANGLE Math.toRadians(60); private static final int MAX_LENGTH 420; private static final double ROTATE_SPEED 0.02; // 弧度/帧 private static final double EXTEND_SPEED 3.0; // 像素/帧 private static final double RETRACT_SPEED 4.0; // 像素/帧 public void update() { switch (state) { case ROTATING - { angle ROTATE_SPEED; if (angle MAX_ANGLE) { angle MAX_ANGLE; // 反向摆动通过切换增量方向实现 rotateDirection -1; } else if (angle -MAX_ANGLE) { angle -MAX_ANGLE; rotateDirection 1; } angle rotateDirection * ROTATE_SPEED; } case EXTENDING - { length EXTEND_SPEED; if (length MAX_LENGTH) { state HookState.RETRACTING; } } case RETRACTING - { length - RETRACT_SPEED * speedFactor; if (length 0) { length 0; state HookState.ROTATING; speedFactor 1.0; // 复位 } } } } // 省略 getter、fire()、attachItem() 等方法 }上面代码里有几个参数需要注意。speedFactor是回收速度系数默认 1.0抓到金块后我会把它调成 0.4抓到大石头调成 0.15这个数字决定拖拽的“沉重感”。EXTEND_SPEED是每帧伸长像素数调大一些会让钩爪射得更快但碰撞穿透风险同步上升调的太小则会让玩家觉得发射绵软无力。MAX_LENGTH取 420 是为了保证钩爪在 640×480 面板里不会超出左右边界太多实际你可以让钩爪越过屏幕底边一小段再回收那要单独加越界判断。旋转时注意rotateDirection字段的初始值必须为 1且每次碰到边界要翻转方向否则钩爪只会在左边或右边单侧摆动。3.2 Item 类把金子石头钻石抽象成对象Item 类存的是静态数据和绘制所需信息。重量感体现在刚才的speedFactor上但 Item 自身还要记录价值、矩形范围、是否已被抓取。抓取状态必须单独标记否则回收过程中钩爪末端还压在物品矩形上每一帧都会触发一次新的碰撞判定造成分数重复计算。public class Item { private int x, y, width, height; private ItemType type; private boolean captured; public enum ItemType { GOLD_NUGGET(金块, 30, 0.5), GOLD_BAR(金条, 60, 0.3), DIAMOND(钻石, 100, 0.9), STONE(石头, 1, 0.15); private final String name; private final int value; private final double retractFactor; ItemType(String name, int value, double retractFactor) { this.name name; this.value value; this.retractFactor retractFactor; } } public Item(ItemType type, int x, int y) { this.type type; this.x x; this.y y; this.width 30; this.height 24; // 石头更大碰撞面积也更大 if (type ItemType.STONE) { this.width 40; this.height 34; } } }这里有个设计取舍如果把retractFactor直接写在 Hook 里每次抓到新物品就要用 if-else 判断类型再赋值代码会越写越长把重量感定义在枚举里新增一种矿物只需要加一个枚举值。你以后想加“炸药桶”或者“宝物袋”直接扩展 ItemType 就行。物品的宽高我故意让石头大于钻石这不仅是视觉真实感也影响碰撞难度钻石小但价值高、回收轻快石头大而笨重适合用来卡钩爪。生成物品时还需要防止重叠做法循环很简单——随机位置生成后遍历已有物品做矩形相交检测相交则重新生成我一般限制每关最多 12 个物品超过就直接放弃生成避免死循环。3.3 GamePanel 主循环把核心逻辑组装起来GamePanel 继承JPanel并实现ActionListener在 16ms 一次的actionPerformed里依次推进 Hook、检测碰撞、更新界面。绘制时用Graphics2D把物品先画出来再画钩爪线段和末端爪子。这里有一个小细节先调用super.paintComponent(g)清背景否则画面会留下上一帧的残影。完整的绘制代码比较长下面只贴主循环和碰撞这一段这是组装逻辑时最容易错的位置。public void actionPerformed(ActionEvent e) { hook.update(); if (hook.getState() HookState.EXTENDING) { checkCollision(); } if (hook.getState() HookState.RETRACTING hook.hasItem()) { moveItemWithHook(); } repaint(); } private void checkCollision() { int endX hook.getEndX(); int endY hook.getEndY(); for (Item item : items) { if (item.isCaptured()) continue; Rectangle rect new Rectangle(item.getX(), item.getY(), item.getWidth(), item.getHeight()); if (rect.contains(endX, endY)) { hook.attachItem(item); // 将 speedFactor 设为 item 的 retractFactor item.setCaptured(true); return; // 一帧只抓一个物品 } } }checkCollision里那两个 if 是这套实现的关键只有在伸长态才允许新捕获只有已经带着物品进回收态才把物品坐标往锚点方向拉。如果漏了return同一帧内钩爪末端可能同时压在两个物品的重叠区域上导致一次发射抓到两块。物品跟随钩爪移动的方法也很直接每次更新把物品坐标设回钩爪末端坐标即可。注意一个容易忽视的点物品一旦被标记captured就不能再参与任何碰撞判定否则拖回途中会因为末端再次碰到它而重复进入捕获流程。主循环里的repaint()只负责请求重绘真正的绘制逻辑在paintComponent里不要试图在这里直接调用绘图。4. 游戏数值与难度曲线用参数把“好玩”这件事量化代码跑通之后很快会遇到一个尴尬阶段钩爪能伸能缩碰撞也正常但玩起来就是没意思。不是逻辑错了是数值没调。黄金矿工的好玩程度几乎完全取决于“能不能勾到好东西”和“勾到一个烂东西要浪费多少时间”之间的张力。这块没有标准答案但有一些通用原则我整理了常用数值表能让你在 30 分钟内调出第一版可玩的手感。4.1 物品价值与回收系数的搭配逻辑游戏的紧张感来源是时间压力因此每个物品真正消耗的资源不是“勾它花了几秒”而是“收回来花了几秒”。如果回收都很快玩家就会无脑发射没有任何取舍。拿到参数后逐项核对确认“重量”和“价值”确实成反比安排。物品类型参考价值回收速度系数勾取建议小钻石1000.9最值得优先勾又快又贵但体积小容易空枪金块300.5体积中等是前期稳定收益来源金条600.3又大又重一次回收耗时较长注意时间余量石头10.15明确负收益只会在钩爪够不到好物品时出现宝物袋2001.0同时加 5 秒游戏时间后期核心目标这里必须解释一个反直觉现象金条价值 60但回收系数只有 0.3比金块慢得多所以单个金条的单位时间收益反而低于金块。我一般不会把价值比较迷的数值写进需求文档直接用“单位时间收益 价值 × 回收速度系数 ÷ 路程”来算。在这个公式下如果距离锚点 200 像素处有一个 60 分的金条它的收益大约等于 150 像素处一个 30 分的金块乘以它的速度系数比值你会发现金条没那么划算。于是玩家自然形成策略优先勾钻石和宝物袋金块作为兜底石头能不碰就不碰。这种“数值逼出策略”的设计就是这类小游戏好玩的底层来源。4.2 难度递进石头占比、深度与时间压力的配比单关数值定下来后难度曲线才是让玩家持续玩下去的关键。第 1 关如果全是石头玩家三分钟就卸游戏全是钻石又撑不过两关就腻。我用的参数是开局只放金块和少量金条第 2 关开始出现石头第 3 关加入钻石第 4 关起每关按比例增加石头数量同时适度提高物品生成深度。所谓深度就是物品中心的 y 坐标均值第 1 关大约在 250 像素附近第 4 关可以推到 380 像素以上——更深意味着钩爪行程变长往返耗时翻倍时间压力自然上来。public ListItem generateLevel(int level) { Random rand new Random(); int itemCount Math.min(6 level * 2, 12); int stoneRatio Math.min(level * 10, 40); // 百分之多少 ListItem result new ArrayList(); for (int i 0; i itemCount; i) { int roll rand.nextInt(100); Item.ItemType type; if (roll stoneRatio) { type Item.ItemType.STONE; } else if (level 3 roll stoneRatio 15) { type Item.ItemType.DIAMOND; } else if (level 2 roll stoneRatio 25) { type Item.ItemType.GOLD_BAR; } else { type Item.ItemType.GOLD_NUGGET; } int x 40 rand.nextInt(560); // 避开左右边缘 int y 180 rand.nextInt(280); // 避开锚点附近 result.add(new Item(type, x, y)); } return result; }这个生成器要注意几个显式参数。itemCount从 6 个起步每关加 2 个封顶 12 个防止后期画面挤成一团。stoneRatio是核心难度控制变量我用level * 10让它线性增长到 40% 就不再涨避免满屏石头直接击穿玩家的耐心。钻石条件设定为level 3给新手保留了前两关的学习时间。y最小值设为 180 是为了避免物品和锚点重叠而rand.nextInt(280)的最大值是由面板高度 480 减去物品高度和安全边距估算出来的改面板分辨率时这里要跟着调否则物品会出界。经验是随机生成后打印一次所有物品坐标肉眼检查最值和重叠情况比写复杂的单元测试更快。4.3 时间奖励怎么给才不失控宝物袋是后期常见的“续命”道具价值高还给额外时间但如果每关都给太多时间压力就失去意义。我一般把时间奖励控制在 3 到 5 秒之间且宝物袋出现的概率独立计算不参与上面那段比率判断。原因在于时间奖励会影响玩家决策剩余 10 秒时出现一个宝物袋玩家会赌一把这种局部随机正是游戏反复游玩的钩子。需要注意实现上的细节是“加时间”不能在游戏主 Timer 回调里直接让倒计时变量倒退因为当时可能处于回收态玩家看到时间减少又突然增加会有数值回跳的怪异感。我把时间奖励也做成一个“待生效”队列在本帧结束时统一结算保证界面上的倒计时逐秒单调递减。5. 黄金矿工开发避坑5 个常见问题与排查方法到这一章我假设你的游戏已经能跑了只是有不满意的地方。下面五条是从这个类型的小项目里反复爬出来的问题每条都按现象、原因、解决三段给到排查思路。如果你遇到现象对不上先别急着怀疑代码多半是某个参数越界引起的连锁反应。5.1 画面闪烁像幻灯片残影严重现象钩爪移动时尾巴拖了长长一条或者整个面板疯狂闪烁看起来像几十年前的卡顿画面。原因paintComponent里没有先调用super.paintComponent(g)清空画布或者你手动用this.setBackground()在每帧开头刷新背景导致背景重绘和内容重绘互相覆盖。Swing 默认启用了双缓冲正常情况下不该闪出现闪烁基本是绘制代码破坏了缓冲机制。解决重写paintComponent时第一行写super.paintComponent(g)背景色在构造函数里设好不要在绘制方法里改。如果仍闪检查是不是你另外开了一个Thread循环调用repaint()那是把非 EDT 线程的重绘请求和 EDT 的绘制挤在一起了换成 Swing Timer 会立刻好转。5.2 钩爪穿过小钻石而抓不到现象钩爪末端明显从小钻石的矩形里穿过去了但没有任何反应。原因大概率是隧道效应——钩爪每帧移动 3 像素小钻石宽 12 像素正常不该穿但你如果把EXTEND_SPEED改成 6 像素甚至更大而钻石被缩到 8 像素宽末端点就会从钻石一侧越到另一侧。解决降低每帧步长或者用连续碰撞检测。常见做法是在checkCollision里把上一帧末端到这一帧末端连成一条线段用lineIntersectsRect判断线段和物品矩形是否相交。boolean hit new Line2D.Double( prevEndX, prevEndY, endX, endY ).intersects(rect);用线段检测要注意一个边界条件上一帧和当前帧都在矩形内部时线段检测会失效因为两个端点都在矩形里intersects返回 false。稳妥做法是同时保留点包含和线段检测两个判断任一命中即视为抓到。代码逻辑并不复杂值得实现因为这是唯一能根治穿透问题的方案。5.3 不同电脑上游戏速度差很多现象同一份代码你的机器上钩爪摆速正好朋友电脑上慢得离谱。原因有人用了while(true) { Thread.sleep(10); }做主循环Thread.sleep的参数只是最短等待时间实际唤醒间隔取决于操作系统调度显示器刷新率不同也会影响 Swing Timer 的实际触发节奏。解决把“每帧移动多少像素”改成“每秒移动多少像素”通过System.nanoTime()计算两帧间隔用间隔乘以每秒像素数得到本帧位移。这个修改在代码里体现为把EXTEND_SPEED等常量从“每帧值”改成“每秒值”并做一次换算。long now System.nanoTime(); double deltaSec (now - lastTime) / 1_000_000_000.0; lastTime now; hook.extend(deltaSec * EXTEND_SPEED_PER_SEC);这样改完游戏在 60Hz 和 144Hz 显示器上转速基本一致。注意deltaSec可能因为系统休眠或切换窗口出现一次很大跳变比如从 16 毫秒跳到 200 毫秒如果直接把位移乘上去画面会出现瞬移。我一般会给deltaSec设一个上限超过 0.05 秒就按 0.05 秒算。这也是 online 游戏同步逻辑里常见的“钳位 deltaTime”做法。5.4 打包成可执行 Jar 后游戏图标全部丢失现象在 IDE 里运行一切正常打出 jar 包后启动背景是黑的物品和钩爪都不见了但程序没有报错。原因图片资源用了new ImageIcon(src/main/resources/images/gold.png)这种基于文件系统路径的写法运行时工作目录一变路径就失效。解决改用类路径加载把图片放进 jar 包用ClassLoader读取资源。这一步是 Java 游戏开发里从“跑通”到“能交付”的分水岭。ImageIcon icon new ImageIcon( GamePanel.class.getResource(/images/gold.png) );这里有个重要提醒ClassLoader.getResource路径以/开头表示从 classpath 根目录查找如果你打包时资源在resources/images下最终放进 jar 里的路径一般就是/images/gold.png。如果你的构建工具把 resources 和 classes 分开打包先解压 jar 确认一下路径再改代码不要凭空猜测。项目交付给别人的时候这个坑几乎必踩所以我习惯在 README 里直接写清楚“JDK 8 并配置好 java 环境变量”和启动命令避免用户解压后双击 jar 没反应就先来问我环境问题。5.5 界面卡死或日志报 Not on Event Dispatch Thread现象运行几秒后界面整体冻住控制台偶尔出现Not on Event Dispatch Thread的异常。原因你大概率在某个监听器回调里启动了new Thread并在这个新线程里更新了 Swing 组件。Swing 是单线程模型所有 UI 更新必须发生在 EDT 上。解决把主循环统一放进 Swing Timer业务线程只计算结果通过SwingUtilities.invokeLater把界面更新提交回 EDT。这个项目里最常见的场景是“倒计时归零后自动停止游戏”有人在 Timer 回调里 sleep 了一秒再更新界面卡顿就是 sleep 阻塞了 EDT。别在任何回调方法里做耗时操作文件保存、图片缩放这种事放到普通线程做再回 EDT 刷新。6. 从能玩到能展示存档、关卡生成与代码体检当游戏基本可玩你应该把精力转向两个方向一是让这个项目能放进简历和代码仓库里“见人”二是加上一点超出课程设计水平的机制。黄金矿工这类小游戏的进阶并不是无限加新玩法而是把“手感细节”和“工程质量”做扎实。它不需要硬塞 Spring Boot、MyBatis 之类的企业框架坚持用纯 Java 和 Swing 反而更聚焦。存档系统是最值得先做的一项。常见做法是把“关卡、当前得分、剩余时间、物品清单”写成 Properties 文件退出游戏时保存下一次启动读档恢复。Properties prop new Properties(); prop.setProperty(level, String.valueOf(level)); prop.setProperty(score, String.valueOf(score)); try (OutputStream out Files.newOutputStream(Paths.get(goldminer.properties))) { prop.store(out, Gold Miner Save Data); }存档有一点要设计好读档时所有物品的captured状态必须重置否则会出现“上次关游戏前抓到一半的金块这次打开卡在半空回不来”。我吃过这个亏存档系统上线后第一天上手就翻了车从那以后凡是恢复对象状态我都会在读取后额外做一遍合法性校验比如物品坐标必须在面板范围内、得分不能为负数。写单元测试时也顺手给存档的写入和读取写一个往返用例保证所有字段序列化后能原样还原。关卡生成器建议继续做深一步把“完全随机”改成“种子随机”。Random(seed)固定种子之后同一关每次生成结果一致这对调试非常有用玩家报“第 5 关太容易出石头”时你可以直接复现他的关卡去排查不用反复刷运气。代码体检的自查清单也是交付前必须过一遍的所有运动参数是否已改成时间驱动所有图片是否通过 classpath 加载钩爪状态切换是否覆盖了全部边界路径物品绘制是否在paintComponent内完成是否还有Thread.sleep出现在 UI 回调里。照着这份清单过一遍哪怕只做到一半整个项目的可读性都会上一个台阶。我给自己刚写的代码做体检时习惯把 Hook 类的三个状态切换点逐个画个日志跑到什么条件切到什么状态直接看得见这比盯着代码猜快得多。希望这套流程对你也有用做完体检之后黄金矿工这个项目就真正从“照着敲”变成“自己会改”了。本文还有配套的精品资源点击获取