☰
Java Swing实战:手写黄金矿工,玩转碰撞检测与状态机
2026/10/24 1:26:24 网站建设 项目流程

1. 项目整体设计:从玩过的游戏到能写的代码

要说Java图形界面练手项目,黄金矿工是我见过性价比最高的一个。它看着简单,玩法一眼就能懂,但真正动手去写,你会发现它恰好踩中了Java桌面开发最核心的几块内容:窗口绘制、事件响应、线程调度、碰撞检测,还有一点简单的物理模拟。我最初接手这个项目的时候,目标很明确——用纯Java,不依赖任何第三方库,在Swing框架下把黄金矿工的核心玩法完整跑通。

1.1 玩法本质:抓取、拉回、计分的循环博弈

黄金矿工这个游戏,拆开来看就是一句话:玩家在屏幕顶部操作一个钩爪,钩爪可以左右摆动,按空格键出钩,钩住地下的金块或钻石后收回,在规定时间内凑够过关分数。但就是这个看似简单的循环,里面藏着整个游戏最核心的设计难点。

钩爪不是一个固定方向的抓取器,它有一个摆锤式的运动。出钩之后,钩子以一定速度沿着当前摆动方向直线射出去,碰到屏幕边界、石头、或者抓到东西之后再原路收回。这个“出钩-抓取-收回”的三段式状态转换,是整个游戏的主状态机。钩子在空中的运动是匀速直线,抓到金块之后收回速度会变慢,金块越大越重,收回速度越慢——这就是游戏策略性的来源,你得在“抓大金块耽误时间”和“抓小金块效率更高”之间做取舍。

从技术角度来说,这个游戏本质上是二维平面上一个运动物体的状态管理问题。钩爪有位置、有角度、有运动状态;金块有位置、有大小、有价值;整个画面还有个时间压力在倒逼玩家快速决策。把这些用Java对象建模,再用一个刷新循环不停重绘画面,游戏就成了。

1.2 技术选型:为什么选Swing而不是JavaFX

很多初学者纠结,写桌面小游戏到底用Swing还是JavaFX。我的看法是,如果目标是把核心玩法做出来、把面向对象思想练扎实,Swing完全够用,甚至更好。

Swing的优势在于,它是JDK自带的,不需要额外装任何环境,新建一个Java工程就能开始写。而且Swing的绘图模型非常直接——你继承一个JPanel,重写paintComponent方法,在里面用Graphics2D画东西,系统会自动帮你管理重绘。这种“画布”式的编程模型,非常适合初学者理解图形界面和事件循环的运作方式。

JavaFX虽然更现代、界面更精美,但它的声明式布局、属性绑定、FXML这些概念,对第一次写项目的同学来说反而是一种负担。追求酷炫的特效,倒不如先把基础功底打牢。

我做这个项目时的技术栈清单如下:

  • JDK 8以上即可,不用任何新特性
  • Swing(JFrame + JPanel + Timer)
  • 纯手写绘图,不使用任何图片素材,所有画面都用Graphics2D绘制
  • 关卡配置文件用properties格式(也可以直接用常量数组)

这种组合最大的好处是,你能看到每一个像素是怎么画出来的,每一个运动是怎么算出来的,不会有“调用了别人封装好的引擎”那种黑盒感。

2. 核心技术拆解:钩爪运动与碰撞检测

黄金矿工这个项目里,最值得花时间琢磨的,就是钩爪的物理运动怎么写。这一段是整个项目真正的技术含量所在,处理好了,游戏手感就出来了;处理不好,操作起来就像在拖一个僵尸。

2.1 钩爪的三个状态与状态切换规则

钩爪运动我建议用一个简单的状态机来管理,定义清楚三个状态:正在出钩、正在回拉、空闲摆动。

  • 空闲状态:钩爪挂在屏幕顶部的机器上,做单摆运动(角度在-60度到60度之间来回摆动)
  • 出钩状态:玩家按空格键触发,钩爪沿着当前摆动角度直线延伸
  • 回拉状态:钩爪碰到边界、抓住金块、或者超时后,原路返回

状态切换的核心规则有几个容易写错的地方。第一个是角度锁定,空格键按下的瞬间,必须把当前摆动的角度固定成出钩方向,之后出钩过程中角度不能再变化,否则钩子会走弧线,玩家很难瞄准。

第二个是回拉速度的差异化。空钩收回时速度可以设快一些,比如出钩速度的1.5倍;抓到金块后,要根据金块的重量降低收回速度。这里我采用的做法是,金块实体类里定义一个weight属性,回拉速度等于基础速度除以(1加上金块重量百分比)。比如基础速度是8像素/帧,一个重量为2的金块,回拉速度大约就是8除以3,约等于2.7像素/帧。重量越大的金块价值越高,回收越慢,玩家必须掂量“时间成本”这笔账。

2.2 碰撞检测:边界、金块与障碍物的优先级

碰撞检测是游戏里最容易出Bug的地方。我在开发过程中把碰撞分成了三个层次来处理,每一层有各自的判定逻辑。

第一层是屏幕边界检测。钩爪的尖端坐标一旦超出游戏区域的左边界、右边界或者下边界,马上触发回拉。这里有个小细节要注意——不能检测钩爪根部的坐标,必须检测钩爪末端的坐标,因为钩子可以伸出去很长,根部到末端有几百像素的距离。

第二层是金块抓取检测。逻辑是:每一帧计算钩爪末端点与每个金块中心点的距离,如果距离小于金块的半径加上钩爪末端钩子半径,就判定为抓取成功。这里需要注意的是,很多新手会写成“如果距离小于金块半径”,漏掉钩爪自身的尺寸,导致钩子已经碰到金块了但还在穿模,视觉上非常难受。

第三层是障碍物检测。障碍物(比如石头)的判定距离要稍微大于金块的判定距离,我建议取石头半径的90%;而且碰到石头之后钩子要立刻停止并进入回拉状态,不能穿过石头继续走。

三个层次的检测顺序也要定义好。我的建议是:先检测障碍物,再检测金块,最后检测边界。因为如果一个石头和金块靠得很近,钩子同时进入两个判定范围时,游戏应该优先回拉而不是优先抓取——这样玩家的体感更“硬”,能明确感觉到这个石头是抓不动的,赶紧调整策略。

注意:所有碰撞检测都放在游戏刷新帧的更新逻辑里做,不要在paintComponent里做。绘图方法的重绘频率不是完全可控的,把逻辑放在绘图里会导致在快速拖动窗口时判定出现抖动。

3. 实操过程:完整实现一个可运行的黄金矿工

说完了思路和原理,接下来我完整走一遍代码实现。这个项目我前后写了大约800行Java代码,下面挑核心部分来讲。

3.1 游戏主循环:Timer驱动的帧刷新机制

Swing里驱动的游戏循环,我推荐用javax.swing.Timer而不是自己写while循环。原因在于,自己开一个while(true)线程去刷新界面,会跟事件分发线程(EDT)产生冲突,界面会卡死或者闪烁;而Swing Timer的每一次回调都会自动在EDT线程中执行,天然避免了并发修改组件的风险。

public class GamePanel extends JPanel { private Timer timer; private GameState state; // 快照保存当前所有游戏对象的状态 public GamePanel() { // 每16毫秒一帧,对应约60FPS timer = new Timer(16, e -> gameUpdate()); timer.start(); } private void gameUpdate() { state.grapple.update(); // 更新钩爪运动 state.checkCollisions(); // 碰撞检测 state.updateTimer(); // 关卡倒计时 repaint(); // 请求重绘 } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; // 绘制背景、金块、钩爪,后面细说 } }

注意gameUpdate里所有对象状态更新完成之后才调用repaint(),repaint只是“请求”重绘,不是立刻重绘,但作用在当前代码里已经完全足够了——它会在下一次EDT空闲时自动触发paintComponent。

3.2 建模:金块、钻石、石头的对象设计

金块是游戏里的核心实体。我建了一个Mineral基类,金块、钻石、石头都继承自它。

public abstract class Mineral { protected int x, y; // 中心位置 protected int size; // 直径 protected int value; // 抓取价值 protected int weight; // 影响回收速度 protected Color color; // 绘制颜色 public Mineral(int x, int y) { this.x = x; this.y = y; } public abstract void draw(Graphics2D g2); } public class GoldNugget extends Mineral { public GoldNugget(int x, int y, int size) { super(x, y); this.size = size; this.value = size * 3; this.weight = size / 10 + 1; this.color = new Color(255, 215, 0); } @Override public void draw(Graphics2D g2) { g2.setColor(color); g2.fillOval(x - size / 2, y - size / 2, size, size); } }

这里设计上最有用的地方是weight和value都是从size推算出来的,不用在关卡配置里单独维护一堆参数。比如一个直径为30的金块,value就是90分,weight是4;一个直径为60的大金块,value就翻倍到180分,但weight是7,回收时会明显感觉到“拖慢”。

钻石的value更高、weight也高,石头的value为0但weight极高——抓到石头就是纯亏时间。这样设计,玩家在屏幕上一眼就能看出不同矿物的决策价值。

3.3 绘制画面:钩爪的摆动与出钩角度

钩爪的绘制是整个视觉反馈的关键。钩爪基座在顶部中间,一根线连着一个钩子。我用的绘制方案是:

public void draw(Graphics2D g2) { int baseX = getWidth() / 2; int baseY = 80; int hookLength = 40; // 钩子末端小钩的长度 // 计算尖端坐标:swingAngle是当前摆动角度 int endX = baseX + (int) (currentLength * Math.sin(swingAngle)); int endY = baseY + (int) (currentLength * Math.cos(swingAngle)); g2.setColor(new Color(139, 69, 19)); g2.setStroke(new BasicStroke(3)); g2.drawLine(baseX, baseY, endX, endY); // 画末端钩子:在尖端画一个小弧线 g2.drawArc(endX - 5, endY - 5, 10, 10, 0, 180); }

出钩前,currentLength是固定的初始长度(比如40像素),摆动角度swingAngle在-1.05到1.05弧度范围里来回变化,也就是正负60度。按空格后,出钩状态里currentLength每帧增加固定值(我用的8像素),角度保持锁定。这个实现非常直观干净。

3.4 关卡配置与过关判定

过关判定很简单:当前分数加上剩余金块的价值总和,大于下一关目标分,就显示过关,否则重新开始这一关。我建议在画面右上角常驻一个分值信息——目标分和当前分并排显示。

关卡配置我用了简单的二维数组,方便关卡设计师(自己)微调:

public class LevelConfig { public static final List<Mineral> level1 = Arrays.asList( new GoldNugget(120, 300, 25), new GoldNugget(350, 450, 45), new GoldNugget(600, 250, 30), new Diamond(750, 400), new Stone(480, 600, 55), new GoldNugget(200, 650, 60) ); }

4. 常见问题与排查技巧实录

写完第一版能跑的游戏之后,运行起来你会发现各种奇奇怪怪的问题。我把自己栽过的坑都整理出来,这些是实际调试中才遇得到的东西。

4.1 钩爪抖动,明明瞄准了却抓不到金块

这个问题最典型。现象是钩子看起来已经碰到金块了,但金块没有反应,钩子穿过去了。排查下来有两个原因。

第一个是判定点用错了坐标。我一度直接用了钩爪基座的坐标来做碰撞检测,导致钩子末端离金块还有几百像素时就判定“碰到”了,钩子还会莫名其妙往回拉。定位方法很简单——在paintComponent里临时画一个红色的小圆圈标记出当前参与碰撞检测的点,运行后立刻就能看到这个点是否在钩爪末端。

第二个是金块的尺寸视觉中心和实际中心对不上。绘制fillOval时x和y是左上角,我圆心计算漏了一半size,导致中心点偏了半个金块直径。建议在金块中心也画一个探测点,对齐之后再删掉。

4.2 回拉速度异常:突然变得特别慢

如果钩爪在抓到任何东西之前就慢吞吞地往回挪,多半是回拉状态的速度计算写错了地方。常见的写法是:

if (state == RETURNING) { speed = baseSpeed / (1 + target.getWeight()); }

这个逻辑本身没问题,问题在于当钩子空手返回时,target是null,如果没判空就执行了target.getWeight(),就会触发NPE。但比NPE更隐蔽的是另一种:把速度更新的计算写在了draw方法里,导致速度在重绘间隙被重复缩放了好几次,指数级下降。

解决方式是所有速度计算统一放在gameUpdate里,draw方法里只做渲染,绝不做逻辑计算。这是个很值得牢记的原则。

4.3 计时器和线程安全问题

Swing Timer的回调在EDT线程执行,这个很安全,但如果你在初始化游戏面板时新开了一个线程做加载,加载完成后直接调用panel.repaint(),这在Swing里是不允许的——非EDT线程操作组件会造成不可预料的绘制异常。

正确做法是用SwingUtilities.invokeLater把更新界面的操作重新丢回EDT线程:

SwingUtilities.invokeLater(() -> { panel.repaint(); });

同理,游戏暂停、恢复的时候,需要调用timer.stop()和timer.start(),只要这两个调用都在EDT线程(通常都在按钮事件里触发),就不会出问题。

4.4 金块和钩爪偶尔“瞬移”

这个现象我排查了很久,最后发现是坐标计算用错了单位。比如出钩时速度标称8像素/帧,但刷新间隔是16毫秒(约60FPS),如果没有考虑到Timer其实是“每16毫秒触发一次”,就简单地把速度直接累加到坐标上,系统卡一下的时候就会出现一次性跳几百像素的瞬移效果。

我用的是固定步长方案:坐标累加值 = 速度 x 固定帧间隔。因为Timer本身回调不稳定(系统繁忙时会延迟),严谨做法是用System.nanoTime()计算真实间隔。但在这个小游戏里,固定步长带来的误差可以接受,把速度值调得合理一些就行。

4.5 窗口拖拽时游戏卡顿

这个问题很具有Swing特色。拖动窗口时,Windows系统会阻塞EDT线程的某些绘制操作,导致游戏画面冻结,但计时器还在跑。实际上玩的时候你就发现关卡时间莫名其妙少了。处理办法:在窗口的componentResized事件里重置游戏布局,同时在Timer回调里利用System.currentTimeMillis跟上次回调的时间差来校正倒计时,而不是简单每帧减16毫秒。

private long lastTickTime; private void gameUpdate() { long now = System.currentTimeMillis(); int elapsed = (int) (now - lastTickTime); lastTickTime = now; state.remainingSeconds -= elapsed / 1000.0; // ... 其余更新 }

用这个方式校正之后,无论画面怎么卡、任务管理器怎么抢CPU,倒计时都是按真实时间走的,不会出现“卡一下时间就没了”的体验。

5. 扩展方向:从黄金矿工到完整项目的进阶路径

如果只是写完核心玩法就收手,这个项目的学习价值只发挥了一半。我强烈建议在此基础上做几个方向上的延伸,难度梯度各不相同,大家可以根据自己的情况选。

最简单的扩展是增加道具系统。现在屏幕上有金块、钻石、石头,如果你再加进“炸药桶”和“幸运草”这两种道具,游戏的策略深度立刻就不一样了。炸药桶在抓到的瞬间会引爆周围区域,把附近的石头全部炸掉、金块价值减半;幸运草则会在下一关生效,所有金块价值乘以1.5倍。实现道具系统的难点在于动画播放和状态记录,要做的事比想象的要多得多。假设抓到一个炸药桶,你需要记录一个爆炸中心点,然后在接下来的几十帧里画一个逐渐扩大的爆炸圈,同时修改区域内所有金块的状态。这个功能我当初加了整整两天才调好。

再进阶一点,可以做一个简单的弹窗商店,在关卡间让玩家用当前金币购买不同类型的钩爪或道具,这就把项目从一个单关卡小游戏扩展成了一个完整的“肉鸽”式游戏循环。商店界面用CardLayout切换就可以实现,需要维护一个玩家的全局背包数据类。

如果还想在这条路上走得更深,可以试试自己写一版基于状态机的AI机器人,让程序自己玩这个游戏。听起来很难,其实核心逻辑就一段:在当前画面里扫描所有金块的位置和价值,计算每个金块到钩爪摆动范围的可行角度,用动态规划算出期望收益最高的一条抓取路线,然后模拟玩家按键操作。我是在这个版本里第一次感受到什么叫“数据驱动决策”——写游戏的和玩游戏的,看同一个画面的角度完全不同。

6. 写在最后的一点总结

整个项目从零到完整跑通,我前后花了两周时间,每天下班后写一两个小时。第一个能玩的版本用了三天,但这个版本手感很差,钩爪像在泥里滑动,金块判定不准;剩下的大部分时间都花在了调参和修Bug上面。

踩了这么多坑之后,我最大的体会是:小游戏项目格外能暴露编程基本功的问题。线程怎么调度、对象怎么设计、碰撞怎么判定,这些不是背概念能会的,写一个能玩的版本下来,你会比看十遍教程记得更牢。

如果你也是刚学完Java基础语法,不知道下一个项目该做什么,我很推荐拿这个题目练手。它的难度曲线很平缓,一口吃不成胖子,但每一口都能吃到肉——写完你会觉得,原来做一个画面上能动的小东西,也没有那么神秘。

最后再分享一个我一直在用的调试技巧:给游戏加一个“调试模式”开关,按F12切换。调试模式下,把所有碰撞判定范围、钩爪速度、得分数值实时绘制在屏幕左上角。有了这个,你后续调任何问题都会快好几倍。这个习惯我带到了所有图形界面项目里,几乎成了标配。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询