ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

捕鱼达人Java版:Swing+Java2D从零手写桌面小游戏

捕鱼达人Java版:Swing+Java2D从零手写桌面小游戏 简介这是一份面向Java初学者与游戏开发爱好者的经典实战项目源码以捕鱼达人小游戏为载体帮助读者理解2D休闲游戏的完整实现思路。项目围绕捕鱼核心玩法展开涵盖鱼类生成与游动、炮台发射与碰撞检测、分值结算、道具与关卡难度调节等模块适合作为课程设计、毕业设计或Java图形界面练手参考。资源包共223个文件约10.49MB其中60个java源文件与93个class文件构成主要逻辑代码另有21个png、10个jpg图片及16个plist、8个xml配置用于界面与动画资源7个ogg与2个mp3提供音效2个ttf字体文件完善显示效果。已有177人学习下载。通过阅读源码读者可掌握多线程刷新、精灵图管理、分数与关卡状态维护等实现方式并借鉴其目录组织与模块划分快速搭建属于自己的捕鱼类小游戏。1. 捕鱼达人 Java 版从零手写一个能跑能玩的桌面小游戏很多人第一次接触 Java 小游戏都是从「捕鱼达人」这类玩法开始的鼠标点一下炮台转向子弹飞出去鱼被击中后掉金币。它看起来简单但真正动手写的时候你会发现它把 Java 图形界面、面向对象设计、定时器循环、碰撞检测、资源管理这几块知识全串起来了。这也是为什么它常年出现在课程设计和 Java 基础练手清单里——比单纯的冒泡排序、控制台计算器有意思得多又不像完整商业项目那样劝退。这篇笔记面向两类人一是刚学完 Java 基础、想找个能看见画面的项目练手的同学二是想快速搭一个可扩展小游戏框架、后面接微信小游戏或抖音小游戏方向的开发者。我会用 Swing Java2D 这套 JDK 自带、零依赖的方案把捕鱼达人的核心循环、对象模型、碰撞判定和性能调优讲透代码可以直接抄下来跑。选 Swing 而不是 LibGDX是因为它不需要额外配置java -jar就能启动对新手最友好也方便你理解游戏循环的本质。2. 用 Swing Java2D 搭出游戏主循环为什么不用 Timer 而用主动循环2.1 游戏循环的三种写法与选型理由任何实时小游戏的心脏都是「游戏循环」不断更新逻辑、重绘画面。Java 里常见三种做法。第一种是javax.swing.Timer它把定时回调挂在事件分发线程EDT上写起来最省事但所有逻辑和绘制都在 EDT 里跑一旦某帧计算量大界面就卡住而且它不保证精确间隔。第二种是java.util.Timer或ScheduledExecutorService在独立线程里按固定频率触发。问题是 Swing 组件不是线程安全的你在别的线程里改画面状态必须用SwingUtilities.invokeLater切回 EDT代码会变得很啰嗦。第三种是「主动循环 双缓冲」自己开一个线程跑while(running)每帧先算逻辑再通过repaint()请求重绘绘制统一在paintComponent里完成。这是我最推荐的做法因为它把「逻辑帧」和「渲染帧」解耦帧率可控也方便后面加暂停、加速、慢动作。下面是最小可运行的主循环骨架。import javax.swing.*; import java.awt.*; public class GamePanel extends JPanel implements Runnable { private Thread gameThread; private volatile boolean running false; private static final int FPS 60; // 目标帧率 private static final long FRAME_TIME 1000L / FPS; // 每帧毫秒数 public GamePanel() { setPreferredSize(new Dimension(960, 640)); setBackground(Color.BLACK); setDoubleBuffered(true); // 开启双缓冲减少闪烁 } public void startGame() { if (running) return; running true; gameThread new Thread(this, game-loop); gameThread.start(); } Override public void run() { long lastTime System.nanoTime(); while (running) { long now System.nanoTime(); double delta (now - lastTime) / 1_000_000_000.0; // 秒 lastTime now; update(delta); // 更新逻辑 repaint(); // 请求重绘 long sleep FRAME_TIME - (System.nanoTime() - now) / 1_000_000; if (sleep 0) { try { Thread.sleep(sleep); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } private void update(double delta) { // 所有对象的位置、状态在这里推进 } Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 所有绘制在这里完成 } }逻辑说明run()里用System.nanoTime()计算两帧之间的真实时间差delta所有运动都乘以delta这样即使某帧卡顿物体移动距离也不会突变这就是「基于时间的运动」。repaint()只是标记需要重绘真正的绘制由 Swing 在 EDT 上调用paintComponent所以逻辑线程和绘制线程天然分离。参数说明FPS 60是目标值实际帧率受机器性能影响FRAME_TIME用来计算休眠时间避免 CPU 空转跑满一个核。setDoubleBuffered(true)是 Swing 默认行为显式写出来是为了提醒你——如果自己再手动建BufferedImage做缓冲反而可能重复。提示running用volatile修饰保证停止信号能被循环线程及时看到否则可能出现点了关闭窗口线程还在跑的情况。2.2 把窗口、面板、启动入口接起来有了面板还需要一个JFrame承载它并处理窗口关闭时正确停止线程。import javax.swing.*; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; public class GameMain { public static void main(String[] args) { SwingUtilities.invokeLater(() - { JFrame frame new JFrame(捕鱼达人 - Java Swing 版); GamePanel panel new GamePanel(); frame.add(panel); frame.pack(); frame.setLocationRelativeTo(null); // 居中 frame.setResizable(false); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); frame.addWindowListener(new WindowAdapter() { Override public void windowClosing(WindowEvent e) { panel.stopGame(); // 先停线程 frame.dispose(); System.exit(0); } }); frame.setVisible(true); panel.startGame(); }); } }逻辑说明SwingUtilities.invokeLater保证所有 UI 创建都在 EDT 上完成这是 Swing 的硬性要求。窗口关闭时先调用stopGame()把循环线程停掉再释放窗口避免线程泄漏。参数说明setResizable(false)让画布尺寸固定省去处理缩放坐标换算的麻烦如果你后面要做自适应需要把逻辑坐标和屏幕坐标分开这里先不引入复杂度。stopGame()的实现很简单public void stopGame() { running false; if (gameThread ! null) { try { gameThread.join(500); } catch (InterruptedException ignored) {} } }到这里一个 60 帧稳定运行的空画布就跑起来了。别小看这一步很多人的捕鱼达人卡顿、闪屏、关不掉根子都在循环和线程没处理好。3. 鱼、炮台、子弹的对象建模面向对象怎么拆才不返工3.1 抽象基类 GameObject 与坐标体系捕鱼达人里所有会动的东西——鱼、子弹、金币特效——都有共同属性位置、速度、存活状态、更新、绘制。先抽一个基类后面所有对象继承它这是避免代码重复的关键。import java.awt.*; public abstract class GameObject { protected double x, y; // 逻辑坐标用 double 保证平滑 protected double vx, vy; // 速度单位像素/秒 protected boolean alive true; public GameObject(double x, double y) { this.x x; this.y y; } // delta 为秒子类实现具体运动 public abstract void update(double delta); public abstract void draw(Graphics2D g2); public boolean isAlive() { return alive; } public void setAlive(boolean alive) { this.alive alive; } public double getX() { return x; } public double getY() { return y; } }逻辑说明坐标用double而不是int因为速度往往是小数比如每秒 120.5 像素用整数会累积误差鱼看起来会「一跳一跳」。速度单位统一成「像素/秒」配合delta就能得到x vx * delta物理意义清晰。参数说明alive标记对象是否还需要更新和绘制子弹飞出屏幕、鱼被捕获后置为false由管理器统一清理避免在遍历集合时直接删除导致ConcurrentModificationException。3.2 鱼的生成、游动路径与出屏回收鱼是游戏里数量最多的对象它的行为决定了游戏好不好玩。常见做法是让鱼从屏幕一侧游入沿一条曲线或直线穿过从另一侧游出后回收。import java.awt.*; import java.util.Random; public class Fish extends GameObject { private final int radius; private final int score; // 捕获得分 private final Color color; private static final Random RAND new Random(); public Fish(double x, double y, double speed, int radius, int score, Color color) { super(x, y); this.radius radius; this.score score; this.color color; // 水平游动带一点垂直抖动看起来更自然 this.vx speed; this.vy (RAND.nextDouble() - 0.5) * 30; } Override public void update(double delta) { x vx * delta; y vy * delta; // 上下边界反弹避免鱼游出可视区 if (y radius || y 640 - radius) { vy -vy; } // 游出右边界则回收 if (x - radius 960) { alive false; } } Override public void draw(Graphics2D g2) { g2.setColor(color); g2.fillOval((int) (x - radius), (int) (y - radius), radius * 2, radius * 2); // 画个眼睛方向感更强 g2.setColor(Color.WHITE); g2.fillOval((int) (x radius / 3), (int) (y - radius / 3), radius / 3, radius / 3); } public int getScore() { return score; } public int getRadius() { return radius; } }逻辑说明update里先按速度推进位置再做边界处理。垂直方向反弹让鱼不会贴着上下边缘直线走视觉上更像活物。出屏判断用x - radius 960即整个鱼身完全离开右边界才回收避免鱼「半截消失」。参数说明speed建议按鱼的「价值」区分小鱼快、大鱼慢这样玩家打大鱼更有成就感radius同时用于绘制和碰撞保持视觉与判定一致否则会出现「明明打中了却没反应」的玄学问题。生成鱼群的管理器import java.awt.*; import java.util.ArrayList; import java.util.List; import java.util.Random; public class FishManager { private final ListFish fishes new ArrayList(); private final Random rand new Random(); private double spawnTimer 0; private static final double SPAWN_INTERVAL 0.8; // 秒 public void update(double delta) { spawnTimer delta; if (spawnTimer SPAWN_INTERVAL) { spawnTimer 0; spawnOne(); } for (Fish f : fishes) f.update(delta); fishes.removeIf(f - !f.isAlive()); // 统一回收 } private void spawnOne() { int radius 15 rand.nextInt(25); double speed 60 rand.nextInt(80); int score 100 / radius * 10; // 越小越值钱简单权重 double y radius rand.nextInt(640 - radius * 2); fishes.add(new Fish(-radius, y, speed, radius, score, randomColor())); } private Color randomColor() { return new Color(rand.nextInt(200) 55, rand.nextInt(200) 55, rand.nextInt(200) 55); } public void draw(Graphics2D g2) { for (Fish f : fishes) f.draw(g2); } public ListFish getFishes() { return fishes; } }逻辑说明spawnTimer累加delta达到间隔就生成一条这样生成频率与帧率无关。removeIf在遍历结束后统一清理比在循环里remove安全。得分用100 / radius * 10做简单反比小鱼分高符合捕鱼类游戏的常见设计。参数说明SPAWN_INTERVAL越小鱼越密难度越高可以随游戏时间动态调小radius范围 15 到 40对应屏幕上大小适中的鱼。3.3 炮台转向与子弹发射炮台固定在底部中央根据鼠标位置计算角度点击时沿该角度发射子弹。import java.awt.*; public class Cannon { private final int baseX 480, baseY 600; private double angle -Math.PI / 2; // 默认朝上 public void aimAt(int mouseX, int mouseY) { // 计算从炮台指向鼠标的角度 angle Math.atan2(mouseY - baseY, mouseX - baseX); } public Bullet fire() { double speed 600; // 像素/秒 double vx Math.cos(angle) * speed; double vy Math.sin(angle) * speed; return new Bullet(baseX, baseY, vx, vy); } public void draw(Graphics2D g2) { g2.setColor(Color.ORANGE); int len 50; int ex (int) (baseX Math.cos(angle) * len); int ey (int) (baseY Math.sin(angle) * len); g2.setStroke(new BasicStroke(8, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); g2.drawLine(baseX, baseY, ex, ey); g2.fillOval(baseX - 20, baseY - 20, 40, 40); } public int getBaseX() { return baseX; } public int getBaseY() { return baseY; } }逻辑说明Math.atan2(dy, dx)直接得到角度省去判断象限的麻烦。发射时用cos/sin把角度转成速度分量子弹就沿炮口方向飞。绘制时用BasicStroke加圆头炮管看起来更顺眼。参数说明speed 600是子弹初速太快看不清轨迹太慢打不到鱼600 到 900 之间比较合适炮管长度len只影响视觉不影响判定。子弹类import java.awt.*; public class Bullet extends GameObject { private final int radius 6; public Bullet(double x, double y, double vx, double vy) { super(x, y); this.vx vx; this.vy vy; } Override public void update(double delta) { x vx * delta; y vy * delta; if (x -20 || x 980 || y -20 || y 660) { alive false; } } Override public void draw(Graphics2D g2) { g2.setColor(Color.YELLOW); g2.fillOval((int) (x - radius), (int) (y - radius), radius * 2, radius * 2); } public int getRadius() { return radius; } }到这里鱼、炮台、子弹三件套齐了游戏已经能「动」起来。接下来是让它们产生交互的碰撞检测。4. 碰撞检测与命中判定圆形判定为什么比矩形更合适4.1 圆与圆的距离判定捕鱼达人里鱼和子弹都是近似圆形用圆与圆的距离判定最自然也最省算力。判定条件是两圆心距离小于半径之和。public class CollisionUtil { // 圆与圆碰撞 public static boolean circleHit(double x1, double y1, int r1, double x2, double y2, int r2) { double dx x1 - x2; double dy y1 - y2; double distSq dx * dx dy * dy; double radiusSum r1 r2; return distSq radiusSum * radiusSum; // 用平方比较省一次开方 } }逻辑说明比较距离平方而不是距离避免每帧对每条鱼每颗子弹都调用Math.sqrt。在鱼和子弹数量都上百时这个优化能明显降低 CPU 占用。参数说明r1、r2分别是子弹和鱼的半径。如果你想让判定更「宽容」可以给半径乘一个系数比如r2 * 1.1玩家体验会更好但别调太大否则会出现「没碰到也算中」的翻车感。4.2 在主循环里组织命中逻辑命中处理放在update里遍历子弹和鱼命中后子弹消失、鱼消失、加分。private void checkCollisions() { ListBullet bullets bulletManager.getBullets(); ListFish fishes fishManager.getFishes(); for (Bullet b : bullets) { if (!b.isAlive()) continue; for (Fish f : fishes) { if (!f.isAlive()) continue; if (CollisionUtil.circleHit(b.getX(), b.getY(), b.getRadius(), f.getX(), f.getY(), f.getRadius())) { b.setAlive(false); f.setAlive(false); score f.getScore(); break; // 一颗子弹只打一条鱼 } } } }逻辑说明外层遍历子弹内层遍历鱼命中后break跳出内层避免一颗子弹连续打中多条鱼。两个isAlive判断跳过已失效对象减少无效计算。参数说明score是累计得分可以配合金币音效和飘分特效。如果鱼的数量很大比如 200 条以上这种 O(n*m) 的双重循环会开始吃力后面第 6 章会讲怎么优化。4.3 命中后的反馈飘分与音效只让鱼消失太干巴加一个飘分文字能显著提升手感。import java.awt.*; public class FloatingText { private double x, y; private final String text; private double life 1.0; // 剩余生命1 到 0 public FloatingText(double x, double y, String text) { this.x x; this.y y; this.text text; } public void update(double delta) { y - 40 * delta; // 向上飘 life - delta; // 逐渐消失 } public boolean isAlive() { return life 0; } public void draw(Graphics2D g2) { int alpha (int) (Math.max(0, life) * 255); g2.setColor(new Color(255, 215, 0, alpha)); g2.setFont(new Font(微软雅黑, Font.BOLD, 20)); g2.drawString(text, (int) x, (int) y); } }逻辑说明life从 1 递减到 0同时控制透明度和存活状态一个变量搞定淡出和回收。y每帧上移形成飘动效果。参数说明40 * delta是上飘速度life - delta表示大约 1 秒后消失可按手感调整。字体建议用系统自带中文字体避免打包后中文变方块。音效方面JDK 自带javax.sound.sampled可以播放 wav但要注意音效播放不能阻塞主循环常见做法是丢到一个单独的线程池里异步播放或者用Clip预加载后setFramePosition(0)重放。这块坑不少下一章专门讲。5. 捕鱼达人开发避坑资源、线程与性能的 5 个血泪教训5.1 图片资源路径在 IDE 能跑、打包成 jar 就报错现象在 Eclipse 或 IDEA 里运行正常java -jar启动后抛NullPointerException定位到加载图片那行。原因用了new File(res/fish.png)这种相对文件系统路径jar 打包后资源在压缩包里不是磁盘文件File找不到。解决统一用类加载器读流getClass().getResourceAsStream(/res/fish.png)路径以/开头表示从 classpath 根开始。图片用ImageIO.read(inputStream)读成BufferedImage后续绘制和缩放都方便。5.2 在游戏循环线程里直接操作 Swing 组件现象偶尔报奇怪的绘制异常或者界面更新延迟、闪烁。原因Swing 组件不是线程安全的在游戏线程里直接label.setText()或改组件状态和 EDT 的绘制撞车。解决游戏逻辑只改自己的数据模型绘制统一走paintComponent确实要更新组件用SwingUtilities.invokeLater切回 EDT。最省事的做法是干脆不用 Swing 组件显示分数直接在paintComponent里用drawString画彻底避开线程问题。5.3 每帧 new 对象导致 GC 频繁卡顿现象游戏跑几分钟后开始规律性卡顿帧率掉一下又恢复。原因在update或draw里频繁new Color()、new Font()、new BasicStroke()甚至每帧 new 一堆临时对象触发频繁垃圾回收。解决把不变的Color、Font、Stroke提成static final常量或成员变量只创建一次。子弹、飘分这类对象用对象池复用而不是打一颗 new 一颗。这是小游戏性能优化里性价比最高的一招。5.4 碰撞检测顺序导致「子弹穿鱼」现象子弹速度快时偶尔从鱼身上穿过去没判定命中。原因子弹每帧位移大于鱼的直径这一帧在鱼左边下一帧已经在鱼右边中间没有采样点圆判定自然漏掉。解决两个办法。一是限制子弹速度让单帧位移小于最小鱼半径二是做「扫掠检测」用子弹这一帧的起点和终点连成线段判断线段与鱼圆是否相交。实战里更简单的是把子弹速度控制在合理范围配合 60 帧基本不会穿。5.5 窗口关闭后线程没停进程残留现象点了关闭按钮窗口没了但任务管理器里 Java 进程还在。原因JFrame默认关闭只隐藏窗口游戏循环线程还在while(running)里跑非守护线程不结束JVM 就不退出。解决关闭时显式调用stopGame()把running置 false 并join等待线程结束再dispose()和System.exit(0)。或者把游戏线程设为守护线程setDaemon(true)但显式停止更干净能保证资源释放。6. 让捕鱼达人更耐玩对象池、难度曲线与一个可复用的帧率验证技巧前面五章把能跑能玩的版本搭完了但一个「经典项目」和「练手 demo」的差距往往在细节。这一章讲三个能明显提升完成度的进阶点都是我实际写小游戏时反复用到的。先说对象池。子弹和飘分文字是高频创建销毁的对象一局下来可能几万个。与其让 GC 反复回收不如预分配一个池子用的时候取、不用的时候还。核心思路是用一个boolean active标记代替newimport java.util.ArrayDeque; import java.util.Deque; public class BulletPool { private final DequeBullet pool new ArrayDeque(); public Bullet obtain(double x, double y, double vx, double vy) { Bullet b pool.pollFirst(); if (b null) { b new Bullet(x, y, vx, vy); // 池空才新建 } else { b.reset(x, y, vx, vy); // 复用重置状态 } return b; } public void recycle(Bullet b) { if (pool.size() 200) { // 限制池大小防止无限增长 pool.offerFirst(b); } } }逻辑说明obtain优先从池里取取不到才新建recycle把失效子弹放回池。Bullet需要加一个reset方法重置坐标、速度和alive状态。池大小设上限避免极端情况下内存只增不减。参数说明池上限 200 对普通捕鱼游戏足够子弹在屏幕上同时存在的数量通常不超过几十。如果你的游戏子弹特别密集可以调到 500。再说难度曲线。固定生成间隔玩久了会腻常见做法是随时间线性或分段提升难度每过 30 秒生成间隔减少 10%鱼速提升 5%但设一个下限避免后期完全没法玩。private double difficulty 1.0; // 难度系数 public void update(double delta) { elapsed delta; difficulty 1.0 elapsed / 30.0 * 0.1; // 每 30 秒涨 0.1 difficulty Math.min(difficulty, 2.5); // 封顶 double interval BASE_INTERVAL / difficulty; // 用 interval 控制生成节奏 }逻辑说明difficulty从 1 开始缓慢增长并封顶生成间隔按它反比缩小。封顶是为了保证游戏始终可玩不至于变成「鱼海」直接劝退。参数说明BASE_INTERVAL是初始生成间隔比如 0.8 秒封顶 2.5 意味着最快时生成间隔约为初始的 40%这个强度对多数玩家刚好。最后是一个我常用的帧率验证技巧在paintComponent里统计每秒实际重绘次数直接画在屏幕角落。别信「我设了 60 帧就是 60 帧」实际帧率受机器、GC、绘制复杂度影响只有测出来才作数。private int frameCount 0; private long lastFpsTime System.currentTimeMillis(); private int fps 0; // 在 paintComponent 末尾调用 private void updateFps() { frameCount; long now System.currentTimeMillis(); if (now - lastFpsTime 1000) { fps frameCount; frameCount 0; lastFpsTime now; } // 绘制到左上角 // g2.drawString(FPS: fps, 10, 20); }逻辑说明每秒统计一次重绘次数得到真实帧率。如果发现帧率长期低于 50就要回头查是不是每帧 new 对象太多、碰撞检测太密、或者绘制了过多抗锯齿图形。参数说明统计窗口 1000 毫秒是经验值太短波动大太长反应迟钝。这个数字建议只在开发期显示正式版可以隐藏或做成开关。我自己的习惯是任何小游戏先把帧率显示做出来再开始堆玩法。因为一旦卡顿你根本分不清是逻辑写错了还是性能不够有个实时数字排查方向立刻清晰。捕鱼达人这个项目看着简单但把循环、对象模型、碰撞、资源、性能这几块都走一遍你对 Java 做实时交互程序的理解会上一个台阶。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进