ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用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: 游戏管理器,单例模式。它是游戏的大脑,负责创建和销毁FishBullet对象,进行碰撞检测,更新分数和金币,管理游戏状态(开始、进行中、结束)。

这个架构清晰地将渲染(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() { List<Bullet> bullets = getAllBullets(); List<Fish> 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频繁,最终卡顿。

  • 对象池模式:这是解决之道。我预先创建一定数量的FishBullet对象放入两个“池子”(如List)。当需要一条新鱼时,从“空闲鱼池”中取一条,重置其状态(位置、血量、类型)后放入“活动鱼池”。当鱼死亡或子弹消失时,不是直接丢弃对象,而是将其状态标记为“空闲”,放回池中。这极大地减少了垃圾回收的压力。
  • 图像加载:所有图片资源(鱼、子弹、背景、UI元素)应在游戏初始化时一次性加载到内存中,存储在Map或静态变量里,避免在游戏循环中反复读取文件。
  • 列表遍历优化:在碰撞检测等频繁操作中,使用for-each遍历ArrayList的副本,或者使用迭代器并注意安全删除,避免ConcurrentModificationException

4. 实战中的“坑”与解决方案

理论很美好,但编码过程才是真正的试金石。下面是我记忆犹新的几个坑。

4.1 线程安全与Swing事件分发线程(EDT)这是我遇到的第一个大坑。我一开始在GameManager中直接用一个ArrayList来存鱼,并在游戏循环(Timer线程,本质上是EDT)中更新,同时在另一个线程(比如一个自动生成鱼的线程)里向这个列表添加新鱼。很快,游戏就抛出了ConcurrentModificationException

提示:尽管javax.swing.Timer的回调在EDT上执行,但如果你自己启动了额外的线程(例如,用ScheduledExecutorService定时生成鱼),那么对这些共享集合(鱼列表、子弹列表)的修改就必须考虑线程安全。

解决方案

  1. 使用线程安全的集合:将ArrayList替换为CopyOnWriteArrayList。它在修改时(如add, remove)会复制整个底层数组,适合读多写少的场景。对于游戏来说,每帧遍历(读)非常频繁,而增删(写)相对较少,这是一个可接受的方案,但要注意写操作的成本。
  2. 将修改操作提交到EDT:这是更Swing的方式。在任何非EDT线程中,如果需要对游戏状态(如列表)进行修改,都通过SwingUtilities.invokeLater()来执行。
    // 在另一个线程中 executor.scheduleAtFixedRate(() -> { SwingUtilities.invokeLater(() -> { gameManager.spawnNewFish(); }); }, 0, 2, TimeUnit.SECONDS);
  3. 我最终采用的方案:为了简化,我放弃了独立的生成线程。我将鱼的生成逻辑也放在了游戏主循环(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); // 更新炮台角度 } });

在绘制炮台时,使用Graphics2Drotate(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,并且对“八股文”之外的应用感到好奇,我强烈建议你亲手实现一个这样的小项目,它带给你的收获,远比死记硬背十个设计模式要大得多。

返回列表