简介:这是一份面向Java初学者与游戏开发爱好者的捕鱼达人小游戏完整源码工程,适合用于课程设计、毕业设计参考或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初学者在学完面向对象、集合和Swing之后,会陷入一个尴尬期:语法都懂,但不知道能做什么。捕鱼达人这个经典项目恰好卡在这个位置上——它足够有趣,能让你有动力写完;又足够复杂,能逼你把面向对象编程、多线程、碰撞检测、定时器、事件监听这些知识点串起来。我见过不少人靠这个项目从“只会写Hello World”过渡到“能独立搭一个小型桌面应用”,也见过不少人卡在鱼群游动卡顿、炮弹打不中鱼、分数计算错乱这些坑里出不来。这篇文章不讲空泛的架构图,而是按实际编码顺序,把Java实现捕鱼达人的核心逻辑、参数设置和常见翻车点一次讲透。适合有Java基础、想找一个完整项目练手的开发者,也适合正在准备课程设计或面试作品的人。
2. 捕鱼达人Java实现的核心机制:鱼群、炮弹与碰撞检测怎么设计
2.1 为什么用Swing而不是JavaFX或游戏引擎
做捕鱼达人这类2D小游戏,Java生态里常见的选择有三个:Swing、JavaFX和LibGDX。Swing虽然老,但它的优势在于JDK自带、零依赖、资料多,而且对于捕鱼达人这种“背景图+精灵图+定时刷新”的模式完全够用。JavaFX的动画API更现代,但配置和打包在部分JDK版本上容易出玄学问题。LibGDX是专业游戏框架,但学习曲线陡,对只想练Java的人来说属于杀鸡用牛刀。
我一般会建议:如果你的目标是练Java本身,选Swing;如果目标是做商业小游戏,直接上Unity或Cocos。捕鱼达人的核心逻辑——对象管理、碰撞检测、状态更新——在Swing里写一遍,换到任何框架都能复用。
Swing实现捕鱼达人的基本结构是:一个JFrame作为窗口,一个JPanel作为游戏画布,在JPanel里重写paintComponent方法绘制所有元素,再用javax.swing.Timer或单独的游戏线程驱动逻辑更新。这里有个关键选择:用Timer还是用Thread。Timer的回调在EDT(事件调度线程)上执行,绘制和逻辑在同一线程,不会出现并发绘制问题,但逻辑复杂时可能阻塞界面。用独立线程做逻辑更新、EDT只负责绘制,性能更好,但需要处理线程安全。对于捕鱼达人这种量级,我推荐用独立线程+repaint()的方式,后面会给出具体代码。
2.2 鱼群生成与游动路径的参数化设计
鱼群不能随机乱跑,否则玩家体验会很差。常见的做法是给每条鱼定义一条预设路径,路径由若干个控制点组成,鱼沿着控制点移动。控制点的生成有两种策略:一种是固定路径池,每次从池里随机取一条;另一种是动态生成,根据屏幕边缘随机出鱼点和入鱼方向计算贝塞尔曲线。
我一般用第一种,因为可控性强,调试方便。具体做法是定义一个FishPath类,包含一个Point列表和一个速度系数。鱼在移动时,每帧根据当前速度向目标点移动,到达后切换到下一个点。当鱼离开屏幕范围后,标记为可回收。
public class Fish { private List<Point> path; // 路径控制点 private int currentIndex; // 当前目标点索引 private double x, y; // 当前位置 private double speed; // 移动速度(像素/帧) private int type; // 鱼的类型,决定分值 private boolean alive; // 是否存活 public void move() { if (currentIndex >= path.size()) { alive = false; // 路径走完,鱼离场 return; } Point target = path.get(currentIndex); double dx = target.x - x; double dy = target.y - y; double dist = Math.sqrt(dx * dx + dy * dy); if (dist < speed) { x = target.x; y = target.y; currentIndex++; } else { x += speed * dx / dist; y += speed * dy / dist; } } }这段代码里,speed是每条鱼的核心参数。speed太大,鱼一闪而过,玩家来不及瞄准;speed太小,鱼群堆积,画面混乱。我的经验值是:小鱼speed在2到4之间,大鱼在1到2之间。另外,currentIndex到达路径末尾时直接标记alive=false,由游戏主循环统一清理,避免在移动过程中删除集合元素导致并发修改异常。
路径控制点的生成,我通常让鱼从屏幕左边缘或右边缘进入,斜向穿过屏幕。比如从左边缘进入的鱼,控制点可以设为(-50, randomY)、(200, randomY±100)、(500, randomY±50)、(850, randomY)。这样鱼会有一个自然的弧线游动,而不是直线平移。
2.3 炮弹发射与碰撞检测的实现细节
炮弹的发射逻辑相对简单:鼠标点击时,在炮台位置生成一个炮弹对象,方向指向点击位置。炮弹沿直线飞行,超出屏幕后销毁。真正容易出问题的是碰撞检测。
捕鱼达人的碰撞检测本质是“点与旋转矩形的相交判断”或者“圆与圆的距离判断”。为了性能和精度平衡,我一般用圆形碰撞盒:每条鱼有一个碰撞半径,炮弹也有半径,两者中心距离小于半径之和即判定命中。
public boolean checkCollision(Bullet bullet, Fish fish) { double dx = bullet.getX() - fish.getX(); double dy = bullet.getY() - fish.getY(); double distance = Math.sqrt(dx * dx + dy * dy); return distance < (bullet.getRadius() + fish.getRadius()); }这里有个血泪经验:鱼的碰撞半径不能直接用图片宽度的一半。因为鱼的图片通常包含透明区域,实际鱼身只占图片中间一部分。如果直接用图片尺寸算半径,会出现“炮弹明明没碰到鱼却判定命中”的情况。我的做法是给每种鱼单独配置一个collisionRadius参数,小鱼8到12像素,大鱼20到30像素,在代码里硬编码或从配置文件读取。
另一个坑是碰撞检测的时机。如果在炮弹移动后立即检测,而鱼也在同一帧移动,可能出现“穿透”现象——炮弹速度太快,一帧移动了30像素,而鱼身只有20像素宽,炮弹直接从鱼身上“跳”过去了。解决办法有两个:一是限制炮弹速度,每帧移动不超过鱼的最小碰撞半径;二是做连续碰撞检测,在炮弹的移动路径上采样多个点分别检测。对于捕鱼达人,限制速度就够了,炮弹速度一般设为8到12像素/帧。
3. 用Java Swing搭出可运行的捕鱼达人:从窗口到游戏循环
3.1 项目结构与核心类划分
在动手写代码之前,先把类结构定好,不然后面改起来很痛苦。我一般会分成这几个包和类:
com.fishing.core:GamePanel(游戏主面板)、GameLoop(游戏循环线程)com.fishing.entity:Fish(鱼)、Bullet(炮弹)、Cannon(炮台)com.fishing.manager:FishManager(鱼群管理)、BulletManager(炮弹管理)、ScoreManager(分数管理)com.fishing.util:ImageLoader(图片加载)、CollisionUtil(碰撞工具)com.fishing.Main:程序入口
这个划分不是必须的,但好处是职责清晰。FishManager负责鱼的生成、移动、回收;BulletManager负责炮弹的创建、移动、销毁;ScoreManager只管分数和金币。GamePanel负责组装和绘制。
图片资源放在src/main/resources/images/目录下,用ImageIO读取。注意:Swing的图片加载要在EDT之外做,否则启动时会卡界面。我一般用一个静态代码块或初始化方法提前把所有图片加载到内存,游戏过程中只引用不重复读取。
3.2 游戏主循环的两种写法与选择依据
游戏主循环是驱动一切的核心。前面提到可以用Timer或Thread,这里给出两种写法的对比。
Timer写法:
Timer timer = new Timer(16, e -> { updateGame(); // 更新鱼、炮弹位置,检测碰撞 repaint(); // 触发重绘 }); timer.start();16毫秒对应约60帧每秒。这种写法简单,所有逻辑在EDT上执行,不需要考虑线程安全。但缺点是如果updateGame耗时超过16毫秒,界面会卡顿,而且Timer会堆积事件。
Thread写法:
Thread gameThread = new Thread(() -> { while (running) { long start = System.currentTimeMillis(); updateGame(); repaint(); long elapsed = System.currentTimeMillis() - start; long sleepTime = Math.max(1, 16 - elapsed); try { Thread.sleep(sleepTime); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); gameThread.start();这种写法把逻辑更新放在独立线程,EDT只负责绘制。好处是逻辑更新不会阻塞界面,帧率更稳定。但要注意:updateGame里修改的集合(鱼列表、炮弹列表)在paintComponent里会被遍历,如果一边改一边遍历,会抛ConcurrentModificationException。解决办法是用CopyOnWriteArrayList,或者在updateGame里先收集要删除的对象,遍历结束后统一删除。
我一般用Thread写法,配合CopyOnWriteArrayList。虽然CopyOnWriteArrayList每次写操作都会复制数组,但对于捕鱼达人这种每秒增删几十个对象的场景,性能完全够用。
3.3 炮台旋转与鼠标事件绑定
炮台需要跟随鼠标旋转,这涉及到三角函数和坐标变换。炮台的旋转角度由鼠标位置和炮台中心位置决定:
double angle = Math.atan2(mouseY - cannonY, mouseX - cannonX);得到弧度后,在绘制炮台时用Graphics2D的rotate方法旋转画布:
Graphics2D g2d = (Graphics2D) g; g2d.rotate(angle, cannonX, cannonY); g2d.drawImage(cannonImage, cannonX - cannonW / 2, cannonY - cannonH / 2, null); g2d.rotate(-angle, cannonX, cannonY); // 恢复画布,避免影响后续绘制这里有个容易翻车的地方:rotate之后一定要恢复,否则后面绘制的鱼、炮弹都会跟着旋转。我见过有人忘了恢复,结果整个游戏画面歪着转,排查了半天。
鼠标事件绑定用MouseAdapter,监听mousePressed或mouseClicked。注意:mouseClicked需要按下和释放在同一位置才触发,快速点击时可能丢失事件。我一般用mousePressed,响应更及时。
addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { if (e.getButton() == MouseEvent.BUTTON1) { fireBullet(e.getX(), e.getY()); } } });fireBullet方法里计算炮弹方向、创建Bullet对象、加入BulletManager。炮弹的初始位置是炮台中心,方向向量归一化后乘以速度。
3.4 分数结算与金币消耗的联动逻辑
捕鱼达人的经济系统很简单:发射炮弹消耗金币,打中鱼获得金币。但这里有几个参数需要仔细调,否则游戏要么太简单要么太劝退。
炮弹消耗:每发炮弹消耗1金币。鱼的分值:小鱼1到2金币,中鱼3到5金币,大鱼8到15金币。鱼的命中概率:不是打中就一定死,大鱼通常需要多次命中。我一般给每条鱼设置一个hp值,小鱼hp=1,中鱼hp=2,大鱼hp=3到5。炮弹命中后hp减1,hp归零则鱼死亡,玩家获得金币。
public void onHit(Fish fish, Bullet bullet) { fish.setHp(fish.getHp() - 1); if (fish.getHp() <= 0) { fish.setAlive(false); scoreManager.addScore(fish.getType()); } bullet.setAlive(false); }金币消耗和获得的平衡,我的经验是:初始金币100,每发炮弹消耗1,小鱼平均2发命中,中鱼平均3发,大鱼平均5发。这样算下来,打小鱼基本不亏不赚,打中鱼和大鱼才有盈利。如果发现玩家金币一直涨,就降低大鱼的分值或提高大鱼hp;如果金币一直掉,就反过来调。
4. 捕鱼达人Java实现中常见的5个坑与排查方法
4.1 鱼群移动卡顿或瞬移
现象:鱼在屏幕上移动时一卡一卡的,或者突然从屏幕一端跳到另一端。
原因:通常是因为在paintComponent里直接修改了鱼的位置,或者游戏循环的sleep时间不稳定。另一个常见原因是图片加载在绘制时才做,每次repaint都读一次磁盘。
解决:把所有图片在初始化时加载到内存,用ImageIO.read一次性读入。游戏循环用固定时间步长,sleep时间根据实际耗时动态调整。不要在paintComponent里做任何逻辑更新,绘制方法只负责画。
4.2 炮弹打中鱼但分数不增加
现象:肉眼看到炮弹碰到鱼了,但鱼没死,分数也没变。
原因:碰撞检测的半径设置太小,或者碰撞检测的时机不对。还有一种可能是鱼的坐标和绘制坐标不一致——比如鱼的位置是逻辑坐标,但绘制时加了偏移量,碰撞检测用的是逻辑坐标,导致判定区域和视觉区域错位。
解决:先确认碰撞半径是否合理,可以在调试模式下把碰撞圆画出来。然后检查绘制时的坐标变换,确保逻辑坐标和屏幕坐标一致。如果用了Graphics2D的translate或scale,碰撞检测前要把坐标转换回去。
4.3 游戏运行一段时间后越来越卡
现象:刚启动时流畅,玩了几分钟后帧率明显下降。
原因:对象没有及时回收。鱼离场后没有从集合中移除,炮弹超出屏幕后还在列表里,导致每帧遍历的对象越来越多。
解决:在游戏循环的update阶段,遍历所有实体,把alive=false的对象从集合中移除。用Iterator的remove方法,或者用removeIf。注意不要在for-each循环里直接调用集合的remove,会抛异常。
fishList.removeIf(fish -> !fish.isAlive()); bulletList.removeIf(bullet -> !bullet.isAlive());4.4 鼠标点击后炮台不旋转或旋转角度错误
现象:炮台始终朝一个方向,或者旋转角度和鼠标位置对不上。
原因:atan2的参数顺序写反了。atan2(y, x)返回的是从x轴正方向到点(x,y)的弧度,如果写成atan2(x, y)就会得到错误的角度。另一个原因是绘制时rotate的中心点不对,导致炮台绕屏幕左上角旋转。
解决:确认atan2的第一个参数是鼠标Y减炮台Y,第二个参数是鼠标X减炮台X。rotate方法的三个参数是弧度、旋转中心X、旋转中心Y,旋转中心要设为炮台的中心点。
4.5 打包成JAR后图片加载失败
现象:在IDE里运行正常,打包成JAR后双击运行,报NullPointerException或图片不显示。
原因:IDE里图片是作为文件读取的,打包后图片在JAR内部,不能用File路径访问。
解决:用getClass().getResourceAsStream("/images/fish.png")读取图片,返回InputStream,再用ImageIO.read(InputStream)。注意路径以斜杠开头,表示从classpath根目录开始。
InputStream is = getClass().getResourceAsStream("/images/fish.png"); BufferedImage fishImage = ImageIO.read(is);5. 让捕鱼达人更耐玩的3个进阶技巧
5.1 用对象池复用炮弹和鱼对象
当游戏运行时间长了,频繁创建和销毁对象会给GC带来压力。对象池的思路是:预先创建一批对象,用的时候从池里取,不用的时候还回去,而不是直接new和丢弃。
对于炮弹这种生命周期短、创建频率高的对象,对象池效果很明显。实现方式是用一个ConcurrentLinkedQueue存放空闲炮弹,fireBullet时poll一个出来重置状态,炮弹销毁时offer回去。
private Queue<Bullet> bulletPool = new ConcurrentLinkedQueue<>(); public Bullet obtainBullet() { Bullet bullet = bulletPool.poll(); if (bullet == null) { bullet = new Bullet(); } return bullet; } public void recycleBullet(Bullet bullet) { bullet.setAlive(false); bulletPool.offer(bullet); }注意:回收前一定要重置对象的所有状态字段,否则下次取出来会带着上次的数据。这个坑我踩过,炮弹复用了但方向没重置,结果新炮弹沿着旧方向飞。
5.2 用配置表驱动鱼的类型和分值
把鱼的类型、图片路径、速度、碰撞半径、hp、分值这些参数写死在代码里,改一个数值就要重新编译,很麻烦。更好的做法是放到一个JSON或properties文件里,程序启动时读取。
{ "fishTypes": [ {"id": 1, "image": "small_fish.png", "speed": 3.0, "radius": 10, "hp": 1, "score": 2}, {"id": 2, "image": "medium_fish.png", "speed": 2.0, "radius": 18, "hp": 2, "score": 5}, {"id": 3, "image": "big_fish.png", "speed": 1.2, "radius": 28, "hp": 4, "score": 12} ] }这样调整数值不需要改代码,也方便做多套配置测试不同难度。读取JSON可以用Jackson或Gson,如果不想引入第三方库,用java.util.Properties也可以,只是嵌套结构写起来麻烦一点。
5.3 用双缓冲消除画面闪烁
Swing的JPanel默认是单缓冲的,repaint时先清空背景再绘制,如果绘制耗时较长,会看到画面闪烁。解决办法是开启双缓冲:在JPanel的构造函数里调用setDoubleBuffered(true),或者自己用BufferedImage做离屏绘制。
public GamePanel() { setDoubleBuffered(true); setPreferredSize(new Dimension(800, 600)); setBackground(Color.BLACK); }如果setDoubleBuffered(true)后还有闪烁,检查是不是在paintComponent里调用了super.paintComponent(g)。super.paintComponent会清空背景,如果背景是纯色,可以去掉这行,直接绘制背景图。
我自己的习惯是:先用setDoubleBuffered(true)解决大部分闪烁,如果还有问题,再上离屏缓冲。离屏缓冲的写法是创建一个和面板同样大小的BufferedImage,所有绘制先画到BufferedImage的Graphics上,最后一次性drawImage到面板。这样闪烁基本绝迹。
最后说一个我自己的教训:做这个项目时,我一开始把鱼的移动逻辑写在了paintComponent里,结果帧率一高鱼就飞出去,帧率一低鱼就慢动作。后来把逻辑和绘制彻底分离,逻辑用固定时间步长更新,绘制只读状态,问题才解决。如果你也在做捕鱼达人,记住一句话:绘制方法里不要改数据,改数据的方法里不要画。希望帮到你。
本文还有配套的精品资源,点击获取