首页 / 资讯中心 / 文章详情

用Pygame制作爱心贺卡:照片切换+心形烟花+背景音乐

用Pygame制作爱心贺卡:照片切换+心形烟花+背景音乐 ★ FEATURED ARTICLE
做《表白贺卡进阶二》这个项目的时候我印象最深的一句话是一个小程序居然能把“照片切换 心形烟花 背景音乐”这三件事同时搞定。核心库只用了一个 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 pillowpygame是核心库负责窗口、图像、声音和事件处理。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.mp3photos文件夹里放照片bgm.mp3是背景音乐。目录名和文件名都用纯英文这是我不想让你踩坑的地方pygame 在部分环境下处理中文路径会出问题报错信息也不直观所以素材目录最好全部用英文。照片数量一般放 3 到 6 张比较合适图片分辨率不用太高超过 1080p 的程序放大了反而占内存加载也慢。2.3 音乐文件的格式选择pygame 对音乐格式的支持有点“挑食”。我实际测试下来最稳的是 WAV 和 OGGMP3 大概率能播但会偶发时间偏移或者初始化失败。如果你的备份只有 MP3可以用格式转换工具转成 OGG 或 WAV。WAV 文件体积大OGG 体积小、音质也不错所以我个人推荐优先用 OGG。3. 整体框架先明白程序怎么跑3.1 为什么选 pygame有的同学会想到用 Tkinter 做窗口程序也能加载图片、放音乐但 Tkinter 对动画的支持太弱了。你要做心形烟花这类粒子效果本质上是在快速刷新屏幕上每个小圆点的位置pygame 本身就是为这类场景设计的它有稳定的游戏循环、精确的帧率控制还有专门处理缩放的 API选它是顺理成章的事。pygame 还有一个优势就是它的学习资料非常多出问题基本都能搜到答案。对初学者来说资料的丰富程度有时比技术先进性更重要。3.2 画布上的图层顺序pygame 的窗口就像一块画布你在上面画东西的顺序决定了图层遮挡关系。后画的会盖在先画的上面。这个项目的图层顺序是背景色填充通常是深色衬托照片和烟花。照片本身。心形烟花粒子。底部状态文字。每次画面刷新不是“往上叠加”而是把所有内容清空、重画一遍。先画背景再画照片然后画烟花最后画文字这就是固定的绘制顺序。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 60pygame.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, scale2.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], scalerandom.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), scalerandom.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, scale1.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 音乐没有声音音乐没声音按优先级排查文件是否存在路径是否正确。格式是否是 WAV/OGG/MP3。建议转成 OGG 再试。系统音量、pygame 混音器音量是否被调到 0。可以在代码里加一行pygame.mixer.music.set_volume(0.8)。是否调用了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 做的贺卡带来的情绪价值一点也不比商业产品差。
阅读完成 · 觉得有帮助?
咨询建站