☰
用Pygame制作爱心贺卡:照片切换+心形烟花+背景音乐
2026/10/22 14:53:56 网站建设 项目流程

做《表白贺卡进阶二》这个项目的时候,我印象最深的一句话是:一个小程序居然能把“照片切换 + 心形烟花 + 背景音乐”这三件事同时搞定。核心库只用了一个 pygame,代码量也没有比基础版多到哪里去,但最终呈现出来的效果完全不是一个量级。这篇教程我尽量按“零基础也能复现”的标准来写,适合刚学完列表、循环、函数和事件处理的同学,也适合想给某个重要的人做一份特别礼物的开发者。看完之后,你不仅能运行出这个效果,还能理解 pygame 里事件循环、粒子系统和资源加载的常见套路。

1. 先看这次能做出什么

先把功能说清楚,这个进阶项目其实就做了四件事:

  • 程序启动后,屏幕上显示第一张照片,照片来自项目目录下的photos文件夹。
  • 按键盘空格键,照片切换到下一张,到最后一张之后自动回到第一张轮播。
  • 屏幕上有心形烟花效果,会从下方或四周随机升起然后在屏幕上散成爱心形状。
  • 背景音乐循环播放,配合照片和烟花,形成一个完整的“数字贺卡”体验。

如果你愿意,还可以加上一个鼠标彩蛋:点击屏幕任意位置,烟花就从点击位置绽放,效果更互动。

这个项目给初学者最大的价值,不是“浪漫”,而是它把 Python 里非常核心的几个知识点串起来了:文件遍历、pygame 图像加载、键盘事件监听、动画循环、粒子动画思想、音乐播放、模块化封装。你每写一小块,都能立刻看到视觉反馈,这种成就感对学习特别重要。

我也遇到过不少同学问:为什么不直接用 HTML、微信小程序或者某个在线贺卡工具去做?答案是,这个项目的目的是用 Python 写代码,是练编程,不是做成品。HTML 写出来的是网页,pygame 写出来的是窗口程序,两者背后训练的能力方向不一样。跟着这篇做完,你对 Python 项目结构会有一个非常直观的理解。

2. 开发前的准备:把环境一次性配好

2.1 Python 与依赖库安装

我的建议是用 Python 3.8 或更高版本。pygame 目前对高版本支持很成熟,不需要刻意选旧版本。

打开终端(Windows 上用 CMD 或者 PowerShell),执行:

pip install pygame pillow

pygame是核心库,负责窗口、图像、声音和事件处理。pillow不是必须的,但建议一起装,它能让 pygame 在处理一些特殊图片格式时更稳定,比如 PNG 的 alpha 通道图片。

安装之后,在终端里输入:

python -c "import pygame; print(pygame.version.ver)"

如果正常输出版本号,就说明环境没问题。

2.2 素材目录怎么摆

这个项目对素材组织方式有严格要求,我不建议你把图片堆在代码文件旁边,因为一旦后面打包成 exe,路径会非常混乱。推荐这样建目录:

love_card/ ├── main.py ├── photos/ │ ├── 01.jpg │ ├── 02.jpg │ └── 03.jpg └── bgm.mp3

photos文件夹里放照片,bgm.mp3是背景音乐。目录名和文件名都用纯英文,这是我不想让你踩坑的地方:pygame 在部分环境下处理中文路径会出问题,报错信息也不直观,所以素材目录最好全部用英文。

照片数量一般放 3 到 6 张比较合适,图片分辨率不用太高,超过 1080p 的程序放大了反而占内存,加载也慢。

2.3 音乐文件的格式选择

pygame 对音乐格式的支持有点“挑食”。我实际测试下来,最稳的是 WAV 和 OGG,MP3 大概率能播,但会偶发时间偏移或者初始化失败。

如果你的备份只有 MP3,可以用格式转换工具转成 OGG 或 WAV。WAV 文件体积大,OGG 体积小、音质也不错,所以我个人推荐优先用 OGG。

3. 整体框架:先明白程序怎么跑

3.1 为什么选 pygame

有的同学会想到用 Tkinter 做窗口程序,也能加载图片、放音乐,但 Tkinter 对动画的支持太弱了。你要做心形烟花这类粒子效果,本质上是在快速刷新屏幕上每个小圆点的位置,pygame 本身就是为这类场景设计的,它有稳定的游戏循环、精确的帧率控制,还有专门处理缩放的 API,选它是顺理成章的事。

pygame 还有一个优势,就是它的学习资料非常多,出问题基本都能搜到答案。对初学者来说,资料的丰富程度有时比技术先进性更重要。

3.2 画布上的图层顺序

pygame 的窗口就像一块画布,你在上面画东西的顺序决定了图层遮挡关系。后画的会盖在先画的上面。

这个项目的图层顺序是:

  1. 背景色填充(通常是深色,衬托照片和烟花)。
  2. 照片本身。
  3. 心形烟花粒子。
  4. 底部状态文字。

每次画面刷新,不是“往上叠加”,而是把所有内容清空、重画一遍。先画背景,再画照片,然后画烟花,最后画文字,这就是固定的绘制顺序。

3.3 事件驱动的核心逻辑

如果你刚开始学,可能会觉得 pygame 的代码结构很奇怪:为什么是while True循环里不断处理事件?

其实 pygame 的程序不是“从上到下执行一次”的脚本,而是“事件驱动”的程序。窗口被移动、鼠标被点击、键盘被按下去,这些动作都会被 pygame 打包成一个个event,放在事件队列里。主循环每转一圈,就用pygame.event.get()把所有事件取出来处理。

这也是为什么“按空格切换照片”必须写在事件循环里,而不是写在一个普通函数里。你不按空格,程序不会主动去读键盘,只有按键事件发生后,代码才会执行切换。

4. 代码实现:从零到完整效果

4.1 窗口初始化和全局配置

把窗口和全局配置先写好。这里我统一用800x600的分辨率,你的照片会等比缩放显示在窗口中。

import pygame import random import math import os pygame.init() SCREEN_W, SCREEN_H = 800, 600 screen = pygame.display.set_mode((SCREEN_W, SCREEN_H)) pygame.display.set_caption("爱心贺卡 - 按空格切换照片") clock = pygame.time.Clock() FPS = 60

pygame.display.set_mode创建窗口,set_caption设置标题,Clock用来控制帧率,FPS = 60表示每秒最多刷新 60 帧。动画是否流畅,很大程度上就靠这个帧率控制。

实际开发里,我习惯把这些常量集中放,后面改分辨率或者调整帧率的时候,只改这里就够了,不用到处找。这个习惯在你项目变大之后会非常有用。

4.2 批量读照片并等比缩放

照片读取是这个项目里最容易出问题的一步。我这里的思路是:遍历photos文件夹,把所有图片文件加载进来,然后统一缩放。

PHOTO_DIR = "photos" def load_photos(): images = [] if not os.path.isdir(PHOTO_DIR): print("警告:没有找到 photos 文件夹") return images for name in sorted(os.listdir(PHOTO_DIR)): if name.lower().endswith((".jpg", ".jpeg", ".png", ".bmp")): path = os.path.join(PHOTO_DIR, name) img = pygame.image.load(path).convert() # 等比缩放,让任何图片都塞进 400x300 的方框 w, h = img.get_size() scale = min(400 / w, 300 / h) if scale < 1.0: new_w = int(w * scale) new_h = int(h * scale) img = pygame.transform.smoothscale(img, (new_w, new_h)) images.append(img) return images

这里有几个细节值得展开。

pygame.image.load之后为什么要调.convert()?.convert()会把图片转换成 pygame 内部更容易渲染的像素格式,绘制速度会明显更快。加载 JPG 用.convert()没问题,加载 PNG 的透明图片建议用.convert_alpha(),否则透明区域可能显示成黑色方块。

为什么缩放要算scale = min(400 / w, 300 / h)?因为 400 和 300 是目标区域,宽高比不能变,变了图片就变形。min会保证任何一边都不超出目标区域,另一边留白。如果你直接写死pygame.transform.smoothscale(img, (400, 300)),竖拍的照片会被横向拉伸,看起来非常奇怪。

sorted是为了让文件按文件名排序。如果你的照片命名是01.jpg、02.jpg、03.jpg,切换顺序就是正常的。如果不排序,程序读取文件系统的顺序可能和你预想的不一样。

4.3 空格键切换照片

照片都加载好了,接下来要让空格键起作用。先定义当前照片索引:

photo_list = load_photos() photo_index = 0

然后在主循环里处理键盘事件:

for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: if photo_list: photo_index = (photo_index + 1) % len(photo_list)

photo_index + 1是下一张,% len(photo_list)是取余数。比如有 3 张照片,当photo_index变成 3 的时候,3 % 3 = 0,自动回到第一张。这是在做循环列表时最常用的写法,代码短,逻辑也清楚。

必须注意,这里判断的是KEYDOWN,不是KEYUP。KEYDOWN是按键按下的瞬间,KEYUP是松开的瞬间。如果你用KEYUP,实际体验上会有一点点延迟,而且某些系统的键盘重复事件会导致切换两次。

还有一个小坑:游戏窗口必须处于焦点状态,也就是鼠标点过窗口,让它处于最前面,这个时候按空格才有反应。如果你焦点在终端窗口上,pygame 窗口是收不到键盘事件的。

4.4 心形烟花:数学公式和粒子类

这是整个项目最酷的部分,也是需要解释最多的部分。心形烟花看起来复杂,本质就是一个粒子系统。

什么叫粒子系统?你可以把它理解成:一个烟花是由几百个“小光点”组成的,每个光点有自己的位置、颜色、寿命。每帧更新位置,每帧把光点画出来,最后所有光点消失,烟花也就结束了。

关键问题来了:这些光点要怎么排列成一个爱心形状?

这里用标准的心形参数方程:

  • x(t) = 16 * sin(t)³
  • y(t) = 13 * cos(t) - 5 * cos(2t) - 2 * cos(3t) - cos(4t)

其中t从 0 到2π变化。这个方程的曲线就是一颗标准爱心。因为屏幕坐标系里 y 轴向下是正方向,而数学坐标系里 y 轴向上是正方向,所以代码里我做了符号翻转。

完整的心形烟花类如下:

class HeartFirework: def __init__(self, cx, cy, scale=2.0): self.cx = cx self.cy = cy self.scale = scale self.particles = [] for _ in range(80): t = random.uniform(0, 2 * math.pi) px = 16 * math.sin(t) ** 3 py = 13 * math.cos(t) - 5 * math.cos(2 * t) - 2 * math.cos(3 * t) - math.cos(4 * t) target = (cx + px * scale, cy - py * scale) self.particles.append({ "pos": [cx, cy], "target": target, "life": random.randint(30, 90), "color": (random.randint(240, 255), random.randint(80, 140), random.randint(140, 200)), }) def update(self): for p in self.particles: dx = p["target"][0] - p["pos"][0] dy = p["target"][1] - p["pos"][1] p["pos"][0] += dx * 0.06 p["pos"][1] += dy * 0.06 p["life"] -= 1 self.particles = [p for p in self.particles if p["life"] > 0] def is_dead(self): return len(self.particles) == 0 def draw(self, screen): for p in self.particles: alpha = p["life"] / 90.0 r = max(1, int(4 * alpha)) color = tuple(int(c * alpha) for c in p["color"]) pygame.draw.circle(screen, color, (int(p["pos"][0]), int(p["pos"][1])), r)

我来解释一下这个类的设计思路。

初始化的时候,每个粒子的初始位置都是烟花中心点(cx, cy),目标位置是心上均匀采样的一个点。因为t是随机的,所以粒子会分布到心形的各个位置。scale控制爱心大小,太小看不见,太大溢出屏幕,我建议在1.5到3.0之间。

update方法做的是“让粒子位置慢慢靠近目标”。我用的是pos += (target - pos) * 0.06,意思是每帧向目标移动剩余距离的 6%。这个写法不是线性移动,而是先快后慢的减速运动,看起来就像烟花爆开然后慢慢定住。因为每个粒子向目标移动速度不完全相同,加上随机寿命,粒子会出现自然的错落感。

life是粒子的寿命,每帧减 1。寿命越低,粒子的透明度越低,画出来的圆点半径也越小。这就做出了“烟花绽放后逐渐消散”的效果。

这里给大家一个建议:写粒子系统不要急着抄代码,先想清楚“每个粒子需要记录什么数据”。“起点”“终点”“当前位置”“剩余寿命”“颜色”这五样,基本就够支撑绝大多数粒子动画了。

4.5 背景音乐循环播放

音乐播放其实只有三行代码,但有一个顺序问题必须注意:必须在pygame.init()之后,或者至少在执行pygame.mixer.init()之后,才能加载音乐。

try: pygame.mixer.init() pygame.mixer.music.load("bgm.mp3") pygame.mixer.music.play(-1) except pygame.error as e: print("音乐加载失败:", e)

play(-1)的-1表示无限循环播放。如果填play(3),就是播完第 3 次后停止。

我为什么加try...except?因为用户很可能忘记把bgm.mp3放进目录,或者在打包后有路径问题。如果不捕获异常,pygame 会在加载失败时直接抛错,整个程序闪退。加了异常捕获后,即使没音乐文件,程序依然能运行,只是没有背景音乐。

这里补充一个经验:如果你的音乐文件是 MP3,并且播放时出现“文件能加载但声音断断续续”的情况,多半是音频采样率和 pygame 内部混音器不匹配。把音乐转成 OGG 或者 WAV 基本能解决。另外,音乐文件不要选太长,30 到 60 秒的纯音乐循环起来效果最好,太长文件大,加载也慢。

4.6 主循环把所有功能串起来

现在把前面所有代码拼到一起,写主循环。这里我会写出完整的可运行代码,你也可以把它直接保存为main.py对照使用。

running = True fireworks = [] spawn_timer = 0 while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: if photo_list: photo_index = (photo_index + 1) % len(photo_list) elif event.type == pygame.MOUSEBUTTONDOWN: fireworks.append(HeartFirework( event.pos[0], event.pos[1], scale=random.uniform(1.5, 2.5) )) # 定时自动放烟花 spawn_timer += 1 if spawn_timer % 40 == 0 and len(fireworks) < 6: fireworks.append(HeartFirework( random.randint(150, SCREEN_W - 150), random.randint(100, SCREEN_H - 150), scale=random.uniform(1.5, 2.8) )) # 更新所有烟花 for f in fireworks: f.update() fireworks = [f for f in fireworks if not f.is_dead()] # 渲染:背景 -> 照片 -> 烟花 -> 文字 screen.fill((15, 10, 30)) if photo_list: img = photo_list[photo_index] x = (SCREEN_W - img.get_width()) // 2 y = (SCREEN_H - img.get_height()) // 2 - 20 screen.blit(img, (x, y)) font = pygame.font.SysFont("simhei", 24) tip = font.render(f"{photo_index + 1}/{len(photo_list)} 按空格切换", True, (255, 255, 255)) screen.blit(tip, (SCREEN_W // 2 - tip.get_width() // 2, SCREEN_H - 60)) else: font = pygame.font.SysFont("simhei", 28) tip = font.render("没有找到照片,请把图片放到 photos 文件夹", True, (255, 255, 255)) screen.blit(tip, (SCREEN_W // 2 - tip.get_width() // 2, 260)) for f in fireworks: f.draw(screen) pygame.display.flip() clock.tick(FPS) pygame.mixer.music.stop() pygame.quit()

主循环的每一部分都有它存在的理由:

screen.fill先填充深色背景。为什么不直接用黑色?深蓝紫色背景和粉色烟花更搭,视觉层次更好。你也可以改成(20, 20, 30)这类颜色。

spawn_timer % 40 == 0表示每隔 40 帧自动生成一个烟花,在 60 帧率下就是大约每 0.67 秒一个。len(fireworks) < 6是上限控制,防止烟花越来越多,最后程序卡顿。

f.update()和f.draw()分离,这是一个非常好的编码习惯。更新逻辑和绘制逻辑分开,未来你要加暂停功能、要调帧率,都不至于牵一发动全身。

clock.tick(FPS)很重要。它保证循环不超过每秒 60 帧,否则动画速度会受电脑性能影响,高性能电脑上烟花飞得巨快,低性能电脑上卡成 PPT。

5. 实测之后补的几个小细节

5.1 照片底部加状态提示

我第一次跑通的时候,照片切换是正常的,但使用者根本不知道“原来可以按空格”。所以我在照片下方加了一行提示文字:1/3 按空格切换。这行字既是操作引导,也是一种状态反馈。

字体我用的是pygame.font.SysFont("simhei", 24)。simhei是 Windows 系统里的黑体,如果你的电脑没有这个字体,文字会显示成方框。更稳妥的办法是先取默认字体:

font = pygame.font.Font(None, 24)

pygame.font.Font(None, 24)表示使用 pygame 自带的默认字体,兼容性最好,不过这种字体只支持英文。如果图片路径和提示文字中一定要有中文,建议你把一个中文字体文件放到项目目录,比如font.ttf,然后用pygame.font.Font("font.ttf", 24)加载。

5.2 烟花不要无限叠加

刚开始我把烟花生成条件写得很激进,每 10 帧就生成一个,结果 30 秒后屏幕上同时存在几百个烟花,每个烟花 80 个粒子,一帧光粒子的渲染就要好几千个圆,程序明显掉帧。

加上len(fireworks) < 6这个限制之后,性能立刻稳了。当然,这个数字不是固定的,如果你的电脑性能一般,可以把上限调成 4;如果性能很强,想要更热闹可以调到 10。

5.3 让切换照片有简单过渡

空格切照片如果瞬间切换,效果很干。我想到一个简单的过渡方案:每次切换的时候,生成一个小的心形烟花在照片旁边绽放,相当于“切换动作”的视觉反馈。

实现方式也很简单,在空格键的事件处理里加一行:

fireworks.append(HeartFirework( photo_list[photo_index + 1 % len(photo_list)].get_width() // 2 + 50, SCREEN_H // 2, scale=1.5 ))

这里我不放整段完整代码了,思路是:切换照片的同时,在照片附近生成一个烟花。这个细节成本极低,但仪式感一下子就出来了。

另一种过渡是“淡入淡出”。把当前照片的透明度从 255 慢慢降到 0,再显示下一张。pygame 的Surface.set_alpha()可以实现,不过需要额外维护一个透明度变量。对于刚入门的朋友,我建议先实现第一种,淡入淡出让代码复杂度增加不少。

5.4 照片的内在加载优化

我遇到过一个非常极端的现象:给程序塞了 30 张照片,每张 4000 像素宽,启动的时候卡了将近 10 秒。原因是pygame.image.load会完整加载原图,然后再缩放。

优化方法是在加载前先用pygame.transform.smoothscale处理一次,但更彻底的做法是:素材图片本身就不要放太大。我建议把投放的照片压到 1920 宽以内,或者直接用手机截图、微信保存的照片,那基本都在 1080 到 1440 这个范围,足够使用了。加载快、内存小、切换也跟手。

6. 常见问题与排查记录

6.1 图片加载失败或程序闪退

最常见的原因是路径不对。注意,pygame.image.load("photos/01.jpg")是相对路径,它的参照点是“你在哪个目录下运行 python 命令”。如果你在其他目录打开终端执行python main.py,程序就找不到photos了。

解决办法有两个:

  • 每次都先在main.py所在目录打开终端。
  • 或者更稳妥:在代码开头获取脚本所在目录。
import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) PHOTO_DIR = os.path.join(BASE_DIR, "photos") BGM_PATH = os.path.join(BASE_DIR, "bgm.mp3")

这样不管在哪个路径下运行,都能正确找到素材。这是打包和部署前必须处理的问题。

还有一个闪退原因是加载了损坏的图片文件。图片后缀是.jpg,但文件内容实际上不是合法图片,pygame 会直接报错。你用系统看图工具打开这些文件试试,如果提示图片损坏,就换掉。

6.2 音乐没有声音

音乐没声音,按优先级排查:

  1. 文件是否存在,路径是否正确。
  2. 格式是否是 WAV/OGG/MP3。建议转成 OGG 再试。
  3. 系统音量、pygame 混音器音量是否被调到 0。可以在代码里加一行pygame.mixer.music.set_volume(0.8)。
  4. 是否调用了pygame.mixer.init()。有时候你只写了pygame.init(),它确实会初始化 mixer,但如果你先加载音乐,再调用pygame.mixer.init(),顺序反了,也会导致无声。

6.3 空格键切换没反应

先检查窗口是否有焦点。如果没有,点击窗口标题栏,再按空格。

然后检查事件代码:

elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:

如果你写成了pygame.K_SPACEBAR或者pygame.K_SPACE == event.key的顺序反了,都不会触发。pygame.K_SPACE是 pygame 里空格键的固定名称。

还有一种不太容易发现的情况:你的循环里可能有time.sleep()或者耗时的重活,导致事件队列排队很久,按键看起来没反应。主循环里尽量不要有耗时的操作,需要预加载的资源放循环外面加载完。

6.4 窗口白屏或者黑屏只有文字

如果窗口能显示背景色和文字,但照片区域是空白的,大概率是照片列表为空。打印一下photo_list的长度。

如果窗口是白屏,什么都没有,很可能是你在screen.blit图片之前没有调用screen.fill,或者代码顺序乱了。pygame 绘制前必须清理画布,否则会有残影和奇怪的图层问题。

7. 打包成 exe 发给对方

7.1 PyInstaller 基本命令

程序写好之后,如果对方电脑上没有 Python 环境,那这个程序就没法运行。所以打包成 exe 是非常有必要的。用 PyInstaller 可以做到。

安装:

pip install pyinstaller

打包命令:

pyinstaller -F -w main.py

-F表示打包成单个文件,-w表示运行时不弹出黑色控制台窗口。

不过这里我要提醒:第一次打包建议不要加-w,先打包成带控制台的版本,双击运行能看到报错信息,方便排查问题。跑通了再加-w。

7.2 打包后找不到照片和音乐

这是打包最常见的问题。原因在于,PyInstaller 打包后,__file__指向的目录和原脚本目录不一样。如果你用os.path.join("photos", "01.jpg")这种相对路径,在打包后的环境里会找不到资源。

我的做法是:在代码里提前把所有路径拼成绝对路径,也就是第 6.1 节提到的BASE_DIR方案。但单独BASE_DIR还是不够,因为 PyInstaller 打出来的单文件 exe 运行时,程序文件会被解压到临时目录,__file__会指向临时目录。

稳妥的思路是:把photos和bgm.mp3放在 exe 同目录下,然后在代码里取 exe 的所在目录:

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

如果你用 PyInstaller 的-F单文件模式,这个方案依然有效,因为__file__指向的是 exe 所在位置,不是解压临时位置。真正需要用到sys._MEIPASS的是“把资源打进 exe 内部”的打包方式。对大多数场景,我建议直接用“exe 旁边放素材文件夹”的方式,简单、透明、方便随时换照片。

7.3 体积瘦身和运行稳定性

pygame库本身就不小,所以-F打包之后 exe 动辄几十 MB,这是正常的,不用焦虑。如果你觉得体积偏大,可以加--exclude-module排除一些用不到的模块,但效果有限。我一般不做极端优化,几十 MB 对现代电脑来说不算什么。

打包完成后,把exe、photos文件夹、bgm.mp3放在同一个文件夹里,整体发给对方。对方只需要双击 exe 就能运行,不需要装 Python,也不需要装 pygame。

这里还有一个容易被忽略的细节:如果你加了-w,程序运行时报错不会显示在控制台里,而是直接闪退。出问题时,先用带控制台的版本跑一下,看到具体报错再回来改。这是我在处理朋友电脑上的运行错误时总结出的经验。

我在这个项目上最后补了一句:图片切换的索引提示、烟花粒子的上限、音乐播放的异常捕获,这些都是把“能跑的代码”变成“好用的小工具”的重要细节。如果你是想做一份送给某个重要的人的贺卡,做完之后记得把photos里的照片换成你们之间的回忆,把音乐换成对你们有意义的曲子,再按空格体验一下切换效果。这个用 Python 做的贺卡,带来的情绪价值一点也不比商业产品差。

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

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

立即咨询