免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

用Java Swing复刻《捕鱼达人》:从游戏循环到碰撞检测的实战解析

用Java Swing复刻《捕鱼达人》:从游戏循环到碰撞检测的实战解析 1. 项目缘起为什么用Java Swing重写《捕鱼达人》几年前我在面试一个初级Java开发岗位时面试官问了一个让我印象很深的问题“除了Spring Boot和微服务你用过Java做过什么有意思的东西吗” 我当时脑子里全是各种框架和八股文一时语塞。后来我意识到很多Java开发者包括我自己都陷入了“业务开发”的舒适区对Java在图形界面、游戏逻辑、实时交互这些更底层的应用场景缺乏实践。这就像只学了语法却从没写过一篇完整的文章。于是我决定挑战自己用最“原始”的Java技术栈——Swing来复刻一个经典的休闲游戏《捕鱼达人》。这个选择听起来有点“复古”甚至有点“低端”毕竟现在都是Unity、UE4或者H5 Canvas的天下了。但恰恰是这种“复古”能让我们抛开繁复的引擎和框架直面游戏开发最核心的几个问题图形渲染、对象管理、碰撞检测、事件驱动和状态同步。这对于理解游戏循环、面向对象设计模式比如观察者、工厂、状态模式以及多线程编程是一次绝佳的实战演练。它不追求炫酷的3D效果而是聚焦于用纯粹的Java代码构建一个稳定、可玩、逻辑完整的应用程序这本身就是对Java基础能力的一次深度检验。2. 技术选型与架构设计为什么是Swing而不是其他提到Java做游戏很多人会想到LibGDX、jMonkeyEngine等专业游戏框架。我选择Swing主要基于以下几点考量2.1 核心诉求聚焦逻辑而非渲染引擎我的目标是深入理解游戏机制本身而不是学习一个特定游戏引擎的API。Swing作为Java标准库的一部分提供了足够的基础绘图Graphics2D和事件处理能力让我能从零开始搭建游戏循环、管理精灵Sprite、处理用户输入。这迫使我去思考“每一帧画面是如何画出来的”、“子弹和鱼的碰撞如何判断”这些根本性问题。2.2 开发与学习成本对于Java开发者而言Swing几乎没有额外的学习成本。JFrame,JPanel,KeyListener,MouseListener这些都是内置类文档齐全。我可以把全部精力投入到游戏业务逻辑的设计上而不是耗费在配置复杂的开发环境或理解引擎特有的概念上。2.3 轻量与可控Swing程序打包后就是一个独立的JAR文件依赖极小启动快。整个游戏的结构完全由我掌控从主循环的计时器javax.swing.Timer到每一颗子弹的运动轨迹代码清晰可见便于调试和优化。这对于教学和原理剖析来说是巨大的优势。2.4 架构设计草图基于以上我设计了如下的核心类结构这是一个典型的面相对象游戏架构GameMain: 程序入口继承JFrame是游戏的主窗口。GamePanel: 游戏画板继承JPanel是游戏渲染的核心。它重写paintComponent(Graphics g)方法负责绘制背景、鱼群、子弹、炮台、分数等所有游戏元素。GameLoop: 游戏循环控制器。我使用一个javax.swing.Timer以固定的时间间隔如每秒60帧即16毫秒触发重绘和游戏状态更新。Sprite基类: 所有可绘制、可移动游戏对象的基类定义位置x, y、速度、图像、是否存活等属性和更新(update)、绘制(draw)方法。Fish类 (继承Sprite): 代表各种鱼类。有不同的类型小鱼、中鱼、大鱼、生命值、移动路径和对应的分数。Bullet类 (继承Sprite): 代表炮弹。有发射角度、速度、威力等属性。Cannon类: 炮台。管理炮台等级、发射角度跟随鼠标、子弹类型和发射冷却。GameManager: 游戏管理器单例模式。它是游戏的大脑负责创建和销毁Fish和Bullet对象进行碰撞检测更新分数和金币管理游戏状态开始、进行中、结束。这个架构清晰地将渲染View、逻辑Controller/Model分离虽然简单但体现了良好的设计思想。3. 核心实现细节拆解从静态画面到动态游戏有了架构接下来就是填充血肉。下面我挑几个最关键的技术点分享我的实现思路和踩过的坑。3.1 游戏主循环与双缓冲游戏的核心是一个永不停止的循环处理输入 - 更新逻辑 - 渲染画面。在Swing中我们不能在一个while(true)循环里直接调用repaint()这会阻塞事件分发线程EDT导致界面卡死。注意Swing的绘图必须在事件分发线程EDT上进行所有对UI组件的修改如repaint()都应通过SwingUtilities.invokeLater()提交到EDT或者在Timer的回调中执行因为Timer的回调默认就在EDT中。我的解决方案是使用javax.swing.Timer。public class GamePanel extends JPanel { private Timer gameTimer; public GamePanel() { gameTimer new Timer(16, new ActionListener() { // ~60 FPS Override public void actionPerformed(ActionEvent e) { updateGameState(); // 更新所有对象状态 repaint(); // 请求重绘触发paintComponent } }); gameTimer.start(); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 清空画布 Graphics2D g2d (Graphics2D) g; // 1. 绘制背景 g2d.drawImage(backgroundImage, 0, 0, getWidth(), getHeight(), this); // 2. 绘制所有鱼 for (Fish fish : gameManager.getAllFish()) { fish.draw(g2d); } // 3. 绘制所有子弹 for (Bullet bullet : gameManager.getAllBullets()) { bullet.draw(g2d); } // 4. 绘制炮台和UI cannon.draw(g2d); drawUI(g2d); } }这里有一个至关重要的技巧双缓冲。如果直接在paintComponent中绘制复杂的场景会导致严重的闪烁。Swing的JPanel默认不是双缓冲的。解决方法很简单在构造函数中调用setDoubleBuffered(true);或者自定义一个继承自JPanel的类并重写isDoubleBuffered()返回true。双缓冲的原理是在内存中先完成一整帧的绘制然后一次性拷贝到屏幕消除了绘制过程中的中间状态画面变得非常平滑。3.2 精灵Sprite的运动与动画鱼和子弹都需要运动。我让Sprite基类有一个update()方法在游戏循环的updateGameState()中被调用。public abstract class Sprite { protected int x, y; // 位置 protected int vx, vy; // 速度向量 protected BufferedImage image; protected boolean alive true; public void update() { // 基础运动根据速度更新位置 x vx; y vy; // 边界检查如果移出屏幕标记为未存活 if (x -image.getWidth() || x GamePanel.WIDTH || y -image.getHeight() || y GamePanel.HEIGHT) { alive false; } } public void draw(Graphics2D g2d) { if (alive image ! null) { g2d.drawImage(image, x, y, null); } } }对于鱼类可以扩展update()方法实现更复杂的运动路径比如正弦波移动public class Fish extends Sprite { private double time 0; // 用于正弦函数的时间变量 private double amplitude; // 振幅 private double frequency; // 频率 Override public void update() { super.update(); // 先执行基础的直线运动 // 叠加正弦运动 y (int) (amplitude * Math.sin(frequency * time)); time 0.1; // 时间递增 } }3.3 碰撞检测子弹与鱼的“亲密接触”这是游戏逻辑的核心。当子弹击中鱼鱼要掉血或死亡玩家要加分。我采用了最常用且高效的矩形碰撞检测。虽然鱼和子弹的图形可能是不规则的但用其外接矩形进行初步判断在性能和精度上是一个很好的折中。在GameManager中每一帧都会检查所有存活的子弹和所有存活的鱼public void checkCollisions() { ListBullet bullets getAllBullets(); ListFish fishes getAllFish(); for (Bullet bullet : new ArrayList(bullets)) { // 遍历副本避免并发修改 if (!bullet.isAlive()) continue; Rectangle bulletRect bullet.getBounds(); // 获取子弹的矩形区域 for (Fish fish : new ArrayList(fishes)) { if (!fish.isAlive()) continue; Rectangle fishRect fish.getBounds(); // 获取鱼的矩形区域 if (bulletRect.intersects(fishRect)) { // 发生碰撞 onCollision(bullet, fish); break; // 一颗子弹通常只击中一条鱼 } } } } private void onCollision(Bullet bullet, Fish fish) { // 子弹命中效果 bullet.setAlive(false); // 子弹消失 // 鱼受到伤害 fish.takeDamage(bullet.getPower()); if (!fish.isAlive()) { // 鱼死亡增加分数 currentScore fish.getScore(); // 可以在这里播放死亡动画或音效如果实现了的话 } }getBounds()方法返回一个Rectangle对象代表精灵图像的边界。intersects()是JavaRectangle类自带的方法用于判断两个矩形是否相交。3.4 资源管理与性能优化游戏运行起来后鱼和子弹会不断地被创建和销毁。如果不加管理会产生大量的对象导致GC频繁最终卡顿。对象池模式这是解决之道。我预先创建一定数量的Fish和Bullet对象放入两个“池子”如List。当需要一条新鱼时从“空闲鱼池”中取一条重置其状态位置、血量、类型后放入“活动鱼池”。当鱼死亡或子弹消失时不是直接丢弃对象而是将其状态标记为“空闲”放回池中。这极大地减少了垃圾回收的压力。图像加载所有图片资源鱼、子弹、背景、UI元素应在游戏初始化时一次性加载到内存中存储在Map或静态变量里避免在游戏循环中反复读取文件。列表遍历优化在碰撞检测等频繁操作中使用for-each遍历ArrayList的副本或者使用迭代器并注意安全删除避免ConcurrentModificationException。4. 实战中的“坑”与解决方案理论很美好但编码过程才是真正的试金石。下面是我记忆犹新的几个坑。4.1 线程安全与Swing事件分发线程EDT这是我遇到的第一个大坑。我一开始在GameManager中直接用一个ArrayList来存鱼并在游戏循环Timer线程本质上是EDT中更新同时在另一个线程比如一个自动生成鱼的线程里向这个列表添加新鱼。很快游戏就抛出了ConcurrentModificationException。提示尽管javax.swing.Timer的回调在EDT上执行但如果你自己启动了额外的线程例如用ScheduledExecutorService定时生成鱼那么对这些共享集合鱼列表、子弹列表的修改就必须考虑线程安全。解决方案使用线程安全的集合将ArrayList替换为CopyOnWriteArrayList。它在修改时如add, remove会复制整个底层数组适合读多写少的场景。对于游戏来说每帧遍历读非常频繁而增删写相对较少这是一个可接受的方案但要注意写操作的成本。将修改操作提交到EDT这是更Swing的方式。在任何非EDT线程中如果需要对游戏状态如列表进行修改都通过SwingUtilities.invokeLater()来执行。// 在另一个线程中 executor.scheduleAtFixedRate(() - { SwingUtilities.invokeLater(() - { gameManager.spawnNewFish(); }); }, 0, 2, TimeUnit.SECONDS);我最终采用的方案为了简化我放弃了独立的生成线程。我将鱼的生成逻辑也放在了游戏主循环Timer回调里通过一个计数器来控制生成频率。这样所有逻辑都在EDT上顺序执行彻底避免了线程安全问题。虽然理论上不如多线程“高级”但对于这个规模的游戏完全够用且稳定。4.2 图像缩放与失真从网上下载的鱼图片尺寸不一。直接drawImage会导致鱼的大小不符合游戏设定或者在高分辨率下模糊。解决方案在加载图片时就将其缩放到目标尺寸并使用高质量的缩放算法。private BufferedImage loadAndScaleImage(String path, int targetWidth, int targetHeight) { try { BufferedImage original ImageIO.read(getClass().getResource(path)); BufferedImage scaled new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d scaled.createGraphics(); // 关键设置渲染提示让缩放更平滑 g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null); g2d.dispose(); // 重要释放Graphics2D对象 return scaled; } catch (IOException e) { e.printStackTrace(); return null; } }记住用完Graphics2D对象一定要dispose()这是一个好习惯可以及时释放原生资源。4.3 鼠标跟踪与炮台旋转炮台需要实时指向鼠标位置。这里涉及坐标转换。鼠标事件得到的坐标是相对于整个JFrame的而炮台绘制在GamePanel上其坐标原点是面板的左上角。解决方案在GamePanel上添加MouseMotionListener在mouseMoved事件中将鼠标的绝对坐标转换为相对于GamePanel的坐标然后计算炮台指向该点的角度。addMouseMotionListener(new MouseAdapter() { Override public void mouseMoved(MouseEvent e) { // e.getX()和e.getY()已经是相对于GamePanel的坐标了 int mouseX e.getX(); int mouseY e.getY(); // 计算炮台中心到鼠标点的角度 int centerX getWidth() / 2; int centerY getHeight() - 50; // 炮台大概在底部中央 double deltaX mouseX - centerX; double deltaY mouseY - centerY; // Math.atan2(y, x) 返回的是从x轴正方向逆时针旋转到点(x,y)的角度弧度 double angle Math.atan2(deltaY, deltaX); cannon.setAngle(angle); // 更新炮台角度 } });在绘制炮台时使用Graphics2D的rotate(angle, pivotX, pivotY)方法围绕炮台底座中心进行旋转就能实现平滑的瞄准效果。5. 功能扩展与游戏性打磨基础版本完成后可以添加更多功能让它更像一个完整的游戏。5.1 多种鱼类与行为模式不要只做一种直线游的鱼。可以定义枚举类FishType。public enum FishType { SMALL(10, 1, “small_fish.png”), // 分数生命值图片 MEDIUM(30, 3, “medium_fish.png”), LARGE(100, 10, “large_fish.png”), BOSS(500, 50, “boss_fish.png”); // ... 构造函数和getter }在Fish类中可以根据类型决定移动策略直线、正弦、圆周、折线逃跑等。这可以用状态模式或简单的策略模式来实现让代码更清晰。5.2 炮台升级与多种子弹炮台可以有等级随着玩家金币增多而升级。升级后子弹威力、射速、甚至子弹形态如从单发变为散射都可以改变。这需要设计一个CannonLevel的数据结构并在Cannon类中管理当前等级和对应的属性。5.3 游戏状态与UI实现开始菜单、游戏进行中、暂停、结束等状态。这可以通过一个GameState枚举和GameManager中的状态机来管理。UI绘制也要根据状态变化例如游戏结束时在屏幕中央绘制“Game Over”和最终得分。5.4 音效与粒子效果进阶虽然Swing对多媒体支持有限但可以播放简单的WAV音效使用javax.sound.sampled.Clip。粒子效果如子弹击中后的水花、鱼死亡后的金币飞溅可以用一堆小的、有生命周期的Sprite对象来模拟它们按照一定的物理规则重力、消散运动为游戏增添不少动感。完成这个项目后我最大的体会是编程语言和框架只是工具解决问题的思路和将复杂系统分解、建模的能力才是核心。用Java Swing写《捕鱼达人》就像用最基础的木工工具做一把椅子过程可能比用电动机器繁琐但你对“椅子”每个部件的连接、承重、比例会有更深刻的理解。这份理解会让你在未来使用任何高级游戏引擎时都更加得心应手。如果你正在学习Java并且对“八股文”之外的应用感到好奇我强烈建议你亲手实现一个这样的小项目它带给你的收获远比死记硬背十个设计模式要大得多。
返回列表