Python情人节浪漫代码:心情泡泡动画小项目实现教程
每年情人节前后总有人问我Python除了写爬虫、做自动化到底能不能搞点浪漫的东西其实能而且门槛低到让人意外。今天这篇就分享一个可以直接拿去送人的小项目python情人节代码之心情泡泡。它的玩法很简单——把你想说又不好意思直接打出来的话装进一个个半透明的小泡泡里让它们在屏幕上慢慢浮起来鼠标一点泡泡会“啪”地原地绽开露出里面藏好的情话。我把整个项目的需求定义、技术选型、完整代码、打包发布和踩坑经验都放在这篇里。无论你是第一次接触GUI编程还是已经写过不少脚本想换个花样都可以照着一步步做完。收到礼物的人不需要装环境、不需要懂Python你最终要交付的是一个双击就能跑起来的exe文件。项目本身不大但该踩的坑一个不少比如中文字体、窗口透明、打包后资源路径、高分屏缩放这些我会全部讲到。1. 先想清楚成品效果别人收到的究竟是个什么东西写代码之前我建议先花十分钟把“成品长什么样”在脑子里过一遍。很多人拿到这类项目就急着敲代码结果做到一半发现效果不是自己想要的返工成本比一开始想清楚高得多。心情泡泡这个项目我一开始设想的最终效果是程序启动后出现一个独立小窗口窗口背景用深夜到玫瑰色的渐变整体氛围接近“安静的夜晚里一场小型的浪漫心事浮起”窗内不断有半透明的彩色泡泡从底部向上飘每个泡泡的颜色、大小、上升速度都不同视觉上像是一堆心事在慢慢浮出鼠标点中某个泡泡后泡泡消失原地变成一张圆角小卡片显示你预先写好的那句话卡片停留大约4秒后自动淡出窗口继续飘新的泡泡用户可以随时右键退出不会出现“点不关、杀不掉”的尴尬。把效果定义清楚以后技术选型就好做了。很多东西看起来效果好但做出来的体积、复杂度、稳定性都会超出预期。收纳礼物的场景有一个共性对方大概率完全不懂编程你的代码越“不挑环境”、越“傻瓜化”越容易成功。所以我从一开始就放弃了“网页HTML版本”“游戏引擎版本”这类方案最后直接在Python GUI框架里挑。这个项目适合什么人参考很简单学完Python基础语法想做一个有实际交付结果的练手项目或者想在情人节、纪念日、生日给身边的人一点不一样的小惊喜。不需要懂图形学不需要会算法基础逻辑就是“生成泡泡 让泡泡动 点击弹出文字”。1.1 先确定一句话词库别等到写代码才想内容我把“心情泡泡”的核心内容定义成一句话词库。你打算让泡泡承载的文字是整个项目的灵魂也是你后续调试时最直观的反馈。默认情话库可以随自己心意写我建议每句话不要超过20个字太长的话卡片显示起来像论文摘要氛围感直接没了。我提供一组默认词库直接在代码里使用遇见你之后我的每一个分支都是happy end你是我这辈子最想修好的关系今天也想你比昨天多了一点点世界的bug很多但想到你一切都能运行我想和你一起把日子过成一首慢诗晚安明天也一起好吗你不在身边的日子我用回忆补丁续命认识你之前人生是REPL认识你之后人生有了完整的脚本如果想让项目在真正送出的时候更贴心可以在exe同目录放一个messages.txt每行一句话程序启动时会优先读取这个文件没有这个文件时就用内置词库。这样你可以反复改词不用重新打包也不用打开代码编辑器。这个细节在后面资源路径章节会细说。2. 为什么选PyQt5和Pygame、tkinter比完就不纠结了很多人一听到“Python做动画效果”第一反应是Pygame其实这种轻度交互项目用Pygame并不舒服。下面这张表可以帮你快速建立判断标准方案动画能力文字排版与卡片效果开发效率打包体积tkinter性能一般强行高频重绘会闪或卡圆角、半透明、渐变这些效果写起来非常费劲中小Pygame强适合粒子、物理、碰撞游戏UI组件基本没有文字弹窗、右键菜单要自己造低中PyQt5足够QTimer驱动paintEvent稳定流畅自带QLabel、QMenu、样式表圆角半透明很容易高偏大但可控Pygame适合做真正的物理泡泡、泡泡互相碰撞、挤压、飘动轨迹复杂的场景代码量至少翻两倍。如果你只是想做一个“点击后弹出情话卡片”的礼物程序核心是界面排版和事件交互PyQt5是最省力的选择。tkinter虽然零依赖但做半透明渐变效果的体验实在不好而且最终还是要打包等于没省事。还有一点要说清楚PyQt5虽然是第三方库但只要你的电脑上能正常pip install pyqt5它就不会给你的项目带来平台兼容性问题。Windows、macOS、Linux都能跑而且它支持无边框透明窗口这给后面的“高级全屏模式”留了扩展空间。我用PyQt5跑下来的结论是开发效率、稳定性、表现力三者平衡得最好。3. 动手实现一个能直接跑起来的情人节心情泡泡代码下面是完整的Python代码。我建议你新建一个文件夹bubble_gift把下面的内容保存为valentine_bubbles.py然后pip install pyqt5再运行就可以了。# -*- coding: utf-8 -*- import sys import os import math import random from PyQt5.QtCore import Qt, QTimer, QPointF from PyQt5.QtGui import QPainter, QColor, QFont, QRadialGradient, QLinearGradient from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QMenu, QAction def load_words(): 优先读取 exe/脚本同目录下的 messages.txt每行一句没有则用内置词库。 try: base_dir os.path.dirname(os.path.abspath(sys.argv[0])) msg_file os.path.join(base_dir, messages.txt) if os.path.exists(msg_file): with open(msg_file, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] if lines: return lines except Exception: pass return [ 遇见你之后我的每一个分支都是 happy end, 你是我这辈子最想修好的关系, 今天也想你比昨天多了一点点, 世界的 bug 很多但想到你一切都能运行, 我想和你一起把日子过成一首慢诗, 晚安明天也一起好吗, 你不在身边的日子我用回忆补丁续命, 认识你之前人生是 REPL认识你之后人生有了完整的脚本, ] class Bubble: 单个心情泡泡记录位置、速度、大小、颜色、透明度与要展示的文字。 def __init__(self, width, height): self.radius random.uniform(45, 75) self.x random.uniform(self.radius, width - self.radius) self.base_x self.x self.y height random.uniform(-20, 80) self.vy random.uniform(90, 170) # 向上飘的速度 self.hue random.randint(0, 359) # 色相 self.alpha random.randint(60, 180) # 透明度 self.phase random.uniform(0, math.pi * 2) # 横向摆动相位 self.word random.choice(load_words()) def move(self, dt): self.y - self.vy * dt self.phase dt * 1.5 self.x self.base_x math.sin(self.phase) * 14 def is_out(self, top): return self.y self.radius top def hit_test(self, px, py): dx px - self.x dy py - self.y return dx * dx dy * dy self.radius * self.radius class BubbleWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle(心情泡泡) self.resize(780, 980) self.bubbles [] self.paused False self.frame_timer QTimer(self) self.frame_timer.timeout.connect(self.on_frame) self.frame_timer.start(33) # 约 30 FPS self.spawn_timer QTimer(self) self.spawn_timer.timeout.connect(self.spawn) self.spawn_timer.start(450) # 每 0.45 秒生成一个泡泡 def spawn(self): if len(self.bubbles) 18: self.bubbles.append(Bubble(self.width(), self.height())) def on_frame(self): dt self.frame_timer.interval() / 1000.0 for b in self.bubbles: b.move(dt) self.bubbles [b for b in self.bubbles if not b.is_out(-100)] self.update() def paintEvent(self, event): painter QPainter(self) painter.setRenderHint(QPainter.Antialiasing) # 背景渐变深夜色过渡到玫瑰色 rect self.rect() grad QLinearGradient(0, 0, 0, rect.height()) grad.setColorAt(0.0, QColor(24, 8, 48)) grad.setColorAt(0.5, QColor(74, 18, 78)) grad.setColorAt(1.0, QColor(148, 42, 76)) painter.fillRect(rect, grad) # 画每个泡泡 for b in self.bubbles: color QColor() color.setHsv(b.hue, 220, 230, b.alpha) highlight QColor(255, 255, 255, b.alpha) edge_color QColor(b.hue, 140, 190, 80) rad_grad QRadialGradient( b.x - b.radius * 0.2, b.y - b.radius * 0.2, b.radius * 1.3 ) rad_grad.setColorAt(0.0, highlight) rad_grad.setColorAt(0.35, color) rad_grad.setColorAt(1.0, edge_color) painter.setBrush(rad_grad) painter.setPen(Qt.NoPen) painter.drawEllipse(QPointF(b.x, b.y), b.radius, b.radius) def mousePressEvent(self, event): if event.button() Qt.LeftButton: px, py event.pos().x(), event.pos().y() for b in self.bubbles: if b.hit_test(px, py): self.bubbles.remove(b) self.show_card(px, py, b.word) return elif event.button() Qt.RightButton: menu QMenu(self) action QAction(暂停/继续, menu) action.triggered.connect(self.toggle_paused) menu.addAction(action) exit_action QAction(退出, menu) exit_action.triggered.connect(self.close) menu.addAction(exit_action) menu.exec_(event.globalPos()) def toggle_paused(self): self.paused not self.paused if self.paused: self.frame_timer.stop() self.spawn_timer.stop() else: self.frame_timer.start() self.spawn_timer.start() def show_card(self, x, y, word): 点击泡泡后在原地生成一张承载情话的卡片4秒后自动消失。 card QLabel(self) card.setText(word) card.setFont(QFont(微软雅黑, 12)) card.setWordWrap(True) card.setStyleSheet( background-color: rgba(255, 255, 255, 235); color: #333333; border-radius: 18px; padding: 18px 22px; ) card.adjustSize() w, h card.width(), card.height() # 边界修正避免卡片在窗口边缘被截断 left max(8, min(x - w // 2, self.width() - w - 8)) top max(8, min(y - h - 20, self.height() - h - 8)) card.move(left, top) card.show() card.raise_() QTimer.singleShot(4000, card.deleteLater) if __name__ __main__: QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) app QApplication(sys.argv) app.setFont(QFont(微软雅黑, 10)) win BubbleWindow() win.show() sys.exit(app.exec_())你直接复制运行大概就能看到效果了。如果你用的是macOS或者Linux代码里的字体微软雅黑可以改成自己系统里存在的中文字体否则会显示成方块。Windows下这个字体基本是必有的正常双击运行不需要担心。3.1 这个代码最需要注意的三个位置第一个是load_words()函数。我写它的时候故意没有只放一个列表而是让它优先尝试读取同目录的messages.txt。这是因为打包成exe之后改内容最友好的方式就是改外部文本文件而不是重新打包整个程序。你以后想换一句情话只用编辑txt再保存连重启程序都不用下一批泡泡就会用新词。第二个是Bubble类的move方法。它里面除了让泡泡向上升还加了一个正弦摆动self.x self.base_x math.sin(self.phase) * 14。没有这行的话泡泡只会直直往上飘整个画面会显得很机械。加了这个轻微的横向摆动泡泡的轨迹看起来会像被风吹着走有呼吸感。第三个是paintEvent里的QRadialGradient。每个泡泡不是用一个纯色圆形画出来的而是一个径向渐变——左上角更亮边缘变淡。这样做是为了让泡泡看起来有体积、有高光而不是一个个扁平的彩色圆片。效果上差别很大但代码只多花了几行。4. 这几个关键设计决定泡泡好不好看、卡不卡代码能跑起来只是第一步。很多人在网上复制类似的代码跑起来后发现卡、闪烁、点击位置不对往往是因为没有理解背后的几个设计决策。我把自己调试过程中最重要的经验拆开讲一下。4.1 为什么用QTimer加paintEvent重绘而不是用一大堆QLabel做动画最简单的做法是给每个泡泡创建一个QLabel控件然后移动它们的位置。我最初试过这个方案泡泡数量超过五六个以后窗口就开始有迟滞感。原因是每个QLabel都是独立的GUI控件移动它要触发布局和重绘数量一多代价就上去了。更好的做法是让每个泡泡只作为普通数据对象既不是控件也没有独立窗口由主窗口统一在paintEvent里把所有泡泡画出来。这样无论屏幕上飘着10个还是18个泡泡最终都只是一次性调用drawEllipse批量绘制性能和代码复杂度都可控。你看到的self.update()并不是立刻重绘而是通知Qt在下一帧统一处理这是Qt的优化机制频繁调用也不会导致闪烁。4.2 点击判定为什么用“距离公式”而不判断控件区域因为泡泡不是控件所以不能直接用QLabel.clicked之类的信号。我采用的方法是鼠标点击后拿到坐标(px, py)然后遍历当前所有泡泡计算它与泡泡圆心(b.x, b.y)之间的距离。如果距离小于等于泡泡半径就认为点中了。这个判定写起来非常简单dx * dx dy * dy r * r。不直接开平方是为了少一次sqrt计算18个泡泡遍历下来几乎没有开销。这个方案的优点是不受任何控件层级影响不管泡泡在程序里是什么形态只要你有圆心的位置数据就能判断。4.3 绘制顺序决定视觉层卡片要用独立QLabel垫在泡泡上面主窗口的paintEvent负责画背景和全部泡泡而点击后弹出的情话卡片用的是独立的QLabel。为什么要这样混着用因为如果卡片也画在paintEvent里它就只能存在一帧下一帧重绘就会被抹掉除非我额外维护一张“卡片队列”并且不断重绘复杂度会明显上升。用QLabel做卡片的好处是它是真实控件Qt会替你管理它的显示生命周期。card.show()和card.raise_()之后它会稳定地压在窗口内容之上。4秒后card.deleteLater()被调用控件自动销毁内存也不会积累。这里唯一的代价是QLabel不能加太花哨的动画但对我们这个场景完全够用。4.4 背景必须每次重绘否则会出现拖影在初版里我偷偷做过一个“优化”背景只画一次每次只画新泡泡指望旧泡泡消失后背景还能保持结果出现了严重的拖影。后来想明白屏幕重绘是一整块刷新的不会自动保留上一帧的像素所以必须每次在paintEvent里先画完整背景再画泡泡。背景渐变用QLinearGradient每次生成一次从下到上由深色过渡到玫瑰色。这个渐变每次重绘都会执行成本完全可以忽略因为颜色渐变只有一段线性插值比绘制几十个泡泡本身轻量得多。这里提醒一下不要试图用“只重绘变化区域”去优化Qt在帧率不高的时候这个优化意义不大还容易引入残影和撕裂感。5. 打包成exe让对方双击就能看见浪漫写代码的过程很愉快但真正让礼物成立的是最终能送到对方手里的那个单文件exe。如果你只是把valentine_bubbles.py发给一个完全没有Python环境的人他大概率会卡在“这是什么文件”“用什么打开”这步。所以必须用PyInstaller打包。5.1 打包步骤建议你在一个干净的虚拟环境里做。这样打包出来的文件不会把开发环境里无关依赖全部塞进来体积和稳定性都好很多。python -m venv .venv .venv\Scripts\activate pip install pyqt5 pyinstaller pyinstaller -F -w --name little_surprise valentine_bubbles.py参数解释-F表示生成单文件exe方便直接发送-w表示不弹出命令行黑窗口因为这是一个纯GUI程序--name指定输出文件名我故意用英文名避免某些系统或安全软件对中文文件名产生额外困扰打包完成后产物在dist目录下文件名是little_surprise.exe。如果你想加一个自定义图标可以在打包命令里加--iconheart.ico。但图标文件格式一定要是.ico直接改图片后缀名是不行的可以用在线转换工具或者Python的PIL库转一下。图标属于锦上添花不影响程序本身。5.2 打包后资源文件路径的坑打包完成后你会发现一件事messages.txt应该放在哪里很多人直接把txt和脚本放在一起打包后却读不到因为PyInstaller在-F模式下会把脚本和资源打进一个临时解压目录__file__指向的位置并不是exe所在目录。我在load_words()里用的是os.path.dirname(os.path.abspath(sys.argv[0]))也就是拿当前exe文件的路径来定位messages.txt。这样运行exe的时候它只会去exe所在的文件夹找同目录的messages.txt逻辑很直观。你修改词库时只需要在exe旁边新建一个文本文件不用重新打包。在打包前的调试阶段这段代码依然可以工作因为sys.argv[0]在普通脚本运行时会指向脚本本身路径。如果你想把词库放进脚本里面而不依赖外部文件项目也支持内置词库已经兜底。5.3 发送之前自己先过一遍测试清单我给这个项目设了一个简单的自测清单每次打包后发给别人之前至少花五分钟测一遍在没有安装Python的Windows虚拟机或另一台电脑上双击exe确认能正常打开点击泡泡确认字体显示正常没有方块、乱码靠近窗口四个边缘点击泡泡确认卡片不会被截断右键菜单里的暂停和退出都有效在系统显示缩放125%或150%下运行点击位置依然准确。前四条很容易测出来第五条容易被忽略。很多Qt程序在其他电脑上显示模糊或者错位就是因为没有开启高分屏支持。代码里QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)这行必须在创建QApplication之前执行我把它放在if __name__ __main__:块的最顶行就是为了确保生效。6. 从通用模板变成专属礼物三个实用进阶方向基础版已经可以送人但如果你想让礼物更特别一点这里有三个方向我实测过难度从低到高可根据自己的时间选。6.1 外部词库编辑器20秒换一批新情话我前面提到的messages.txt就是最轻量的“外置配置”。你可以在exe旁边新建一个messages.txt编码存成UTF-8每行一句话程序启动后会直接使用。这个方案的价值在于当你灵感来了、或者想根据日期临时加一句“第520天快乐”之类的话不用重新打包打开txt改一行保存就行。唯一要注意的是编码问题。Windows自带记事本在保存时可能会带BOM头第一行的第一句前面会多个看不见的字符。我在读取时没有做utf-8-sig处理如果你碰到第一句文字前有小黑点就用其他编辑器保存成“UTF-8无BOM”格式。这个小坑我在给朋友做礼物的时踩过一次印象很深。6.2 点击泡泡后显示“在一起第几天”这是一个成本很低但效果很炸裂的定制。只需要在show_card方法计算一下当前日期和约定日期的差值拼进显示文字里。import datetime start_day datetime.date(2022, 2, 14) days (datetime.date.today() - start_day).days card_text f{b.word}\n\n我们已经在一起 {days} 天把这段逻辑放在show_card的card.setText之前会得到一行额外的日子提醒。虽然只是几行代码但对收礼物的人来说这种细节远比花哨特效动人。如果你用外部词库还可以保留几个特殊词命中后自动追加天数元素更丰富。6.3 “彩蛋泡泡”随机出现的小惊喜我还做过一个版本在词库里故意放一个极少出现的关键词当泡泡文字是这句时点击后不弹普通卡片而是在屏幕中央显示一张照片或者二维码海报。实现方式是用新的QLabel配合QPixmap加载图片把原来纯文字的卡片替换掉。原理并不复杂在show_card里判断语句是否等于预设彩蛋词如果是就换成图片。照片文件可以放当前目录打包后也可以作为外部资源随时更换。要注意的是二维码或照片尺寸不要设计得太大否则在小窗口里显示会超出边界。这种“只有特定泡泡才有的隐藏内容”非常适合浪漫场景也适合做纪念日惊喜。7. 这些坑我提前帮你踩过了最后集中说一下最容易让人挫败的细节。这些坑我在开发和打包过程中都真实遇到过每一条都能省下你两三个小时的排查时间。第一个坑第一次打包出来的exe双击后没有任何反应也没有窗口。排查后发现是打包时加了-w程序一旦在启动阶段报错错误信息不会显示在任何地方看起来就像“死了”。解决办法是先不加-w重新打包一次让控制台窗口暴露出来看到真正的报错再针对性修复。记住不要一开始就追求“无黑窗口”先能看到错误再隐藏错误。第二个坑中文显示成方块。如果你用的是Windows把字体设置成微软雅黑基本就不会有问题。但在某些精简版系统或PE环境下系统中文字体被精简掉了这时需要把字体文件放在exe同目录并在代码里用QFontDatabase.addApplicationFont(xxx.ttf)动态加载。不夸张地说字体问题遇到一次之后你会养成“永远不依赖系统默认字体”的习惯。第三个坑卡片文字被窗口截断。很多人以为把卡片坐标直接设置成点击位置就没问题实际上一句话长一点卡片会超出窗口边界看不见。我在show_card里加了边界修正left max(8, min(x - w // 2, self.width() - w - 8)) top max(8, min(y - h - 20, self.height() - h - 8))这行的作用是如果卡片预定位置在窗口外就强制拉回到窗口内并且留出8像素的安全边距。这个细节能明显提升体验尤其是点击窗口边缘的泡泡时。第四个坑高分屏缩放导致点击错位。如果你在系统设置里把显示缩放调成125%或者150%再运行这个程序会发现鼠标点击的位置和泡泡实际位置对不上。这跟Qt的坐标体系和全局DPI缩放有关。解决方式就是我前面说的在创建QApplication之前设置Qt.AA_EnableHighDpiScaling。注意这行代码的位置很重要放在创建之后是无效的。第五个坑杀毒软件误报。PyInstaller打包出来的exe在一些杀毒软件眼里天然可疑因为它们没有数字签名又是“单文件、释放临时资源”的运行方式。我遇到过朋友反馈exe被电脑直接删掉的情况。目前没有100%一劳永逸的办法但有几个缓解手段尽量在配置好的干净环境打包文件名不要带“破解”“外挂”等敏感字眼如果对方电脑误删让他隔离区恢复并添加信任即可。这个属于打包工具生态的通病跟你代码本身是否安全没有关系。第六个坑不要只发代码文件。很多人把valentine_bubbles.py直接发给对方以为对方能自己跑。实际上对方大概率不会安装Python也看不懂报错。所以要送就送exe或者退一步送一个包含bat启动脚本的压缩包。这个项目真正的交付物是一个可执行文件不是一堆源码。源码只是你实现它的过程。我在实际调试中还有一个很深的体会技术上的东西做得再好如果收到礼物的人连续碰壁三次起不来程序好感度会被消磨光。所以千万不要高估对方的“动手能力”也不要高估杀毒软件的“耐心”。你替对方把环境、交互、边界问题都处理好才是最用心的礼物。如果做完这个项目你还想继续折腾同一个代码框架可以改成节日祝福壁纸、桌面倒计时、会议签到彩蛋等版本。只要把握住“一个窗口、几个浮动圆形、点击显示信息”这个核心结构扩展空间其实挺大的。现在你可以把电脑上的代码打包好亲手点开第一个泡泡了。