ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java实现捕鱼达人游戏源码:碰撞检测、对象池与帧循环详解

Java实现捕鱼达人游戏源码:碰撞检测、对象池与帧循环详解 简介这是一份基于Java实现的捕鱼达人游戏完整源码面向具备Java基础、希望切入游戏开发领域的开发者与在校学生。项目以经典街机玩法为蓝本完整呈现从海洋场景搭建、大炮发射到鱼类捕捉计分的全流程逻辑可作为课程设计、毕业设计或自学练手的实战案例。压缩包共233个文件约12.8MB包含63个java源文件与95个class编译文件另有png、jpg图片素材、plist动画配置、ogg与mp3音频、jar依赖库及ttf字体等覆盖代码、美术与音效资源。源码涉及Swing图形界面、多线程主循环、鼠标事件监听、帧率控制、对象碰撞检测、音频播放与资源管理等关键技术点并配有run.bat启动脚本便于直接运行调试。目前已有801人学习下载适合通过阅读与改造源码理解游戏开发基本流程与设计思路。1. 从零手写捕鱼达人Java 游戏源码到底能解决什么问题很多人第一次搜「Java实现捕鱼达人游戏源码」心里想的其实是两件事一是想找一个能直接跑起来的完整项目二是想搞清楚这类游戏背后的碰撞检测、对象池、帧循环到底怎么落地。捕鱼达人看着简单炮台转个角度、鱼游来游去、子弹打中鱼就加分但真动手写你会发现难点全在细节里几百条鱼同时游动时 GC 频繁触发、子弹和鱼的碰撞判定用矩形还是圆形、金币结算和炮台等级怎么解耦。这套源码的价值不在于「抄一份交作业」而在于它把 2D 游戏里最典型的几个工程问题压缩在一个几千行的项目里让你能一次看清对象生命周期管理、渲染循环和状态同步的完整链路。适合有 Java 基础、想从 CRUD 转向图形交互开发的人也适合准备用 Java 做小游戏练手、但不知道从哪下手的开发者。下面我按自己实际写过的路径把这件事拆开讲透。2. 捕鱼达人的核心机制帧循环、碰撞检测与对象池怎么设计2.1 为什么用 Swing 而不是 JavaFX 或 LibGDX选渲染方案是第一个分叉口。JavaFX 的动画 API 更现代LibGDX 跨平台能力更强但如果你只是想在本地跑通一个捕鱼达人、理解游戏循环的本质Swing 反而是最透明的选择。原因有三第一Swing 的JPanel配合Timer就能搭出稳定的帧循环不需要引入额外依赖java.desktop模块自带第二它的双缓冲机制默认开启画几百个精灵不会闪屏第三出问题时你能直接看到 AWT 事件线程和游戏线程的关系不像引擎那样把细节藏起来。我一般会用一个固定时间步长的循环而不是while(true)裸跑。裸跑会让 CPU 占用飙到 100%而且不同机器上鱼的速度不一致。常见做法是用javax.swing.Timer延迟设为 16 毫秒约等于 60 帧// GamePanel.java —— 游戏主循环固定 16ms 步长 import javax.swing.*; import java.awt.*; public class GamePanel extends JPanel { private final Timer gameTimer; public GamePanel() { setPreferredSize(new Dimension(960, 640)); setBackground(Color.BLACK); // 16ms 约 60FPSdelay 越小帧率越高但 CPU 越吃紧 gameTimer new Timer(16, e - { updateGame(); // 先更新逻辑鱼移动、子弹飞行、碰撞 repaint(); // 再触发重绘paintComponent 里只做渲染 }); gameTimer.start(); } private void updateGame() { // 所有实体的位置、状态在这里推进 } Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 只负责把当前状态画出来不修改任何数据 } }这段代码的关键在于把「更新」和「渲染」彻底分开。updateGame()里改数据paintComponent()里只读数据画图。很多新手会把移动逻辑写进paintComponent()结果帧率一波动鱼的速度就跟着变这是血泪教训。Timer的 delay 参数不是越小越好16ms 是 60FPS 的经典值设成 8ms 在低配机器上反而会因为来不及绘制而卡顿。2.2 碰撞检测圆形判定为什么比矩形更合适捕鱼达人里子弹和鱼的形状都不规则但用矩形包围盒判定会出现「明明没碰到却算命中」的玄学情况因为鱼的贴图四周有大量透明区域。我一般用圆形判定给每条鱼和每颗子弹维护一个碰撞半径比较圆心距离和半径之和。// CollisionUtil.java —— 圆形碰撞检测 public class CollisionUtil { /** * param x1,y1 子弹圆心 * param r1 子弹碰撞半径 * param x2,y2 鱼圆心 * param r2 鱼碰撞半径 * return 是否碰撞 */ public static boolean hit(float x1, float y1, float r1, float x2, float y2, float r2) { float dx x1 - x2; float dy y1 - y2; float distSq dx * dx dy * dy; float radiusSum r1 r2; // 用平方比较省去开方每帧几百次判定时差别明显 return distSq radiusSum * radiusSum; } }半径参数怎么定子弹半径取贴图宽度的四分之一左右鱼半径取鱼身最宽处的二分之一。大鱼半径大、小鱼半径小这样打小鱼需要更精准。注意不要用Math.sqrt直接比较平方值一帧几百次碰撞判定时这点优化是实打实的。如果鱼群密集还可以先按网格分区只检测相邻格子里的对象这是进阶做法后面第 6 章会讲。2.3 对象池为什么不能每开一炮就 new 一颗子弹捕鱼达人最典型的性能陷阱就是子弹和金币特效的频繁创建销毁。一秒钟开十炮每炮产生一颗子弹命中后还要生成金币动画如果每次都new Bullet()几分钟后 GC 就会频繁触发画面出现周期性卡顿。解决办法是对象池游戏启动时预创建一批子弹对象用的时候从池里取不用了标记为「空闲」还回去。// BulletPool.java —— 子弹对象池 import java.util.ArrayDeque; import java.util.Deque; public class BulletPool { private final DequeBullet pool new ArrayDeque(); public BulletPool(int initialSize) { for (int i 0; i initialSize; i) { pool.push(new Bullet()); } } // 取子弹池空则临时新建保证不阻塞游戏 public Bullet obtain() { return pool.isEmpty() ? new Bullet() : pool.pop(); } // 还子弹重置状态后放回池中 public void recycle(Bullet b) { b.reset(); // 清空速度、坐标、存活标记 pool.push(b); } }initialSize设多少按最大同时在线子弹数估算一般炮台射速 5 发/秒、子弹存活 2 秒同屏最多 10 到 15 颗预创建 32 颗足够。reset()方法必须把所有可变字段归位否则会出现「回收的子弹带着上次的速度飞出去」这种翻车现场。对象池不只用于子弹金币飘字、爆炸特效同样适用原理一致。3. 从零搭建可运行项目目录结构、依赖与启动流程3.1 项目目录怎么划分才不乱一个能长期维护的捕鱼达人源码目录结构比代码本身更重要。我习惯按职责分层而不是按类型堆在一起fishing-game/ ├── src/main/java/com/example/fishing/ │ ├── core/ # 游戏循环、主窗口、全局常量 │ │ ├── GameFrame.java │ │ ├── GamePanel.java │ │ └── GameConfig.java │ ├── entity/ # 实体类鱼、子弹、炮台、金币 │ │ ├── Fish.java │ │ ├── Bullet.java │ │ └── Cannon.java │ ├── pool/ # 对象池 │ │ └── BulletPool.java │ ├── util/ # 工具类碰撞、资源加载 │ │ ├── CollisionUtil.java │ │ └── ResourceLoader.java │ └── Main.java # 入口 └── src/main/resources/ ├── images/ # 鱼、炮台、子弹贴图 └── config/ # 鱼群配置、关卡参数core放循环和窗口entity放所有会动的对象pool单独拎出来因为它是性能关键util放无状态工具。这样分的好处是当你要把 Swing 换成 JavaFX 时只需要动core和渲染部分entity和pool几乎不用改。资源文件统一放resources用getClass().getResource(/images/fish_01.png)加载打包成 jar 后路径不会丢。3.2 最小可运行版本的启动链路从main方法到画面出现中间发生了什么这条链路必须清楚否则出问题你不知道该在哪打断点。// Main.java —— 程序入口 import javax.swing.SwingUtilities; public class Main { public static void main(String[] args) { // Swing 组件必须在事件分发线程EDT创建 SwingUtilities.invokeLater(() - { GameFrame frame new GameFrame(); frame.setVisible(true); }); } }// GameFrame.java —— 主窗口 import javax.swing.JFrame; public class GameFrame extends JFrame { public GameFrame() { setTitle(捕鱼达人); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); GamePanel panel new GamePanel(); add(panel); pack(); // 按 panel 的首选尺寸调整窗口 setLocationRelativeTo(null); // 居中显示 } }启动顺序是main→invokeLater把创建窗口的任务丢给 EDT →GameFrame构造时创建GamePanel→GamePanel构造时启动Timer→ 每 16ms 触发一次updateGame()和repaint()。这里有个容易忽略的点pack()必须在add(panel)之后调用否则窗口尺寸是错的。另外setResizable(false)配合固定尺寸能避免玩家拉伸窗口导致坐标错乱捕鱼达人的炮台位置是写死的窗口一变布局就崩。3.3 鱼群配置用外部文件而不是硬编码鱼的种类、速度、分值、出现概率这些参数如果全写在Fish.java里每次调平衡都要重新编译。我一般抽到一个fish_config.json或 properties 文件里启动时读进来。用 properties 更轻量不引入 JSON 库# fish_config.properties # 格式鱼ID.属性值 fish.small.score2 fish.small.speed3.5 fish.small.radius18 fish.medium.score10 fish.medium.speed2.0 fish.medium.radius32 fish.boss.score100 fish.boss.speed0.8 fish.boss.radius64读取时用Properties类加载把每条鱼的参数封装成一个FishConfig对象缓存起来。这样改分值、调速度不用重新编译策划或者你自己改完配置文件重启就生效。speed的单位是「像素/帧」因为我们的循环是固定 16ms 步长所以速度值直接对应每帧移动多少像素直观好调。radius就是前面碰撞检测用的半径和贴图尺寸挂钩。4. 炮台、子弹与金币结算交互逻辑的完整实现4.1 炮台跟随鼠标旋转的角度计算炮台要始终指向鼠标位置本质是算一个反正切。但直接Math.atan2出来的角度是弧度Swing 的Graphics2D.rotate用的也是弧度所以中间不用转换省一步。// Cannon.java —— 炮台角度更新 import java.awt.geom.AffineTransform; public class Cannon { private float centerX, centerY; // 炮台旋转中心 private double angle; // 当前角度弧度 // 根据鼠标位置更新角度 public void aimAt(int mouseX, int mouseY) { double dx mouseX - centerX; double dy mouseY - centerY; // atan2 返回 -PI 到 PI0 表示指向正右方 this.angle Math.atan2(dy, dx); } public double getAngle() { return angle; } }atan2(dy, dx)的参数顺序不能反反了角度会差 90 度这是新手最常见的翻车点。炮台贴图的「默认朝向」要约定好我一般让美术把炮管画成朝右这样angle为 0 时正好对上不用额外加偏移。如果贴图是朝上的就得在rotate时补一个-Math.PI/2。4.2 子弹发射从炮口坐标到速度分解子弹要从炮口出来不是从炮台中心出来否则视觉上子弹像是从炮台肚子里钻出来的。炮口坐标 中心坐标 炮管长度 × (cos(angle), sin(angle))。// Cannon.java —— 发射子弹 public Bullet fire(BulletPool pool, float barrelLength, float bulletSpeed) { Bullet b pool.obtain(); // 炮口位置 float muzzleX (float) (centerX Math.cos(angle) * barrelLength); float muzzleY (float) (centerY Math.sin(angle) * barrelLength); // 速度分解沿角度方向飞 float vx (float) (Math.cos(angle) * bulletSpeed); float vy (float) (Math.sin(angle) * bulletSpeed); b.init(muzzleX, muzzleY, vx, vy); return b; }barrelLength取炮台贴图长度的一半左右bulletSpeed一般设成鱼速的 3 到 5 倍太慢打不中太快看不清弹道。子弹的init方法要把存活标记设为 true坐标和速度写进去。注意子弹速度也用「像素/帧」和鱼速同一套单位这样碰撞判定时不用做单位换算。4.3 命中结算金币飘字与分值累加子弹命中鱼之后要做三件事鱼进入死亡动画、子弹回收、金币飘字生成。这三件事的顺序不能乱尤其是子弹回收必须在碰撞判定之后否则会出现「子弹已经回池了还在参与碰撞」的诡异现象。// GamePanel.java —— 碰撞与结算 private void checkCollisions() { for (Bullet b : activeBullets) { if (!b.isAlive()) continue; for (Fish f : activeFishes) { if (!f.isAlive()) continue; if (CollisionUtil.hit(b.getX(), b.getY(), b.getRadius(), f.getX(), f.getY(), f.getRadius())) { f.kill(); // 鱼进入死亡状态 b.setAlive(false); // 子弹标记为待回收 score f.getScore(); // 累加分值 coinEffects.add(new CoinEffect(f.getX(), f.getY(), f.getScore())); break; // 一颗子弹只打一条鱼 } } } // 统一回收避免在遍历中修改集合 activeBullets.removeIf(b - !b.isAlive()); activeFishes.removeIf(f - !f.isAlive() f.isDeathAnimDone()); }这里用removeIf而不是在循环里直接remove是为了避免ConcurrentModificationException。break保证一颗子弹只结算一次否则穿透弹会一次打中多条鱼导致分值暴涨。金币飘字用独立的CoinEffect列表管理它有自己的生命周期向上飘 30 帧后消失和鱼、子弹互不干扰。5. 避坑与排查捕鱼达人源码里最容易翻车的 5 个地方5.1 画面闪烁或撕裂现象鱼和子弹移动时边缘有锯齿状撕裂或者整个画面一闪一闪。原因通常是没开双缓冲或者paintComponent里直接画到了屏幕而不是缓冲图像。解决Swing 的JPanel默认开启双缓冲但如果你重写了paint而不是paintComponent双缓冲会失效。确保只重写paintComponent并调用super.paintComponent(g)。另外不要在paintComponent里创建Graphics2D对象直接用传入的g强转即可。5.2 子弹回收后「诈尸」现象明明已经命中的子弹过一会儿又从炮口飞出来或者出现在屏幕中间。原因是对象池的reset()没清干净残留了上一次的坐标或速度。解决reset()里把所有可变字段显式归零包括x、y、vx、vy、alive、radius。我习惯在reset()里加一行日志调试阶段打印「子弹已重置」确认每次回收都走到了。5.3 鱼群越玩越卡现象开局流畅玩几分钟后帧率明显下降。原因通常是鱼游出屏幕后没有从activeFishes里移除列表越积越大每帧遍历的对象越来越多。解决在updateGame()里检查鱼的坐标超出屏幕边界一定距离比如左右各多 100 像素就标记为可移除。注意不要一超出边界就立刻删否则鱼在边缘「探头」时会突然消失视觉上很突兀。5.4 鼠标点击和炮台朝向不同步现象鼠标已经移到左边炮台还指着右边或者延迟半秒才转过去。原因是鼠标事件在 EDT 上处理而游戏循环也在 EDT 上如果updateGame()耗时过长鼠标事件会被排队延迟。解决把updateGame()里的重活比如碰撞检测控制在几毫秒内实在不行就把碰撞检测分帧做每帧只检测一部分子弹。另外鼠标监听器里只记录坐标不要在监听器里做任何计算。5.5 打包成 jar 后图片加载失败现象在 IDE 里跑得好好的mvn package之后双击 jar 启动鱼和炮台全变成空白。原因是用了new File(src/main/resources/images/fish.png)这种相对路径jar 里没有文件系统路径。解决统一用getClass().getResourceAsStream(/images/fish.png)读取返回InputStream交给ImageIO.read()。路径开头的/表示从 classpath 根目录开始找不能省。6. 进阶技巧用空间网格把碰撞检测从 O(n²) 降下来当同屏鱼的数量超过 50 条、子弹超过 20 颗时双重循环的碰撞检测就是 O(n×m)每帧上千次判定低配机器开始掉帧。我一般会引入一个简单的空间网格把屏幕切成 64×64 像素的格子每个对象只和它所在格子及相邻 8 个格子里的对象做碰撞。// SpatialGrid.java —— 简易空间网格 import java.util.*; public class SpatialGrid { private final int cellSize; private final MapLong, ListObject cells new HashMap(); public SpatialGrid(int cellSize) { this.cellSize cellSize; } // 把对象按坐标放进对应格子 public void insert(float x, float y, Object obj) { long key key((int) (x / cellSize), (int) (y / cellSize)); cells.computeIfAbsent(key, k - new ArrayList()).add(obj); } // 查询某点周围 3×3 格子里的所有对象 public ListObject queryNearby(float x, float y) { ListObject result new ArrayList(); int cx (int) (x / cellSize); int cy (int) (y / cellSize); for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { ListObject list cells.get(key(cx dx, cy dy)); if (list ! null) result.addAll(list); } } return result; } public void clear() { cells.clear(); } private long key(int cx, int cy) { return ((long) cx 32) | (cy 0xffffffffL); } }用法是每帧先clear()把所有鱼和子弹insert进去然后对每颗子弹调queryNearby拿到附近对象再做精确碰撞。cellSize取 64 是因为鱼的最大半径约 64相邻格子能覆盖到。这个改造做完同屏 200 条鱼加 50 颗子弹碰撞检测耗时从十几毫秒降到一两毫秒帧率稳在 60。验证方法很简单在updateGame()开头和结尾各打一个System.nanoTime()算出差值正常应该在 5 毫秒以内。如果超过 10 毫秒说明还有优化空间优先查碰撞检测和对象池的reset()有没有漏字段。我自己写这类游戏最大的习惯是每加一个新实体先问它「什么时候死、死了怎么回收」而不是先想它怎么动。捕鱼达人里鱼、子弹、金币、特效全是短生命周期对象把回收路径理顺了性能问题就解决了一大半。希望帮到你。本文还有配套的精品资源点击获取
返回列表