简介:一份基于Java实现的黄金矿工小游戏完整工程,适合有一定Java基础、希望了解桌面游戏开发流程的学习者。项目围绕面向对象设计、Swing图形界面、事件监听、多线程调度等核心知识点展开,通过矿工、钩子、金矿等对象的交互演示了游戏循环与碰撞判定逻辑。压缩包共30个文件,包含6个Java源文件、9个class编译文件,以及png、gif、jpg图片素材、manifest配置与可直接运行的jar包,整体仅240KB,结构简洁,文件用途一目了然。目前已有1187人学习下载,适合用来做课程设计或面试作品参考;也可作为起点,按源码模块逐步拆解画面渲染、动画刷新与得分机制,并在此基础上扩展关卡或音效。开发者还能从中体会对象状态管理、碰撞检测、定时刷新和资源加载的常见写法,对理解经典小游戏的整体架构很有帮助。
1. Java实现黄金矿工.zip:这份源码包里真正值得你动手的事
拿到一份Java实现黄金矿工.zip,多数人的第一反应是解压、点运行、看它能不能玩。但如果你已经写过几年代码,或者正在刷 Java 面试题,就会意识到这个小游戏包是一个比 CRUD 项目更适合练手的东西:它把 Swing 界面、线程模型、碰撞检测、状态机和对象序列化全部压进一个可以双击运行的窗口里。黄金矿工的玩法看着简单,钩子出去、抓住金子、拉回来,但实现起来最难的不是画面,而是“钩子往返运动与抓取判定”这套物理和状态设计。
这篇文章按我做这类 Swing 小游戏的习惯,把解压之后怎么读、怎么跑、怎么改、哪些地方容易翻车一次讲透。适合两类读者:刚学完 Java 基础、想找一个不依赖框架的完整项目练手的人;以及准备面试、需要手写一个带交互状态的小程序来证明自己真写过代码的人。读完你不仅能跑起来,还能把它改造成能写进简历的作品。
2. 黄金矿工核心机制拆解:钩子物理、抓取判定与状态机
2.1 钩子运动模型:不是随机摆动,是“摆锤 + 伸缩长度”
黄金矿工的钩子运动,常被误以为是一段随机动画,其实它是一个很干净的物理模型:固定点在天花板,钩子从固定点出发,当前角度决定左右方向,当前长度决定深入矿井的距离。每一帧更新时做两件事——角度按固定角速度来回摆动,长度按固定速度伸出或收回。这个模型在 2D 游戏里非常经典,很多“钓鱼”“抓娃娃”玩法的底层都是同一套。
把钩子看成两个状态的叠加:
- 释放阶段(Swinging):方向为出去,长度随时间匀速增加,同时角度继续摆动,钩尖的位置由“固定点坐标 + 长度 × sin/cos(角度)”算出。
- 收回阶段(Retracting):方向反转,长度随时间匀速减少,角度固定不变,锁住抓到的物品一起往上走。
用 Java 表示这个模型,常见做法是定义一个HookState枚举,再加一个Hook类维护角度、长度和状态。核心更新方法如下:
public class Hook { public enum State { SWINGING, RETRACTING } private static final double MIN_ANGLE = Math.toRadians(30); private static final double MAX_ANGLE = Math.toRadians(150); private static final double ANGLE_SPEED = Math.toRadians(0.8); private static final double EXTEND_SPEED = 3.0; private static final double RETRACT_SPEED = 4.0; private double angle = Math.toRadians(90); private double length = 20; private State state = State.SWINGING; private boolean movingRight = true; public void update() { if (state == State.SWINGING) { // 先摆角,再伸长 swingAngle(); length += EXTEND_SPEED; } else { // 收回阶段锁住角度,只缩短长度 length -= RETRACT_SPEED; if (length <= 20) { length = 20; state = State.SWINGING; } } } private void swingAngle() { if (movingRight) { angle += ANGLE_SPEED; if (angle >= MAX_ANGLE) { angle = MAX_ANGLE; movingRight = false; } } else { angle -= ANGLE_SPEED; if (angle <= MIN_ANGLE) { angle = MIN_ANGLE; movingRight = true; } } } }这段代码的关键参数有三个:ANGLE_SPEED控制摆动快慢,EXTEND_SPEED和RETRACT_SPEED控制钩子伸缩速度。你会发现收回速度比伸出快,这是刻意的。如果两者一样,游戏会在收回阶段浪费大量等待时间,手感拖沓;收回快一些,玩家能更快进入下一轮抓取,正反馈更强。这个参数配比是这类游戏手感的核心,建议在 3.0 / 3.5 / 4.0 之间自己试。
2.2 抓取判定与物品状态机:为什么钩子会“空抓”和“抓不动”
钩子碰到物品不等于抓取成功。真正的抓取判定分三层:几何相交检测、重量判定、状态写入固定点。几何相交检测是每帧拿钩尖坐标去和物品的包围盒做比较,Java 里可以用Rectangle.contains或intersects,但钩尖是一个点,更稳的做法是手动判断物品中心点和钩尖的距离小于物品半径的一半。
更隐蔽的是重量系统。黄金矿工里钻石很值钱但很重,金块中等,石头特别重。钩子有最大载重,超过载重会抓不起来,表现为“碰到了但钩子直接弹回,物品纹丝不动”。这个逻辑如果不做,游戏就会变成无脑挖金,策略性少一半。
物品端的判定用状态机更清晰。每个物品可以有IDLE → ATTACHED → PULLED → COLLECTED四个状态。钩子碰到物品时,物品先进入ATTACHED,表示“钩住了但还没确认”,此时做重量校验;校验通过才进入PULLED,跟随钩子移动;钩子收回后物品进入COLLECTED,从场景中移除并计分。
public class GoldItem { public enum ItemState { IDLE, ATTACHED, PULLED, COLLECTED } private ItemState state = ItemState.IDLE; private int x, y; private final int weight; private final int value; public boolean tryAttach(int hookCapacity) { if (state == ItemState.IDLE && weight <= hookCapacity) { state = ItemState.ATTACHED; return true; } return false; } public void pullTo(int targetX, int targetY) { if (state == ItemState.ATTACHED || state == ItemState.PULLED) { state = ItemState.PULLED; this.x = targetX; this.y = targetY; } } }这一步的逻辑要点是:tryAttach只做状态迁移的允许判断,真正的位置移动不在钩子类里做,而是在绘制线程里统一读取“钩子当前坐标”然后调用pullTo。这样物品本身不维护运动逻辑,避免出现“物品自己飘走”这类状态错乱。
2.3 线程模型:Swing 的 Event Dispatch Thread 是新手第一个坑
很多人写 Swing 游戏,直接在paint()里做循环,结果界面卡死。原因在于 Swing 的组件绘制和事件分发都跑在 Event Dispatch Thread(EDT)上,paint()返回之前 EDT 不能处理鼠标和键盘事件。如果你的游戏逻辑把while(true)写进paint(),等于把 EDT 占死,界面自然变成白屏或假死。
正确做法是游戏逻辑跑在独立线程里,每隔 16ms 更新一次状态,然后调用repaint()通知 EDT 重绘。repaint()是异步请求,不会阻塞游戏线程。这个“逻辑线程 + 绘制线程分离”的模型是所有 Java 2D 游戏的地基,也是面试时容易被追问的点。
3. 从解压到跑通:JFrame、双缓冲绘制与键盘响应
3.1 工程结构与入口类:先看懂包里是什么
解压一份 Java 黄金矿工源码包,先别急着运行,花两分钟看清目录结构。标准做法至少包含以下部分:
| 目录 / 文件 | 作用 |
|---|---|
src/ | Java 源码,通常按model、view、controller分包 |
res/或assets/ | 图片、音频等静态资源 |
bin/或out/ | 编译产物,部分打包的 zip 里会带 |
GameMain.java | 入口类,含main()方法 |
README.md或运行说明.txt | 说明 JDK 版本与运行方式 |
入口类的标准写法是创建一个JFrame,把游戏画布JPanel加进去,然后启动游戏线程:
public class GameMain { public static void main(String[] args) { JFrame frame = new JFrame("Gold Miner"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setSize(800, 600); frame.setResizable(false); GamePanel panel = new GamePanel(); frame.add(panel); frame.setVisible(true); Thread gameThread = new Thread(panel, "GameLoop"); gameThread.start(); } }这段代码有四个细节值得注意。一是frame.setSize只设了窗口大小,内部画布大小要靠panel接管;二是必须用setDefaultCloseOperation,否则点关闭按钮后线程还在跑,形成幽灵进程;三是frame.setResizable(false),避免玩家拉伸窗口后钩子坐标和物品坐标错位;四是游戏线程用Thread(panel)而不是new Thread(new Runnable(){...}),因为GamePanel本身实现Runnable,省一个匿名类。
3.2 双缓冲绘制与每秒帧数控制
Swing 默认没有开启双缓冲,直接画会出现明显闪烁。开启方式是调用JPanel的setDoubleBuffered(true),这是老生常谈但极容易被忽略的配置。真正的性能瓶颈在于循环节流:没有节流的while(running)会以几百上千的帧率空转,CPU 占用拉满,画面反而因为重绘请求过于频繁而掉帧。
帧率控制常见做法是用System.nanoTime()计算每帧耗时,然后Thread.sleep补足剩余时间:
public class GamePanel extends JPanel implements Runnable { private volatile boolean running = true; private static final int FPS = 60; private static final long FRAME_TIME = 1_000_000_000L / FPS; @Override public void run() { long lastTime = System.nanoTime(); while (running) { long now = System.nanoTime(); long elapsed = now - lastTime; lastTime = now; update(elapsed); repaint(); long sleepTime = FRAME_TIME - (System.nanoTime() - now); if (sleepTime > 0) { try { Thread.sleep(sleepTime / 1_000_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 绘制背景、矿井、钩子、物品 } }这里running用volatile修饰是因为它在游戏线程里被读取,但在主线程或事件线程里可以被改为false,不加volatile可能出现线程缓存导致的停不下来。update()接收的是纳秒级耗时,内部把耗时换算成物理位移时用elapsed / 1_000_000.0得到毫秒,再乘速度系数,就能保证在不同帧率下游戏速度一致。
还有一个容易踩的坑:super.paintComponent(g)必须先调用。它负责把面板背景清干净,不调用的话,上一帧的画面会残留,产生拖影。很多人第一次写 Swing 游戏看到“画面叠在一起”,十有八九就是漏了这一行。
3.3 键盘响应:方向键控制角度,空格键发射钩子
键盘控制的实现有两条路:给JFrame加KeyListener,或者用InputMap/ActionMap。老手一般用前者,因为代码直观;但要注意一个细节——必须让画布获得焦点,否则按键事件根本不会到达游戏面板。
panel.setFocusable(true); panel.addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT -> hook.setMovingDirection(true); case KeyEvent.VK_RIGHT -> hook.setMovingDirection(false); case KeyEvent.VK_SPACE -> hook.release(); } } });这里release()不是立刻让钩子飞出去,而是把钩子状态从“可发射”切换成“释放中”。如果状态没做干净,玩家连按空格会导致同一个钩子被发射两次,这就进入了第 2 章说的状态机问题。正确的release()应该只在钩子处于可发射状态时才动作:
public boolean release() { if (state == State.READY) { state = State.SWINGING; return true; } return false; }READY → SWINGING → RETRACTING → READY这个循环是闭环的,任何一次发射和收回都会回到READY,不会出现“钩子还在空中却能再次发射”的玄学问题。这个设计我在多个项目里验证过,与其用布尔变量到处标记,不如老老实实用枚举状态,排查起来一眼就能看穿当前到底在哪一步。
4. 关卡、随机、道具与计分:把“运气”做成可调参数
4.1 矿井物品生成:权重随机而不是均匀随机
黄金矿工每一关的矿井里,金块、钻石、石头分布在不同的深度。如果物品是均匀随机生成的,玩家会很快发现规律,游戏变得无聊。常见做法是给每个物品配一个“出现权重”和“深度区间”,每一关按权重抽样决定物品类型,按深度区间决定摆放位置。
权重抽样是这类游戏的核心算法。比如第 1 关:金块权重 5,钻石权重 1,石头权重 3,小袋子权重 8。权重值不是概率,而是相对大小,实现时用累计和来抽样:
public class ItemFactory { private final List<ItemSpawnRule> rules; private final Random random = new Random(); public GoldItem randomItem() { int totalWeight = rules.stream().mapToInt(ItemSpawnRule::weight).sum(); int roll = random.nextInt(totalWeight); int cumulative = 0; for (ItemSpawnRule rule : rules) { cumulative += rule.weight(); if (roll < cumulative) { return rule.createItem(); } } // 兜底:总权重计算有误时返回最轻的物品,避免空指针 return rules.get(rules.size() - 1).createItem(); } }这里的边界坑是random.nextInt(totalWeight)返回 0 到totalWeight - 1之间的整数,所以判断要用<而不是<=。如果用<=,权重为 1 的物品出现概率会翻倍,这是典型的“差一个符号导致概率错一半”的问题。
另外,Random实例一定要复用。如果你在randomItem()里每次new Random(),在极短时间内多个物品会拿到相同的种子序列,生成结果会呈现“一串重复的石头”这种肉眼可见的规律。这算是随机数使用里最常见的玄学现场,排查时检查有没有复用一个Random实例优先级最高。
4.2 道具系统:炸药、幸运草与磁铁的生效时机
黄金矿工常见道具是炸药、幸运草和磁铁。炸药的逻辑是“抓取到石头时可以选择消耗炸药销毁它”,而非“炸掉整个屏幕的障碍物”。很多新手会把炸药做成全屏爆炸,结果把金块也炸没了,玩家体验非常差。
道具生效时机建议等所有道具逻辑统一在“抓取成功时”检查,具体流程如下:
| 道具 | 生效时机 | 效果 | 实现建议 |
|---|---|---|---|
| 炸药 | 钩子抓到石头后,收回前 | 消耗 1 个炸药,石头消失,钩子继续伸出 | 钩子状态加BOMBING分支,物品标记为销毁 |
| 幸运草 | 关卡开始时 | 本关物品价值乘以 1.5 | 计分时乘系数,显示层标注 |
| 磁铁 | 关卡开始时 | 金币自动向钩子方向移动一定距离 | 每帧按磁力方向更新物品坐标 |
炸药的实现有个坑:钩子已经抓到石头,石头处于ATTACHED状态,此时如果玩家按空格触发炸药,石头要进入一个DESTROYED状态,同时钩子回到SWINGING且长度归零重新伸出。如果不给石头加销毁状态,后面绘制时它还会残留一帧,看起来像是“炸了一下又变回来了”。
4.3 计分与矿车容量:数值表驱动而不是魔法数字
计分系统看起来简单,但用魔法数字到处写会非常痛苦。金块 30 分、钻石 100 分、石头 5 分,这些数值散落在if-else里,改一个就要翻遍整个项目。推荐的做法是把数值集中在枚举里:
public enum ItemType { GOLD_NUGGET(10, 30, 2), GOLD_BAR(25, 100, 5), DIAMOND(15, 180, 8), STONE(45, 5, 4), BAG_SMALL(5, 20, 1); private final int weight; private final int value; private final int size; ItemType(int weight, int value, int size) { this.weight = weight; this.value = value; this.size = size; } }前面两个参数是重量和分值,第三个size用于绘制和碰撞检测的半径。整个游戏的数值调整都只需改枚举,不用动逻辑代码。这也就是 Java 基础里“枚举比常量类更安全”的实战体现——你无法在运行时传一个不存在的物品类型,编译器就在入口帮你挡住了。
矿车容量则用“累计重量”判断:本关目标分达到后进入下一关,但矿车有容量上限,超载后即使钩子抓到也会在收回前自动放弃。这个“自动放弃”判定要和抓取判定解耦:抓取时只做重量校验,收回时再做容量校验。如果合在一个方法里,会出现“抓起 25 重量的金条,矿车还剩 20 容量,系统直接判定失败”这种逻辑矛盾。
5. Java 黄金矿工落地避坑:线程、路径、编码与碰撞的 5 个翻车现场
5.1 双击 jar 包没反应:控制台不可见导致的黑匣子
现象:打包成 jar 后双击运行,窗口没弹出来,任务管理器里也看不到 Java 进程,看起来像是程序根本没启动。
原因:jar 包里的Main-Class配置错误,或者运行时缺少资源文件抛了异常,但双击运行的控制台是被隐藏的,异常输出直接丢弃,成了黑匣子。
解决:永远不要用双击来验证打包结果。先用命令行跑java -jar GoldMiner.jar,把所有异常信息暴露在终端里。如果提示Error: Could not find or load main class,检查MANIFEST.MF里的Main-Class: com.example.GameMain这一行,类名必须带完整包名且不能有.class后缀。如果提示FileNotFoundException,说明代码里用了相对路径读取资源,实际 jar 运行时工作目录和源码运行时不一致。
5.2 图片加载失败:文件系统路径和 classpath 路径混用
现象:在 IDE 里运行正常,打包成 jar 后所有图片消失,但程序没报错。
原因:代码写的是new File("res/background.png"),IDE 运行时的项目根目录刚好能匹配;jar 运行时File基于启动目录,不可能读取到 jar 内部的文件。
解决:资源文件必须通过 classpath 加载,这算是 Java 资源加载里的一条铁律:
InputStream is = GamePanel.class.getResourceAsStream("/images/background.png"); ImageIcon icon = new ImageIcon(ImageIO.read(is));路径开头的/表示 classpath 根目录,res目录要配置为源码根目录之一。一旦用getResourceAsStream,代码既能在 IDE 跑,也能在 jar 里跑,不用关心工作目录。
5.3 中文乱码:源码编码与编译编码不一致
现象:代码里写的中文提示信息(比如“游戏结束”)在运行时显示成乱码。
原因:源码文件用 UTF-8 保存,但javac默认按平台编码读取。Windows 平台的默认编码是 GBK,编译时读到一半解析失败,或者解析出错误字符。
解决:显式指定编码编译。用 Maven 就在pom.xml里设置<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>;直接用命令行就写javac -encoding UTF-8。如果你在 IDE 里没遇到这个问题,多半是 IDE 已经帮你做了编码转换,但打包脚本没做,所以线上翻车。检查点:所有.java文件统一 UTF-8,构建脚本加-encoding UTF-8,两者缺一不可。
5.4 钩子碰到大金块时卡住:碰撞检测中心的偏移
现象:钩子明明已经碰到金块边缘,抓取判定却一直不触发;或者钩子还没碰到,物品就已经被“隔空吸走”。
原因:物品的坐标是绘制左上角,但钩尖是精确到像素的点。用item.x + item.width和钩尖坐标做碰撞时,如果物品宽高很大,就会在视觉上出现“已经碰到了但逻辑上没碰到”的偏差。
解决:几何相交统一基于物品中心点计算,且用较短边的一半作为有效半径:
public boolean isHitByHook(int hookX, int hookY, GoldItem item) { int centerX = item.x + item.size / 2; int centerY = item.y + item.size / 2; int radius = item.size / 3; int dx = hookX - centerX; int dy = hookY - centerY; return sqrt(dx * dx + dy * dy) <= radius; }这段代码直接把勾股定理写在里面,没有用Math.hypot,避免每次碰撞检测都新建函数调用栈。半径取size / 3而非size / 2,是为了给玩家一个“命中窗口适应区间”,否则钩尖必须精确扎在物品正中心才能抓到,手感会非常差。这个容差参数就是游戏手感的关键,建议固定为短边的 30% 到 40% 之间。
5.5 游戏线程无法停止导致 CPU 占用 100%
现象:关闭游戏窗口后,任务管理器里javaw.exe仍然存在,CPU 占用居高不下。
原因:JFrame.setDefaultCloseOperation只是关了窗口,Runnable里的while(running)还在死循环运行。没有机制通知游戏线程退出。
解决:在窗口关闭事件里显式终止线程:
frame.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { panel.shutdown(); } }); public void shutdown() { running = false; }加了shutdown()之后,run()里的循环条件会在下一帧判断为false,然后线程自然退出。这里不建议用Thread.stop(),它已经被标记为不安全,会释放掉所有锁,导致数据处于不一致状态。我见过有人为了省事直接System.exit(0)强制退出,也能解决,但关闭窗口后如果有资源要释放,比如写存档文件,就来不及做,可能损坏本地数据。
6. 把黄金矿工改造成简历级项目:自动化验证、存档与可扩展性
如果你想拿这个项目去面试 Java 岗位,光“能运行”远远不够。面试官不会只看你贴出来的代码截图,他们会追问细节。最容易暴露水分的是两个点:物理引擎的验证方法和存档数据的安全性。
先做一个可验证的物理回归测试。钩子运动是纯数学逻辑,不依赖 Swing,所以完全可以用 JUnit 验证。写一个测试用例,固定初始角度,连续调用update()若干次,断言钩子长度的单调性:
@Test public void hookLengthNeverExceedsBounds() { Hook hook = new Hook(); for (int i = 0; i < 1000; i++) { hook.update(); assertTrue(hook.getLength() <= 500); assertTrue(hook.getLength() >= 20); } }这个测试的意义在于:任何对速度、角度的后续修改,只要跑到这个用例,就能立刻发现“钩子伸出屏幕外”或“长度变成负数”的回归。没有自动化测试前,你只能在游戏里反复点空格试,改一个参数要手测几分钟,改了十个参数就不知道是哪次改坏了。测试不是给自己增加工作量,是给后续改代码买保险。
存档用序列化还是文本文件,取决于数据量。最高分只是几个数字,Properties文件足够;如果要存整个关卡进度和道具状态,建议 JSON。千万别直接用ObjectOutputStream序列化整个GameState类——一旦后续添加了新的字段,反序列化旧存档时会抛InvalidClassException,玩家会丢掉全部进度。用 JSON 的好处是字段增减只影响默认值,不会整体崩溃。
最后一个值得投入的方向是事件系统解耦。目前键盘监听直接修改钩子状态,耦合度高。改成事件发布后,比如GameEvent.SPACE_PRESSED发布到中央事件队列,后续添加“长按空格持续加速”这类功能时只需加一个监听器,不用改游戏主循环。这一步做完,你就从“能跑的游戏”跨到了“可扩展的游戏框架”,写进简历时的含金量完全不一样。我自己的经验是,Swing 小游戏项目的难点从来不在 Swing 本身,而在于你是否有意识地把逻辑、渲染、输入三层分开。这次把钩子状态机、随机权重、线程模型和资源加载都梳理清楚,下次再遇到类似游戏项目,半天就能搭出骨架。希望帮到你。
本文还有配套的精品资源,点击获取