Java实现捕鱼达人游戏源码:Swing窗口、对象池与碰撞检测全解析
发布时间:2026/9/29 17:40:41来源:尧图网络
简介一套基于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是一劳永逸的做法。接下来是核心的 GamePanelpublic 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/removeGC 与并发修改捕鱼达人的节奏是每秒钟都可能生成几十条鱼、发射几十发子弹。如果直接在 ArrayList 里new Fish()然后list.remove()高性能跑几分钟之后JVM 的 GC 日志会非常难看而且remove本身在遍历时特别容易触发ConcurrentModificationException。我一般用对象池管理鱼和子弹的复用。public class FishPool { private final DequeFish idle new ArrayDeque(); private final ListFish 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() { IteratorFish 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、从头部 popArrayDeque 是标准答案。这个点拿到了也可以直接写进 java 面试题的回答里面试官基本都会追问一句「为什么不是 ArrayList」。子弹也可以复用同一个池化思路区别只是重置参数不同子弹没有 random 速度和方向而是按炮台当前的发射角度算好初始位置和向量。如果两个池子的代码重复太多可以进一步抽一个泛型ObjectPoolT 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、Netpool放对象池ui放 GameFrame 和 GamePanelstate放 GameStateutil放 VectorUtil 这类工具。每个类的类注释写清楚「这个类负责什么」和「谁调用了它」方法注释只写业务规则比如「连击窗口 1500ms超过重置」不写废话。这份源码如果拿来准备 java 面试题重点准备三个追问一是对象池为什么用 ArrayDeque 而不是 ArrayList二是 synchronized 在这里解决了什么问题能不能换成 volatile三是鱼穿网的问题如何用 deltaTime 解决。这三个点能把面试官引导到你真正下过功夫的地方远比背八股文有用。做完这些整个捕鱼达人才算从「一份能跑的源码」变成「一份能讲清楚的设计」——这也是我反复跟做课程设计的同学说的一句话不要追求代码多花哨追求每个类都能回答「为什么存在」。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网