Java实现捕鱼达人游戏框架:碰撞检测与多线程调度
发布时间:2026/9/28 14:31:26来源:尧图网络
简介本资源是一套基于Java开发的《捕鱼达人》游戏完整源码实现面向Java初学者与游戏开发入门者帮助理解面向对象设计、Swing图形界面、多线程并发控制及帧动画原理等核心知识点。包内共333个文件含284张PNG鱼体动画帧图支撑视觉暂留式摆尾效果、5个核心Java源文件如Fish、Pool、Net、FishlordFrame等、6个编译后class文件及7个XML配置文件整体压缩包仅4.15MB轻量易导入适合IDEA或Eclipse快速运行调试。已有2978人学习下载资源结构清晰以鱼池继承JPanel为容器每条鱼独立线程驱动移动与动画通过矩形重叠检测实现渔网捕获逻辑代码注释充分类设计体现“对象→数据模型→类”的典型开发路径附带config、.gitignore等工程规范文件是实践OOP、GUI与多线程协同的优质教学案例。1. 用 Java 从零搭起《捕鱼达人》不是玩具 Demo是能跑通碰撞、粒子、音效、多线程鱼群调度的可交互游戏框架你在网上搜“Java 捕鱼达人 源码”大概率会撞上两类东西一类是 Swing 写的静态界面截图堆砌点一下鱼不动、打一枪没反馈另一类是 GitHub 上标着“完整源码”但缺资源、少注释、连main方法都藏在三层包里运行报NullPointerException十分钟找不到根。这不是 Java 不行而是多数人把“能编译”误当成“能运行”。真正的《捕鱼达人》核心不在美术——而在鱼的生命周期管理生成/游动/死亡/回收、子弹与鱼的像素级碰撞判定、渔网/炸弹等道具的异步触发逻辑、以及音频资源的低延迟混音调度。本篇不讲 MVC 分层理论不画 UML 图只带你用标准 JDK 8无需 JavaFX 或第三方游戏引擎搭出一个可调试、可扩展、鱼群不卡顿、打中必有反馈、关掉 IDE 也能双击 jar 运行的最小可行版本。适合 Java 基础扎实、想补足图形化实战能力的中级开发者或是课程设计需要交出“真能玩”的同学——它不是玩具是能让你在面试时打开 IDE 现场改 bug 的底气来源。2. 游戏主循环与线程模型为什么 Swing Timer 不够用而 ExecutorService volatile 是更稳的选择2.1 主循环的本质不是“每秒刷新 60 次”而是“帧时间可控 逻辑与渲染解耦”很多初学者直接套用javax.swing.Timer设个 16ms 间隔调repaint()。问题立刻暴露鱼游动忽快忽慢、连发子弹出现“漏帧”、音效播放延迟半拍。根本原因在于 Swing Timer 的回调执行依赖 EDTEvent Dispatch Thread一旦某帧绘制耗时略高比如加载一张新鱼贴图后续所有定时任务就会堆积形成雪崩。真实游戏循环必须满足三点逻辑更新频率固定如每 16ms 执行一次物理计算、碰撞检测渲染尽可能快只要 GPU 跟得上就立即画两者不互相阻塞逻辑卡住不能拖垮画面画面卡住不能停掉鱼游动我们弃用 Timer改用ScheduledExecutorService驱动逻辑帧Swing 的paintComponent()仍走 EDT 负责渲染——这是最轻量且兼容性最强的解耦方案。// GameLoop.java public class GameLoop { private final ScheduledExecutorService logicExecutor; private final AtomicBoolean isRunning new AtomicBoolean(true); private final long FRAME_TIME_MS 16; // 目标 60 FPS public GameLoop() { this.logicExecutor Executors.newSingleThreadScheduledExecutor( r - { Thread t new Thread(r, GameLogic-Thread); t.setDaemon(true); // 防止 JVM 因此线程不退出 return t; } ); } public void start() { // 固定周期执行逻辑更新非严格实时但足够稳定 logicExecutor.scheduleAtFixedRate(this::updateGameLogic, 0, FRAME_TIME_MS, TimeUnit.MILLISECONDS); } private void updateGameLogic() { if (!isRunning.get()) return; // 1. 更新所有鱼的位置含路径算法 FishManager.getInstance().updateAllFish(); // 2. 检测子弹与鱼碰撞 CollisionDetector.checkBulletFishCollisions(); // 3. 更新道具状态渔网持续时间、炸弹倒计时 PropManager.getInstance().updateAllProps(); // 4. 刷新分数、金币等 UI 数据volatile 变量供 paintComponent 读取 ScoreBoard.update(); } public void stop() { isRunning.set(false); logicExecutor.shutdownNow(); } }提示updateGameLogic()中所有操作必须是无阻塞、无 IO、无 GUI 更新的纯计算。Swing 组件如 JLabel 显示分数的更新要放到SwingUtilities.invokeLater()中否则跨线程修改组件会抛IllegalStateException。2.2 鱼群调度用对象池避免 GC 频繁停顿用状态机控制行为流《捕鱼达人》里鱼不是静态图片——它们有入场、游动、受击、死亡、消失五种状态每条鱼还要按不同路径直线、S 形、螺旋移动。若每次生成新Fish对象100 条鱼同时游动时 GC 会频繁触发导致卡顿。解决方案对象池Object Pool 状态机State Pattern。我们定义FishState枚举并让Fish类持有当前状态引用// Fish.java public class Fish { private FishState state FishState.ENTERING; private Point position; private Path currentPath; // 实现 Path 接口getPointAtTime(long time) private int health 1; // 1 表示普通鱼2 为 BOSS 鱼 private BufferedImage sprite; public void update(long deltaTimeMs) { switch (state) { case ENTERING: enterLogic(deltaTimeMs); break; case SWIMMING: swimLogic(deltaTimeMs); break; case HIT: hitLogic(deltaTimeMs); break; case DYING: dieLogic(deltaTimeMs); break; case DEAD: // 已死亡等待回收 break; } } private void enterLogic(long dt) { // 从屏幕外沿入场匀速移动到起始游动点 if (position.distance(startPoint) 5) { state FishState.SWIMMING; } } // ... 其他状态逻辑 }对象池实现极简版生产环境建议用 Apache Commons Pool// FishPool.java public class FishPool { private static final int MAX_POOL_SIZE 200; private final QueueFish available new ConcurrentLinkedQueue(); private final AtomicInteger createdCount new AtomicInteger(0); public Fish acquire() { Fish fish available.poll(); if (fish null createdCount.get() MAX_POOL_SIZE) { fish new Fish(); createdCount.incrementAndGet(); } return fish ! null ? fish.reset() : null; // reset() 清空状态、重置位置 } public void release(Fish fish) { if (fish ! null available.size() MAX_POOL_SIZE) { available.offer(fish); } } }参数说明MAX_POOL_SIZE不是凭空设的。实测发现单局最多同时存在 80~120 条活跃鱼含 BOSS预留 200 容量可覆盖峰值并留缓冲。reset()方法必须重置所有字段包括sprite引用否则旧鱼贴图会残留。3. 碰撞检测从矩形 AABB 到像素级判定为什么“打中鱼尾却没得分”是常见翻车点3.1 为什么 Rectangle.intersects() 在捕鱼游戏里是玄学初学者常写if (bullet.getBounds().intersects(fish.getBounds())) { fish.takeDamage(); }这会导致两个严重问题误伤子弹明明打在鱼头前方空白处因getBounds()返回的是整个贴图的矩形框含大量透明像素只要矩形重叠就算命中漏判鱼身细长如飞镖鱼子弹击中鱼身中部但矩形框因旋转角度未对齐实际不重叠。真正可靠的方案是分层检测先用 AABB 快速剔除明显不相交的物体90% 的计算在此截断再对通过的候选对做像素级判定。3.2 像素级碰撞用 BufferedImage.getRGB() 提取 Alpha 通道做掩膜比对核心思想只比对两张图片非透明像素的重叠区域。步骤如下获取子弹和鱼的BufferedImage计算两图在屏幕坐标系下的重叠矩形overlapRect遍历overlapRect内每个像素检查双方该位置是否均为非透明Alpha 0任一像素同时非透明即判定为碰撞。// CollisionDetector.java public static boolean pixelPerfectCollision(BufferedImage img1, BufferedImage img2, int x1, int y1, int x2, int y2) { Rectangle r1 new Rectangle(x1, y1, img1.getWidth(), img1.getHeight()); Rectangle r2 new Rectangle(x2, y2, img2.getWidth(), img2.getHeight()); Rectangle overlap r1.intersection(r2); if (overlap.width 0 || overlap.height 0) return false; // 转换为各自图片内的局部坐标 int localX1 overlap.x - x1; int localY1 overlap.y - y1; int localX2 overlap.x - x2; int localY2 overlap.y - y2; for (int y 0; y overlap.height; y) { for (int x 0; x overlap.width; x) { int rgb1 img1.getRGB(localX1 x, localY1 y); int rgb2 img2.getRGB(localX2 x, localY2 y); if (hasAlpha(rgb1) hasAlpha(rgb2)) { return true; } } } return false; } private static boolean hasAlpha(int rgb) { return (rgb 24) 0xFF 10; // Alpha 值 10 视为不透明防抗锯齿边缘误判 }血泪经验getRGB()在大图上遍历极慢必须加缓存——为每张鱼贴图预计算其Alpha 掩膜数组boolean[][]并在Fish初始化时完成。运行时直接查数组速度提升 50 倍以上。代码见Fish.preloadAlphaMask()。3.3 子弹与鱼的碰撞优化空间分区Grid Partition降低 O(n²) 复杂度当屏幕上有 100 条鱼 20 发子弹时暴力两两检测需 2000 次像素判定CPU 占用飙升。引入4x3 网格分区将屏幕划分为 12 个区域每帧只检测同一网格内的子弹与鱼// SpatialPartition.java public class SpatialPartition { private final ListListBullet gridBullets new ArrayList(); private final ListListFish gridFishes new ArrayList(); public SpatialPartition() { for (int i 0; i 12; i) { gridBullets.add(new ArrayList()); gridFishes.add(new ArrayList()); } } public void updateAndCheckCollisions(ListBullet allBullets, ListFish allFishes) { clearGrids(); populateGrids(allBullets, allFishes); checkWithinGrids(); } private void populateGrids(ListBullet bullets, ListFish fishes) { for (Bullet b : bullets) { int gridIndex getGridIndex(b.getX(), b.getY()); gridBullets.get(gridIndex).add(b); } for (Fish f : fishes) { int gridIndex getGridIndex(f.getX(), f.getY()); gridFishes.get(gridIndex).add(f); } } private void checkWithinGrids() { for (int i 0; i 12; i) { for (Bullet b : gridBullets.get(i)) { for (Fish f : gridFishes.get(i)) { if (pixelPerfectCollision(b.getSprite(), f.getSprite(), (int) b.getX(), (int) b.getY(), (int) f.getX(), (int) f.getY())) { handleHit(b, f); } } } } } private int getGridIndex(double x, double y) { int col Math.min(3, (int) (x / (WIDTH / 4.0))); int row Math.min(2, (int) (y / (HEIGHT / 3.0))); return row * 4 col; } }注意网格数不是越多越好。实测 4x312 区在 1920x1080 屏幕下平衡了精度与性能超过 20 区反而因哈希计算开销增大而变慢。4. 音效与粒子系统用 Clip 实现毫秒级响应用对象池管理爆炸粒子4.1 为什么 AudioInputStream Clip 比 Clip.play() 更可靠Java 原生音效 API 有两个坑Clip.open()加载耗时不能在游戏循环中反复调用Clip.start()无返回值无法判断是否真播放成功静音时毫无反馈。正确做法预加载所有音效到内存用Clip的setFramePosition(0)start()实现复用// SoundManager.java public class SoundManager { private static final MapString, Clip CLIPS new HashMap(); static { try { // 预加载放在 static 块类加载时完成 CLIPS.put(shoot, loadClip(/sounds/shoot.wav)); CLIPS.put(hit, loadClip(/sounds/hit.wav)); CLIPS.put(bomb, loadClip(/sounds/bomb.wav)); } catch (Exception e) { e.printStackTrace(); } } public static void play(String name) { Clip clip CLIPS.get(name); if (clip ! null !clip.isRunning()) { clip.setFramePosition(0); // 重置到开头 clip.start(); // 异步播放不阻塞主线程 } } private static Clip loadClip(String path) throws Exception { AudioInputStream audioIn AudioSystem.getAudioInputStream( SoundManager.class.getResource(path)); Clip clip AudioSystem.getClip(); clip.open(audioIn); audioIn.close(); return clip; } }参数说明WAV 格式必须是PCM 编码、16-bit、小端序、单声道或立体声。MP3 需额外引入 JLayer 库增加包体积且延迟更高不推荐。4.2 爆炸粒子用 Color Alpha 动画模拟“碎裂飞散”而非加载 PNG 序列帧粒子系统不必复杂——每颗粒子就是一个Particle对象记录位置、速度、颜色、透明度衰减率。关键在用 Graphics2D 的 setComposite() 控制 Alpha 渲染// Particle.java public class Particle { private double x, y; private double vx, vy; // 速度 private float alpha 1.0f; private final float decayRate; // 每帧 Alpha 减少量 private final Color color; public Particle(double startX, double startY, Color baseColor) { this.x startX; this.y startY; // 随机方向与速度模拟爆炸飞散 double angle Math.random() * Math.PI * 2; double speed 1.0 Math.random() * 3.0; this.vx Math.cos(angle) * speed; this.vy Math.sin(angle) * speed; this.color baseColor; this.decayRate 0.02f (float) Math.random() * 0.03f; } public void update() { x vx; y vy; alpha - decayRate; if (alpha 0) alpha 0; } public void render(Graphics2D g2d) { if (alpha 0) return; Composite original g2d.getComposite(); g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha)); g2d.setColor(color); g2d.fillOval((int) x - 2, (int) y - 2, 4, 4); // 4px 圆点 g2d.setComposite(original); } }粒子池管理同 FishPool// ParticlePool.java public class ParticlePool { private final QueueParticle available new ConcurrentLinkedQueue(); private static final int MAX_PARTICLES 500; public Particle acquire(double x, double y, Color color) { Particle p available.poll(); return p ! null ? p.reset(x, y, color) : new Particle(x, y, color); } public void release(Particle p) { if (p ! null available.size() MAX_PARTICLES) { available.offer(p); } } }避坑Graphics2D.setComposite()是线程不安全的必须在paintComponent()内调用且每次render()后恢复原 composite否则影响其他绘制。5. 避坑指南那些让项目卡在“能编译但不能玩”的 4 个致命细节5.1 现象鱼游动轨迹僵硬、像机器人走路原因路径算法用了Math.sin(t)但t是System.currentTimeMillis()导致所有鱼同步波动缺乏随机相位差。解决为每条鱼生成独立偏移量phaseOffset Math.random() * Math.PI * 2路径计算改为y centerY amplitude * Math.sin(time * frequency phaseOffset)。5.2 现象双击 jar 包闪退控制台无报错原因资源路径写成images/fish1.png但打包后资源在 jar 包内File构造失败ImageIO.read()返回 null后续getRGB()抛 NPE。解决统一用getClass().getResource(/images/fish1.png)获取 URL再传给ImageIO.read()。路径以/开头表示从 classpath 根开始找。5.3 现象连续点击鼠标子弹发射速率远低于预期应 200ms 一发实际 500ms原因MouseListener.mouseClicked()在快速点击时可能触发多次但未做防抖debounce。解决加lastShootTime时间戳每次点击前检查System.currentTimeMillis() - lastShootTime 200满足才发射并更新时间戳。5.4 现象BOSS 鱼死亡后屏幕卡住几秒才显示“5000 分”动画原因ScoreBoard.showBonus(5000)内部用了Thread.sleep(2000)阻塞 EDT导致整个 Swing 界面冻结。解决用Swing Timer替代 sleep动画帧由 Timer 回调驱动EDT 始终畅通。注意所有涉及 GUI 更新的操作JLabel.setText()、repaint()必须在SwingUtilities.invokeLater()中执行哪怕你确定当前在线程里——这是 Swing 的铁律绕不开。6. 进阶技巧如何用 JUnit 5 测试鱼的行为逻辑让“游戏代码”也具备可维护性很多人觉得游戏逻辑没法单元测试——鱼游动、碰撞、粒子飞散都是视觉效果怎么断言其实核心规则完全可以剥离 UI用纯数据验证。关键在于把“状态变更”从“绘制”中解耦出来。6.1 提取可测试的领域模型FishBehavior 和 CollisionRule我们定义FishBehavior接口描述鱼的行为契约// FishBehavior.java public interface FishBehavior { /** 返回鱼在指定时间后的预期位置用于测试 */ Point expectedPositionAfter(long ms); /** 返回鱼被击中后应进入的状态 */ FishState onHit(); /** 返回鱼死亡后应掉落的金币数 */ int dropCoins(); }为普通鱼实现// SimpleFishBehavior.java public class SimpleFishBehavior implements FishBehavior { private final double speed; private final double amplitude; private final double frequency; public SimpleFishBehavior(double speed, double amplitude, double frequency) { this.speed speed; this.amplitude amplitude; this.frequency frequency; } Override public Point expectedPositionAfter(long ms) { // 假设水平游动 垂直正弦波动 double x speed * ms / 1000.0; double y amplitude * Math.sin(ms * frequency / 1000.0); return new Point((int) x, (int) y); } Override public FishState onHit() { return FishState.HIT; } Override public int dropCoins() { return 10; } }6.2 编写 JUnit 5 测试验证鱼的运动数学模型// FishBehaviorTest.java class FishBehaviorTest { Test void shouldCalculateExpectedPositionCorrectly() { // 给定速度 100px/s振幅 20px频率 1Hz FishBehavior behavior new SimpleFishBehavior(100.0, 20.0, 2.0 * Math.PI); // 当 1000ms 后x 应为 100y 应为 20 * sin(2π) 0 Point pos behavior.expectedPositionAfter(1000); assertEquals(100, pos.x, 1); // 允许 1px 误差 assertEquals(0, pos.y, 1); } Test void shouldReturnCorrectStateOnHit() { FishBehavior behavior new SimpleFishBehavior(100.0, 20.0, 2.0 * Math.PI); assertEquals(FishState.HIT, behavior.onHit()); } }6.3 碰撞规则的测试用 Mock 图片验证像素判定逻辑// CollisionDetectorTest.java class CollisionDetectorTest { Test void shouldDetectOverlapWhenBothPixelsOpaque() { // 构造两张 2x2 的测试图片左上角像素为不透明红其余透明 BufferedImage img1 createTestImage(2, 2, new int[]{0xFFFF0000, 0, 0, 0}); BufferedImage img2 createTestImage(2, 2, new int[]{0, 0, 0xFFFF0000, 0}); // 两张图左上角对齐重叠区域为 (0,0)-(1,1)其中 (0,0) 在 img1 不透明(0,0) 在 img2 透明 → 不碰撞 assertFalse(CollisionDetector.pixelPerfectCollision(img1, img2, 0, 0, 0, 0)); // img2 向右下平移 1px则 (1,1) 重叠双方该点均不透明 → 应碰撞 assertTrue(CollisionDetector.pixelPerfectCollision(img1, img2, 0, 0, 1, 1)); } private BufferedImage createTestImage(int w, int h, int[] pixels) { BufferedImage img new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); img.setRGB(0, 0, w, h, pixels, 0, w); return img; } }我的习惯每次新增一种鱼类型如“电鳗鱼”带电击范围我先写EelFishBehaviorTest确保它的expectedPositionAfter()、onHit()、dropCoins()行为符合设计再集成到游戏里。这样改需求时不用靠眼睛盯屏幕跑一遍 test 就知道逻辑有没有坏。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网