☰
Java手写捕鱼达人:2D游戏架构与性能优化实战
2026/10/13 3:46:29 网站建设 项目流程

简介:这是一份面向Java初学者与游戏开发爱好者的捕鱼达人游戏完整源码,基于Java语言实现,涵盖Swing/JavaFX界面、多线程主循环、事件监听、碰撞检测、动画帧率控制、音频播放与资源管理等核心知识点,适合作为课程设计、毕业设计或自学练手项目。压缩包共233个文件,约12.8MB,包含63个java源文件、95个class编译文件、21张png图片、16个plist动画配置、10个jar依赖库及ogg、mp3音效等,源码与素材齐备,可直接导入运行。目前已有801人学习下载。通过研读该源码,读者能理解玩家、鱼群、子弹、得分等对象的类设计与协作方式,掌握子弹与鱼的碰撞判定、鱼群游动线程调度、大炮角度力度控制等游戏逻辑,并借鉴资源加载与错误处理思路,快速搭建自己的小游戏框架。

1. 从零手写捕鱼达人:为什么 Java 桌面游戏是练手架构的最佳靶子

很多人第一次听到「Java实现捕鱼达人游戏源码」,脑子里浮现的是满屏游动的鱼和一张撒出去的网。但真正动手写过的人知道,这个标题背后藏着的是一套完整的桌面 2D 游戏循环:渲染线程、碰撞检测、对象池、状态机、音效调度,一个都不少。它不像贪吃蛇那样简单到没有架构可言,也不像大型 RPG 那样复杂到劝退,刚好卡在「能跑起来」和「能讲清楚设计」之间的甜点区。

我见过不少同学拿它当课程设计或者面试作品集,结果卡在三个地方:一是鱼群一多就掉帧,二是炮弹和鱼的碰撞判定玄学频发,三是游戏状态切换时资源释放不干净导致内存泄漏。这三个问题恰好对应了游戏开发里最核心的三件事——渲染性能、碰撞精度、生命周期管理。把这三点吃透,捕鱼达人就不只是一个游戏,而是一个可以复用的 2D 游戏框架雏形。

这篇文章面向的是有 Java 基础、想通过一个完整项目理解桌面游戏架构的开发者。我会从工程结构讲起,把渲染循环、对象池、碰撞检测、状态管理逐层拆开,每个环节给出可运行的代码骨架和参数说明。读完你拿到的不只是一份源码,而是一套能迁移到其他 2D 游戏上的工程思路。

2. 工程骨架与渲染循环:把 60 帧稳定跑起来

2.1 为什么选 Swing + Java2D 而不是 JavaFX

做 Java 桌面游戏,第一个岔路口就是渲染框架选型。JavaFX 的 Canvas 性能更好,支持硬件加速,但它的线程模型和场景图对新手不够透明,出问题很难定位。Swing 配 Java2D 虽然老,但胜在可控——每一帧画什么、什么时候画、画在哪个缓冲上,全在你手里。

我一般会这样选:如果目标是教学演示或者中小规模 2D 游戏,Swing + Java2D 足够,而且调试成本低;如果要做粒子特效密集或者需要 GPU 加速的场景,再考虑 JavaFX 或者 LWJGL。捕鱼达人这个量级,同屏鱼的数量通常在 30 到 80 条之间,加上炮弹和特效,Java2D 的双缓冲完全扛得住。

工程结构上,我习惯按职责分包,而不是按类型分包。很多教程喜欢把所有的类扔进一个包,或者按model、view、controller分。前者后期找不到类,后者在游戏项目里容易把渲染逻辑和更新逻辑搅在一起。我的做法是按游戏子系统分:

src/ main/ java/ com.example.fishing/ core/ // 游戏循环、时间管理、输入 entity/ // 鱼、炮弹、网、金币 pool/ // 对象池 state/ // 游戏状态机 render/ // 渲染器、精灵加载 audio/ // 音效管理 config/ // 常量、参数配置

这个结构的好处是,当你需要优化渲染时,只动render包;需要调碰撞时,只动entity和core。边界清晰,改一处不会牵连全身。

2.2 固定时间步长的游戏循环怎么写

游戏循环是整个项目的心脏。最常见的翻车写法是在paintComponent里直接更新逻辑,然后靠repaint驱动。这样做帧率和逻辑更新耦合在一起,机器一卡,鱼就瞬移,碰撞判定直接失效。

正确的做法是把逻辑更新和渲染分开,用固定时间步长(fixed timestep)驱动更新,渲染则尽可能快。下面是一个最小可用的循环骨架:

public class GameLoop implements Runnable { private static final int TARGET_FPS = 60; private static final long OPTIMAL_TIME = 1_000_000_000L / TARGET_FPS; private static final double MAX_ACCUMULATOR = 0.25; // 防止死亡螺旋 private volatile boolean running = true; private double accumulator = 0; private long lastTime = System.nanoTime(); @Override public void run() { while (running) { long now = System.nanoTime(); long elapsed = now - lastTime; lastTime = now; // 累加器限制,防止卡顿后一次性补太多帧 accumulator += elapsed / 1_000_000_000.0; if (accumulator > MAX_ACCUMULATOR) { accumulator = MAX_ACCUMULATOR; } while (accumulator >= 1.0 / TARGET_FPS) { update(1.0 / TARGET_FPS); // 固定步长更新逻辑 accumulator -= 1.0 / TARGET_FPS; } render(); // 渲染不等待,尽可能快 try { long sleep = (lastTime - System.nanoTime() + OPTIMAL_TIME) / 1_000_000; if (sleep > 0) Thread.sleep(sleep); } catch (InterruptedException e) { Thread.currentThread().interrupt(); running = false; } } } private void update(double deltaTime) { // 更新鱼的位置、炮弹轨迹、碰撞检测 } private void render() { // 在 EDT 上触发重绘 } }

这段代码里有三个关键参数需要理解。TARGET_FPS设为 60 是桌面游戏的通用值,再高对肉眼收益很小,但 CPU 占用会明显上升。MAX_ACCUMULATOR设为 0.25 秒,意思是如果某一帧卡了超过 250 毫秒,最多只补 250 毫秒的逻辑,剩下的丢弃。没有这个限制,一旦出现长时间 GC 或者系统卡顿,累加器会疯狂补帧,导致更卡,形成死亡螺旋。OPTIMAL_TIME用来计算休眠时间,避免循环空转吃满 CPU。

注意:update和render不在同一个线程上执行时,共享数据必须做同步或者用不可变快照。我一般会让update在游戏线程跑,render通过SwingUtilities.invokeLater提交到 EDT,两者之间用只读的渲染列表传递状态。

2.3 双缓冲与脏矩形:鱼多了怎么不掉帧

Java2D 默认在 Swing 组件上已经开了双缓冲,但如果你在paintComponent里每帧重画整个背景和所有精灵,鱼一多还是会掉帧。优化手段有两个方向:减少重绘面积,减少每帧的绘制调用。

减少重绘面积用脏矩形(dirty rectangle)策略。捕鱼达人的背景通常是静态的,鱼和炮弹只在局部区域移动。你可以记录每一帧所有移动对象的外接矩形并集,只重绘这个区域。但实现起来要小心,脏矩形算错会导致残影,调试成本不低。

更稳妥的做法是分层渲染加缓冲。把静态背景画到一个BufferedImage上,每帧只画一次背景图,然后画动态对象。动态对象本身如果数量多,可以按类型分组,用Graphics2D的drawImage批量绘制。下面是一个渲染器的核心片段:

public class GameRenderer { private final BufferedImage backgroundLayer; private final List<Fish> fishList; private final List<Bullet> bulletList; public void render(Graphics2D g2d, int width, int height) { // 第一层:静态背景,直接贴图 g2d.drawImage(backgroundLayer, 0, 0, null); // 第二层:鱼群,按 Y 轴排序保证遮挡关系正确 fishList.sort(Comparator.comparingDouble(Fish::getY)); for (Fish fish : fishList) { if (fish.isVisible()) { g2d.drawImage( fish.getCurrentFrame(), (int) fish.getX(), (int) fish.getY(), fish.getWidth(), fish.getHeight(), null ); } } // 第三层:炮弹和网 for (Bullet bullet : bulletList) { g2d.drawImage(bullet.getSprite(), (int) bullet.getX(), (int) bullet.getY(), null); } // 第四层:UI 和金币数字 drawHud(g2d); } }

这里有个血泪经验:fishList.sort每帧都排一次,鱼多的时候排序本身也是开销。如果鱼的数量超过 100,建议改成插入排序或者维护一个按 Y 排序的树结构,利用帧间连贯性减少排序量。另外drawImage的缩放版本比原尺寸版本慢很多,精灵图最好预缩放到目标尺寸,不要每帧缩放。

3. 对象池与碰撞检测:炮弹和鱼的判定怎么做才不玄学

3.1 为什么不能每发炮弹都 new 一个对象

捕鱼达人里炮弹的发射频率很高,一秒可能好几发,加上命中后的网、金币飘字、粒子特效,如果每次都new,年轻代 GC 会频繁触发。GC 一停,帧率就抖,玩家看到的就是炮弹卡顿或者鱼瞬移。

对象池的思路是预先创建一批对象,用的时候从池里取,用完还回去,而不是销毁。Java 里实现对象池不需要引入额外库,一个ArrayDeque加状态标记就够了。下面是一个通用的对象池实现:

public class ObjectPool<T> { private final Deque<T> available; private final Supplier<T> factory; private final Consumer<T> resetter; private final int maxSize; public ObjectPool(Supplier<T> factory, Consumer<T> resetter, int initialSize, int maxSize) { this.factory = factory; this.resetter = resetter; this.maxSize = maxSize; this.available = new ArrayDeque<>(initialSize); for (int i = 0; i < initialSize; i++) { available.push(factory.get()); } } public T obtain() { T obj = available.poll(); if (obj == null) { obj = factory.get(); // 池空了就新建,但不超过 maxSize 的约束由调用方保证 } return obj; } public void recycle(T obj) { if (obj == null) return; if (available.size() >= maxSize) return; // 池满了就丢弃,交给 GC resetter.accept(obj); available.push(obj); } }

使用的时候,炮弹的创建和回收长这样:

ObjectPool<Bullet> bulletPool = new ObjectPool<>( Bullet::new, bullet -> { bullet.setActive(false); bullet.setX(0); bullet.setY(0); }, 50, // 初始 50 个 200 // 最多缓存 200 个 ); // 发射 Bullet bullet = bulletPool.obtain(); bullet.init(cannonX, cannonY, angle, power); activeBullets.add(bullet); // 回收 if (bullet.isOutOfBounds() || bullet.hasHit()) { activeBullets.remove(bullet); bulletPool.recycle(bullet); }

参数上,初始大小按同屏最大炮弹数的 1.5 倍设,最大缓存按 3 倍设。设太小会频繁新建,设太大浪费内存。resetter里一定要把所有可变字段重置,否则下一发炮弹会带着上一发的状态,出现「炮弹从上次位置出发」的灵异现象。

3.2 圆形碰撞检测:比矩形更贴合鱼的实际形状

捕鱼达人的碰撞判定,新手最容易犯的错是用矩形包围盒。鱼是流线型的,矩形包围盒在鱼头鱼尾方向会多出一大块空白,导致「明明没碰到却判定命中」。用圆形碰撞检测,每个鱼用一个中心点和半径表示,炮弹也用圆形,判定公式就是圆心距离小于半径之和。

public class CollisionDetector { public static boolean circleHit( float x1, float y1, float r1, float x2, float y2, float r2) { float dx = x1 - x2; float dy = y1 - y2; float minDist = r1 + r2; return dx * dx + dy * dy <= minDist * minDist; } }

这里有个优化点:开方运算比乘法慢,所以比较距离平方而不是距离本身。dx * dx + dy * dy和minDist * minDist比较,避免Math.sqrt。

但圆形检测也有边界情况。鱼在转向的时候,中心点不变但朝向变了,圆形半径如果按鱼身长度设,转向时判定范围会偏大。我的做法是给每条鱼设两个半径:一个用于鱼身主体,一个用于鱼尾,分别检测。或者更简单粗暴——按鱼的类型设不同的碰撞半径,小鱼用小半径,大鱼用大半径,这个参数放在鱼的配置里。

3.3 碰撞检测的性能边界:什么时候该上空间分割

同屏 50 条鱼、10 发炮弹,两两检测是 500 次,完全无压力。但如果加上渔网、金币、粒子,对象数量到 200 以上,两两检测就是 4 万次,每帧 4 万次虽然不算多,但加上渲染和其他逻辑,在低端机上就会掉帧。

这时候需要空间分割。最简单的做法是网格法:把屏幕分成固定大小的格子,每个对象只和同格子及相邻格子的对象检测。实现上用一个Map<Long, List<Entity>>,key 是格子坐标的哈希。

public class SpatialGrid { private final int cellSize; private final Map<Long, List<Entity>> grid = new HashMap<>(); public SpatialGrid(int cellSize) { this.cellSize = cellSize; } private long key(int cx, int cy) { return ((long) cx << 32) | (cy & 0xffffffffL); } public void insert(Entity e) { int cx = (int) (e.getX() / cellSize); int cy = (int) (e.getY() / cellSize); grid.computeIfAbsent(key(cx, cy), k -> new ArrayList<>()).add(e); } public List<Entity> queryNearby(float x, float y) { int cx = (int) (x / cellSize); int cy = (int) (y / cellSize); List<Entity> result = new ArrayList<>(); for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { List<Entity> cell = grid.get(key(cx + dx, cy + dy)); if (cell != null) result.addAll(cell); } } return result; } }

cellSize一般设为最大对象直径的 2 倍。太小格子多,查询时遍历的格子多;太大退化成两两检测。捕鱼达人里鱼的最大尺寸大概 120 像素,cellSize设 200 到 250 比较合适。每帧开始前清空网格,重新插入所有活动对象,然后炮弹只查询自己所在格子及相邻格子的鱼。

注意:空间分割是优化手段,不是必须的。如果你的同屏对象稳定在 100 以内,直接两两检测更简单,出 bug 的概率也低。不要为了优化而优化,先跑起来,再测性能,最后才优化。

4. 状态机与资源管理:游戏切换场景时怎么不泄漏

4.1 用枚举状态机管住开始、暂停、结算

捕鱼达人的游戏状态至少有:加载中、主菜单、游戏中、暂停、结算。新手容易用一堆布尔变量来标记,比如isPlaying、isPaused、isGameOver,然后到处写if判断。状态一多,布尔组合就爆炸,出现「暂停时还能发射炮弹」这种 bug。

用枚举状态机,每个状态定义进入、更新、退出三个行为,切换时先退出旧状态再进入新状态。下面是一个轻量实现:

public enum GameState { LOADING { @Override public void update(GameContext ctx, double dt) { /* 加载资源 */ } @Override public void onEnter(GameContext ctx) { /* 显示进度条 */ } @Override public void onExit(GameContext ctx) { /* 释放加载界面 */ } }, MENU { @Override public void update(GameContext ctx, double dt) { /* 菜单动画 */ } @Override public void onEnter(GameContext ctx) { /* 播放背景音乐 */ } @Override public void onExit(GameContext ctx) { /* 停止菜单音乐 */ } }, PLAYING { @Override public void update(GameContext ctx, double dt) { ctx.getFishManager().update(dt); ctx.getBulletManager().update(dt); ctx.getCollisionSystem().check(); } @Override public void onEnter(GameContext ctx) { /* 重置分数、初始化鱼群 */ } @Override public void onExit(GameContext ctx) { /* 暂停音效、保存进度 */ } }, PAUSED { @Override public void update(GameContext ctx, double dt) { /* 不更新逻辑 */ } @Override public void onEnter(GameContext ctx) { /* 弹出暂停面板 */ } @Override public void onExit(GameContext ctx) { /* 关闭暂停面板 */ } }, SETTLEMENT { @Override public void update(GameContext ctx, double dt) { /* 结算动画 */ } @Override public void onEnter(GameContext ctx) { /* 计算得分、显示结算 */ } @Override public void onExit(GameContext ctx) { /* 清理本局对象 */ } }; public abstract void update(GameContext ctx, double dt); public abstract void onEnter(GameContext ctx); public abstract void onExit(GameContext ctx); }

状态切换用一个StateMachine类管理,保证onExit和onEnter成对调用。这样暂停时逻辑更新自然停止,不需要在每个系统里写if (paused) return。

4.2 精灵图与音效的加载和释放时机

资源管理是内存泄漏的重灾区。捕鱼达人的资源分三类:精灵图(鱼的帧动画、炮弹、网、UI)、音效(发射、命中、金币)、字体。精灵图如果每局重新加载,加载时的卡顿很明显;如果一直不释放,内存又扛不住。

我的策略是分级加载。全局资源(UI 字体、按钮、背景音乐)在游戏启动时加载一次,常驻内存。局内资源(鱼的精灵图、炮弹、音效)在进入PLAYING状态时加载,退出时释放。释放的时候要注意,BufferedImage本身不需要手动释放,但如果你用了ImageIO读流,流要关;如果用了Clip播放音效,Clip要close。

public class ResourceManager { private final Map<String, BufferedImage> imageCache = new HashMap<>(); private final Map<String, Clip> audioCache = new HashMap<>(); public BufferedImage loadImage(String path) { return imageCache.computeIfAbsent(path, p -> { try (InputStream is = getClass().getResourceAsStream(p)) { return ImageIO.read(is); } catch (IOException e) { throw new RuntimeException("加载图片失败: " + p, e); } }); } public void playSound(String path) { Clip clip = audioCache.computeIfAbsent(path, p -> { try (AudioInputStream ais = AudioSystem.getAudioInputStream( getClass().getResource(p))) { Clip c = AudioSystem.getClip(); c.open(ais); return c; } catch (Exception e) { throw new RuntimeException("加载音效失败: " + p, e); } }); if (clip.isRunning()) clip.stop(); clip.setFramePosition(0); clip.start(); } public void releaseLevelResources() { // 释放局内资源,保留全局资源 audioCache.entrySet().removeIf(entry -> { if (entry.getKey().startsWith("/audio/level/")) { entry.getValue().close(); return true; } return false; }); imageCache.keySet().removeIf(k -> k.startsWith("/images/level/")); } }

音效这里有个坑:同一个Clip连续start多次,后面的会打断前面的。如果你想要「连发炮弹音效叠加」,需要为每个音效创建多个Clip实例轮换,或者用SourceDataLine自己混音。简单场景下,打断就打断,影响不大。

4.3 用配置表驱动鱼群参数,不改代码就能调难度

鱼的种类、血量、速度、出现频率、分值,这些参数如果硬编码在类里,每次调难度都要重新编译。我习惯把它们抽到一个配置文件里,用 JSON 或者 properties 格式。下面是一个 JSON 配置的示例:

{ "fishTypes": [ { "id": "small_yellow", "sprite": "/images/fish/small_yellow.png", "frameCount": 8, "frameDuration": 0.08, "collisionRadius": 24, "speed": 120, "hp": 1, "score": 2, "spawnWeight": 50 }, { "id": "medium_blue", "sprite": "/images/fish/medium_blue.png", "frameCount": 10, "frameDuration": 0.1, "collisionRadius": 40, "speed": 80, "hp": 3, "score": 10, "spawnWeight": 30 } ] }

spawnWeight是生成权重,数值越大出现概率越高。frameDuration控制帧动画速度,collisionRadius就是前面说的碰撞半径。调难度的时候只改这个文件,重启游戏即可生效。如果要做热更新,加一个文件监听线程,检测到修改就重新加载配置。

注意:配置文件里的路径要用相对路径,并且和打包后的资源目录结构一致。用 Maven 或 Gradle 打包时,src/main/resources下的文件会被复制到 classpath 根目录,所以路径写/images/fish/xxx.png而不是src/main/resources/images/fish/xxx.png。

5. 避坑与排查:那些让我熬夜的 bug 和它们的解法

5.1 鱼游到边界外不消失,内存里堆了几千条

现象是游戏运行几分钟后越来越卡,用 JProfiler 一看,Fish对象数量持续增长。原因是鱼的回收逻辑只写了「出界后从活动列表移除」,但忘了把鱼还回对象池,或者对象池的recycle没被调用。更隐蔽的情况是,鱼在出界前被炮弹打中,走了命中分支,命中分支里只处理了得分和特效,没有回收鱼对象。

解决方法是把回收逻辑收敛到一个地方。不管是出界、被击中还是状态切换,都调用同一个despawnFish(fish)方法,里面统一做「从活动列表移除 + 还池 + 触发事件」。不要在每个分支里各写各的回收代码,迟早漏一个。

5.2 碰撞判定偶尔失效,炮弹穿过鱼却不爆炸

现象是大部分时候正常,偶尔炮弹从鱼身上穿过去。原因通常是更新顺序问题:炮弹先更新位置,然后做碰撞检测,但鱼在这一帧还没更新位置,导致炮弹的新位置和鱼的旧位置错开了。或者反过来,鱼更新了但炮弹没更新。

解决方法是把一帧内的更新顺序固定下来:先更新所有鱼的位置,再更新所有炮弹的位置,然后统一做碰撞检测。碰撞检测用的位置必须是同一帧的最终位置。如果还有穿透,说明炮弹速度太快,一帧移动距离超过了鱼的直径,需要做连续碰撞检测(CCD),在炮弹的上一帧位置和当前位置之间做线段与圆的相交测试。

5.3 暂停后恢复,鱼全部瞬移或者动画错乱

现象是暂停再恢复,鱼的位置跳了一大截。原因是暂停期间accumulator还在累加,恢复后一次性补了大量帧。或者暂停时lastTime没有重置,恢复后elapsed计算出一个巨大的值。

解决方法是在状态切换到PAUSED时,把accumulator清零,并且记录暂停时刻。恢复时把lastTime重置为当前时间,而不是用暂停前的时间。这样恢复后的第一帧elapsed是正常的帧间隔,不会补帧。

5.4 音效播放延迟,点击发射后半秒才响

现象是音效和操作不同步。原因是Clip第一次加载时要从磁盘读流并打开,这个 IO 操作在第一次播放时发生,导致延迟。如果音效文件大或者磁盘慢,延迟更明显。

解决方法是在进入PLAYING状态时预加载所有局内音效,调用clip.open()但不start()。播放时只做setFramePosition(0)和start(),这两个操作是内存级的,几乎没有延迟。预加载的代价是进入游戏时多等几百毫秒,但比操作延迟体验好得多。

5.5 打包成 jar 后图片加载失败,IDE 里却正常

现象是 IDE 里跑得好好的,mvn package之后双击 jar 运行,图片全部变成空白或者抛NullPointerException。原因是代码里用了new File("src/main/resources/...")这种文件路径,打包后资源在 jar 内部,不是文件系统路径。

解决方法是统一用getClass().getResourceAsStream("/images/xxx.png")读取资源,返回InputStream。ImageIO.read(InputStream)可以正常处理 jar 内的资源。所有资源路径以/开头,表示从 classpath 根目录开始找。

6. 进阶技巧:用插值让低帧率下的鱼也游得顺滑

前面讲的固定时间步长解决了逻辑更新的稳定性,但渲染如果也是固定 60 帧,在低端机或者复杂场景下掉到 30 帧时,鱼的运动看起来会一顿一顿的。这是因为逻辑更新是离散的,渲染直接画了当前逻辑位置,没有利用两次更新之间的时间。

解决办法是渲染插值。在每次逻辑更新时,保存对象的上一个位置和当前位置。渲染时,根据当前时间在两次更新之间的比例,计算一个中间位置来画。这样即使逻辑更新是 30 次每秒,渲染是 60 次每秒,鱼的运动也是平滑的。

public class Fish { private float prevX, prevY; private float currX, currY; public void update(double dt) { prevX = currX; prevY = currY; currX += vx * dt; currY += vy * dt; } public float getRenderX(float alpha) { return prevX + (currX - prevX) * alpha; } public float getRenderY(float alpha) { return prevY + (currY - prevY) * alpha; } }

alpha是渲染时刻在两次逻辑更新之间的比例,范围 0 到 1。计算方式是accumulator / (1.0 / TARGET_FPS)。渲染时用getRenderX(alpha)和getRenderY(alpha)代替直接读currX和currY。

这个技巧的代价是每个对象多存两个字段,以及渲染时多一次乘加运算。收益是低帧率下的视觉平滑度提升非常明显,尤其是鱼群密集的时候。我一般会在性能允许的情况下默认开启插值,只有在极端低端设备上才关掉。

还有一个进阶方向是鱼的转向动画。捕鱼达人里的鱼不是直线游的,它们会沿着曲线路径游动,转向时鱼头要跟着转。如果用精灵图,需要为每个方向准备不同的帧,或者用Graphics2D的rotate做旋转。旋转的代价是每帧多一次坐标变换,鱼多的时候开销不小。我的做法是只对转向中的鱼做旋转,直线游动的鱼用预渲染的朝向帧,这样大部分鱼不需要旋转,性能影响可控。

最后说一个我踩过的坑:插值渲染和碰撞检测用的位置必须分开。碰撞检测用currX、currY,渲染用插值后的位置。如果碰撞也用插值位置,会出现「视觉上碰到了但判定没碰到」的偏差。这个偏差在鱼速快的时候尤其明显,血泪教训。

希望帮到你。

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

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

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

立即咨询