1. 为什么选黄金矿工作为Java练手项目
很多人学完Java基础语法之后,最大的困惑就是“我该拿什么练手”。做管理系统吧,来来回回就是增删改查,做多了容易腻;做算法题吧,又跟实际工程脱节。而《黄金矿工》这个游戏,恰好卡在一个非常舒服的位置上——它足够简单,简单到一个人两三天就能把核心玩法跑通;但它又足够完整,完整到你能在里面练到游戏循环、碰撞检测、状态机、资源加载、音效播放这些真正属于“做项目”的东西。
我当初选这个题材,就是看中了它的几个特性。第一,它的核心机制是“钩子摆动加抓取”,这个机制天然适合用角度和三角函数来描述,不需要引入复杂的物理引擎。第二,它的游戏状态非常清晰,无非就是“摆动中”“发射中”“回收中”三种,用状态机写出来干净利落。第三,它的资源需求极低,几张矿石图片、一张矿工图片、一段背景音乐就能跑起来,不需要美术团队。
说白了,黄金矿工是一个“麻雀虽小五脏俱全”的项目。你把它做出来,就等于把Java做桌面小游戏的全流程走了一遍。后面你再去做更复杂的游戏,无非就是在这个骨架上加东西。
这篇文章我会从零开始,把整个项目的设计思路、核心代码、踩过的坑全部摊开讲。不管你是刚学完Java SE想找个项目练手,还是已经工作但没做过游戏开发想补补课,都能跟着走一遍。我默认你有Java基础语法、知道什么是类什么是继承,至于Swing和AWT,不会也没关系,用到的时候我会讲清楚。
2. 技术选型:为什么是Swing而不是别的
2.1 Swing、JavaFX、LWJGL到底怎么选
做Java桌面游戏,摆在面前的选择其实就那么几个。我把它们拉出来对比一下,你就知道为什么我最终选了Swing。
| 技术方案 | 上手难度 | 适合场景 | 本项目是否适用 |
|---|---|---|---|
| Swing/AWT | 低 | 2D小游戏、工具界面 | 非常适用 |
| JavaFX | 中 | 2D游戏、富客户端 | 可用但偏重 |
| LWJGL | 高 | 3D游戏、高性能2D | 杀鸡用牛刀 |
| LibGDX | 中高 | 跨平台游戏 | 学习成本偏高 |
Swing是JDK自带的,不需要引入任何第三方依赖,新建一个项目就能直接写。它的双缓冲机制虽然老,但对于黄金矿工这种画面更新频率不高的游戏来说完全够用。JavaFX虽然画面更现代,但它的API跟Swing差异较大,而且从JDK 11开始就不再默认捆绑了,需要额外配置。LWJGL和LibGDX则是给真正要做商业游戏的人准备的,对于练手项目来说,光是环境配置就能劝退一半人。
我的建议是:如果你只是想练手、想快速看到成果,Swing是最优解。等你把Swing版本做完了,想升级画面再考虑JavaFX也不迟。
2.2 项目整体架构长什么样
在动手写代码之前,我习惯先把架构想清楚。黄金矿工这个项目,我把它拆成了四个核心模块。
第一个是游戏主循环模块,负责控制帧率、更新逻辑、重绘画面。第二个是游戏对象模块,包括矿工、钩子、矿石这些实体。第三个是状态管理模块,管理游戏进行中、暂停、结束这些状态。第四个是资源与音效模块,负责加载图片和播放声音。
这四个模块之间的关系是:主循环每帧调用状态管理,状态管理根据当前状态决定更新哪些对象,对象更新完之后主循环触发重绘,重绘的时候从资源模块拿图片画到屏幕上。听起来有点绕,但写出来之后你会发现逻辑非常清晰。
我见过很多新手一上来就写一个几百行的paint方法,把所有绘制逻辑堆在一起。这样写刚开始很快,但改到后面就是灾难。所以哪怕项目再小,该分的模块还是要分。
2.3 开发环境与依赖说明
这个项目对开发环境几乎没有要求。JDK 8以上都行,我实测JDK 8、11、17都能跑。IDE用IntelliJ IDEA或者Eclipse都可以,我个人习惯用IDEA,因为它的代码提示和重构功能确实好用。
依赖方面,零第三方依赖。所有的图形绘制用java.awt和javax.swing,音效播放用javax.sound.sampled,事件监听用java.awt.event。这些都是JDK自带的,不需要在pom.xml或者build.gradle里加任何东西。
如果你用的是Maven项目,直接建一个空的Maven工程就行。如果你用的是普通Java项目,那更简单,新建一个包,把类丢进去就能跑。
3. 钩子摆动与抓取的数学原理
3.1 用三角函数描述钩子的摆动
钩子的摆动,本质上就是一个角度在某个范围内来回变化。假设钩子的悬挂点在屏幕坐标(originX, originY),钩子长度为L,当前摆动角度为angle,那么钩子末端的位置就是:
// 计算钩子末端坐标 int endX = originX + (int)(L * Math.sin(angle)); int endY = originY + (int)(L * Math.cos(angle));这里用sin算X方向偏移,用cos算Y方向偏移,是因为我们把角度定义成了“与竖直向下方向的夹角”。当angle为0的时候,钩子垂直向下;当angle为正的时候,钩子向右偏;为负的时候向左偏。
摆动的实现就是每帧给angle加上一个速度值,当角度超过最大值或者小于最小值的时候,把速度取反。代码大概长这样:
// 每帧更新摆动角度 angle += angleSpeed; if (angle > maxAngle) { angle = maxAngle; angleSpeed = -angleSpeed; } else if (angle < -maxAngle) { angle = -maxAngle; angleSpeed = -angleSpeed; }maxAngle一般取45度到60度之间,太小了钩子摆幅不够,太大了看起来不自然。angleSpeed控制摆动快慢,我一般取0.02到0.05弧度每帧。
3.2 钩子发射与回收的状态切换
钩子的状态我用一个枚举来表示:
public enum HookState { SWINGING, // 摆动中 SHOOTING, // 发射中 RETRACTING // 回收中 }状态切换的触发条件很明确。当玩家按下空格或者鼠标点击时,如果当前是SWINGING状态,就切换到SHOOTING,同时记录下当前的角度,之后钩子就沿着这个角度直线伸出。当钩子碰到矿石或者到达最大长度时,切换到RETRACTING,钩子开始往回缩。当钩子缩回到原点时,切换回SWINGING,继续摆动。
这里有个细节要注意:发射和回收的速度是不一样的。发射的时候速度快,回收的时候根据抓到的矿石重量决定速度。抓到金子回收慢,抓到石头回收快,这是黄金矿工的核心手感之一。
// 回收速度根据矿石重量计算 double retractSpeed = baseSpeed / oreWeight;baseSpeed是基础速度,oreWeight是矿石重量。这样金子重、回收慢,石头轻、回收快,玩家就能感受到“抓金子要等更久”的紧张感。
3.3 碰撞检测为什么用矩形而不是圆形
碰撞检测我一开始想用圆形,因为矿石看起来是圆的。但实际写下来发现,圆形碰撞检测需要算圆心距离,还要考虑半径,代码量比矩形多不少。而且黄金矿工的矿石大小不一,用圆形反而容易出现“看起来碰到了但没抓到”的情况。
后来我改用了Rectangle的intersects方法,一行代码搞定:
// 钩子与矿石的碰撞检测 Rectangle hookRect = new Rectangle(hookEndX - 10, hookEndY - 10, 20, 20); Rectangle oreRect = new Rectangle(ore.getX(), ore.getY(), ore.getWidth(), ore.getHeight()); if (hookRect.intersects(oreRect)) { // 抓到了 }hookRect是以钩子末端为中心、边长20像素的正方形。矿石的矩形就是它的绘制区域。这样检测虽然不如圆形精确,但实际玩起来手感反而更好,因为玩家会觉得“差不多碰到就算抓到”,不会因为几像素的偏差而失手。
这里有个坑:钩子的碰撞矩形要跟着钩子末端走,而不是跟着钩子原点走。我一开始写错了,导致钩子还没伸出去就能抓到东西,调试了半天才发现。
4. 从零搭建游戏主循环
4.1 继承JPanel还是直接用Canvas
Swing里做游戏绘制,有两个选择:继承JPanel并重写paintComponent,或者用Canvas配合BufferStrategy。我选的是前者,原因很简单——JPanel的双缓冲是自动开启的,不需要手动管理。Canvas虽然性能更好,但需要自己处理缓冲,对于黄金矿工这种画面简单的游戏来说,没必要。
继承JPanel之后,重写paintComponent方法,在里面调用super.paintComponent(g)清屏,然后画背景、画矿工、画钩子、画矿石、画分数。注意一定要先调super,否则会出现画面残影。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 画背景 g.drawImage(backgroundImage, 0, 0, null); // 画矿工 g.drawImage(minerImage, minerX, minerY, null); // 画钩子 drawHook(g); // 画矿石 for (Ore ore : oreList) { ore.draw(g); } // 画分数 g.setColor(Color.WHITE); g.setFont(new Font("微软雅黑", Font.BOLD, 24)); g.drawString("分数: " + score, 20, 40); }4.2 用Timer控制帧率还是用Thread
游戏主循环的实现方式,我试过两种。一种是用javax.swing.Timer,每隔固定毫秒触发一次actionPerformed,在里面更新逻辑并调用repaint。另一种是开一个独立的Thread,在while(true)循环里更新逻辑、repaint、然后Thread.sleep。
两种方式我都跑过,最后选了Timer。原因是Timer的回调是在EDT(事件调度线程)上执行的,跟Swing的绘制线程是同一个,不需要考虑线程安全问题。用Thread的话,更新逻辑在子线程,绘制在EDT,共享数据就得加锁,麻烦。
// 用Timer控制帧率,约60帧每秒 Timer timer = new Timer(16, e -> { updateGame(); repaint(); }); timer.start();16毫秒大约对应60帧,实际跑下来帧率稳定,画面流畅。如果你觉得不够流畅,可以调到10毫秒,但没必要,因为黄金矿工本身不需要高帧率。
4.3 游戏对象的更新与绘制分离
我强烈建议把“更新逻辑”和“绘制逻辑”分开。更新逻辑负责改坐标、改状态、算分数,绘制逻辑只负责根据当前数据画图。这样做的好处是,当你需要暂停游戏的时候,只需要停止调用updateGame,绘制照常进行,画面就定格了。
private void updateGame() { if (gameState != GameState.RUNNING) { return; // 非运行状态不更新逻辑 } hook.update(); checkCollision(); checkGameOver(); }gameState是一个枚举,有RUNNING、PAUSED、GAME_OVER三个值。按P键切换暂停,分数达到目标或者时间用完切换到结束。
5. 矿石生成、重量与分数结算
5.1 矿石类型与随机生成策略
黄金矿工里的矿石,我分了四种:小金块、大金块、小石头、大石头。金块分数高但重,回收慢;石头分数低但轻,回收快。这种设计让玩家在“抓大的还是抓小的”之间做取舍,增加了策略性。
矿石的生成我用了一个简单的随机算法。每次生成时,随机决定类型,然后随机决定位置。位置要保证不重叠,也不能太靠近矿工,否则钩子还没伸出去就碰到了。
// 随机生成矿石 Random random = new Random(); int type = random.nextInt(4); // 0-3 int x = random.nextInt(700) + 50; // 50-750 int y = random.nextInt(300) + 300; // 300-600这里X范围是50到750,Y范围是300到600,是因为矿工在顶部,矿石要生成在下方区域。具体数值根据你的窗口大小调整。
5.2 重量如何影响回收速度
重量对回收速度的影响,我用的公式是:
double retractSpeed = 8.0 / (1 + oreWeight * 0.5);oreWeight是矿石重量,小金块1、大金块3、小石头0.5、大石头1.5。这样算下来,抓小金块回收速度约5.3,抓大金块约2.3,差距明显。玩家能直观感受到“抓大金块要等更久”。
这个公式不是拍脑袋想的,我调了好几版。第一版直接用baseSpeed / oreWeight,结果大金块回收慢到让人抓狂。后来改成baseSpeed / (1 + oreWeight * 0.5),曲线就平滑多了。
5.3 分数结算与关卡目标设计
每关有一个目标分数,达到目标才能进入下一关。目标分数我设计成逐关递增:
int targetScore = 200 + level * 150;第一关目标350分,第二关500分,第三关650分,以此类推。这样前期轻松,后期有压力。同时每关有时间限制,一般是60秒,时间用完还没达到目标就游戏结束。
分数结算的时机是钩子回收完成的那一刻。抓到矿石后,矿石从列表里移除,分数加到总分上,然后判断是否达到目标。
// 回收完成时结算 if (hookState == HookState.SWINGING && caughtOre != null) { score += caughtOre.getScore(); oreList.remove(caughtOre); caughtOre = null; if (score >= targetScore) { level++; initLevel(); } }6. 音效、图片资源与常见崩溃问题
6.1 音效加载为什么用Clip而不是SourceDataLine
Java播放音效,javax.sound.sampled包提供了两种方式:Clip和SourceDataLine。Clip适合播放短音效,它会把整个音频数据加载到内存里,播放的时候直接输出,延迟低。SourceDataLine适合流式播放,比如播放长音乐,但代码复杂。
黄金矿工的音效都是短音效,抓取声、得分声、背景音乐,用Clip就够了。
// 加载音效 AudioInputStream ais = AudioSystem.getAudioInputStream( getClass().getResource("/sounds/catch.wav")); Clip clip = AudioSystem.getClip(); clip.open(ais); clip.start();注意Clip加载的音频格式必须是PCM的WAV,MP3不支持。如果你手头的音效是MP3,需要先转成WAV。
6.2 图片资源路径的坑
图片加载最常见的坑就是路径问题。用getClass().getResource("/images/ore.png")的时候,路径是以src为根目录的。也就是说,你的图片要放在src/images/下面,而不是项目根目录的images/。
我一开始把图片放在项目根目录,结果一直报NullPointerException,找了半天才发现是路径问题。后来统一放到src/main/resources/images/下面,用getResourceAsStream加载,就再也没出过问题。
如果你用的是Maven项目,资源文件要放在
src/main/resources下面,编译后会被复制到target/classes,getResource才能找到。
6.3 内存泄漏与资源释放
Clip用完之后一定要调close()释放资源,否则反复加载会导致内存泄漏。我一开始没注意,玩了几十关之后程序就卡死了。后来在每次播放完音效之后调clip.close(),问题就解决了。
clip.addLineListener(event -> { if (event.getType() == LineEvent.Type.STOP) { clip.close(); } });图片资源倒是不用担心,Image对象由JVM的垃圾回收器管理,只要不再引用就会被回收。
7. 实测中的手感调优与扩展方向
7.1 钩子摆动速度与抓取手感的平衡
手感这个东西,参数差一点,体验就差很多。我调了大概十几版,最后定下来的参数是:摆动角度范围正负50度,摆动速度0.03弧度每帧,发射速度12像素每帧,回收基础速度8像素每帧。
摆动速度太快,玩家来不及反应;太慢,又觉得拖沓。0.03弧度每帧,大概1.7度每帧,60帧下来一秒摆100度左右,一个来回不到两秒,节奏刚好。
发射速度要快,让玩家觉得“钩子嗖地出去了”。回收速度要慢,让玩家有“等待收获”的期待感。这个快慢对比是黄金矿工的核心手感。
7.2 关卡难度曲线的设计经验
难度曲线我踩过坑。一开始我让每关的矿石数量固定,只是目标分数递增。结果玩到后面,矿石不够抓,玩家再怎么努力也达不到目标,体验很差。
后来改成矿石数量也随关卡增加,同时金块的比例略微下降,石头的比例略微上升。这样玩家需要更精准地抓取,而不是随便抓几个就过关。
// 矿石数量随关卡增加 int oreCount = 8 + level * 2; // 金块比例随关卡下降 double goldRatio = Math.max(0.3, 0.6 - level * 0.05);7.3 后续可以加什么功能
这个项目做完之后,可扩展的方向其实很多。比如加一个“炸弹”道具,抓到之后可以炸掉一块石头;加一个“强化钩子”,让回收速度变快;加一个“双钩模式”,同时伸出两个钩子。
再往大了做,可以加存档功能,把最高分和关卡进度存到本地文件;可以加排行榜,用SQLite存分数;可以加网络对战,两个人轮流抓矿比分数。
但我的建议是,先把基础版本做扎实,把代码结构理清楚,再去加功能。否则功能越加越多,代码越来越乱,最后自己都不想维护了。
7.4 我踩过的三个印象最深的坑
第一个坑是坐标系统混乱。Swing的坐标原点在左上角,Y轴向下为正。我一开始按数学坐标系来想,结果钩子方向全反了。后来统一按屏幕坐标系来算,才理顺。
第二个坑是状态切换的时机。钩子从发射切换到回收,我一开始是在碰撞检测里直接改状态,结果一帧内多次碰撞,状态被反复切换,钩子抽搐。后来改成用一个标志位记录“本帧是否已抓到”,抓到之后就不再检测,问题解决。
第三个坑是音效播放延迟。Clip第一次播放的时候会有明显延迟,因为要加载数据。解决办法是在游戏初始化的时候就把所有音效预加载好,播放的时候直接setFramePosition(0)然后start(),就没有延迟了。
// 预加载音效 clip.open(ais); clip.setFramePosition(0); // 播放时 clip.setFramePosition(0); clip.start();这三个坑,每一个都花了我至少半天时间。但踩过之后,对Swing游戏开发的理解就深了一层。现在再让我做类似的项目,这些坑都不会再踩了。
最后分享一个小技巧:如果你觉得调参数太麻烦,可以做一个调试面板,用滑块实时调整摆动速度、发射速度这些参数,边玩边调,找到最舒服的数值之后再写死到代码里。这个技巧帮我省了大量反复编译的时间。