Java AWT/Swing实现捕鱼达人游戏核心逻辑
2026/9/12 16:54:51 网站建设 项目流程

简介:这是一份基于Java开发的《捕鱼达人》桌面游戏完整源码项目,面向Java初学者与图形界面编程学习者,帮助理解面向对象设计、Swing GUI开发、多线程动画实现等核心实践技能。资源包含333个文件,主体为284张PNG鱼体动画帧图、5个核心Java源文件(如Fish.java、Pool.java、Net.java)、6个编译后class文件及7个XML配置文件,支撑起从类设计、图像渲染到线程调度的全链路逻辑;压缩包仅4.15MB,轻量易导入,结构清晰便于逐模块研读。已有2977人学习下载,项目完整呈现了“对象建模→矩形碰撞检测→鱼群并发移动→帧动画循环播放”等关键技术路径,尤其对视觉暂留动画实现(通过Index取余驱动图片数组轮播)、渔网与鱼范围重叠判定(点在矩形内算法)等细节做了可复用的代码封装,是Java GUI与游戏开发入门的优质实操范例。

1. 这不是玩具项目:用纯 Java AWT/Swing 实现《捕鱼达人》核心逻辑,不依赖 Unity、不调用 OpenGL,靠线程+图像数组+碰撞检测撑起整套游戏循环

很多人看到“Java 做游戏”第一反应是“性能不行”“早该淘汰”,但当你真正拆开这个《捕鱼达人》源码包,会发现它根本没碰 JNI、没上 LWJGL、没用任何第三方游戏引擎——全靠javax.swing.JComponent继承链 +Thread子类管理鱼群行为 +BufferedImage数组驱动动画帧。它解决的不是“能不能跑”,而是“怎么在无硬件加速前提下,让 20+ 条异步移动、带多帧动画、响应鼠标点击捕获的鱼,在 60fps 下不卡顿、不撕裂、不丢帧”。关键不在炫技,而在对 AWT 事件调度机制、Swing 双缓冲策略、线程安全绘图边界(paintComponent()调用时机)的精准拿捏。适合 Java 后端工程师补全客户端图形逻辑认知,也适合教学场景讲透“面向对象建模→线程协作→视觉暂留原理→矩形碰撞判定”这条完整技术链。如果你正被 Spring Boot 的自动配置绕晕,不妨回来写个Fish.move()看看 step 如何影响轨迹密度。

2. 从鱼池建模到线程调度:为什么必须让每条鱼继承 Thread,而不是用 Timer 或 ExecutorService

2.1 鱼类行为差异决定线程粒度不能粗放

源码中Fish.class直接extends Thread,而非实现Runnable后交由线程池托管,这是经过权衡的显式设计。观察Fish类成员变量:

public class Fish extends Thread { private int step; // 每帧移动像素数,不同鱼取值不同(如小丑鱼 step=2,鲨鱼 step=5) private BufferedImage[] images; // 动画帧数组,长度即帧数(常见 4~8 帧) private int index; // 当前播放帧索引 private Rectangle bounds; // 鱼自身矩形边界(x,y,width,height),用于碰撞检测 private boolean isAlive; // 是否存活(被捕获后置 false) }

提示:若用ExecutorService统一调度,所有鱼共享线程池队列,无法保证step差异带来的运动节奏分离——小丑鱼本该每 16ms 移动 2px,鲨鱼每 16ms 移动 5px,但线程池任务提交时间片不可控,会导致视觉上“所有鱼同步抖动”,丧失真实感。而每个Fish实例独占线程,可精确控制sleep(16)实现理论 60fps 基准,再结合step微调位移量,形成自然速度梯度。

2.2Pool类作为容器的双重职责:JPanel 渲染上下文 + 鱼群生命周期管理中心

Pool.class继承JPanel,但重写了paintComponent(Graphics g)并启用双缓冲:

@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d = (Graphics2D) g.create(); // 启用抗锯齿(避免鱼边缘锯齿) g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 双缓冲:先绘制到 BufferedImage,再整体拷贝到屏幕 if (offscreenImage == null || offscreenImage.getWidth() != getWidth() || offscreenImage.getHeight() != getHeight()) { offscreenImage = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB); } Graphics2D offG = offscreenImage.createGraphics(); // 绘制背景(海水渐变) GradientPaint bg = new GradientPaint(0, 0, new Color(30,144,255), 0, getHeight(), new Color(0,191,255)); offG.setPaint(bg); offG.fillRect(0, 0, getWidth(), getHeight()); // 绘制所有存活鱼 for (Fish fish : fishList) { if (fish.isAlive()) { BufferedImage currentImg = fish.getImages()[fish.getIndex()]; offG.drawImage(currentImg, fish.getX(), fish.getY(), null); } } // 将离屏图像绘制到组件 g2d.drawImage(offscreenImage, 0, 0, null); offG.dispose(); g2d.dispose(); }

这段代码揭示两个关键点:

  • 渲染与逻辑分离paintComponent()只负责绘制,不修改fishList状态;鱼的移动、死亡判断在各自run()方法中完成;
  • 双缓冲强制生效offscreenImagepaintComponent中动态创建,避免getWidth()/getHeight()为 0 导致 NPE;Graphics2D对象必须dispose(),否则内存泄漏风险极高(AWT 图形上下文未释放会持续占用显存)。

2.3FishlordFrame的事件注入机制:鼠标点击如何触发渔网范围判定

FishlordFrame.class是主窗口,其核心在于将鼠标坐标映射到Pool坐标系并执行碰撞检测:

// 在 FishlordFrame 构造函数中注册监听器 pool.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { // 获取鼠标在 Pool 组件内的相对坐标 int mouseX = e.getX(); int mouseY = e.getY(); // 定义渔网矩形范围(固定尺寸 80x80,中心锚定在鼠标点) Rectangle netRect = new Rectangle( mouseX - 40, mouseY - 40, 80, 80 ); // 遍历所有鱼,检查是否与渔网矩形重叠 for (Fish fish : pool.getFishList()) { if (fish.isAlive() && netRect.intersects(fish.getBounds())) { fish.setAlive(false); // 标记被捕获 score += 10; // 基础分 // 触发鱼死亡动画(此处省略帧切换逻辑) } } } });

Rectangle.intersects(Rectangle r)是 JDK 内置方法,其底层实现为轴对齐矩形相交判定(AABB),公式简洁高效:

return !(r.x > this.x + this.width || r.y > this.y + this.height || r.x + r.width < this.x || r.y + r.height < this.y);

注意:此判定仅适用于渔网与鱼均为矩形且无旋转的场景。若后续需支持旋转渔网,则必须改用分离轴定理(SAT)或转换坐标系,但当前源码选择矩形简化方案,符合“捕鱼达人”早期版本交互逻辑。

3. 动画实现与性能优化:用循环队列思想驱动帧序列,避免 ArrayList 频繁扩容

3.1Fish类的帧索引自增取余机制解析

源码中动画播放核心逻辑位于Fish.run()

@Override public void run() { while (isAlive()) { try { // 1. 更新帧索引:循环取余,实现无缝播放 index = (index + 1) % images.length; // 2. 更新位置:根据 step 和方向(direction)计算新坐标 x += step * direction; // direction 为 1 或 -1,控制左右游动 // 3. 边界检测:触边反弹 if (x <= 0 || x >= poolWidth - width) { direction *= -1; // 反向 x = Math.max(0, Math.min(x, poolWidth - width)); // 防止越界 } Thread.sleep(16); // 理论 60fps(1000/60≈16.67ms) } catch (InterruptedException e) { break; } } }

index = (index + 1) % images.length是典型循环队列思想应用。假设images数组长度为 4(四帧摆尾动画),则index序列为0→1→2→3→0→1...,无需判断边界重置,CPU 指令级高效。对比if (index >= images.length) index = 0;,取余运算在现代 JVM 中已被高度优化,且代码更简洁。

3.2 图像资源加载策略:ImageIO.read()的阻塞风险与预加载方案

FishlordGameLauncher.class中图像加载代码如下:

private static BufferedImage[] loadFishImages(String prefix, int count) { BufferedImage[] imgs = new BufferedImage[count]; for (int i = 0; i < count; i++) { try { // 从 classpath 加载资源,路径为 /images/fish_0.png 等 imgs[i] = ImageIO.read(FishlordGameLauncher.class.getResource("/images/" + prefix + "_" + i + ".png")); } catch (IOException e) { throw new RuntimeException("Failed to load image: " + prefix + "_" + i, e); } } return imgs; }

提示:ImageIO.read()是阻塞式 I/O,若图片较大或磁盘慢,会导致Fish线程启动延迟。生产环境应改为预加载:在FishlordFrame初始化阶段统一读取所有鱼图集到内存,再按需分配给各Fish实例。当前源码未做此优化,属于教学简化设计。

3.3 线程安全边界:为什么isAlive()不需要 synchronized,而setAlive(false)需要?

Fish类中isAlive()setAlive(boolean alive)方法定义如下:

private volatile boolean alive = true; public boolean isAlive() { return alive; } public void setAlive(boolean alive) { this.alive = alive; }

关键在于alive字段声明为volatile

  • volatile保证了对该变量的读写操作具有可见性(一个线程修改后,其他线程立即可见)和有序性(禁止指令重排序);
  • isAlive()仅读取,无需加锁;
  • setAlive(false)写入时,volatile已足够保证原子性(boolean 写入本身是原子操作);
  • 若字段为intlong(64位),在 32 位 JVM 上可能非原子,此时才需synchronizedAtomicBoolean

4. 碰撞检测进阶:从单点判定到渔网范围扩展,以及多目标捕获的计分逻辑

4.1 单点判定的局限性与矩形重叠的工程取舍

原始需求描述中提到“渔网的范围和鱼的范围重叠。——判断一个点是否在矩形范围之内即可”,这实际存在理解偏差。mousePressed事件获取的是单点坐标(x,y),但渔网本身有面积(80×80 像素),因此必须构造Rectangle netRect,再调用intersects()判定。若真用“点在矩形内”,则需netRect.contains(mouseX, mouseY),但这仅验证鼠标点是否在渔网内,无法捕获鱼——因为鱼是独立矩形对象,必须比较两个矩形。

源码采用netRect.intersects(fish.getBounds())是正确解法,其数学本质是:

  • 鱼矩形左边界fish.x与渔网右边界netRect.x + netRect.width不重叠?→fish.x > netRect.x + netRect.width
  • 鱼矩形右边界fish.x + fish.width与渔网左边界netRect.x不重叠?→fish.x + fish.width < netRect.x
  • 同理判断 Y 轴
  • 四者均不成立,则相交

该算法时间复杂度 O(1),比逐像素扫描快 3 个数量级。

4.2 多目标捕获的计分与状态同步

mousePressed中遍历fishList时,存在并发风险:Fish线程可能正在修改bounds(更新位置),而 UI 线程正在读取fish.getBounds()。源码未加锁,依赖Rectangle的不可变性(getBounds()返回新实例)规避问题:

// Fish.class 中 getBounds() 实现 public Rectangle getBounds() { return new Rectangle(x, y, width, height); // 每次返回新对象 }

这样即使Fish.run()正在执行x += step * direction,UI 线程拿到的也是旧坐标快照,不会出现xy不一致的“撕裂”状态。计分逻辑则直接累加:

鱼类型基础分是否含倍率
小丑鱼10
章鱼30
鲨鱼100
鱼王500是(连续捕获3条触发2倍)

当前源码仅实现基础分累加,倍率逻辑需扩展FishlordFrame中的计数器与状态机。

4.3 验证碰撞逻辑的调试技巧:临时绘制渔网与鱼边界框

开发时可快速验证碰撞是否准确,在paintComponent()中添加调试绘制:

// 在绘制鱼之后、绘制离屏图像之前插入 if (debugMode) { // 绘制渔网边界(红色虚线) g2d.setColor(Color.RED); g2d.setStroke(new BasicStroke(2, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0, new float[]{5}, 0)); g2d.draw(netRect); // netRect 需作为成员变量暂存 // 绘制每条鱼的边界框(绿色实线) for (Fish fish : fishList) { if (fish.isAlive()) { g2d.setColor(Color.GREEN); g2d.draw(fish.getBounds()); } } }

启用debugMode后,屏幕上将显示红色渔网框与绿色鱼框,直观确认intersects()判定结果是否符合预期。此技巧比断点调试更高效,尤其适合验证边界条件(如鱼刚好擦边时是否捕获)。

5. 从源码到可运行:编译、资源路径、JVM 参数调优三步落地

5.1 编译与运行命令链(适配 JDK 8+)

源码无 Maven 依赖,需手动编译。假设项目结构为:

src/ ├── FishlordGameLauncher.java ├── Fish.java ├── Pool.java ├── Net.java └── images/ ├── fish_0.png └── ...

执行以下命令:

# 1. 创建输出目录 mkdir -p out # 2. 编译所有 .java 文件,输出到 out 目录,并包含资源文件 javac -d out -sourcepath src src/*.java # 3. 复制 images 资源到 out 目录(关键!否则 ImageIO.read() 找不到) cp -r src/images out/ # 4. 运行主类(注意 classpath 指向 out 目录) java -cp out FishlordGameLauncher

注意:-sourcepath src告诉编译器源码位置;-d out指定字节码输出目录;cp -r src/images out/是易遗漏步骤——若跳过,getResource("/images/...")返回 null,导致ImageIO.read()抛出NullPointerException

5.2 JVM 启动参数优化:解决 Swing 线程饥饿与 GC 频繁

默认 JVM 参数下,20+Fish线程可能因抢占 CPU 导致 UI 卡顿。推荐启动参数:

java -Xms256m -Xmx512m \ -XX:+UseG1GC \ -Dsun.java2d.opengl.fbobject=false \ -Dswing.aatext=true \ -cp out FishlordGameLauncher

参数说明:

  • -Xms256m -Xmx512m:设置堆内存初始与最大值,避免频繁 GC;
  • -XX:+UseG1GC:启用 G1 垃圾收集器,降低停顿时间;
  • -Dsun.java2d.opengl.fbobject=false:禁用 OpenGL 后端(Swing 在部分 Linux 环境下启用后反而性能下降);
  • -Dswing.aatext=true:开启 Swing 文字抗锯齿,提升界面清晰度。

5.3 关键日志埋点:定位线程阻塞与图像加载失败

Fish.run()FishlordGameLauncher.loadFishImages()中添加日志:

// Fish.java run() 方法开头 System.out.println("Fish " + this.getName() + " started at " + System.currentTimeMillis()); // loadFishImages() 中 catch 块 System.err.println("Failed to load image: /images/" + prefix + "_" + i + ".png. ClassLoader: " + FishlordGameLauncher.class.getClassLoader());

通过日志可快速识别:

  • 若某条鱼线程未打印启动日志 →start()未被调用;
  • Failed to load image出现 →images目录未正确复制到out
  • 若大量Fish started时间戳间隔过大 → JVM 线程调度异常,需检查-Xmx是否过小。

最终运行效果:窗口弹出,海背景滚动,各色鱼按不同速度游动,鼠标点击生成红色渔网框,框内鱼消失并计分增加——一个完全由 Java 标准库驱动的、可验证的《捕鱼达人》最小可行原型就此诞生。

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

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

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

立即咨询