Java Swing动画实战:蛋糕雨+音乐+表白弹窗桌面程序
简介:这份资源面向具备Java基础的开发者与编程爱好者,提供一套用Java实现生日礼物的完整项目源码,涵盖音乐播放、蛋糕动画与心形图案绘制等浪漫表达场景,适合作为GUI与Java2D综合练习的参考案例。压缩包共109个文件,约52.13MB,以62个png图片素材、13个java源文件、14个class编译文件为主,辅以9个xml配置、2个mp3音频及jar、mf等打包相关文件,源码与素材齐备,便于直接运行与二次修改。已有3227人学习下载。项目围绕Swing界面、Java Sound API音乐循环播放、Java2D动态爱心与蜡烛动画展开,并包含名字匹配触发特殊情节的个性化逻辑,代码中可见主窗口、绘制线程、音乐与数据等模块划分,读者可从中理解定时器控制动画帧率、单例与观察者模式的实际运用,以及清晰可维护的代码组织方式。
1. 一个用 Java 写的“生日礼物”:蛋糕雨、音乐和表白弹窗到底怎么跑起来
有个朋友去年问我,能不能用 Java 做个东西,点开就能看到满屏蛋糕往下掉、背景放着音乐、最后弹出一句表白。他不想用网页模板,也不想装一堆前端框架,就想拿一个能双击运行的桌面程序。这个项目就是干这个的:一个纯 Java 的桌面小应用,核心是 Swing 窗口加定时器驱动的动画循环,配合音频播放和文字渐显,把“蛋糕雨 + 音乐 + 表白”三件事串成一个可执行的礼物程序。它适合两类人:一是想给身边人做点小惊喜、但只会 Java 基础的开发者;二是想拿一个完整可运行的小项目练手 Swing 动画、事件调度和资源加载的人。下面我按“能干什么、怎么跑、代码长什么样、怎么改成自己的”这条线拆一遍。
2. 项目结构与技术选型:为什么是 Swing 而不是网页
2.1 目录长什么样,每个包负责什么
这个项目没有用 Maven 多模块那套重结构,常见做法是一个标准 IDEA 工程,源码放在src/main/java下,资源放在src/main/resources。按职责拆成四个包,读代码时先认包再认类,效率会高很多。
birthday-gift/ ├── src/main/java/ │ ├── com/gift/app/ │ │ └── GiftApplication.java // 入口,组装窗口和场景 │ ├── com/gift/scene/ │ │ ├── CakeRainScene.java // 蛋糕雨动画面板 │ │ └── ConfessionScene.java // 表白文字渐显面板 │ ├── com/gift/audio/ │ │ └── MusicPlayer.java // 背景音乐循环播放 │ └── com/gift/resource/ │ └── ResourceLoader.java // 图片/音频统一加载 └── src/main/resources/ ├── images/ │ ├── cake.png // 蛋糕贴图(建议 64x64 透明底) │ └── background.png // 背景图 └── audio/ └── bgm.wav // 背景音乐app包只做一件事:把窗口、场景、音乐按顺序拼起来,不写业务逻辑。scene包是动画主体,每个场景继承JPanel,自己管自己的绘制和定时器。audio包封装播放细节,避免在场景里直接碰Clip。resource包解决一个很实际的问题:打成 jar 之后,new File("images/cake.png")会失效,必须走 classpath 读取。这个拆分不复杂,但边界清楚,后面改场景不会牵一发动全身。
2.2 技术选型:Swing + Timer + Clip 的组合逻辑
选 Swing 的理由很直接:JDK 自带,不用引入任何第三方依赖,双击 jar 就能跑,对“送人”这个场景来说,少一个安装步骤就少一次翻车。动画循环用javax.swing.Timer,而不是自己开Thread加Thread.sleep。原因是 Swing 的组件更新必须在事件分发线程(EDT)上做,Timer的回调天然在 EDT 执行,省掉SwingUtilities.invokeLater的包裹,也避免多线程改组件状态导致的偶发闪烁。
音频用javax.sound.sampled.Clip,支持循环播放,适合背景音乐这种“放一遍不够、要一直放”的需求。注意Clip对 wav 支持最稳,mp3 需要额外解码库,所以资源里放的是bgm.wav。整个技术栈就是 JDK 标准库,没有 Spring、没有 JavaFX、没有前端构建,这也是它能在任何装了 JDK 8 以上的机器上跑起来的原因。
| 技术点 | 选型 | 作用 | 替代方案与取舍 |
|---|---|---|---|
| 窗口与绘制 | SwingJFrame+JPanel | 承载场景、双缓冲绘制 | JavaFX 更现代,但要多带运行时 |
| 动画驱动 | javax.swing.Timer | 固定间隔触发重绘 | 自开线程需手动切 EDT,易闪烁 |
| 音频播放 | javax.sound.sampled.Clip | 循环播放 wav | 第三方 mp3 库增加依赖 |
| 资源加载 | ClassLoader.getResourceAsStream | jar 内资源可读 | File路径打包后失效 |
2.3 入口类怎么把三件事串起来
入口类不长,但顺序有讲究:先加载资源,再建窗口,再启动音乐,最后启动动画。音乐放在动画之前,是为了避免第一帧蛋糕已经掉了、声音还没起来的割裂感。
public class GiftApplication { public static void main(String[] args) { // 所有 Swing 组件创建都放到 EDT,避免初始化竞争 SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("生日快乐"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setSize(900, 700); frame.setLocationRelativeTo(null); // 居中显示 frame.setResizable(false); // 先起音乐,再切场景,保证声画同步起点 MusicPlayer.getInstance().playLoop("/audio/bgm.wav"); // 用 CardLayout 管理场景切换:先蛋糕雨,后表白 CardLayout cards = new CardLayout(); JPanel root = new JPanel(cards); CakeRainScene rain = new CakeRainScene(() -> { root.add(new ConfessionScene(), "confession"); cards.show(root, "confession"); }); root.add(rain, "rain"); frame.setContentPane(root); frame.setVisible(true); rain.start(); // 场景自己启动定时器 }); } }这段代码里有两个参数值得注意。setResizable(false)不是随便加的:蛋糕雨的坐标是按固定画布算的,允许拉伸会让贴图位置和碰撞区域对不上,后面调起来很烦。setLocationRelativeTo(null)让窗口居中,送人时打开就在屏幕中间,观感上更完整。场景切换用CardLayout而不是销毁重建窗口,好处是背景和音乐不断,切换只换面板内容。CakeRainScene构造时传入一个回调,雨停之后由它触发切到表白场景,这样场景之间不互相持有引用,耦合度低。
3. 核心模块拆解:蛋糕雨、音乐循环和表白渐显
3.1 蛋糕雨:粒子列表 + 定时器重绘
蛋糕雨的本质是一组“粒子”,每个粒子有横坐标、纵坐标、下落速度、旋转角度。定时器每隔几十毫秒把所有粒子往下推一点,越界就回收或重置到顶部,然后整屏重绘。关键是把“数据更新”和“绘制”分开,更新只改字段,绘制只读字段。
public class CakeRainScene extends JPanel { private final List<CakeParticle> cakes = new ArrayList<>(); private final Timer timer; private final Runnable onFinish; private int frameCount = 0; private static final int MAX_FRAMES = 600; // 约 10 秒后切场景 public CakeRainScene(Runnable onFinish) { this.onFinish = onFinish; setBackground(Color.BLACK); // 预生成 40 个粒子,初始 y 分散在屏幕上方,避免同时出现 for (int i = 0; i < 40; i++) { cakes.add(new CakeParticle( Math.random() * 900, -Math.random() * 700, 2 + Math.random() * 3, // 下落速度 2~5 像素/帧 Math.random() * 360 // 初始旋转角 )); } // 33ms 一帧,约 30fps,兼顾流畅和 CPU 占用 timer = new Timer(33, e -> tick()); } public void start() { timer.start(); } private void tick() { frameCount++; for (CakeParticle c : cakes) { c.y += c.speed; c.angle += 2; if (c.y > getHeight()) { // 落出屏幕,回到顶部重新下落 c.y = -60; c.x = Math.random() * getWidth(); } } repaint(); // 触发 paintComponent if (frameCount >= MAX_FRAMES) { timer.stop(); onFinish.run(); // 通知入口切到表白场景 } } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; // 开启抗锯齿,旋转后的贴图边缘更干净 g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); Image cakeImg = ResourceLoader.loadImage("/images/cake.png"); for (CakeParticle c : cakes) { AffineTransform old = g2.getTransform(); g2.translate(c.x, c.y); g2.rotate(Math.toRadians(c.angle)); g2.drawImage(cakeImg, -32, -32, 64, 64, null); // 以中心为锚点 g2.setTransform(old); // 还原变换,避免影响下一个粒子 } } }Timer(33, ...)里的 33 是毫秒,对应约 30 帧每秒。这个值不是越小越好:调到 16 虽然更顺,但每帧都要重绘 40 个旋转贴图,低配机器上 CPU 会明显上去。MAX_FRAMES = 600配合 33ms,大约 20 秒,实际跑下来 10 秒左右观感就够,可以按需改。drawImage的-32, -32是因为贴图按 64x64 绘制,锚点要挪到中心,否则旋转会绕左上角转,看起来像在甩。g2.setTransform(old)这行是血泪经验:不还原变换的话,下一个粒子的坐标会叠加在上一个的旋转上,画面会整体漂移,第一次写很容易漏。
3.2 音乐循环:Clip 的打开、循环和释放
Clip的用法有固定套路:拿到AudioInputStream,open,然后loop(Clip.LOOP_CONTINUOUSLY)。坑在于资源路径和释放时机,jar 内资源必须用getResourceAsStream,播放结束或窗口关闭时要close,否则音频设备句柄会挂着。
public class MusicPlayer { private static final MusicPlayer INSTANCE = new MusicPlayer(); private Clip clip; public static MusicPlayer getInstance() { return INSTANCE; } public void playLoop(String classpathPath) { try (InputStream is = getClass().getResourceAsStream(classpathPath)) { if (is == null) { System.err.println("音频资源未找到: " + classpathPath); return; } // 先转成带缓冲的流,部分 JDK 对直接读 jar 内流支持不好 AudioInputStream raw = AudioSystem.getAudioInputStream( new BufferedInputStream(is)); clip = AudioSystem.getClip(); clip.open(raw); clip.loop(Clip.LOOP_CONTINUOUSLY); // 无限循环 } catch (Exception e) { // 音频失败不应阻断动画,降级为静音继续跑 System.err.println("音频播放失败,继续静音运行: " + e.getMessage()); } } public void stop() { if (clip != null && clip.isRunning()) { clip.stop(); clip.close(); // 释放音频设备 } } }LOOP_CONTINUOUSLY是常量-1,表示一直循环,直到显式stop。这里用BufferedInputStream包一层,是因为某些 JDK 版本直接对 jar 内的InputStream调getAudioInputStream会抛UnsupportedAudioFileException,包一层缓冲能绕过去。异常处理里选择“静音继续”而不是直接退出,是礼物场景的现实考虑:对方机器声卡驱动千奇百怪,不能因为没声音就整个程序打不开。stop()要在窗口关闭事件里调,否则进程退了音频线程可能还占着设备。
3.3 表白渐显:Alpha 通道做文字淡入
表白场景不复杂,但“渐显”比“直接出现”观感好很多。做法是用AlphaComposite控制绘制透明度,配合定时器逐步提高 alpha 值,文字从透明到不透明。
public class ConfessionScene extends JPanel { private float alpha = 0f; private final Timer fadeTimer; private static final String TEXT = "生日快乐,愿你每天都开心"; public ConfessionScene() { setBackground(Color.BLACK); // 每 50ms 增加 0.02,约 2.5 秒完成淡入 fadeTimer = new Timer(50, e -> { alpha = Math.min(1f, alpha + 0.02f); repaint(); if (alpha >= 1f) ((Timer) e.getSource()).stop(); }); fadeTimer.start(); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g2.setFont(new Font("微软雅黑", Font.BOLD, 36)); g2.setColor(Color.WHITE); // 设置透明度后再绘制文字 g2.setComposite(AlphaComposite.getInstance( AlphaComposite.SRC_OVER, alpha)); FontMetrics fm = g2.getFontMetrics(); int x = (getWidth() - fm.stringWidth(TEXT)) / 2; // 水平居中 int y = getHeight() / 2; g2.drawString(TEXT, x, y); } }alpha + 0.02f配合 50ms 间隔,大约 50 帧、2.5 秒淡入完成,节奏不拖沓。AlphaComposite.SRC_OVER是标准叠加模式,alpha 为 0 时完全透明,为 1 时完全不透明。文字居中用FontMetrics.stringWidth算宽度,比写死坐标可靠,换字体或换文案都不用重新调位置。这里没有做逐字渐显,是因为逐字需要拆字符串、算每个字的 x 偏移,复杂度上去但观感提升有限,礼物场景够用就行。
4. 跑起来和改起来:编译、打包、换素材的完整步骤
4.1 从源码到可双击的 jar
先确认 JDK 版本,java -version看到 8 以上即可。用 IDEA 打开工程,确认src/main/resources被标记为 Resources Root,否则打包后图片音频进不去。命令行编译和打包如下:
# 1. 编译,输出到 out 目录 javac -encoding UTF-8 -d out $(find src/main/java -name "*.java") # 2. 把资源复制进 out,保持包路径 cp -r src/main/resources/* out/ # 3. 打包成可执行 jar,指定入口类 jar --create --file birthday-gift.jar \ --main-class com.gift.app.GiftApplication \ -C out . # 4. 运行验证 java -jar birthday-gift.jar-encoding UTF-8不能省,源码里有中文文案,不指定编码在部分系统上会编译出乱码。--main-class写全限定类名,少了它 jar 双击只会报“找不到主类”。资源复制那步是很多人翻车的地方:只编译 java 不复制 resources,运行时getResourceAsStream返回 null,表现就是黑屏没图没声音。打包完先java -jar跑一遍,确认没问题再发给别人。
4.2 换蛋糕图、换音乐、换文案要动哪里
换素材是最常见的二次改动,路径都集中在resources下。蛋糕图建议 64x64 或 128x128 的透明底 PNG,尺寸变了要同步改drawImage里的宽高和锚点偏移。音乐必须是 wav,采样率常见 44100Hz、16 位、单声道或立体声,mp3 直接改名成 wav 是放不出来的,得先转格式。文案改ConfessionScene里的TEXT常量即可,字号在new Font(...)里调,长文案记得把字号调小或换行,否则会超出窗口。
| 想改的东西 | 改哪个文件 | 注意点 |
|---|---|---|
| 蛋糕贴图 | resources/images/cake.png | 透明底 PNG,尺寸变了改绘制宽高 |
| 背景图 | resources/images/background.png | 在paintComponent里先画背景再画粒子 |
| 背景音乐 | resources/audio/bgm.wav | 必须是 wav,mp3 需先转码 |
| 表白文案 | ConfessionScene.TEXT | 长文案调字号或手动换行 |
| 动画时长 | CakeRainScene.MAX_FRAMES | 配合 Timer 间隔算总时长 |
4.3 调参:速度、密度、时长三个旋钮
蛋糕雨的观感由三个参数决定:粒子数量、下落速度、总帧数。粒子数量在构造函数的循环里,40 个是中等密度,想要更热闹加到 80,但每帧绘制次数翻倍,低配机器要留意。下落速度是2 + Math.random() * 3,范围 2 到 5 像素每帧,想慢一点改成1 + Math.random() * 2。总帧数MAX_FRAMES控制多久切到表白场景,600 帧约 20 秒,觉得久改成 300。这三个值建议一次只改一个,改完跑一遍看效果,同时改容易分不清是哪个参数起的作用。
5. 避坑与排查:这几个问题我实际遇到过
5.1 打包后图片和音乐全没了
现象是 IDEA 里跑正常,打成 jar 双击就黑屏、没声音。原因是代码里用了new File("images/cake.png")这种相对路径,jar 内没有文件系统路径,只有 classpath。解决是把所有资源读取统一走getClass().getResourceAsStream("/images/cake.png"),路径以/开头表示从 classpath 根开始。改完重新打包,用jar tf birthday-gift.jar确认images/和audio/确实在包里。
5.2 动画卡顿或画面撕裂
现象是蛋糕下落一顿一顿,或者旋转贴图边缘有锯齿。原因通常是定时器间隔太小导致重绘压力大,或者没开抗锯齿。解决是把Timer间隔从 16 调到 33,粒子数量先降到 40 观察;绘制前设置KEY_ANTIALIASING为VALUE_ANTIALIAS_ON。如果还有撕裂,确认JPanel默认双缓冲没被关掉,不要手动setDoubleBuffered(false)。
5.3 音乐播一次就停,或者关窗口后进程不退
现象一是音乐只响一遍,原因是用了clip.start()而不是clip.loop(Clip.LOOP_CONTINUOUSLY)。现象二是关掉窗口后任务管理器里 java 进程还在,原因是Clip没close,音频设备句柄没释放。解决是在JFrame的WindowListener.windowClosing里调MusicPlayer.getInstance().stop(),并在stop里先clip.stop()再clip.close()。
5.4 中文文案变成方块或乱码
现象是表白文字显示成方框。原因一是编译时没指定-encoding UTF-8,源码中文已经损坏;原因二是运行环境缺中文字体,new Font("微软雅黑", ...)找不到就回退到默认字体。解决是编译命令固定带-encoding UTF-8,字体名换成系统常见的中文字体,或者用Font.createFont加载字体文件随包分发。改完先在本机跑,再换一台机器验证。
5.5 换了大图之后蛋糕位置全偏了
现象是换了 128x128 的蛋糕图,旋转中心不对、下落位置也偏。原因是drawImage的锚点偏移还是按 64x64 写的-32, -32。解决是把偏移改成-width/2, -height/2,宽高从图片实际尺寸读,不要写死。顺手把粒子初始 x 范围也按新尺寸留点边距,避免大图从屏幕边缘只露一半。
6. 二次开发:把它改成你自己的版本
这个项目骨架小,改造空间反而大。最值得先动的是场景编排:现在只有“蛋糕雨 → 表白”两段,可以在GiftApplication里用CardLayout加第三段,比如照片墙或者倒计时,每段实现一个JPanel加自己的Timer,入口只负责按顺序show。这样加内容不用改已有场景,符合开闭原则,也方便你按对方喜好拼装。
第二个改造点是粒子系统泛化。现在CakeParticle只存位置和速度,可以加一个type字段,让蛋糕、爱心、星星混着掉,绘制时按type选贴图。再进一步,把“下落”抽象成接口,实现类可以是直线下落、正弦摆动、螺旋下落,粒子持有接口引用,运行时随机分配,画面层次立刻不一样。这个改动不大,但能让礼物看起来不是模板货。
第三个是音频和文案的外部化。现在文案写死在类里,音乐路径写死在入口,改一次要重新编译。常见做法是把文案和资源路径放进config.properties,启动时用Properties.load读,这样对方想换一句话、换一首歌,改配置文件就行,不用碰代码。打包时把config.properties放 resources 根目录,读取走 classpath,和图片音频一个套路。
验证改造是否成功,我一般按三步走:先在 IDEA 里跑,确认场景切换和声画同步;再java -jar跑打包版,确认资源都在;最后换一台没装过开发环境的机器跑,确认 JDK 版本兼容。第三步最容易暴露问题,尤其是字体和音频设备差异。
说个我自己的教训。第一次做这类礼物程序时,我把所有逻辑塞进一个JPanel,定时器、绘制、音频、场景切换全在一起,跑是能跑,但对方说想换首歌,我改了半小时才找到音频初始化藏在paintComponent里。从那以后我每次做小项目都强制先分好包、把资源和逻辑分开,哪怕只有几百行。这个项目按现在的结构,你拿到手先跑通,再按上面三个方向改,基本不会踩我当年的坑。希望帮到你。
本文还有配套的精品资源,点击获取