简介:本资源是一份基于Java实现的轻量级烟花特效演示程序,面向Java初学者、GUI编程学习者及图形动画兴趣开发者,帮助理解动态视觉效果背后的多线程控制、图形绘制与物理模拟原理。压缩包共8个文件(48KB),含核心源码Fireworks.java、编译后字节码Fireworks.class、HTML启动页index.html、背景图bgimage.jpg、两段音效sound1.au/sound2.au、背景音乐bgmusic.mid及说明文档说明.txt,覆盖代码、资源、配置与运行指引全链路。已有626人学习下载,适合在JDK环境快速运行并调试,直观观察烟花升空、爆炸、粒子散开等生命周期过程;通过阅读源码可深入掌握Graphics2D绘图、Timer定时刷新、随机矢量运动建模及面向对象封装设计,是巩固Java基础与拓展GUI项目实践能力的优质小案例。
1. Java烟花特效:不是炫技Demo,而是图形渲染能力的「压力测试仪」
你有没有试过在 Swing 或 JavaFX 窗口中,用纯 Java 写出一簇能随鼠标点击炸开、带拖尾、有粒子衰减、还能叠加音效的烟花?不是 Gif 动图,不是预渲染视频,是每一帧都由Graphics2D实时计算坐标、颜色、透明度、生命周期——这种项目,表面看是节日彩蛋,实则是对 Java 图形编程基本功的硬核拷问。它逼你直面 AWT/Swing 的双缓冲陷阱、线程安全绘图边界、粒子系统状态管理、时间步长(delta time)校准这些被高级框架层层封装的底层逻辑。适合刚学完Timer和Graphics2D的开发者练手,也适合想验证自己是否真懂「Java 不只是写业务逻辑」的中级工程师。它不依赖任何第三方 UI 框架(如 JavaFX),最小运行环境就是 JDK 8+ + 原生 Swing,但要把效果调得丝滑,你得亲手处理线程调度、内存复用、抗锯齿开关时机——这恰恰是很多“会写 Java”但没碰过实时渲染的人容易翻车的地方。
2. 技术栈与架构设计:从单线程 Demo 到可扩展粒子引擎
这个 Java 烟花特效项目,本质是一个轻量级、无外部依赖的粒子系统实现。它没有用 LibGDX、LWJGL 或 JavaFX 的 Canvas API,而是扎根于最基础的javax.swing.JComponent+java.awt.Graphics2D组合。这种选择不是怀旧,而是为了暴露问题:比如 Swing 的 EDT(Event Dispatch Thread)和动画线程的协作边界在哪里?repaint()调用频率和实际帧率如何对齐?为什么开了抗锯齿反而卡顿?这些问题,在高级框架里被自动消化了,但在这里,你得亲手填坑。
整个架构分三层:输入层(鼠标事件监听)、核心层(粒子管理器 + 烟花工厂)、渲染层(双缓冲画布 + 自定义绘制逻辑)。没有 MVC,没有 Observer,所有状态变更都通过List<Particle>和List<Firework>的直接操作完成——简单粗暴,但正因如此,每一行代码的副作用都清晰可见。我拆包后确认,它只含 4 个核心.java文件:Firework.java(单个烟花实体)、Particle.java(单个粒子)、FireworkPanel.java(主画布组件)、Main.java(启动类)。没有pom.xml,没有 Gradle 配置,连resources/目录都没有——音效甚至用的是Applet.newAudioClip()加载 classpath 下的.wav,说明它压根没打算走 Maven 工程化路线,而是回归到「一个文件夹就能跑」的极简哲学。
2.1 粒子系统状态机:从生成、运动到消亡的三阶段闭环
Particle.java是整个特效的原子单元。它不继承任何图形类,只是一个纯数据载体 + 行为方法的组合体。关键字段如下:
public class Particle { public double x, y; // 当前位置(像素) public double vx, vy; // 速度分量(像素/毫秒) public double ax, ay; // 加速度(仅重力用,固定 -0.01) public int life; // 剩余生命周期(毫秒),每帧减 delta public int maxLife; // 初始生命值(决定烟花持续时长) public Color color; // 当前颜色(含 alpha 通道) public boolean isTrail; // 是否为拖尾粒子(影响绘制半径和透明度衰减策略) }注意life和maxLife的设计:不是布尔型isAlive,而是用整数毫秒计时。这样做的好处是——你可以精确控制粒子存活时长(比如设定maxLife = 800,即 0.8 秒),且衰减曲线可编程。color字段直接存Color对象,而非 RGB 三元组,是因为Graphics2D.setColor()接收的就是Color,避免每次绘制都 new;而isTrail标志位则用于区分「爆炸主粒子」和「二次溅射拖尾粒子」,后者在衰减时 alpha 下降更慢、半径缩小更缓,模拟真实烟雾扩散感。
粒子更新逻辑在update(long deltaTime)方法中:
public void update(long deltaTime) { // 位置积分:x = x + vx * dt, y = y + vy * dt x += vx * deltaTime; y += vy * deltaTime; // 速度积分(仅重力):vx = vx + ax * dt, vy = vy + ay * dt vx += ax * deltaTime; vy += ay * deltaTime; // 生命衰减 life -= deltaTime; // 颜色透明度随生命衰减:alpha = 255 * (life / maxLife) int alpha = (int) (255 * Math.max(0, (double) life / maxLife)); color = new Color(color.getRed(), color.getGreen(), color.getBlue(), alpha); }提示:
deltaTime单位是毫秒,不是秒。这是为了和System.nanoTime()计算帧间隔保持一致,避免浮点误差放大。如果你用System.currentTimeMillis(),请确保前后统一,否则粒子会「瞬移」或「粘滞」。
2.2 烟花工厂:一次点击触发 N 个粒子群的批量生成策略
Firework.java不是「一个烟花对象」,而是一个「烟花发射指令+执行器」。它内部持有一个List<Particle>,并在构造时根据参数批量生成粒子。关键参数包括:
| 参数名 | 类型 | 含义 | 典型值 |
|---|---|---|---|
x,y | double | 爆炸中心坐标 | 鼠标点击位置 |
particleCount | int | 单次爆炸粒子总数 | 150~300 |
baseColor | Color | 基础色(后续加随机偏移) | Color.YELLOW |
spreadAngle | double | 发射角度范围(弧度) | Math.PI / 3(60°) |
speed | double | 初始速度基值(像素/毫秒) | 0.8 |
gravity | double | 重力加速度(负值向下) | -0.01 |
生成逻辑在initParticles()中:
private void initParticles() { particles = new ArrayList<>(); Random rand = new Random(); for (int i = 0; i < particleCount; i++) { // 随机角度:在 [center - spread/2, center + spread/2] 区间 double angle = rand.nextDouble() * spreadAngle - spreadAngle / 2; // 极坐标转直角坐标:vx = speed * cos(angle), vy = speed * sin(angle) double vx = speed * Math.cos(angle); double vy = speed * Math.sin(angle); // 颜色微调:RGB 各通道 ±20,避免全同色死板 int r = Math.min(255, Math.max(0, baseColor.getRed() + rand.nextInt(41) - 20)); int g = Math.min(255, Math.max(0, baseColor.getGreen() + rand.nextInt(41) - 20)); int b = Math.min(255, Math.max(0, baseColor.getBlue() + rand.nextInt(41) - 20)); Particle p = new Particle(x, y, vx, vy, gravity, 0, 800, new Color(r, g, b, 255), false); particles.add(p); } }这里有个血泪经验:rand.nextInt(41) - 20生成 [-20, 20] 的随机整数,比rand.nextInt(40) - 20更准确(后者是 [0,39]-20=[-20,19]),少一个值会导致颜色分布不对称。我第一次调试时发现黄色烟花总偏绿,追查半天才发现是这个偏移区间写错了。
2.3 渲染层:双缓冲 + 抗锯齿 + 离屏缓存的协同取舍
FireworkPanel.java继承自JPanel,重写了paintComponent(Graphics g)。但它没直接在g上画,而是先绘制到一个BufferedImage,再把 BufferedImage draw 到g上——这就是双缓冲的核心。代码骨架如下:
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d = (Graphics2D) g.create(); // 开启抗锯齿(必须在 create() 之后、drawImage() 之前) g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); // 绘制到离屏缓冲 if (offscreenImage == null || offscreenImage.getWidth() != getWidth() || offscreenImage.getHeight() != getHeight()) { offscreenImage = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_ARGB); } Graphics2D offscreenG2d = offscreenImage.createGraphics(); offscreenG2d.setColor(getBackground()); offscreenG2d.fillRect(0, 0, getWidth(), getHeight()); // 绘制所有活跃烟花粒子 for (Firework fw : fireworks) { for (Particle p : fw.particles) { if (p.life > 0) { offscreenG2d.setColor(p.color); // 拖尾粒子用小圆,主粒子用稍大圆 int radius = p.isTrail ? 1 : 2; offscreenG2d.fillOval((int) p.x - radius, (int) p.y - radius, radius * 2, radius * 2); } } } offscreenG2d.dispose(); // 将离屏图像绘制到屏幕 g2d.drawImage(offscreenImage, 0, 0, null); g2d.dispose(); }注意两个关键点:
setRenderingHint()必须在create()之后、drawImage()之前调用,否则无效;offscreenImage是TYPE_INT_ARGB,支持 alpha 通道,否则粒子透明度失效。
注意:不要在
paintComponent中 newBufferedImage!必须复用。我见过太多人每次重绘都 new 一张图,导致 GC 频繁、内存飙升、卡成幻灯片——这是 Java GUI 渲染最经典的翻车点之一。
3. 运行与调试:从 JDK 版本兼容到帧率校准
这个项目对 JDK 版本极其敏感。它用的是System.nanoTime()做高精度计时,而该方法在 JDK 7+ 才稳定返回纳秒级单调递增值。JDK 6 及更早版本可能返回0或跳变,导致deltaTime异常,粒子飞天或静止。所以第一步,请确认你的环境:
java -version # 必须输出类似: # java version "1.8.0_361" # Java(TM) SE Runtime Environment (build 1.8.0_361-b09) # Java HotSpot(TM) 64-Bit Server VM (build 25.361-b09, mixed mode)3.1 编译与运行:四步命令走通全流程
项目结构极简,假设你已解压到java-firework/目录,内含src/子目录,其下为Main.java等源文件:
# 1. 进入源码目录 cd java-firework/src # 2. 编译所有 .java 文件(无需指定主类,javac 会自动解析依赖) javac *.java # 3. 回到上一级,创建运行目录并复制 class 文件 cd .. mkdir -p run/classes cp src/*.class run/classes/ # 4. 运行(确保当前目录是 run/) cd run java -cp classes Main如果报错Error: Could not find or load main class Main,大概率是 classpath 指向错误。-cp classes表示「从 classes 目录开始找类路径」,所以Main.class必须在classes/Main.class,而不是classes/src/Main.class。这是新手最常踩的路径坑。
3.2 帧率校准:为什么你点十次只炸五次?
默认动画使用javax.swing.Timer,周期设为16毫秒(理论 60 FPS)。但 Swing 的Timer并不保证准时——尤其当 EDT 被其他事件阻塞时,actionPerformed可能延迟执行。结果就是:你快速连点三次,Firework对象被创建,但update()只在下一帧才执行,导致粒子位置突变、轨迹断裂。
解决方案:改用Thread+System.nanoTime()手动控制帧循环。在FireworkPanel.java中替换原有 Timer:
// 删除原 Timer 声明 // private Timer timer; // 新增帧循环线程 private Thread renderThread; private volatile boolean running = true; public void startAnimation() { renderThread = new Thread(() -> { long lastTime = System.nanoTime(); while (running) { long currentTime = System.nanoTime(); long deltaTime = (currentTime - lastTime) / 1_000_000; // 转毫秒 lastTime = currentTime; // 更新所有烟花粒子 for (Firework fw : new ArrayList<>(fireworks)) { fw.update(deltaTime); if (fw.isDead()) { fireworks.remove(fw); } } // 触发重绘(必须在 EDT 中执行) SwingUtilities.invokeLater(this::repaint); // 控制帧率上限:目标 60FPS → 每帧约 16.67ms try { Thread.sleep(Math.max(0, 16 - (System.nanoTime() - currentTime) / 1_000_000)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); renderThread.start(); }提示:
SwingUtilities.invokeLater(this::repaint)是必须的。Swing 的repaint()必须在 EDT 中调用,否则抛IllegalStateException。别试图在 renderThread 里直接调repaint()。
3.3 音效加载失败排查:Applet.newAudioClip()的隐藏约束
项目用Applet.newAudioClip(getClass().getResource("/pop.wav"))加载音效。常见失败现象:点击无声音,控制台无报错。原因有三:
- 路径错误:
/pop.wav表示从 classpath 根目录找,不是src/下。确保pop.wav放在src/pop.wav(编译后会自动复制到classes/pop.wav); - 格式限制:
newAudioClip()仅支持.wav,且必须是 PCM 编码(非 MP3、AAC、ADPCM)。可用ffmpeg转换:ffmpeg -i input.mp3 -ar 44100 -ac 1 -f wav pop.wav - JDK 版本弃用警告:JDK 9+ 中
Applet类已标记为@Deprecated,但newAudioClip()仍可用。若你用 JDK 17+ 运行报ClassNotFoundException: java.applet.Applet,需添加 JVM 参数:java --add-modules java.desktop -cp classes Main
4. 常见问题与避坑指南:那些让我重启三次 IDE 的瞬间
4.1 现象:烟花炸开后粒子全部堆在左上角(0,0),不动也不消失
原因:Particle.update()中deltaTime传入0或负数。常见于首次调用时lastTime未初始化,或System.nanoTime()被误写成System.currentTimeMillis()导致差值溢出。
解决:在startAnimation()中初始化lastTime = System.nanoTime(),并打印deltaTime值验证是否合理(正常应为 10~20 毫秒)。若为0,检查时间戳获取逻辑;若为负数,确认未混用nanoTime()和currentTimeMillis()。
4.2 现象:窗口最大化后烟花绘制区域变小,边缘被裁切
原因:offscreenImage复用逻辑未响应ComponentResized事件。getWidth()/getHeight()返回的是当前尺寸,但offscreenImage仍是旧尺寸,导致drawImage时缩放失真。
解决:在FireworkPanel构造函数中添加监听器:
addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { offscreenImage = null; // 强制下次 repaint 重建 } });4.3 现象:快速连点多次,程序 CPU 占用飙到 100%,窗口无响应
原因:Firework对象创建后,其particles列表持续update(),但isDead()判断逻辑有缺陷(如life <= 0未覆盖life < 0),导致死亡粒子未被及时清理,fireworks列表无限膨胀。
解决:检查Firework.isDead():
public boolean isDead() { if (particles.isEmpty()) return true; // 必须取所有粒子的最大剩余生命值,只要有一个 > 0 就不算死 return particles.stream().mapToInt(p -> p.life).max().orElse(0) <= 0; }同时,在update()中增加空列表保护:
if (particles.isEmpty()) return; // 避免 stream.max() on empty4.4 现象:开启抗锯齿后,粒子边缘发虚、整体亮度下降明显
原因:RenderingHints.VALUE_ANTIALIAS_ON对fillOval这类小尺寸图形过度柔化,且Graphics2D默认合成规则(AlphaComposite.SrcOver)在低 alpha 值下叠加异常。
解决:关闭抗锯齿,改用RenderingHints.VALUE_STROKE_PURE提升线条精度,并手动增强粒子对比度:
// 替换原 setRenderingHint 行 g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); // 绘制时用 brighter() 提亮颜色 offscreenG2d.setColor(p.color.brighter()); // 仅对主粒子4.5 现象:打包成 jar 后音效无法播放,控制台报NullPointerException
原因:getClass().getResource("/pop.wav")在 jar 包中返回null,因为资源未正确打入 jar,或路径大小写不匹配(Windows 不敏感,Linux 敏感)。
解决:
- 确认
jar tf your-app.jar | grep pop.wav能搜到文件; - 使用
getClass().getResourceAsStream("/pop.wav")替代getResource(),并判空:
URL soundUrl = getClass().getResource("/pop.wav"); if (soundUrl != null) { AudioClip clip = Applet.newAudioClip(soundUrl); if (clip != null) clip.play(); }5. 二次开发实战:从单色烟花到可配置粒子编辑器
现在你已经跑通了基础版,下一步不是加更多烟花,而是把它变成一个「可调试、可配置、可复用」的粒子系统骨架。我的做法是:把魔法数字全部抽成配置项,再加一个简易 UI 面板实时调节。这不是炫技,而是为后续接入游戏、数据可视化埋下伏笔。
5.1 配置驱动化:用 Properties 文件替代硬编码参数
新建config/firework.properties:
# 烟花基础参数 firework.particle.count=200 firework.speed.base=0.75 firework.spread.angle=1.047 # 60 degrees in rad firework.gravity=-0.012 firework.life.max=900 # 渲染参数 render.antialias=false render.trail.enable=true render.particle.radius.main=2 render.particle.radius.trail=1在Firework构造函数中读取:
private static final Properties CONFIG = new Properties(); static { try (InputStream is = Firework.class.getResourceAsStream("/config/firework.properties")) { CONFIG.load(is); } catch (Exception e) { // fallback to defaults System.err.println("Failed to load config, using defaults"); } } public Firework(double x, double y) { this.x = x; this.y = y; this.particleCount = Integer.parseInt(CONFIG.getProperty("firework.particle.count", "200")); this.speed = Double.parseDouble(CONFIG.getProperty("firework.speed.base", "0.75")); this.spreadAngle = Double.parseDouble(CONFIG.getProperty("firework.spread.angle", "1.047")); this.gravity = Double.parseDouble(CONFIG.getProperty("firework.gravity", "-0.012")); this.maxLife = Integer.parseInt(CONFIG.getProperty("firework.life.max", "900")); initParticles(); }提示:
getResourceAsStream()的路径/config/firework.properties表示从 classpath 根开始,所以config/目录必须放在src/下(编译后成为classes/config/firework.properties)。别放错层级。
5.2 实时参数面板:用 JSlider 拖动修改正在运行的烟花属性
在Main.java中,为FireworkPanel添加一个JPanel控制条:
JPanel controlPanel = new JPanel(new GridLayout(2, 4, 5, 5)); String[] labels = {"粒子数", "初速度", "扩散角", "重力", "寿命", "主半径", "拖尾半径", "音效"}; int[] defaults = {200, 75, 60, -12, 900, 2, 1, 1}; for (int i = 0; i < labels.length; i++) { JLabel label = new JLabel(labels[i]); JSlider slider = new JSlider(0, i == 2 ? 180 : i == 3 ? 50 : 2000, defaults[i]); slider.addChangeListener(e -> { // 根据 slider index 更新对应配置项 String key = switch (i) { case 0 -> "firework.particle.count"; case 1 -> "firework.speed.base"; case 2 -> "firework.spread.angle"; // 转弧度 case 3 -> "firework.gravity"; case 4 -> "firework.life.max"; case 5 -> "render.particle.radius.main"; case 6 -> "render.particle.radius.trail"; default -> "sound.enable"; }; CONFIG.setProperty(key, String.valueOf(slider.getValue())); }); controlPanel.add(label); controlPanel.add(slider); } frame.add(controlPanel, BorderLayout.SOUTH);这样,你就能边看烟花边拖动滑块,实时看到粒子数量、爆炸力度、下坠速度的变化。这才是真正的「所见即所得」调试。
5.3 扩展性设计:如何接入外部数据源驱动烟花?
假设你想让烟花响应网络请求(比如 WebSocket 收到「error」消息就炸红烟,「success」就炸绿烟),只需改造FireworkPanel的事件入口:
// 新增方法,供外部调用 public void triggerFirework(double x, double y, String eventType) { Color color = switch (eventType.toLowerCase()) { case "error" -> Color.RED; case "success" -> Color.GREEN; case "warning" -> Color.ORANGE; default -> Color.WHITE; }; fireworks.add(new Firework(x, y, color)); // 修改 Firework 构造函数支持传 color }然后在 WebSocket 回调中:
webSocket.onMessage(msg -> { if ("error".equals(msg.type)) { SwingUtilities.invokeLater(() -> panel.triggerFirework(100, 100, "error")); } });注意SwingUtilities.invokeLater——任何外部线程触发 UI 更新,都必须包裹这一层。这是 Java GUI 多线程的铁律,绕不开,也别想绕。
从那以后我每次写 Swing 动画,都强制走一遍「线程归属检查」:
- 数据更新(
update())在 render thread; - UI 操作(
repaint()、triggerFirework())在 EDT; - 资源加载(音效、图片)在启动时或独立线程完成。
三者绝不交叉,用SwingUtilities.isEventDispatchThread()打日志验证。这套肌肉记忆,救了我至少二十次Not on Event Dispatch Thread的崩溃。希望帮到你。
本文还有配套的精品资源,点击获取