Java Swing黄金矿工游戏开发实战:从零实现钩子摆动与碰撞检测 1. 项目缘起与整体设计思路黄金矿工这个玩法估计很多人在网页小游戏或者早期的Flash时代都刷到过——一个钩子来回摆动按一下放钩钩到金块、石头、钻石就往回拉拉回来的东西按价值算钱每关有目标金额达不到就Game Over。规则简单到一句话能说清但玩起来特别上头。这次我打算用Java把它复刻出来一方面是给自己练手另一方面也是想给刚学完Java基础、想找个完整项目练一练的朋友一个能直接抄作业的参考。先说清楚这个项目适合谁看。如果你已经学过Java的变量、循环、条件判断、类与对象、继承和多态这些基础语法但还没写过超过三百行的完整程序那这个项目非常适合你。它不涉及网络编程、不涉及数据库、不涉及多线程就是一个纯桌面端的Swing应用所有逻辑都在一个进程里跑。你能学到的是怎么用面向对象的方式组织一个游戏、怎么用定时器驱动游戏循环、怎么处理键盘和鼠标事件、怎么用Java 2D画图API绘制游戏画面、怎么管理游戏状态和关卡数据。为什么选Swing而不是JavaFX或者LibGDX原因很实际。Swing是JDK自带的不需要额外引入任何依赖你新建一个Java项目就能直接写不用配Maven、不用下jar包。对于练手项目来说少一个依赖就少一个坑。JavaFX从JDK 11开始就不再捆绑了要单独引入模块对新手不友好。LibGDX是专业的游戏框架功能强大但学习曲线陡用来写黄金矿工属于杀鸡用牛刀。Swing的Graphics2D API虽然老但画个2D小游戏绰绰有余而且网上资料多遇到问题好查。整个项目的架构我采用的是经典的游戏循环模式一个定时器每隔固定毫秒数触发一次每次触发做三件事——更新游戏逻辑钩子摆动、钩子伸缩、碰撞检测、重绘画面、检查游戏状态是否过关、是否失败。这个模式是所有实时游戏的基础你把这个搞懂了以后写任何游戏都能套这个骨架。代码结构上我分了这么几个类GoldMinerGame主类继承JFrame负责窗口创建和游戏主循环的启动。GamePanel继承JPanel是游戏的核心面板负责绘制和逻辑更新。Hook钩子类管理钩子的摆动、发射、收回、抓取状态。Mineral矿物类表示金块、石头、钻石等可抓取对象。LevelConfig关卡配置类定义每一关的矿物分布、目标金额、时间限制。这样分的好处是职责清晰。钩子只管自己的状态机矿物只管自己的属性和绘制面板负责协调它们之间的关系。后面想加新矿物类型只需要在Mineral里加一个类型枚举和对应的价值、重量参数就行不用动其他代码。提示不要一上来就想着把代码写得多完美。先把能跑的版本写出来哪怕所有逻辑都堆在一个类里跑通了再重构。我见过太多新手卡在“怎么设计类结构”这一步结果一行代码没写。先让它动起来再让它好看。2. 核心机制拆解与关键参数设计黄金矿工看起来简单但有几个核心机制如果参数没调好玩起来就会特别别扭。这一部分我把每个机制拆开讲包括我实际调试时用的参数值和调整理由。2.1 钩子摆动机制与角度控制钩子从顶部中心点出发像钟摆一样左右摆动。摆动的实现方式是用一个角度变量angle每次定时器触发时给angle加上一个增量angleSpeed。当angle超过最大角度比如75度或者小于最小角度比如-75度时把angleSpeed取反这样钩子就会往回摆。这里有个细节角度用度数还是弧度Swing的Graphics2D.rotate()方法用的是弧度但人在调试的时候用度数更直观。我的做法是内部存储用度数画图的时候用Math.toRadians()转一下。这样调参数的时候脑子不用绕弯。摆动速度angleSpeed我试过好几个值。设成1.5的时候摆动太慢玩家等得着急设成4.0又太快根本来不及反应。最后定在2.5度/帧定时器间隔16毫秒大约60帧每秒一个完整的左右摆动周期大概1.2秒节奏刚刚好。钩子的长度也分两种状态摆动时是固定长度比如60像素发射时长度逐渐增加。发射速度extendSpeed我设的是8像素/帧收回速度分两种情况——空钩收回快一些10像素/帧抓到东西收回慢一些4像素/帧而且矿物的重量会影响收回速度。这个后面讲矿物的时候再说。2.2 矿物类型与价值重量参数矿物我设计了五种类型每种有不同的价值、重量和出现概率矿物类型价值金币重量系数出现概率绘制颜色小金块501.030%亮金色大金块1202.020%深金色小石头101.525%灰色大石头203.015%深灰色钻石3000.810%青色重量系数的作用是影响收回速度。收回速度的计算公式是实际收回速度 基础收回速度 / 重量系数。比如抓到钻石重量系数0.8收回速度反而比空钩还快基础速度4 / 0.8 5这符合钻石“又值钱又轻”的设定。抓到石头就惨了大石头重量系数3.0收回速度只有1.33像素/帧拉半天拉不回来这就是游戏里石头用来恶心人的机制。价值设计上钻石300块一枝独秀但出现概率只有10%而且体积小钩子不容易对准。大金块120块概率20%是稳定收入来源。石头价值低但重量大抓到了基本就是浪费时间。这个数值体系让玩家在“抓大的还是抓稳的”之间做取舍游戏性就出来了。注意概率总和必须是100%而且每次生成矿物时要检查位置不能重叠。我的做法是用一个列表记录已生成的矿物位置新矿物生成时随机位置如果和已有矿物的距离小于两者半径之和就重新随机最多重试50次。如果50次还找不到位置就跳过这个矿物不生成。2.3 碰撞检测与抓取判定碰撞检测用的是最简单的圆形碰撞钩子尖端有一个坐标(hookTipX, hookTipY)每个矿物有一个中心坐标和半径。每次定时器触发时计算钩子尖端和矿物中心的距离如果距离小于矿物半径就算抓到了。这里有个坑钩子尖端移动很快8像素/帧如果矿物很小比如钻石半径只有12像素有可能一帧之前距离是20像素下一帧距离已经变成-5像素穿过去了但中间没有检测到。这叫“穿透”问题。解决办法有两种一是减小每帧移动距离降低速度二是用线段和圆的碰撞检测代替点与圆的碰撞。我选了第二种因为不想牺牲手感。线段与圆的碰撞检测原理是把钩子尖端这一帧的移动轨迹看成一条线段判断这条线段和矿物圆是否相交。具体算法是求圆心到线段的最近距离如果最近距离小于半径就算相交。这个算法在java.awt.geom.Line2D里有现成的方法ptSegDist()可以直接用不用自己写数学公式。// 线段与圆碰撞检测示例 Line2D hookLine new Line2D.Double(prevTipX, prevTipY, currentTipX, currentTipY); double distance hookLine.ptSegDist(mineral.getCenterX(), mineral.getCenterY()); if (distance mineral.getRadius()) { // 抓到了 }这个检测方式实测下来很稳即使钩子速度再快也不会穿透。2.4 关卡目标与时间压力设计每一关有两个硬性指标目标金额和时间限制。目标金额逐关递增第一关500块第二关800块第三关1200块后面每关增加400块。时间限制统一是60秒但矿物总价值会随着关卡提升而增加保证理论上能在时间内赚够目标金额。时间压力的设计有个技巧不要一开始就给玩家很大压力。第一关的矿物总价值我设置在目标金额的2.5倍左右也就是说玩家只要抓到40%的矿物就能过关。到了第五关以后矿物总价值降到目标金额的1.8倍玩家必须抓到55%以上才能过关。这样难度曲线是平滑上升的不会突然卡死。关卡数据我放在一个LevelConfig类里用静态方法返回每一关的配置。这样想调整难度只需要改这一个类不用翻遍整个代码。3. 完整实操流程与核心代码实现这一部分我把从零开始写这个游戏的完整流程走一遍包括环境准备、窗口创建、游戏循环、绘制、事件处理、状态管理。每一步都给出可运行的代码和详细的注释说明。3.1 开发环境准备与项目骨架搭建环境要求很简单JDK 8或以上版本任意IDEIntelliJ IDEA、Eclipse、VS Code都行不需要任何第三方依赖。新建一个Java项目包名随意我用的com.example.goldminer。项目骨架就是前面说的五个类。先建一个GoldMinerGame主类import javax.swing.*; public class GoldMinerGame extends JFrame { private GamePanel gamePanel; public GoldMinerGame() { setTitle(黄金矿工); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); gamePanel new GamePanel(); add(gamePanel); pack(); setLocationRelativeTo(null); setVisible(true); gamePanel.startGame(); } public static void main(String[] args) { SwingUtilities.invokeLater(() - new GoldMinerGame()); } }这里用SwingUtilities.invokeLater()是Swing的标准做法确保所有UI操作都在事件分发线程EDT上执行。虽然这个小游戏不用这个也不会出大问题但养成好习惯没坏处。GamePanel的构造方法里设置面板大小和背景色然后初始化游戏数据public class GamePanel extends JPanel { public static final int WIDTH 800; public static final int HEIGHT 600; private Timer timer; private Hook hook; private ListMineral minerals; private int score; private int targetScore; private int timeLeft; private int currentLevel; private GameState state; public GamePanel() { setPreferredSize(new Dimension(WIDTH, HEIGHT)); setBackground(new Color(30, 30, 40)); setFocusable(true); initGame(); } }GameState是一个枚举表示当前游戏状态PLAYING、LEVEL_COMPLETE、GAME_OVER、PAUSED。用枚举而不是整数常量代码可读性好很多。3.2 游戏主循环与定时器驱动游戏循环是整个程序的心脏。我用javax.swing.Timer间隔设16毫秒对应大约60帧每秒。为什么不用Thread.sleep()因为Swing的绘制必须在EDT上执行用Timer可以保证每次触发都在EDT上不用做线程同步。public void startGame() { if (timer ! null timer.isRunning()) { timer.stop(); } timer new Timer(16, e - { updateGame(); repaint(); }); timer.start(); requestFocusInWindow(); } private void updateGame() { if (state ! GameState.PLAYING) return; hook.update(); checkCollision(); checkLevelStatus(); updateTimer(); }updateGame()里做四件事更新钩子状态、检测碰撞、检查关卡状态、更新倒计时。注意repaint()不在updateGame()里面而是单独在Timer的回调里调用这样逻辑更新和画面重绘分开结构更清晰。倒计时的实现不是每帧减1而是用一个计数器累计帧数每60帧大约1秒减1秒。这样比用System.currentTimeMillis()更简单也不用处理系统时间跳变的问题。private int frameCounter 0; private void updateTimer() { frameCounter; if (frameCounter 60) { frameCounter 0; timeLeft--; if (timeLeft 0) { timeLeft 0; if (score targetScore) { state GameState.LEVEL_COMPLETE; } else { state GameState.GAME_OVER; } } } }3.3 钩子状态机与伸缩逻辑实现钩子的状态我用一个枚举HookState表示SWINGING摆动中、EXTENDING发射中、RETRACTING收回中。状态之间的转换规则是SWINGING状态下按空格键 → 切换到EXTENDING记录发射角度。EXTENDING状态下钩子长度达到最大值或者碰到矿物 → 切换到RETRACTING。RETRACTING状态下钩子长度回到初始长度 → 切换到SWINGING如果有抓到的矿物就结算分数。public class Hook { public enum State { SWINGING, EXTENDING, RETRACTING } private double angle; // 当前角度度 private double angleSpeed; // 摆动速度 private double length; // 当前长度 private double maxLength; // 最大长度 private State state; private Mineral grabbedMineral; public static final double INIT_LENGTH 60; public static final double MAX_LENGTH 450; public static final double EXTEND_SPEED 8; public static final double RETRACT_BASE_SPEED 10; public void update() { switch (state) { case SWINGING: angle angleSpeed; if (angle 75 || angle -75) { angleSpeed -angleSpeed; } break; case EXTENDING: length EXTEND_SPEED; if (length maxLength) { state State.RETRACTING; } break; case RETRACTING: double speed RETRACT_BASE_SPEED; if (grabbedMineral ! null) { speed RETRACT_BASE_SPEED / grabbedMineral.getWeight(); } length - speed; if (length INIT_LENGTH) { length INIT_LENGTH; if (grabbedMineral ! null) { // 结算分数 grabbedMineral null; } state State.SWINGING; } break; } } }钩子尖端的坐标计算用三角函数public double getTipX() { return GamePanel.WIDTH / 2.0 Math.sin(Math.toRadians(angle)) * length; } public double getTipY() { return 80 Math.cos(Math.toRadians(angle)) * length; }这里80是钩子悬挂点的Y坐标也就是屏幕顶部往下80像素的位置。角度为0时钩子垂直向下角度为正时向右偏为负时向左偏。实操心得钩子收回时如果抓到了矿物矿物的坐标要跟着钩子尖端走。我的做法是在Mineral类里加一个followHook标志绘制的时候如果这个标志为true就把矿物的绘制坐标设为钩子尖端坐标。这样不用每帧更新矿物的实际坐标省去了同步的麻烦。3.4 矿物生成与绘制细节矿物的生成在每一关开始时执行一次。生成逻辑是根据关卡配置确定每种矿物的数量然后在屏幕下半部分随机位置生成。位置范围是X从50到750Y从250到550避免和顶部的钩子悬挂点重叠。private void generateMinerals() { minerals.clear(); Random rand new Random(); LevelConfig config LevelConfig.getLevel(currentLevel); for (int i 0; i config.getSmallGoldCount(); i) { spawnMineral(Mineral.Type.SMALL_GOLD, rand); } // ... 其他类型同理 } private void spawnMineral(Mineral.Type type, Random rand) { int attempts 0; while (attempts 50) { int x 50 rand.nextInt(700); int y 250 rand.nextInt(300); if (isPositionValid(x, y, type.getRadius())) { minerals.add(new Mineral(type, x, y)); return; } attempts; } }绘制矿物的时候金块用填充的圆角矩形加渐变石头用填充的圆形加纹理线条钻石用多边形。Swing的Graphics2D支持GradientPaint画金块的时候用一下视觉效果会好很多。private void drawMineral(Graphics2D g2d, Mineral m) { switch (m.getType()) { case SMALL_GOLD: case LARGE_GOLD: GradientPaint gp new GradientPaint( m.getX(), m.getY(), new Color(255, 215, 0), m.getX() m.getRadius(), m.getY() m.getRadius(), new Color(184, 134, 11) ); g2d.setPaint(gp); g2d.fillRoundRect(m.getX() - m.getRadius(), m.getY() - m.getRadius(), m.getRadius() * 2, m.getRadius() * 2, 10, 10); break; case DIAMOND: // 绘制菱形多边形 int[] xPoints {m.getX(), m.getX() m.getRadius(), m.getX(), m.getX() - m.getRadius()}; int[] yPoints {m.getY() - m.getRadius(), m.getY(), m.getY() m.getRadius(), m.getY()}; g2d.setColor(new Color(0, 255, 255)); g2d.fillPolygon(xPoints, yPoints, 4); break; // ... 石头类型 } }3.5 键盘事件与游戏状态切换键盘事件用KeyAdapter监听只关心空格键和R键。空格键在SWINGING状态下发射钩子R键在GAME_OVER状态下重新开始。addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() KeyEvent.VK_SPACE) { if (state GameState.PLAYING hook.getState() Hook.State.SWINGING) { hook.fire(); } } else if (e.getKeyCode() KeyEvent.VK_R) { if (state GameState.GAME_OVER) { restartGame(); } } } });这里有个常见问题面板失去焦点后键盘事件不响应。解决办法是在startGame()里调用requestFocusInWindow()并且在鼠标点击面板时也调用一次。我还在面板上加了setFocusable(true)确保它能获取焦点。游戏状态切换的逻辑集中在checkLevelStatus()方法里private void checkLevelStatus() { if (state ! GameState.PLAYING) return; if (score targetScore hook.getState() Hook.State.SWINGING) { state GameState.LEVEL_COMPLETE; } }注意过关判定要等钩子回到摆动状态才触发否则玩家刚抓到最后一个金块、钩子还在收回途中就弹过关界面体验很突兀。4. 常见问题排查与调试技巧实录代码写完了能跑但实际调试过程中我踩了不少坑。这一部分把典型问题和解决办法整理出来你遇到类似情况可以直接对照排查。4.1 画面闪烁与重绘优化最开始我用的是paint()而不是paintComponent()结果画面闪得厉害。原因是paint()会先调用背景绘制再调用paintComponent()中间有一帧是空白的。解决办法是重写paintComponent()在里面先调用super.paintComponent(g)清屏然后一次性画完所有内容。Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; // 开启抗锯齿 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); drawBackground(g2d); drawMinerals(g2d); drawHook(g2d); drawHUD(g2d); drawStateOverlay(g2d); }另外Swing默认开启了双缓冲不用自己再搞一个BufferedImage。如果你发现画面还是闪检查一下是不是在paintComponent()外面调用了repaint()太频繁或者有没有在updateGame()里直接操作UI组件。提示Graphics2D用完不需要手动释放但如果你创建了新的Graphics2D对象比如从BufferedImage获取的记得调用dispose()。这个小游戏里没这个问题但养成习惯。4.2 钩子穿透矿物的排查与解决前面提到过穿透问题这里展开说排查过程。我一开始用的是点与圆的碰撞检测代码很简单double dx hookTipX - mineral.getX(); double dy hookTipY - mineral.getY(); if (Math.sqrt(dx * dx dy * dy) mineral.getRadius()) { // 抓到了 }测试的时候发现抓大金块半径30没问题但抓钻石半径12十次有三次抓不到。用调试模式打印每帧的钩子尖端坐标和钻石中心坐标发现钩子尖端从距离钻石中心15像素的位置直接跳到了距离-8像素的位置中间没有落在12像素范围内。换成线段与圆的碰撞检测后问题解决。Line2D.ptSegDist()方法返回的是点到线段的最短距离即使线段穿过了圆最短距离也是0肯定小于半径。这个方法的计算量比点与圆略大但每帧最多检测几十个矿物性能完全不是问题。4.3 关卡数据配置错误的快速定位关卡数据我一开始是硬编码在GamePanel里的后来抽到LevelConfig类。抽离的过程中出过一次bug第五关的目标金额写成了12000多了一个零结果玩家怎么抓都过不了关。排查的时候我加了一个调试快捷键按D键在控制台打印当前关卡的所有配置数据if (e.getKeyCode() KeyEvent.VK_D) { System.out.println(Level: currentLevel); System.out.println(Target: targetScore); System.out.println(Time: timeLeft); System.out.println(Minerals: minerals.size()); System.out.println(Total value: calculateTotalValue()); }这个调试快捷键在开发阶段非常有用能快速确认关卡数据是否符合预期。正式发布的时候可以去掉或者保留但不在界面上提示。4.4 常见问题速查表问题现象可能原因解决办法画面闪烁严重重写了paint()而不是paintComponent()改为重写paintComponent()先调super.paintComponent(g)键盘按键无响应面板没有获取焦点调用requestFocusInWindow()设置setFocusable(true)钩子抓不到小矿物点与圆碰撞检测穿透改用Line2D.ptSegDist()做线段与圆检测矿物生成重叠随机位置没有做重叠检查生成时检查与已有矿物的距离重试最多50次过关后钩子还在动过关判定没有等钩子回到摆动状态在checkLevelStatus()里加hook.getState() SWINGING条件倒计时不准确用System.currentTimeMillis()受系统时间影响改用帧计数器每60帧减1秒收回速度计算错误重量系数用反了收回速度 基础速度 / 重量系数重量越大速度越慢窗口关闭后程序不退出有非守护线程在运行确保Timer在窗口关闭时停止或调用System.exit(0)4.5 几个提升手感的小技巧第一个技巧是钩子发射时给一个短暂的加速。具体做法是发射的前5帧用1.5倍速度之后恢复正常速度。这样钩子射出去的时候有一种“弹射”的感觉比匀速伸出爽快很多。实现方式是在Hook类里加一个extendFrameCounter发射时置0每帧加1前5帧速度乘以1.5。第二个技巧是抓到矿物时加一个轻微的屏幕震动。实现很简单在GamePanel里加一个shakeOffset变量抓到矿物的瞬间把它设为一个随机值比如±3像素然后每帧衰减。绘制的时候把所有元素的坐标加上这个偏移量。震动幅度不要太大3像素足够了太大反而晕。第三个技巧是矿物被抓住时改变颜色。比如金块被抓住后变成更亮的金色给玩家一个视觉反馈。这个在Mineral类里加一个grabbed标志绘制的时候根据标志切换颜色就行。第四个技巧是背景加一点渐变。纯色背景看久了很单调用GradientPaint从上到下画一个深蓝到深灰的渐变成本很低但视觉效果提升明显。GradientPaint bgGradient new GradientPaint( 0, 0, new Color(20, 20, 40), 0, HEIGHT, new Color(50, 50, 70) ); g2d.setPaint(bgGradient); g2d.fillRect(0, 0, WIDTH, HEIGHT);这些技巧都不复杂但加进去之后游戏的完成度会高一个档次。我个人的经验是小游戏的手感往往就藏在这些细节里多花半个小时调一调玩起来的感觉完全不一样。5. 关卡扩展与后续玩法迭代方向基础版本跑通之后如果想继续扩展有几个方向可以尝试。这些扩展不需要推翻现有代码都是在现有架构上加东西。5.1 增加矿物类型与特殊效果现在的矿物只有五种可以加一些有特殊效果的矿物。比如炸药桶抓上来之后会炸掉周围一定范围内的矿物但自身不得分。实现方式是给Mineral加一个explosive标志抓到后遍历矿物列表把距离小于爆炸半径的矿物移除。再比如移动的矿物金块会缓慢左右移动增加抓取难度。实现方式是在Mineral类里加一个moveSpeed每帧更新X坐标碰到边界反向。这个改动很小但游戏难度会明显提升。还有限时矿物出现后5秒内不抓就会消失。实现方式是给Mineral加一个lifetime计数器每帧递减到0就从列表中移除。绘制的时候可以根据剩余时间改变透明度给玩家紧迫感。5.2 道具系统与商店机制在关卡之间加一个商店界面玩家可以用赚到的金币购买道具。道具可以设计成强力钩子下一关收回速度提升50%价格200金币。幸运石下一关钻石出现概率翻倍价格300金币。时间沙漏下一关时间限制增加15秒价格250金币。商店界面的实现可以用一个简单的JPanel覆盖层显示道具列表和价格点击购买后扣金币并设置对应的标志位。下一关开始时检查标志位应用效果。这个系统的代码量不大但能让游戏有更强的成长感和重复可玩性。玩家会为了买道具而反复刷前面的关卡游戏时长自然就上去了。5.3 音效与背景音乐的接入Swing本身不支持音频播放但可以用javax.sound.sampled包里的Clip类播放WAV格式的音频。把音频文件放在项目的resources目录下用getClass().getResource()加载。private void playSound(String path) { try { AudioInputStream ais AudioSystem.getAudioInputStream( getClass().getResource(path)); Clip clip AudioSystem.getClip(); clip.open(ais); clip.start(); } catch (Exception e) { // 音频播放失败不影响游戏运行 } }音效不需要多钩子发射、抓到金块、抓到石头、过关、失败各一个就够了。背景音乐可以循环播放一个轻快的8位风格曲子。注意音频文件不要太大WAV格式虽然无损但体积大可以用低采样率22050Hz和单声道来减小体积。注意音频播放是异步的clip.start()之后不会阻塞游戏循环。但每次播放都创建一个新的Clip对象会消耗内存频繁播放的音效比如钩子发射最好预加载并复用。我的做法是用一个MapString, Clip缓存已经加载的音频播放时先查缓存。5.4 排行榜与数据持久化如果想记录最高分可以用简单的文件读写。把最高分存到一个文本文件里游戏启动时读取游戏结束时如果分数更高就更新文件。private void saveHighScore(int score) { try (PrintWriter writer new PrintWriter(new FileWriter(highscore.txt))) { writer.println(score); } catch (IOException e) { e.printStackTrace(); } } private int loadHighScore() { try (BufferedReader reader new BufferedReader(new FileReader(highscore.txt))) { return Integer.parseInt(reader.readLine()); } catch (Exception e) { return 0; } }这个功能很简单但能给玩家一个持续挑战的动力。排行榜不需要联网本地记录就够了。如果想做得更正式一点可以用JSON格式存储记录每一关的最高分和总游戏次数但那就需要引入JSON库了对于练手项目来说有点过度设计。5.5 从Swing迁移到JavaFX的考量如果以后想把这个游戏做成更现代的界面可以考虑迁移到JavaFX。JavaFX的动画系统AnimationTimer比Swing的Timer更流畅而且支持CSS样式和更丰富的图形效果。但迁移成本不低所有绘制代码都要重写事件处理方式也不一样。我的建议是如果你只是练手Swing版本足够了。如果你打算把这个游戏作为作品集展示或者想加复杂的粒子效果和动画那可以考虑用JavaFX重写。但不要为了迁移而迁移先把Swing版本的功能做完整再考虑换框架的事。这个项目我从开始写到基本能玩大概花了三个周末的时间。第一个周末搭框架和画图第二个周末调手感和修bug第三个周末加关卡和优化。代码总量大概一千二百行左右不算多但覆盖了Java桌面开发的方方面面。如果你跟着走一遍对面向对象、事件驱动、图形绘制这些概念的理解会上一个大台阶。后面想加什么功能框架已经搭好了往里填就行。