Java Swing开发捕鱼达人:从游戏逻辑到碰撞检测实战
2026/9/2 1:45:25 网站建设 项目流程

简介:这是一份面向Java学习者与游戏开发初学者的完整捕鱼达人桌面游戏源码项目,把一个典型的Java游戏开发流程完整呈现出来:玩家控制大炮发射子弹捕捉游鱼,捕获不同鱼种获得对应分数,背后需要综合运用对象建模、界面绘制、多线程调度、事件监听、动画与帧率控制、碰撞检测等核心技能。压缩包体积约12.8MB,一共包含233个文件,其中既有63个Java源文件和95个class编译产物,也有png图片素材、ogg与mp3音效文件、plist配置说明以及jar依赖库,方便对照代码理解图片、音频、配置等资源在游戏中的加载方式。同时还提供了bat启动脚本和项目配置文件,便于直接运行和二次开发,适合课程设计或毕业设计场景。已有800人学习/下载,读者可以从这个项目中掌握游戏循环、资源管理、对象组织与常见排错思路,是一份兼具完整性与实用性的入门练手项目。 看到“Java实现捕鱼达人游戏源码”这个标题,我的第一反应是:又是一个经典到不能再经典的Java练手项目。捕鱼达人这游戏,从街机厅火到手机端,玩过的人不少,但真正自己动手写过的,恐怕没那么多了。其实拿Java来复刻一个捕鱼达人,是一个性价比极高的学习路径——它能一次性覆盖Swing图形界面、事件监听、多线程刷新、碰撞检测、游戏状态管理等一堆Java核心知识点,而且做完之后成就感是实打实的。这篇文章我就把这个项目的完整思路、核心代码逻辑和我在实际开发中踩过的坑完整拆一遍,给你一份可以直接照着写的“作业参考”。

1. 项目整体设计与玩法拆解

动手写代码之前,我通常先不做任何技术选型,而是先把“游戏应该是什么样”定义清楚。捕鱼达人这个项目如果剥掉华丽的外皮,核心其实就四件事:鱼从哪来、鱼往哪走、子弹怎么打、命中了怎么算。

1.1 捕鱼达人的核心玩法逻辑

捕鱼达人最基础的玩法是:玩家控制炮台,点击屏幕发射炮弹,炮弹在水中飞行,碰到鱼时触发捕获判定。不同种类的鱼有不同的分值,捕获难度也不同。小丑鱼、灯笼鱼这类容易打、分低;金鲨、蓝鲸这类难打,但打中一条就能让分数大幅上涨。

在落地成Java代码的时候,这五个环节可以拆成五个独立的模块来设计:

  • 鱼群生成模块:负责在屏幕边缘随机生成鱼,控制生成频率和鱼的类型。
  • 鱼群移动模块:负责让鱼按指定路径游动,游出屏幕后回收。
  • 炮台与子弹模块:负责炮台角度跟随鼠标、子弹发射和飞行。
  • 碰撞检测模块:负责检测子弹与鱼是否相交,并计算捕获结果。
  • 计分与游戏状态模块:负责金币、炮台等级、游戏结束条件等UI和状态更新。

这个拆法不仅让代码结构清晰,也方便你后期扩展——比如将来想加一个新鱼种,只需要新增一个鱼类型,然后把它挂到生成器里就行,完全不需要改动其他模块。

1.2 项目包结构与类设计规划

很多初学者拿到这个项目就直接开写,最后写出来的代码全挤在几个类里,改一个功能能引发三个bug。我建议在一开始就把类结构规划好。当时我的项目包结构长这样:

com.fish.game ├── main/GameFrame.java // 游戏主窗口 ├── panel/GamePanel.java // 游戏画布、核心绘制与循环 ├── model/Fish.java // 鱼类 ├── model/FishType.java // 鱼类型枚举 ├── model/Bullet.java // 子弹类 ├── model/Turret.java // 炮台类 ├── manager/FishManager.java // 鱼群生成与管理 ├── manager/CollisionManager.java // 碰撞检测 └── util/ImageUtil.java // 图片资源加载

核心思路是“模型-视图-控制器”的简化版变体:Fish、Bullet这些是模型,只管数据和状态;GamePanel是视图兼控制器,负责绘制和接收用户输入;Manager类负责游戏逻辑的调度。

提示:没必要一上来就套太重的框架或设计模式。捕鱼达人这个体量的项目,做到“各司其职”就够了,过度设计反而是负担。

1.3 为什么选Swing而不是JavaFX或其他框架

这是不少人问过我的问题。Java做游戏最常被提到的选项有三个:纯Swing/AWT、JavaFX、还有LibGDX这种外部库。

我的建议是练手项目优先选Swing。原因很简单:Swing是JDK自带的,不需要额外配置依赖,你只要有JDK就能跑;JavaFX虽然是Java官方的GUI框架,但配置起来相对繁琐,而且它的线程模型和Swing不太一样,对新手来说反而多一层学习成本;LibGDX确实强大,但它面向的是专业游戏开发,引入之后你得先了解它的生命周期、资源加载机制、渲染管线,已经偏离了“用Java实现一个游戏”的学习目标。

Swing虽然性能不如专业游戏引擎,但捕鱼达人这种2D游戏,绘制速度完全够用。核心做法是在JPanel上重写paintComponent方法,用双缓冲绘制,后面我会详细说。

2. 核心技术选型与关键实现原理

这个项目看着像是个玩具,但里面涉及的技术点其实相当硬核。每个核心机制背后都有值得深挖的原理,尤其是线程模型和碰撞检测,这两块容易写错,也最容易在面试时被追问。

2.1 Swing绘制机制与双缓冲原理

Swing的绘制原理表面上很简单:你继承JPanel,重写paintComponent(Graphics g),然后在这个方法里画东西。但实际运行时会涉及一个关键机制——重绘调度。

当你调用repaint()方法时,其实并不是立刻就会去调用paintComponent(),而是由Swing的事件分发线程(EDT,Event Dispatch Thread)在未来的某个时间点统一处理。这种机制的好处是避免了多线程并发绘制同一个组件导致的界面崩溃问题。

但随之而来的问题是:如果你的绘制逻辑比较复杂,画面会闪烁。这是因为系统在擦除背景和重新绘制之间有一个短暂的时间差,人眼能感知到这个闪烁。解决方案就是双缓冲——先在内存里的“虚拟画布”上完成所有绘制,再把整个画布一次性复制到屏幕上。

在Swing里,JPanel默认就已经双缓冲了,只要你没有手动关闭它。所以你在写代码的时候,只需要放心地在paintComponent里绘制你的鱼和子弹,不需要关心底层双缓冲细节。这也是我推荐Swing的一个重要原因:底层机制帮你处理好了很多头疼的事。

2.2 游戏主循环与多线程刷新设计

游戏和普通GUI程序的本质区别在于:游戏需要持续不断地刷新界面,形成动画效果。捕鱼达人里的鱼在游动,子弹在飞行,炮台在转动,这些都在每一帧变化。

实现方式有两种:

  1. 使用javax.swing.Timer定时器,每隔固定毫秒数触发一个事件,在事件里更新游戏状态并重绘。
  2. 使用独立线程循环,在run方法里执行“更新逻辑”然后调用repaint()。

我的建议是用Swing Timer。用独立线程容易踩线程安全问题的坑——Swing组件只能在EDT线程中进行操作,你在另一个线程里直接调用repaint()虽然通常不会报错,但如果你在非EDT线程里修改了组件状态,可能会引发偶发性的界面错乱或异常。

Timer则是天然跑在EDT线程上的,每次触发就相当于在Swing的事件流里执行一次更新和重绘,不会产生线程安全问题。

当时我的主循环设置的是每秒60帧,也就是每隔16毫秒刷新一次:

Timer timer = new Timer(16, e -> { updateGameState(); // 更新鱼位置、子弹位置 repaint(); // 请求重绘 }); timer.start();

这个帧率对于捕鱼达人来说已经足够流畅了。设置太高没有意义,因为人眼能感知到的流畅度上限大概就是60帧;设置太低画面会卡顿,尤其当鱼多子弹多的时候。

2.3 碰撞检测的核心算法选择

碰撞检测是整个游戏里最核心的逻辑之一,也是最容易写出bug的地方。捕鱼达人里的碰撞检测有两种主流方案:

  • 矩形碰撞检测:把鱼和子弹都看作一个矩形(包围盒),检测两个矩形是否相交。
  • 圆形碰撞检测:把鱼和子弹都看作一个圆,计算圆心距离是否小于半径之和。

矩形检测的实现最简单,Java的Rectangle类自带intersects(Rectangle r)方法,直接用就行:

boolean hit = fish.getBounds().intersects(bullet.getBounds());

圆形检测稍微复杂一点,但更贴合实际形状,代码也很简单:

double dx = fishCenterX - bulletCenterX; double dy = fishCenterY - bulletCenterY; double distance = Math.sqrt(dx * dx + dy * dy); boolean hit = distance < fishRadius + bulletRadius;

我当时是混用两种方案的。子弹和鱼都用矩形包围盒做一次初步检测,如果矩形不相交,那么肯定没碰撞,直接跳过。如果矩形相交了,再判断是否真的命中。这个思路本质上就是“先粗检测,再精检测”,效率高且不易漏判。

注意:鱼的形状通常是长条形的,用矩形做包围盒会比实际鱼身大一圈,如果直接按矩形判定,会出现子弹打中空气但鱼被捕获的情况。所以精检测阶段建议用圆形或更小的内嵌矩形来缩小判定范围。

2.4 捕获概率设计:让游戏真正“好玩”的关键

我说个很多人会忽略的细节:捕鱼达人真正的游戏性不在碰撞检测,而在捕获概率的设计上。如果子弹碰到鱼就100%捕获,那这个游戏玩起来就很乏味,因为只要你瞄准了,什么鱼都能打下来。

常见的设计思路是:子弹命中鱼后,系统根据鱼的等级生成一个捕获概率。小鱼概率高,大鱼概率低,同时配合炮弹等级来影响概率。具体公式可以简单设计成:

捕获概率 = 基础捕获率 * (炮弹等级系数 / 鱼的等级)

比如小丑鱼的基础捕获率是90%,金鲨的基础捕获率是20%,你的炮弹等级如果提升一级,捕获率就乘以1.1。

这个概率判断放在碰撞检测命中之后:

if (CollisionManager.checkHit(fish, bullet)) { double probability = bullet.getLevel() * FishType.getBaseRate(fish.getType()); if (Math.random() < probability) { catchFish(fish, bullet); } }

从实现角度来说,这只是一行随机数判断,但从游戏设计角度来说,这决定了你整个游戏的平衡性和耐玩度。我建议参数调优时不要只靠感觉,把概率做成可以动态调整的配置,多测几轮再固化。

3. 核心玩法模块的源码实现

理论讲得差不多了,接下来咱们看真正能跑的代码。这一部分我会把核心模块的完整实现思路和关键代码写出来,你先别复制粘贴,跟着思路走一遍,理解了再自己写。

3.1 鱼类设计:属性、移动与回收机制

Fish这个类承载了所有鱼相关的数据和逻辑。属性包括:鱼的类型、当前位置坐标、移动速度和移动方向、图片、宽度高度等。为了让鱼看起来自然,不同鱼种的速度应该不同,小鱼游得快,大鱼游得慢。

我定义了FishType这个枚举来统一管理鱼的类型参数:

public enum FishType { MINNOW(0, 30, 1, 1.0), // 小丑鱼:10分,速度快 YELLOW_FISH(1, 50, 2, 0.8), // 小黄鱼:20分 SHARK(2, 100, 5, 0.4), // 蓝鲨:100分,速度慢 GOLDFISH(3, 200, 8, 0.5), // 金鱼:200分 WHALE(4, 300, 15, 0.3); // 大鲸鱼:300分,极慢 private final int typeId; private final int score; private final int probabilityWeight; private final double speedFactor; // 构造方法和getter省略 }

枚举里每个鱼种都配置了分数、出现权重和速度系数。出现权重决定了鱼在生成时被选中的概率,权重高的出现更频繁,这样游戏难度曲线是平滑的——你大部分时间打的都是小鱼,偶尔碰到大鱼,会更有惊喜感。

鱼移动的逻辑就写在update方法里:

public void update() { x += speed * directionX; y += speed * directionY; }

方向向量在生成鱼时随机赋予。为了让鱼游动得自然一点,我加了轻微的y轴波动,让鱼在游动时不是一条直线,而是有上下起伏的效果。

当鱼完全游出屏幕后,就把它的状态标记为“已移除”,由FishManager统一清理,避免内存中积累一堆看不见的鱼对象。

3.2 子弹系统:鼠标瞄准、发射与生命周期

捕鱼达人里炮台的瞄准方式很简单——鼠标指向哪里,炮口就朝向哪里,点击时发射一枚子弹。

鼠标事件的监听直接写在GamePanel里:

addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (gameState == GameState.RUNNING) { Turret.fire(e.getX(), e.getY()); } } }); addMouseMotionListener(new MouseMotionAdapter() { @Override public void mouseMoved(MouseEvent e) { turret.setAngleByTarget(e.getX(), e.getY()); } });

炮台角度计算用Math.atan2方法,这个方法能根据鼠标坐标和炮塔坐标的差值算出角度,非常方便:

public void setAngleByTarget(int targetX, int targetY) { double dy = targetY - turretCenterY; double dx = targetX - turretCenterX; angle = Math.atan2(dy, dx); }

子弹发射后,会沿着炮台指向的角度匀速飞行。每一帧更新子弹的位置:

public void update() { x += Math.cos(angle) * speed; y += Math.sin(angle) * speed; }

子弹也有生命周期。如果子弹飞行了足够长的距离还没有碰撞到任何鱼,就应该让它消失。否则子弹会无限飞,浪费内存和性能。这个逻辑通过检查子弹X、Y坐标是否超出屏幕范围实现。

3.3 碰撞检测与捕获判定逻辑

碰撞检测的思路前面已经说过了,关键是把检测逻辑封装在独立的CollisionManager里,而不是散落在各个类中,这样方便统一维护。

我当时的实现逻辑分三步:

第一步,遍历当前所有存活子弹,判断子弹是否与某条鱼相交。

第二步,先用矩形包围盒粗检测,再用圆形精检测。因为鱼比较多的时候,直接对所有鱼做圆形距离计算会有一定成本,先用Rectangle的intersects方法过滤掉大部分不相交的样本,能省很多计算。

第三步,命中后按概率决定是否捕获。捕获成功,则给玩家加分,移除鱼和子弹;捕获失败,只移除子弹,鱼继续游走。

public static boolean checkHit(Fish fish, Bullet bullet) { Rectangle fishRect = fish.getBounds(); Rectangle bulletRect = bullet.getBounds(); if (!fishRect.intersects(bulletRect)) { return false; } // 精检测:以中心和半径做圆碰撞 double dx = fish.getCenterX() - bullet.getCenterX(); double dy = fish.getCenterY() - bullet.getCenterY(); double distance = Math.sqrt(dx * dx + dy * dy); return distance < fish.getRadius() + bullet.getRadius(); }

这套逻辑看起来简单,但在实际运行时会发现一个新问题:子弹飞行速度很快,可能某一帧子弹还在鱼的左侧,下一帧就直接穿到了鱼的右侧,但两帧之间鱼和子弹的位置差让它们永远没有“相交”的那一刻。这就是经典的“子弹穿透问题”。

解决方法是做“连续碰撞检测”,把子弹在本帧的移动路径当作一条线段,检测这条线段是否与鱼的包围盒相交。在Swing这种简单场景里,可以在子弹位置更新之前记录旧位置,然后判断“旧位置到新位置”这条线段与鱼的矩形区域是否有交点。如果不需要这么精细,另一个简单粗暴的办法是把子弹的速度控制在一个合理范围,子弹每帧移动的距离小于鱼的包围盒尺寸,基本也能避免穿透。

3.4 计分系统与游戏状态管理

计分这块用Swing并不复杂。在GamePanel顶部用JLabel展示当前金币数和炮弹等级,每次捕获鱼后更新文本内容。

但游戏状态管理这部分值得多说一句。捕鱼达人不是一个“单局制”的游戏,但为了代码完整,我还是给它加了状态机:READY(准备中)、RUNNING(运行中)、PAUSED(暂停)、GAMEOVER(结束)。

我用一个枚举来管理游戏状态:

public enum GameState { READY, RUNNING, PAUSED, GAMEOVER }

主循环每次更新和绘制之前都先检查当前游戏状态,不同状态走不同的逻辑分支。这样整个游戏的生命周期是可控的,不会出现“暂停了鱼还在游”这种问题。

金币扣除的逻辑是:每发射一颗子弹,就从玩家金币里扣除炮弹等级对应的金币数。如果金币不够,不允许发射。捕获成功的鱼的分值由鱼类型和炮弹等级共同决定,炮弹等级越高,分数倍率越高——这也和街机厅的原版设计保持一致。

4. 常见问题与排查技巧实录

这部分可能是最有价值的内容了。我在实际写这个项目的过程中踩了不少坑,这些问题如果你提前知道解决方案,能省下大量调试时间。

4.1 画面闪烁,鱼在屏幕上“闪来闪去”

这是我遇到的第一个问题。一开始我用的是线程循环加repaint()的方式刷新界面,画面抖动得非常厉害。后来排查发现,问题出在绘制逻辑上——在paintComponent里,我用的是直接绘制,背景清理和图像绘制之间有延迟,人眼能感知到。

解决方法简单粗暴:要么确保JPanel的双缓冲生效,要么在绘制之前调用super.paintComponent(g)来清屏。实际上Swing默认双缓冲已经开着,但我人为覆盖paintComponent时忘了调用super的方法,导致背景没有正确清除,画面当然会花。

经验教训:重写paintComponent时,第一行代码务必调用super.paintComponent(g),这不仅仅是惯例,它会触发布局管理器更新和清屏操作。

4.2 鱼和子弹都动得“一卡一卡”的

卡顿的原因通常是主循环里做了太重的计算。比如每次生成新鱼时都重新加载一次图片对象,磁盘I/O累积多了就会拖慢整个刷新频率。

优化思路是做一个图片缓存工具类:

public class ImageUtil { private static final Map<String, Image> IMAGE_CACHE = new HashMap<>(); public static Image load(String path) { return IMAGE_CACHE.computeIfAbsent(path, p -> { try { return ImageIO.read(ImageUtil.class.getResource(p)); } catch (IOException e) { e.printStackTrace(); throw new RuntimeException("图片加载失败: " + p); } }); } }

用这个工具类统一加载和管理图片资源,所有鱼和子弹的图片都从缓存中获取,避免每次new ImageIcon时重复加载文件。

4.3 图片路径找不到,运行时报文件不存在

这个问题很典型,尤其当你用IDE直接运行和打包成Jar之后,行为还不一样。很多人直接用相对路径“images/fish1.png”,本地能跑,但打成Jar包之后路径就失效了。

标准做法是使用类加载器来获取资源路径:

URL imageUrl = getClass().getClassLoader().getResource("images/fish1.png"); Image image = ImageIO.read(imageUrl);

关键是确保图片文件放在了src/main/resources目录下。如果你用的是普通Java项目,要确保构建路径中包含了图片目录。

4.4 暂停之后重新开始,鱼和子弹都“穿越”了

这个问题出在线程逻辑没有真正暂停,只是停止了更新和重绘。我一开始用线程循环,暂停时只让线程sleep一段时间,没有真正暂停状态更新,导致恢复后鱼一下子跳到了很远的位置。

用了Swing Timer之后这个问题迎刃而解。因为定时器本身就可以被stop和start控制,暂停时调用timer.stop(),恢复时调用timer.start(),鱼和子弹的位置就从暂停时的状态继续推进,不会有穿越问题。

4.5 内存占用持续增长,游戏跑久了变卡

这个问题的元凶是:鱼生成后没有及时回收。当时我用一个ArrayList存放所有鱼对象,鱼游出屏幕后我只是把它的可见性设为false,但没从list里移除。时间一长,list里积累了成千上万条“消失但没移除”的鱼,遍历所有鱼做碰撞检测时,性能自然就崩了。

解决方法是维护一个“待移除队列”,把已经游出屏幕或者被捕获的鱼统一标记,然后在主循环的更新阶段统一移除。其实更高效的做法是用对象池来复用鱼对象,但对于这个体量的项目,ArrayList + 及时移除已经足够。

总结一下我这个项目的最终实现效果

我最后完成的项目支持炮台旋转瞄准、四种子弹等级、十种鱼、多线程鱼群生成、概率捕获判定、计分与暂停/继续功能。主循环稳定运行在60帧,运行一小时内存占用没有持续增长。

我个人的体会是,这个项目的核心难点不在于某个单一的技术点,而在于如何把它们串起来,让整个游戏处于“均衡运行”的状态。鱼生成频率多少合适,子弹速度多快合适,捕获概率怎么调才让人既觉得爽又不会腻——这些都是用调试和时间慢慢磨出来的。如果你是Java初学者,可以只实现最基础的功能,先跑通流程;如果你已经有一定基础,不妨在这个基础上做个局域网联机版,或者加一些特殊道具,这些扩展方向我在实际开发过程中也都会顺手实验一下,每一个小改进都会让游戏变得更有趣。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询