简介:黄金矿工小游戏Java版是面向Java课程设计或算法练手的完整项目,基于GUI实现经典抓金玩法,适合初学Java和数据结构的学生研究界面交互、对象建模与基础算法。压缩包共28个文件,约1.35MB,包含src、bin、settings等目录,5个java源代码、7个class编译文件、10个png和2个jpg图片素材,以及project、classpath、prefs等Eclipse工程配置和README说明,源码、素材、配置齐全,导入IDE即可运行。目前已有374人学习/浏览,程序经过测试可直接运行,适合作为课程设计参考或自学练手。透过该项目可完整看到小游戏从窗口绘制、角色控制、碰撞判断到计分逻辑的实现脉络,既能复习Java集合与事件处理,也能体会如何组织可维护的小型项目,为后续算法和数据结构实践提供直观样例,也有助于在此基础上扩展新玩法,并可通过改参数体验不同难度。
1. 黄金矿工小游戏(Java).zip 是什么:一个能落地的 Swing 练手项目
从解压一个 zip 开始。里面不是几张截图加一段说明,而是一整套用纯 Java 标准库写出来的小游戏:顶部左右摆动的钩爪、藏在土层里的金块和钻石、拖不动的石头、会炸开周围矿物的炸药桶,以及倒计时和过关分数。多数人把它当成 Java 基础阶段的玩具项目,实际上这个黄金矿工的算法密度比大部分后台管理系统都高:一个常驻的帧循环、三角函数驱动的摆动模型、类似射线检测的抓取判定、受物体质量影响的回拉速度,外加一套用继承和多态设计的矿物体体系,几乎就是一个简化版物理引擎。
它适合两类人。一类是刚学完面向对象、数据类型和 Swing 的初学者,想找一个不靠增删改查就能把语法串起来的练兵场;另一类是课程设计或简历项目缺落地感的人,黄金矿工在纯 JDK 内可编译可运行,不依赖第三方引擎,打包成可执行 jar 后反而更能体现你对游戏循环、绘制管道和资源加载的控制力。接下来先从核心机制讲起,再给出一套最小可运行实现,最后把我实际开发中反复翻车的几个地方原样列出来。
2. 先立住核心机制:钩爪摆动、伸缩与抓取判定的物理模型
2.1 游戏循环的底线:Swing Timer 与帧更新的节奏
常见的错误是直接在事件回调里写 while(true) 加 sleep,结果界面卡死。Swing 组件必须在 EDT(事件分发线程)上操作,所以最稳妥的做法是用 javax.swing.Timer 驱动游戏循环。Timer 的回调本身就在 EDT 里执行,逻辑更新和重绘天然不打架。
public class GamePanel extends JPanel implements ActionListener { private Timer timer; private Hook hook; // 钩爪对象 private List<GoldField> fields; // 场景里的矿物体 public GamePanel() { hook = new Hook(); fields = new ArrayList<>(); timer = new Timer(16, this); // 16ms 一帧,约 60 FPS timer.start(); } @Override public void actionPerformed(ActionEvent e) { hook.update(fields); // 先更新所有逻辑状态 repaint(); // 再触发重绘 } }这段代码的关键参数是 16ms 间隔。经验是小于 12ms 时 Swing 的绘制开销会明显占用主线程,大于 30ms 时摆动和回收的连贯性变差,卡顿感上来。如果机器性能一般,把间隔改成 20ms 也能接受。真正要命的是不要在 actionPerformed 里做文件读取或创建对象,那会让帧率产生肉眼可见的抖动。
2.2 钩爪摆动角度:坐标系翻车点与三角函数边界
钩爪未发射时在锚点上方来回摆动,幅度通常限制在 -60 度到 60 度。这个参数直接决定游戏难度:弧度范围越大,可覆盖的矿区越宽,但玩家瞄准难度也会上升。Swing 的 y 轴是向下增长的,所以用 Math.sin 计算 y 分量时,正角度会让钩子朝屏幕下方伸出,视觉上呈现一个向下开口的扇形。
public class Hook { private double angle = 0; // 当前摆动角度(弧度) private double minAngle = Math.toRadians(-60); private double maxAngle = Math.toRadians(60); private int swingDir = 1; // 1 顺时针,-1 逆时针 private double swingSpeed = 2.2; // 每帧角度增量(度) public void swing() { angle += swingDir * Math.toRadians(swingSpeed); if (angle > maxAngle) { angle = maxAngle; swingDir = -1; } else if (angle < minAngle) { angle = minAngle; swingDir = 1; } } public double getTipX(int anchorX) { return anchorX + HOSE_LENGTH * Math.cos(angle); } public double getTipY(int anchorY) { return anchorY + HOSE_LENGTH * Math.sin(angle); } }新手最容易在这里翻车:把角度算出来后想当然地对 y 取负,结果钩子在屏幕上方乱晃。原因就是没有意识到 y 轴方向已经由 Swing 坐标系决定了。angle 用的是弧度制,swingSpeed 用角度制是为了方便配置,换算一次后参与运算即可。摆动边界用区间判断而不是取模运算,这样方向切换不会出现振荡。
2.3 抓取判定:用钩尖到圆心的距离做命中测试
矿物体在画面上是圆形或近似圆形的区块,所以最可靠的命中判定不是矩形相交,而是计算钩尖点与物体中心的欧氏距离。矩形判定在钩子斜向切入时会误判,尤其在金块边缘和石头边缘接近的情况下,差几个像素就是完全不同的表现。
public boolean hitTest(Hook hook) { double dx = hook.getTipX() - x; double dy = hook.getTipY() - y; double dist = Math.sqrt(dx * dx + dy * dy); return dist < radius + HOOK_TIP_TOLERANCE; // 钩尖容差 2px }HOOK_TIP_TOLERANCE 给到 2 像素比较合适。太小了会出现在金块边缘擦过却抓空的情况,太大了又会在没碰到时提前吸附,看起来像磁铁。矿物体用圆形建模还有个额外好处:无论绘制成金块、钻石还是石头,判定逻辑不用改,只需要让每个子类把自己的 radius 赋值。这也是面向对象里多态最直接的体现,后面数据模型会进一步展开。
3. 跑通最小可玩版本:主类、双缓冲绘制与数据模型
3.1 解压与编译运行:从 zip 到窗口的最小命令
拿到 zip 包后先确认目录结构。常见做法是 src 目录放源码,res 目录放图片或音频,根目录放 README。优先用支持 UTF-8 的解压工具,避免中文目录名在 Windows 上变成乱码。进入项目根目录后,命令行编译只需要两条命令:
javac -encoding UTF-8 -d out src/org/goldminer/*.java java -cp out org.goldminer.Main-encoding UTF-8 是必须的。源码里如果有中文字符串或中文注释,Windows 默认按 GBK 编码编译,会抛“编码 GBK 的不可映射字符”。运行前确认 JDK 版本,JDK 8 往上都没问题,来自 jdk8 zip 下载包的环境也能直接跑。如果 java 命令找不到,就回到 win11 系统 java 环境配置那一套:先设 JAVA_HOME,再把 %JAVA_HOME%\bin 加入 Path。
3.2 双缓冲绘制:paintComponent 里画了什么
直接在 paintComponent 里逐帧画图会有闪烁,尤其当钩爪快速摆动、物体位置频繁更新时。标准做法是用离屏缓冲:先在一张 BufferedImage 上完成所有绘制,再一次 drawImage 画到面板上。这一步是黄金矿工画面稳定不掉帧的前提。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; // 离屏缓冲:尺寸变化时重建,平时复用 if (buffer == null || buffer.getWidth() != getWidth() || buffer.getHeight() != getHeight()) { buffer = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_RGB); } Graphics2D bg = buffer.createGraphics(); drawBackground(bg); // 画地层、土质纹理 hook.draw(bg); // 画锚点、钢索、钩爪 for (GoldField field : fields) { field.draw(bg); // 每个矿物体自己画自己 } drawScoreBar(bg); // 分数、倒计时、目标分 g2.drawImage(buffer, 0, 0, null); bg.dispose(); }这段代码体现了两个设计习惯。第一,矿物体的绘制方法定义在父类,子类各自实现,这样新增一种矿物时不用改绘制循环;第二,逻辑更新和绘制彻底分离,update 只改数据,paintComponent 只读数据,避免在绘制过程中修改集合结构导致 ConcurrentModificationException。Java 容器里遍历 fields 时如果需要移除物体,要先用迭代器或者在 update 阶段统一处理。
3.3 面向对象数据模型:矿物体、关卡与分数
矿物体是整个项目里最适合体现面向对象编程 Java 的地方。所有矿物都可以抽出一个基类,把坐标、半径、价值、重量这些共性字段放进去,再让大金块、小金块、钻石、石头、炸药分别继承并覆盖绘制方法。
public abstract class GoldField { protected int x, y; // 左上角位置 protected int radius; // 判定半径 protected int value; // 抓取后加多少分 protected double weight; // 重量系数,影响回收速度 public abstract void draw(Graphics2D g); public abstract double getWeight(); } public class GoldLump extends GoldField { public GoldLump(int x, int y) { this.x = x; this.y = y; this.radius = 22; this.value = 500; this.weight = 1.8; // 大金块偏重,回收慢 } @Override public void draw(Graphics2D g) { g.setColor(new Color(255, 185, 20)); g.fillOval(x, y, radius * 2, radius * 2); } } public class Diamond extends GoldField { public Diamond(int x, int y) { this.x = x; this.y = y; this.radius = 12; this.value = 1200; this.weight = 0.9; // 钻石轻,回收快 } @Override public void draw(Graphics2D g) { // 画一个四角星形,value 最高但体积小 } }这套继承体系在后续扩展里收益很大。想加一种“神秘矿”,只需要写个子类,定义半径、价值、重量和绘制方式,游戏循环、碰撞判定、回收逻辑一行都不用动。这也是黄金矿工这个项目放在简历上比较有分量的原因:它展示的不是会写 if 和 for,而是能用抽象把变化隔离出来。
4. 抓取与回收的实现细节:碰撞检测、速度衰减与计分
4.1 发射与超长自动回收:有限状态机的转移条件
钩爪有三种状态:摆动、伸出、回收。用枚举表达状态机,每帧只执行当前状态对应的逻辑。发射时机是玩家按下空格键的那一帧,此时当前角度立刻固化为方向向量,不再随摆动更新。
public enum HookState { SWING, EXTEND, RETRACT } public void update(List<GoldField> fields) { switch (state) { case SWING -> { swing(); if (firePressed) { // 固化方向向量,进入伸出状态 dirX = Math.cos(angle); dirY = Math.sin(angle); state = HookState.EXTEND; } } case EXTEND -> { tipX += dirX * extendSpeed; tipY += dirY * extendSpeed; length += extendSpeed; // 超出钢索最大长度后自动回收 if (length >= HOSE_LIMIT) { state = HookState.RETRACT; break; } // 每帧检测是否碰到矿物体 for (GoldField field : fields) { if (distanceTo(field) < field.radius + 2) { captured = field; state = HookState.RETRACT; break; } } } case RETRACT -> { // 回拉处理,下一节展开 } } }HOSE_LIMIT 是钩爪能伸出的最大长度,常见取值在 380 到 450 像素之间。太小会导致上方中央区域大量矿物体永远够不到,太大会让玩家一次操作就能跨越大半个屏幕,失去瞄准的紧张感。extendSpeed 按帧计约 6 到 9 像素,太快会让碰撞判定跳过目标,太慢则操作反馈迟钝。
4.2 回拉速度衰减:物体重量如何影响钩爪速度
这个环节是整个游戏“手感”的核心。黄金矿工最微妙的体验是:抓到大金块时,钩子明显变慢、像是吃力地拖着走;抓到石头时几乎停滞;抓到钻石时依然轻快。实现上就是在回拉时用物体的重量系数去除基准回拉速度。
case RETRACT -> { if (captured == null) { // 什么都没抓到:按原方向匀速收回 tipX -= dirX * retractSpeed; tipY -= dirY * retractSpeed; length -= retractSpeed; } else { // 抓到物体:回收速度 = 基准速度 / 重量系数 double weight = captured.getWeight(); double speed = retractSpeed / weight; tipX -= dirX * speed; tipY -= dirY * speed; length -= speed; // 回到锚点,结算得分,重新进入摆动 if (length <= 0) { score += captured.value; captured = null; state = HookState.SWING; } } }基准回拉速度数值不大,大约 4 到 6 像素每帧。重量系数 1.0 表示不影响速度,1.8 表示回收速度降为原来的约 55%,2.5 以上就接近拖不动。钻石和金币重量低于 1.0,抓回来时还能稍微加速,形成一种爽快感。
另一个细节是方向向量的处理。方向向量在发射瞬间就固化了,回拉过程中只改变速率标量,不重新计算 dirX、dirY。否则如果每帧按当前钩尖指向重新取反,角度微小变化会被不断累加,出现回收时左右乱晃的“甩尾”翻车现场。这条经验在避坑章节里会再次出现。
4.3 计分与关卡目标:达标判定与倒计时
计分逻辑不复杂,但关卡节奏靠它撑起来。每个关卡由两个数字定义:目标分数和时限。抓回一个物品时把 value 累加到 score,倒计时归零时如果分数没到 target 就失败。
public class LevelConfig { private int targetScore; // 过关目标分 private int timeLimit; // 关卡秒数,常见 60-120 秒 private double swingSpeed; // 摆动角速度,越高越难瞄 private int hoseLimit; // 钩爪最大长度 public boolean isLevelPassed(int score) { return score >= targetScore; } public boolean isTimeUp(int remainSeconds) { return remainSeconds <= 0; } }关卡设计上有个经验:目标分不能等于全部矿物体价值之和,要留出 20% 到 30% 的容错,否则玩家一个失误就必须重开。倒计时的显示建议放在界面右上角,用红色在最后 10 秒渐变色提示。这部分也可以用 java 排序来做一个小排行榜,按得分把历史关卡排个序,虽然对游戏机制没影响,但会让项目的完成度高出一截。
5. 避坑排查:六个让黄金矿工在 Java 里翻车的现场
5.1 现象:钩爪碰到金块边缘直接穿过去
这是最常见的第一版 bug。勾爪明明从金块表面上划过,却没有触发抓取,看起来像穿模。原因有两种:一是判定用的是矩形相交,而金块被绘制成圆形或圆角矩形,视觉边缘和判定边界不重合;二是钩子每帧位移过大,一帧跨了十几个像素,点与圆的距离检测在两个采样点之间漏掉了交点。
解决方法是把命中判定改成钩尖到圆心的距离判断,同时把伸出速度控制在每帧不超过 8 像素。如果希望更精确,可以用线段与圆的交点检测,但多数场景下点距离加小速度就够用,代码复杂度低得多。
5.2 现象:石头一抓到手就开始左右甩尾
抓到大块石头时,钩爪不是平稳回收,而是像钟摆一样乱晃,镜头感很差。根因是回拉时重新计算了方向向量,而不是使用发射瞬间固化的方向。钩尖在回拉过程中左右偏差了几个像素,取反后的方向向量叠加位移偏差,误差逐帧放大,最终形成甩尾。
解决方式是发射时用 dirX、dirY 记录方向,回拉只改变长度或速度,不再调用三角函数刷新方向。这条修正后,石头回收路径会变成一条稳定的直线,手感立刻变扎实。
5.3 现象:窗口一缩放,矿工位置错位,画面闪烁
固定坐标写死在 paintComponent 里,窗口从默认大小被拉大后,所有矿物体和钩爪锚点都停留在左上角区域,画面背景留下大片空白。闪烁则是没有双缓冲的直接结果。
解决方式是把游戏逻辑坐标固定在一个虚拟分辨率下,绘制时统一缩放。常见做法是设定逻辑宽高为 800×600,在 paintComponent 里计算缩放比例,用 Graphics2D 的 scale 变换后再绘制所有内容。配合 3.2 节的离屏缓冲,缩放和闪烁问题一起解决。
5.4 现象:点击空格后界面像卡死,CPU 占用拉满
在 actionPerformed 里加了 Thread.sleep 做帧率控制,或者用了 daemon 线程里的死循环配合 repaint。这两种写法都会阻塞 EDT,导致按钮事件、键盘事件全部排队,界面表现为卡死。
解决方式是只使用 Swing Timer 驱动循环。如果偏好用 java 定时任务框架里的 ScheduledExecutorService,那么更新逻辑可以在独立线程里跑,但最终调用 repaint 前必须通过 SwingUtilities.invokeLater 回到 EDT,而且所有共享数据要加锁或使用 volatile,复杂度会上升。这个项目阶段没必要。
5.5 现象:javac 编译报“编码 GBK 的不可映射字符”
源码是 UTF-8 编码,而 Windows 上 javac 默认按 GBK 编译,只要代码里有中文注释或中文对话框文本就会报错。这个报错出现在项目的最早阶段,属于 java 编码问题里最经典的一个。
解决方式就是编译命令里显式加 -encoding UTF-8,同时在 IDE 里把项目编码也设置为 UTF-8。如果 zip 包里的文件名带中文,解压时也要注意选择保留 UTF-8 的解压工具,否则资源路径会和代码里写的不一致。
5.6 现象:双击 jar 包或命令行运行直接异常退出
最常见的是资源加载路径写死成绝对路径,比如 new File("res/icon.png")。打包成 jar 后资源在压缩包内部,File 方式根本读不到,于是抛出空指针或文件找不到异常。这类问题在 java 启动失败怎么解决的排查序列里排第一。
解决方式是任何资源都用 getResourceAsStream 从 classpath 加载,图片用 ImageIO.read(getClass().getResourceAsStream("/res/icon.png"))。源码目录里放文件没问题,但打包成 jar 以后,只有通过 classpath 访问才是可靠的。这一点改完,打包后的可执行 jar 就能在任何目录双击运行了。
提示:以上六条是我自己从零写黄金矿工时挨个踩过的,前两条是算法问题,后面四条是 Java 平台特有的工程问题。做类似 Swing 游戏时,建议按这个清单先排查一轮再动功能。
6. 进阶玩法:用关卡配置与存档把玩具变成作品
6.1 用 Properties 驱动关卡配置
把目标分、时限、摆动速度、钢索长度这些数值硬编码在类里,每调一次手感就要改代码重新编译。更可持续的做法是把它们全部挪到配置文件里,用 java.util.Properties 读取。properties 文件本身也是纯文本,解压 zip 后可以直接编辑,不需要任何额外工具。
Properties props = new Properties(); try (InputStream in = getClass() .getResourceAsStream("/config/level1.properties")) { props.load(in); } LevelConfig config = new LevelConfig(); config.targetScore = Integer.parseInt(props.getProperty("targetScore")); config.timeLimit = Integer.parseInt(props.getProperty("timeLimit")); config.swingSpeed = Double.parseDouble(props.getProperty("swingSpeed")); config.hoseLimit = Integer.parseInt(props.getProperty("hoseLimit"));属性名建议用驼峰风格,和 Java 字段保持严格一致,解析时不容易拼错。给每个关卡一个独立 properties 文件,比一个大文件里用注释分隔更清晰。关卡文件名即关卡编号,后续扩展新关卡只需要新增配置文件,游戏主体代码一行不改。
6.2 进度存档:序列化与用户目录
存档系统不用复杂,把当前关卡、当前分数、已解锁关卡数写进本地文件即可。常见做法是用 Properties 保存进度,文件名放在用户主目录而不是项目目录,避免打包后没有写权限。
Properties save = new Properties(); save.setProperty("level", String.valueOf(currentLevel)); save.setProperty("score", String.valueOf(score)); save.setProperty("unlocked", String.valueOf(unlockedLevel)); String userHome = System.getProperty("user.home"); Path savePath = Paths.get(userHome, ".goldminer_save.properties"); try (OutputStream out = Files.newOutputStream(savePath)) { save.store(out, "goldminer progress"); }6.3 验证手感:三个关键指标和一段经验
验证一个黄金矿工改得好不好玩,不建议靠感觉,而是看三个量化指标:从发射到命中平均耗时、抓取后回收时长、单局完成时间占时限比例。平均命中耗时超过 3 秒说明摆动速度过慢或范围太小;回收时长超过 4 秒说明重量系数设置得不合理;单局完成时间超过时限的 85% 说明目标分偏高,玩家挫败感会暴涨。
| 参数 | 推荐区间 | 超出后的表现 |
|---|---|---|
| 摆动速度 | 40–75 度/秒 | 低于 30 度/秒瞄准太简单,高于 90 度/秒没法精确操作 |
| 钩爪最大长度 | 380–450 px | 低于 350 摸不到上层矿物,高于 500 一杆跨全屏 |
| 大金块重量 | 1.5–2.0 | 低于 1.3 没沉重感,高于 2.5 回收拖太久 |
| 钻石价值 | 1000–1500 | 低于 800 没人愿意冒险,高于 2000 破坏分数平衡 |
我自己最早做的版本把大金块重量设成 3.0,石头设成 5.0,结果抓什么都像在拉一艘船,一局打满 120 秒都到不了目标分。后来把重量系数改成区间推荐值,手感立刻正常了。如果你照着这篇文章写完第一版,建议先不改功能,调三组数值跑几局,你会发现手感差异比加十个新矿物体都明显。希望这个方向能帮到你。
本文还有配套的精品资源,点击获取