news 2026/9/29 17:40:39

Java实现捕鱼达人游戏源码:Swing窗口、对象池与碰撞检测全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java实现捕鱼达人游戏源码:Swing窗口、对象池与碰撞检测全解析

简介:一套基于Java实现的捕鱼达人游戏完整源码,面向具备Java基础、希望进阶学习游戏开发的开发者。项目将玩家、鱼群、子弹、得分等元素抽象为类,完整演示了Swing/JavaFX界面搭建、多线程实时渲染、事件监听、动画帧率控制、碰撞检测、背景音乐播放及游戏进度保存等关键技术,是理解Java游戏开发流程的实用案例。压缩包共233个文件,包含95个class编译文件、63个java源文件,以及png/jpg图像素材、ogg/mp3音频、jar依赖库和plist配置,整体约12.8MB,目录结构清晰,便于对照源码与资源文件学习。源码内含鱼群管理、炮弹发射、计分系统、音效控制等多个独立模块,并附有run.bat启动脚本,方便直接运行体验。已有801人学习下载,适合通过阅读源码和调试运行来掌握游戏循环设计、对象管理与资源加载等核心思路。

1. 捕鱼达人这个题目,用 Java 写源码到底难在哪

捕鱼达人这个项目,在 Java 课程设计和面试作品里的出现频率相当高。画面热闹、玩法直白,可真要把它落成一份「Java实现捕鱼达人游戏源码」,你会发现它不是普通的管理系统练手题:窗口怎么画、鱼怎么动、子弹怎么判定命中、鱼被打中后对象怎么回收、分数在多线程下怎么不错乱,每一环都在拿 java 基础里的线程、容器和面向对象编程 java 的功底出来考你。这篇笔记从一套能跑通的最小源码结构讲起,按 Swing 窗口、主循环、对象池、碰撞检测、避坑五层拆开讲,适合想拿游戏项目当 java 课程设计案例源码或 java 面试题素材的同学照着做,也能帮熟手快速绕开常见翻车点。

2. 从零搭 Java 游戏窗口:Swing 选型、主循环与双缓冲

2.1 为什么选 Swing:课程设计与面试场景下的现实考量

做 Java 客户端游戏,绕不开 Swing 和 JavaFX 之争。我的结论很直接:除非你明确要做动画编辑器、复杂样式皮肤这类项目,否则捕鱼达人用 Swing 就够了,而且它才是更适合课程设计和面试题的选项。

理由有三层。第一,JavaFX 需要额外引入模块和运行时配置,很多同学还在 java 环境变量配置阶段就卡住了;Swing 是 JDK 自带的,java 安装完成之后直接能跑,交付源码给老师或面试官时,对方不需要安装任何额外依赖。第二,Swing 的历史代码量极大,网上能搜到的参考片段、踩坑记录都多,对新手友好。第三,也是最重要的一点:捕鱼达人需要的不是一堆复杂 UI 控件,而是一块能完全自己掌控的绘图画布。Swing 里一个 JPanel 覆盖 paintComponent 就能画鱼、画子弹、画网,几乎所有逻辑都握在自己手里,讲起来也清楚。

JavaFX 在动画和时间线上做得更漂亮,但它的生命周期、平台线程模型对新手来说是额外的黑匣子。你不是来学框架的,你是来证明自己能写游戏逻辑的,所以选 Swing 是性价比最高的路径。

2.2 最小可运行骨架:窗口、画布和 60 FPS 主循环

先看最外层窗口代码。我习惯把窗口和画布分成两个类:GameFrame 负责 JFrame 的生命周期,GamePanel 负责绘图和游戏循环。

public class GameFrame extends JFrame { public GameFrame() { setTitle("CatchFish - 捕鱼达人"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); // 锁定窗口大小,避免布局变形 GamePanel panel = new GamePanel(); setContentPane(panel); pack(); // 按 GamePanel 的 preferredSize 撑开窗口 setLocationRelativeTo(null); // 窗口居中 setVisible(true); } public static void main(String[] args) { SwingUtilities.invokeLater(GameFrame::new); } }

这段代码的逻辑说明:setResizable(false)是捕鱼游戏特别重要的一个决定——一旦允许用户拖拽窗口,画布坐标系和鼠标点击坐标就对不上了,后续所有子弹发射、炮管旋转都要做坐标换算,复杂度成倍增加。pack()依赖 GamePanel 里设置的 preferredSize,所以窗口尺寸最终由画布说了算。

这里有一个新手常犯的错:直接在 main 里new GameFrame(),不经过SwingUtilities.invokeLater。Swing 组件不是线程安全的,必须在事件分发线程上创建,否则在高分屏或某些 Linux 桌面环境下会出现窗口画不出来、点击无响应这类奇怪现象。把入口包进invokeLater是一劳永逸的做法。

接下来是核心的 GamePanel:

public class GamePanel extends JPanel implements Runnable { private Thread gameThread; private volatile boolean running = true; private static final int FPS = 60; private static final int WIDTH = 900; private static final int HEIGHT = 600; private int frameCount; public GamePanel() { setPreferredSize(new Dimension(WIDTH, HEIGHT)); setBackground(new Color(0x0c, 0x3d, 0x6e)); gameThread = new Thread(this, "game-loop"); gameThread.start(); } @Override public void run() { long lastTime = System.nanoTime(); double nsPerFrame = 1_000_000_000.0 / FPS; while (running) { long now = System.nanoTime(); if (now - lastTime >= nsPerFrame) { update(); // 更新所有精灵的状态 repaint(); // 触发 paintComponent 重绘 lastTime += nsPerFrame; frameCount++; } else { Thread.yield(); } } } private void update() { // 这里后续会更新鱼、子弹、网的位置 } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 这里后续会绘制所有精灵 } }

逻辑说明:主循环用System.nanoTime()做纳米级计时,而不是Thread.sleep(1000 / FPS)。sleep的精度受操作系统调度影响,高负载下误差可能到几十毫秒,游戏画面就会一顿一顿的。固定步长的思路是:每帧只消耗一个固定时间片,如果某帧耗时超过额定时间,后续帧自动补跳,保证游戏内物体的移动速度始终与真实时间挂钩,而不是与帧率挂钩。

repaint()也不是直接调用paint(Graphics),而是给 Swing 的事件分发线程发一个重绘请求。这样绘图操作被统一收敛到单一线程上,避免多线程同时画图造成画面撕裂。Thread.yield()是主动让出 CPU,防止游戏线程在屏幕刷新间隙空转,在低配置机器上能明显降低 CPU 占用。这一段跑起来之后,你应该看到一片深蓝色窗口,如果看不到,优先检查自己有没有配置好 java 环境变量,直接在命令行执行java -version和javac -version确认版本一致。

2.3 双缓冲与坐标系:先解决闪烁和方向两个新手问题

很多人在这个阶段遇到的第一大坑是:窗口出来了,但画面疯狂闪烁,鱼和子弹全是残影。原因多半是你手动在paintComponent里搞了什么「双缓冲优化」。Swing 的 JPanel 默认已经开启了双缓冲(setDoubleBuffered(true)),你只需要负责把自己的内容画到Graphics g上,Swing 后台会先把整帧画到一块离屏缓冲,再一次性提交到屏幕。如果你自己又去 new 一张 Image 做第二层缓冲,等于把两次平滑合并变成四次拷贝,反而闪得更厉害。

坐标系的坑更隐蔽。屏幕坐标的 Y 轴是向下的,数学课本里的 Y 轴是向上的。你写鱼往左上角游,代码里x -= speed、y -= speed,结果鱼真的往左上角走了,但如果你给炮台写「跟随鼠标旋转」,就会遇到Math.atan2(dy, dx)算出来的角度和屏幕上炮管指向不一致的问题。原因就是 Y 轴方向翻转。我的习惯是:所有精灵内部统一使用屏幕坐标,只有需要「角度」的场景才做一次-y换算,把这段逻辑独立封装成一个工具方法:

public class VectorUtil { /** 屏幕坐标下两点间的角度,返回弧度,0 表示水平向右 */ public static double angleTo(double fromX, double fromY, double toX, double toY) { return Math.atan2(-(toY - fromY), toX - fromX); } }

说明:atan2返回的角度范围是 -π 到 π,符号规则和数学坐标系一致;这里把dy取反,就完成了从屏幕坐标到数学坐标的映射。炮管绘制时再用cos(angle)和sin(angle)算炮口坐标,方向就对了。这一步是小细节,但如果不做,后面所有「炮台朝鼠标方向开炮」的功能都会乱套。

3. 鱼群与子弹的对象管理:从精灵类到对象池的一步步重构

3.1 精灵基类:鱼、子弹、网共用的字段与方法

捕鱼达人里的活动物体有三类:鱼、子弹、渔网。它们都有坐标、速度和存活状态,都要被update()移动、被paint(Graphics)绘制。如果每个类型单独写一套移动和绘制方法,代码很快就会长出大量重复片段。我一般会抽一个 Sprite 基类,把公共字段和生命周期行为收拢进去。

public abstract class Sprite { protected double x; protected double y; protected double speed; protected int width; protected int height; protected boolean alive = true; public void update() { } public void paint(Graphics g) { } public Rectangle getBounds() { return new Rectangle((int) x, (int) y, width, height); } public boolean isAlive() { return alive; } public void setAlive(boolean alive) { this.alive = alive; } }

逻辑说明:这里用的是面向对象编程 java 最常见的模板方法思路——基类把通用字段和 getter 写好,子类只负责覆写update()和paint()。getBounds()返回一个java.awt.Rectangle,后面做碰撞检测统一走它就能拿到四边坐标,不用每个子类暴露一堆 x、y、width、height 给外部。

Fish 继承 Sprite 之后,具体行为就很有区分度了:

public class Fish extends Sprite { private int value; // 这条鱼被捕获后给多少分 private int direction; // 1 向右游,-1 向左游 private long bornTime; // 出生时间戳,用来做难度曲线 public Fish() { this.speed = 1.0 + Math.random() * 3.0; this.value = (int) (speed * 10); this.width = 60; this.height = 40; } @Override public void update() { x += speed * direction; if (direction > 0 && x - width > GamePanel.WIDTH) { alive = false; // 游出右边界,等待回收 } if (direction < 0 && x + width < 0) { alive = false; // 游出左边界,等待回收 } } }

参数说明:speed同时决定了这条鱼的价值——速度越快越难打,给的分也越多,这是捕鱼达人玩法里「风险与收益匹配」的核心。direction用 1 和 -1 两个整数而不是布尔值,好处是乘法公式x += speed * direction直接复用一套逻辑,不需要 if 分两支。bornTime暂时没用上,等做难度曲线时会用来淘汰「活了太久还没被打死」的鱼,避免满屏全是老油条鱼,新手一个都打不中。

3.2 对象池替代频繁 new/remove:GC 与并发修改

捕鱼达人的节奏是每秒钟都可能生成几十条鱼、发射几十发子弹。如果直接在 ArrayList 里new Fish()然后list.remove(),高性能跑几分钟之后,JVM 的 GC 日志会非常难看,而且remove本身在遍历时特别容易触发ConcurrentModificationException。我一般用对象池管理鱼和子弹的复用。

public class FishPool { private final Deque<Fish> idle = new ArrayDeque<>(); private final List<Fish> active = new ArrayList<>(); private static final int MAX_IDLE = 50; private static final int MAX_ACTIVE = 120; public Fish acquire() { Fish fish = idle.isEmpty() ? new Fish() : idle.pop(); fish.setAlive(true); fish.reset(); // 重新随机出生位置和速度 active.add(fish); return fish; } public void release(Fish fish) { fish.setAlive(false); active.remove(fish); if (idle.size() < MAX_IDLE) { idle.push(fish); // 池子有上限,多余的鱼直接让 GC 回收 } } public void releaseDead() { Iterator<Fish> it = active.iterator(); while (it.hasNext()) { Fish fish = it.next(); if (!fish.isAlive()) { it.remove(); if (idle.size() < MAX_IDLE) { idle.push(fish); } } } } }

逻辑说明:acquire()从空闲队列头部取一条鱼,如果池子里没有,就 new 一条;拿到之后调用reset()重新随机化位置和速度,保证旧状态不会污染新鱼。releaseDead()用Iterator遍历并调用it.remove(),这是在遍历集合过程中删除元素的唯一安全姿势,直接调list.remove(fish)会抛并发修改异常。MAX_IDLE限制空闲池大小,避免「内存泄漏」变成「内存故意占着不放」。

为什么用ArrayDeque而不是LinkedList或者ArrayList?这里涉及 java 容器的一个细节:ArrayDeque 在两头增删都是 O(1),而且是数组结构,缓存命中比 LinkedList 好。对象池的典型操作就是往头部 push、从头部 pop,ArrayDeque 是标准答案。这个点拿到了也可以直接写进 java 面试题的回答里,面试官基本都会追问一句「为什么不是 ArrayList」。

子弹也可以复用同一个池化思路,区别只是重置参数不同:子弹没有 random 速度和方向,而是按炮台当前的发射角度算好初始位置和向量。如果两个池子的代码重复太多,可以进一步抽一个泛型ObjectPool<T extends Sprite>,但课程设计里我不建议过度设计——能说清楚一个池子的原理就够了,多做一层抽象只是给自己增加答辩负担。

3.3 鱼群生成策略:密度、方向和难度曲线

鱼怎么生成,直接决定游戏手感。全屏随机撒鱼会让画面看起来像一锅粥;太有规律又会被玩家摸透。我一般把生成逻辑设计成「波次 + 密度控制」:

public class FishSpawner { private final FishPool pool; private int spawnCount = 0; private long lastSpawnAt = 0; private static final int SPAWN_INTERVAL_MS = 600; public void update(long now) { if (now - lastSpawnAt >= SPAWN_INTERVAL_MS && pool.activeCount() < 80) { int batch = 1 + (int) (Math.random() * 3); // 一次生成1~3条 for (int i = 0; i < batch; i++) { Fish fish = pool.acquire(); double y = 50 + Math.random() * 450; fish.setPosition( Math.random() < 0.5 ? -fish.getWidth() : GamePanel.WIDTH, y ); fish.setDirection(Math.random() < 0.5 ? 1 : -1); } lastSpawnAt = now; } } }

参数说明:SPAWN_INTERVAL_MS = 600意味着每秒平均生成不到 5 个批次,实际鱼的数量还受到pool.activeCount() < 80的闸门限制。这个「总量闸门」比单纯控制生成频率更有效——玩家打掉鱼速度快时,闸门放水速度跟着加快,节奏感自然出来。y = 50 + Math.random() * 450是为了避开顶部可能放 UI 分数栏的区域,也避免鱼贴着上下边缘游动时玩家视线不舒适。

难度曲线不是写在生成器里的,而是在鱼的value和速度参数上做文章。我一般让分数高的鱼出现概率随时间缓慢上升:先固定一个鱼池权重表,Java 里实现加权随机时注意,Math.random()生成的是 0 到 1 的浮点,乘权重总和后落在哪个区间,就选哪个档位的鱼。这段逻辑如果写在Fish.reset()里,还要传一个difficulty参数进去,否则所有鱼永远是一个概率分布,玩两分钟就腻了。

4. 碰撞检测与得分结算:让子弹和网真正「打中」鱼

4.1 子弹命中判定:圆与矩形的相交检测

捕鱼达人里的子弹是一个移动的小圆,而鱼是一张贴图,走的是带方向的矩形。碰撞判定不能简单用Rectangle.intersects,因为圆到矩形远角的距离比边的距离大,矩形两个角附近会有「打到了但边上还空着」的视觉误差。

public boolean hit(Fish fish, Bullet bullet) { Rectangle r = fish.getBounds(); double cx = bullet.x; double cy = bullet.y; double cr = bullet.radius; // 找矩形上离圆心最近的点 double closestX = Math.max(r.x, Math.min(cx, r.x + r.width)); double closestY = Math.max(r.y, Math.min(cy, r.y + r.height)); double dx = cx - closestX; double dy = cy - closestY; return dx * dx + dy * dy <= cr * cr; }

逻辑说明:这段代码不直接做矩形与矩形相交,而是先算出矩形边界上离圆心最近的点,再比较圆心到这个点的距离是否小于等于子弹半径。Math.max和Math.min的组合本质是把圆心坐标「夹」到矩形区间里——如果圆心在矩形内部,closestX就等于cx,此时dx和dy为 0,必然判定命中;如果圆心在右上角外侧,最近点就是矩形的右上角顶点,距离会明显变大,符合视觉直觉。这样写的另一个好处是避免引入Ellipse2D这类复杂几何对象,算力开销极小,六十帧每帧跑上百次也没压力。

4.2 渔网范围判定:展开瞬间结算捕获

渔网和子弹的行为不同:子弹打到一条鱼就湮灭,渔网则是飞到目标点后展开,然后一次性捕获半径内的所有鱼。这里有个手感层面的设计选择——我一般不在网持续存在的每一帧都做判定,而是只在网展开的那一帧做一次范围结算,因为玩家看到网撒出去就期待「这一网定了生死」,持续判定会让玩家觉得网已经收回来了但鱼还在掉,反而像 bug。

public void expandAndCapture(FishPool pool, GameState state) { Rectangle captureBounds = new Rectangle( (int) (x - radius), (int) (y - radius), (int) (radius * 2), (int) (radius * 2) ); for (Fish fish : pool.getActiveList()) { if (captureBounds.intersects(fish.getBounds())) { state.addScore(fish.getValue()); // 结算分数 pool.release(fish); // 鱼从舞台上移除并回到池子 } } this.alive = false; // 这张网结束生命周期,等待回收 }

逻辑说明:expandAndCapture被主循环在网展开的那一帧调用一次。captureBounds是一个以网中心为原点的正方形区域,实际游戏里网的图片是圆形,用矩形判定会有四个小角多余,但因为网图片本身透明度高、边缘模糊,玩家基本感知不到这一点误差。如果后续要精确到圆形,可以把Rectangle.intersects换成之前章节里的圆与矩形最近点检测,原理完全一样。

有个小坑要特别注意:pool.getActiveList()返回的如果是active集合本身,这段代码里pool.release(fish)会修改集合,而外层主循环可能正在用同一个集合做绘制。稳妥做法是让对象池的getActiveList()返回一个Collections.unmodifiableList的视图,或者干脆在 release 内部用之前说的Iterator.remove()方案。这里能直接体现你对 java 容器和并发基础掌握得扎不扎实。

4.3 得分与连击:GameState 统一管理可变数值

分数、金币、连击数如果散落在各个对象里,很难保证一致性。比如连击清零的逻辑写在 Fish 里、加分逻辑写在 Net 里,两个地方对「什么算一次有效命中」的理解稍有出入,分数就对不上了。我一般把所有可变数值收拢到一个 GameState 类里,这也是面试官最愿意深挖的点。

public class GameState { private int score; private int combo; private long lastHitTime; private static final long COMBO_WINDOW_MS = 1500; public synchronized void addScore(int value) { long now = System.currentTimeMillis(); if (now - lastHitTime <= COMBO_WINDOW_MS) { combo++; score += value * combo; } else { combo = 1; score += value; } lastHitTime = now; } public synchronized int getScore() { return score; } }

逻辑说明:synchronized保证同一时间只有一个线程能改分数。捕鱼达人的得分入口可能来自主循环里的网结算,也可能来自某个减速子弹的延迟爆发效果,多线程场景下不加锁会出现分数被后写覆盖的问题。纯 Swing 项目里主循环和事件线程是两个线程,getScore()也加锁是为了防止读取到中间态。

COMBO_WINDOW_MS = 1500是连击窗口:1.5 秒内连续命中,连击递增,分数按连击数翻倍;超过 1.5 秒没再打中,连击重置为 1。这个参数直接决定游戏是「爽快型」还是「惩罚型」——窗口越长,玩家越容易滚出高连击,金币增长越快。这里没有做复杂的浮点或保证数据一致性方案,因为业务足够简单,synchronized是最直白、最好向面试官解释的选择。如果你愿意,还可以在addScore里加一个本次命中的特效回调,把「刚刚发生了什么」反馈到画面上。

5. 避坑清单:Java 游戏源码里最常见的 5 个翻车现场

5.1 界面假死:把游戏循环直接写在事件线程里

现象:窗口能显示出来,但鼠标点按钮没反应、标题栏一直转圈,整个界面像被冻住。

原因:有人图省事,直接在paintComponent里写一个while (true)循环来更新鱼和子弹。paintComponent运行在 Swing 的事件分发线程上,这个线程被占死后,所有鼠标事件、按键事件、重绘请求全部排队等待,看起来就是死机。捕鱼达人的游戏循环必须独立线程。

解决:把update()和repaint()挪到单独创建的Thread里,也就是第 2 章里 GamePanel 实现 Runnable 的那种结构。记住一条铁律:游戏循环里只能调repaint()请求重绘,绝不能直接调paint()或者在自己的线程里碰任何组件的方法。

5.2 鱼穿网而过:帧率不固定导致判定漂移

现象:显卡性能好的机器上鱼能被网抓住,显卡不行的机器上同样的网撒出去,鱼却「穿」过去了,尤其鱼速度越快越明显。

原因:渔网的展开和捕获只发生在一帧内。如果帧率降到 20 FPS,鱼每帧移动的距离比网展开的半径还大,上一帧鱼还在网外,下一帧鱼已经越过整个网的范围,判定自然失败。这是典型的时间步长与位移步长不匹配问题。

解决:给鱼的update()传入上一帧到这一帧的真实时间差deltaMs,让移动距离与时间挂钩而不是与帧数挂钩:x += speed * deltaMs / 16.67。如果坚持固定 60 FPS,还要给捕获判定加一个「速度补偿」——用渔网在前一瞬间和当前瞬间两个位置的并集做判定区域,彻底消除穿越。最简单有效的验证方式:程序里临时把 FPS 降到 30,看同一只鱼是否还是能被稳定捕获。

5.3 内存持续上涨:对象池没回收或监听器泄漏

现象:游戏运行 20 分钟之后,任务管理器里内存占用曲线一路向右上角爬,最后 OOM 闪退。

原因:最常见的是FishPool.releaseDead()只在鱼alive = false时才回收,但部分鱼在 spawn 阶段就设置了错误的初始位置,比如direction = 1且出生点x = GamePanel.WIDTH,鱼永远在屏幕外循环,一辈子不会被标记为alive = false。另一种情况是给按钮和画布注册了匿名内部类监听器,游戏结束时没有removeActionListener,导致对象无法被 GC 回收。

解决:在update()开头加一个强制越界检查:if (x > WIDTH + 200 || x + width < -200) alive = false,给游出界外的鱼一个兜底回收。监听器泄漏的排查方式是在 GameFrame 的关闭逻辑里主动清空所有监听器。如果想严格起见,可以用 JVisual VM 看一下堆内存里 Fish 对象的数量是否在一段长时间后持续增长。

5.4 图片资源加载失败:路径和类加载器的坑

现象:在自己 IDE 里跑得好好的,打包成 JAR 发给别人,双击运行后就剩下白茫茫一片,鱼和背景全没了,Java 控制台报IOException。

原因:很多人写资源路径用的是"src/main/resources/fish.png",这在自己的文件系统里没问题,但 JAR 包内部没有src目录,资源变成了压缩包里的内部路径,文件系统方式直接失效。

解决:统一用类加载器读取资源,而不是文件路径:

InputStream in = GamePanel.class.getResourceAsStream("/images/fish.png");

说明:/images/fish.png是相对于 classpath 根目录的路径,如果资源放在src/main/resources/images/下,打包后 JAR 内部就是/images/fish.png。无论从 IDE 还是 JAR 启动,这个方式都能拿到流。拿到流之后用ImageIO.read(in)解码并缓存成静态字段,避免每生成一条鱼就重新读一次图片文件。这条经验基本属于所有 Java 课程设计案例源码打包交付时必踩的坑。

5.5 分数错乱:多线程修改同一个集合

现象:鱼被捕获时画面提示加了分,但右上角的总分偶尔会跳回旧值,比如从 120 变成 90。

原因:捕获结算发生在主循环线程,而画面上的分数文本绘制发生在事件线程。两个线程同时读写了同一个int score字段,读到一个还没来得及写入的中间态。更隐蔽的是,分数列表或鱼列表在某个线程里被clear(),而另一个线程正拿着迭代器在遍历,抛异常或者空数据。

解决:业务上按第 4 章的 GameState 加synchronized锁;集合层面不用手写锁,而是改用CopyOnWriteArrayList或者在遍历前用Collections.synchronizedList包裹。不过要注意,synchronizedList只保证单次操作安全,迭代时仍需自己在外部加锁。课程设计里不建议引入并发包里的复杂结构,直接把分数改成AtomicInteger,把鱼列表的遍历和修改都收拢到主循环线程内,是最省心也最好解释的方案。

6. 让这份源码拿得出手:调参、验证和交付的一点经验

6.1 必调的三个参数:鱼速、网半径、生成间隔

整个游戏手感的好坏,游走在三个核心参数之间。我的习惯是先跑一遍默认值,再按下面的判断标准逐项微调。

参数建议初始值调大之后的效果判断标准
鱼速度 speed1.0 ~ 4.0画面更紧张,命中率下降玩家平均 3 秒内应能打中一条
网半径 radius40 ~ 60捕获范围变大,得分变容易一张网不应覆盖超过 1/4 屏幕
生成间隔 SPAWN_INTERVAL_MS600ms鱼密度上升,屏幕变拥挤同屏鱼数量稳定在 40 ~ 70 之间

参数调整有一个特别容易忽略的原则:改任何参数都要同步检查分数曲线。捕鱼达人这类玩法的本质是「单位时间产出金币」和「单位时间消耗金币」的平衡,如果一网下去平均金币是 50,而一炮要花 80,玩家会觉得憋屈;反过来一网平均 200,瞬间就能升级炮台,游戏又失去了目标感。调试时我一般在 GameState 里加两个累加器:totalSpent和totalEarned,游戏运行五分钟后看两者比值是否落在 1.2 到 1.5 之间,这个区间是让人「越打越想打」的甜蜜点。

6.2 用状态快照验证核心逻辑而不是靠肉眼

游戏这类程序,肉眼调试只能看出「大概差不多」,但很难确认碰撞边界是不是每帧都精确。我一般在开发阶段给游戏循环加一个 debug 开关,把关键状态每帧写入一个环形日志:

if (debug && frameCount % 60 == 0) { System.out.printf( "鱼=%d 子弹=%d 分数=%d 连击=%d FPS=%d%n", pool.activeCount(), bulletPool.activeCount(), state.getScore(), state.getCombo(), fpsCounter.getFps() ); }

说明:每 60 帧输出一次,相当于每秒一次状态快照。这一个习惯帮我解决过至少三次「明明感觉鱼被网住了但分数没涨」的问题——日志一看,鱼对象确实从池子的 active 列表里消失了,说明问题出在得分入账而不是捕获判定;如果鱼还在列表里,说明捕获判定的矩形边界没覆盖到位。把「哪里坏了」从黑洞变成一行日志,排查效率完全两样。

6.3 从「能跑」到「能答辩」:代码结构和注释的细节

最后交付源码时,结构比功能更值钱。我一般按这个分包整理:entity放 Sprite、Fish、Bullet、Net;pool放对象池;ui放 GameFrame 和 GamePanel;state放 GameState;util放 VectorUtil 这类工具。每个类的类注释写清楚「这个类负责什么」和「谁调用了它」,方法注释只写业务规则,比如「连击窗口 1500ms,超过重置」,不写废话。

这份源码如果拿来准备 java 面试题,重点准备三个追问:一是对象池为什么用 ArrayDeque 而不是 ArrayList;二是 synchronized 在这里解决了什么问题,能不能换成 volatile;三是鱼穿网的问题如何用 deltaTime 解决。这三个点能把面试官引导到你真正下过功夫的地方,远比背八股文有用。做完这些,整个捕鱼达人才算从「一份能跑的源码」变成「一份能讲清楚的设计」——这也是我反复跟做课程设计的同学说的一句话:不要追求代码多花哨,追求每个类都能回答「为什么存在」。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 17:40:34

AI视频抖动怎么解决?光流引导+时序注意力+后处理稳定实战

1. AI视频抖动问题的本质拆解1.1 抖动到底从哪里来很多人第一次接触AI视频生成&#xff0c;看到画面里人物走路像踩了电门、镜头平移时背景像果冻一样晃&#xff0c;第一反应是"模型不行"。但我实际拆过几套流程之后发现&#xff0c;抖动这件事从来不是单一原因造成的…

作者头像 李华
网站建设 2026/9/29 17:39:56

前端滚动位置恢复全指南:从localStorage到SPA路由的完整实践

1. 从需求说起&#xff1a;为什么"回不到上次位置"会劝退用户 先聊一个我最近真实遇到的场景。 后台有一个资产管理页面&#xff0c;列表很长&#xff0c;每行资产点进去是一个详情页&#xff0c;详情页里还有若干个 Tab 标签页&#xff0c;用户可能从"设备台账…

作者头像 李华
网站建设 2026/9/29 17:37:50

libtorch底层原理:C++深度学习的内存、计算图与编译器契约

1. 这不是“C 深度学习”的第七讲&#xff0c;而是整个链条里最常被跳过的那一环很多人点开“C 深度学习&#xff08;七&#xff09;”这个标题&#xff0c;第一反应是&#xff1a;“哦&#xff0c;又一个讲 ResNet 或 Transformer 的 C 实现教程”。但如果你真去翻前六讲——尤…

作者头像 李华
网站建设 2026/9/29 17:37:19

光伏储能三端口DC-DC变换器:拓扑选型、STM32控制与协同策略实战

光伏储能系统里&#xff0c;三端口DC-DC变换器是个绕不开的核心部件。它要同时对接光伏板、储能电池和负载母线三个端口&#xff0c;既要保证光伏最大功率输出&#xff0c;又要管理电池充放电&#xff0c;还得稳住母线电压。我接触这个方向有几年了&#xff0c;从最早用分立MOS…

作者头像 李华
网站建设 2026/9/29 17:37:15

GROMACS 2026 Beta异构GPU集群部署实战:RTX 5090与CUDA 12.8全指南

GROMACS 2026 Beta 源码包刚放出来&#xff0c;我就在组里那台专门给新卡预留的节点上试了一遍。第一次编译就翻车&#xff1a;系统里的 CUDA 11.8 根本不认 sm_120&#xff0c;只有把工具链整体切到 CUDA 12.8 之后&#xff0c;RTX 5090 才真正被 GROMACS 识别并跑起来。这篇部…

作者头像 李华
网站建设 2026/9/29 17:37:04

YuE|SSP:面向音乐创作的结构化谱面生成与实时编辑引擎

1. 项目概述&#xff1a;从单句歌词到完整金曲的“作曲工业化”现场你有没有过这样的体验&#xff1a;凌晨三点&#xff0c;手机备忘录里躺着一句突然闪现的歌词——“雨停在睫毛上&#xff0c;像未寄出的信”&#xff0c;心头一热&#xff0c;可接下来呢&#xff1f;旋律卡壳、…

作者头像 李华