故事的开始
故事的开始,在抖音上刷到有人使用豆包生成桌面宠物
比如把自己的猫猫图片给豆包,之后再把下面这段话给他
就可以生成桌宠了
bash
项目目标
开发一个基于 Windows 平台的桌面宠物应用程序,单文件 EXE 运行,无需安装依赖。
核心功能要求
窗口特性
透明与无边框:窗口背景完全透明,仅显示角色图像,无系统默认边框和标题栏。
置顶逻辑:默认"始终置顶",但在右键菜单中可切换开关。
鼠标穿透:除角色主体外,透明区域应支持鼠标穿透(即点击透明处不会阻挡下方窗口的操作)。
图像处理
自动抠图:程序启动时自动去除图片白色背景(建议使用色度键控或边缘检测算法),确保角色边缘平滑。
资源加载:支持加载用户提供的 PNG/JPG 格式角色图。
交互逻辑
拖拽移动:按住鼠标左键在角色身上拖动,可自由移动桌宠位置。
滚轮缩放:滚动鼠标滚轮可实时调整桌宠大小(需设置最小/最大缩放限制,防止过小或溢出屏幕)。
点击互动:
左键单击角色,随机触发以下动画之一(需通过代码变换实现,无需额外素材):
跳跃:Y轴快速位移后回落。
压扁回弹:X轴拉伸、Y轴压缩后恢复。
左右抖动:X轴快速左右微移。
对话气泡:触发互动时,在角色头顶或侧方随机显示一条中文趣味台词。气泡需有独立背景色(不透明),且位置需动态计算,严禁遮挡角色面部。
系统菜单(右键)
点击鼠标右键呼出上下文菜单,包含:
调整大小:输入具体数值或滑块调整。
置顶开关:切换 AlwaysOnTop 状态。
退出程序:彻底关闭进程。
交付形式
最终交付物为一个独立的 .exe 文件,双击即可运行,无需用户配置 Python 环境或安装库。

结果就是把我的豆包累坏了,中道休息,提示我7月26日才能为我继续服务
正文
好在执行过程中我看到了他的思考过程,大致了解了实现的思路,于是便有了下面这段代码。
我用夸克网盘给你分享了「桌面宠物」打开「夸克APP」即可获取。
链接:https://pan.quark.cn/s/815d05e72fe6
功能介绍
单机人物,会有对话提示,以及触发跳一跳,摇一摇动作:

右键退出程序

🐱 桌面宠物功能清单
加载 pet.png 作为宠物图标,自动去除白色背景(逐像素处理)
无边框透明悬浮窗,始终显示在桌面上
1. 鼠标交互
| 操作 | 效果 |
|---|---|
| 左键单击 | 触发互动(随机动画 + 对话气泡) |
| 左键拖拽 | 拖动宠物移动位置 |
| 右键 | 弹出功能菜单 |
| 滚轮 | 放大/缩小宠物(范围 30% ~ 300%) |
2. 随机互动(单击触发)
- 对话 :宠物头顶弹出对话,3 秒后自动消失
- 气泡文字从 6 条短语中随机选取(如 "今天也要加油鸭!")
- 动画 :随机执行以下一种
- 跳跃 🐇 --- 上跳 再落回
- 摇晃 🤪 --- 左右快速摆动
3. 右键菜单
| 菜单项 | 功能 |
|---|---|
| 切换置顶 | 切换窗口是否始终在最前 |
| 退出程序 | 直接退出 |
代码
python
import sys
import random
from PyQt5.QtWidgets import QApplication, QLabel, QMenu, QAction
from PyQt5.QtCore import Qt, QTimer, QPoint, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import QPixmap, QImage, QColor
from PyQt5 import sip
# --- 配置区 ---
PET_IMAGE_PATH = "pet.png"
DIALOGS = [
"今天也要加油鸭!", "摸摸头~", "你在看什么呢?",
"好饿呀...", "动一动更健康!", "哇!被发现了!"
]
class DesktopPet(QLabel):
def __init__(self):
super().__init__()
self._animations = [] # 持有动画引用,防止被GC回收导致崩溃
self.init_ui()
self.load_and_process_image()
self.setup_timers()
def init_ui(self):
# 【修复】使用 Qt.Window + CustomizeWindowHint 替代 SubWindow/Tool
# 这是 Windows 下透明悬浮窗最稳定的标志组合
self.setWindowFlags(
Qt.Window |
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.CustomizeWindowHint
)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setAttribute(Qt.WA_NoSystemBackground)
# 关键:禁用窗口自动重置,防止动画/点击时系统隐藏窗口
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setCursor(Qt.OpenHandCursor)
self.base_size = 200
self.current_scale = 1.0
self.resize(self.base_size, self.base_size)
self.dragging = False
self.offset = QPoint()
self.drag_start_pos = QPoint()
self.animating = False
self._click_cooling = False # 点击冷却标志,防连点
# 确保窗口不会被任务栏捕获
self.setWindowModality(Qt.NonModal)
def load_and_process_image(self):
"""【修复】使用纯Qt原生方式加载并去白底,彻底移除PIL"""
try:
pixmap = QPixmap(PET_IMAGE_PATH)
if pixmap.isNull():
raise FileNotFoundError(f"无法加载图片: {PET_IMAGE_PATH}")
image = pixmap.toImage().convertToFormat(QImage.Format_ARGB32)
w, h = image.width(), image.height()
# 原生Qt逐像素去白底(安全,无跨库内存问题)
for y in range(h):
for x in range(w):
color = image.pixelColor(x, y)
if color.red() > 240 and color.green() > 240 and color.blue() > 240 and color.alpha() > 0:
image.setPixelColor(x, y, QColor(255, 255, 255, 0))
self.original_pixmap = QPixmap.fromImage(image)
self.update_display()
except Exception as e:
print(f"图片加载失败: {e}")
self.setText("图片加载失败\n请检查 pet.png")
self.setStyleSheet("color: white; background: black;")
def update_display(self):
if hasattr(self, 'original_pixmap'):
new_w = int(self.base_size * self.current_scale)
new_h = int(self.base_size * self.current_scale)
scaled = self.original_pixmap.scaled(new_w, new_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.setPixmap(scaled)
self.resize(scaled.size())
def setup_timers(self):
self.bubble_timer = QTimer(self)
self.bubble_timer.timeout.connect(self.hide_bubble)
# --- 交互逻辑 ---
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.dragging = True
self.drag_start_pos = event.globalPos()
self.offset = event.globalPos() - self.frameGeometry().topLeft()
self.setCursor(Qt.ClosedHandCursor)
elif event.button() == Qt.RightButton:
self.show_context_menu(event.globalPos())
def mouseMoveEvent(self, event):
if self.dragging:
self.move(event.globalPos() - self.offset)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.dragging = False
self.setCursor(Qt.OpenHandCursor)
# 【防连点】冷却中直接忽略点击
if not self.animating and not self._click_cooling:
dist = (event.globalPos() - self.drag_start_pos).manhattanLength()
if dist < 10:
self.trigger_interaction()
def wheelEvent(self, event):
delta = event.angleDelta().y()
if delta > 0:
self.current_scale *= 1.1
else:
self.current_scale /= 1.1
self.current_scale = max(0.3, min(self.current_scale, 3.0))
self.update_display()
def trigger_interaction(self):
if self.animating or self._click_cooling:
return
self.animating = True
self._click_cooling = True # 进入冷却,防连点
# 800ms 后解除冷却(比任何动画都长,确保安全)
QTimer.singleShot(800, lambda: self._end_cooldown())
action = random.choice(["jump", "squash", "shake"])
self.show_dialog()
if action == "jump":
self.animate_jump()
elif action == "squash":
self.animate_squash()
else:
self.animate_shake()
def _end_cooldown(self):
"""安全解除点击冷却"""
if sip.isdeleted(self):
return
self._click_cooling = False
def show_dialog(self):
text = random.choice(DIALOGS)
if hasattr(self, 'bubble_label'):
self.bubble_label.close()
# 使用独立顶层窗口,避免父窗口裁剪子控件
self.bubble_label = QLabel(text)
self.bubble_label.setWindowFlags(
Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
)
self.bubble_label.setAttribute(Qt.WA_TranslucentBackground)
self.bubble_label.setStyleSheet("""
background-color: white; border: 2px solid #333; border-radius: 10px;
padding: 5px 10px; color: black; font-size: 14px; font-weight: bold;
""")
self.bubble_label.adjustSize()
# 气泡显示在宠物正上方(屏幕坐标)
x = self.x() + (self.width() - self.bubble_label.width()) // 2
y = self.y() - self.bubble_label.height() - 10
self.bubble_label.move(x, y)
self.bubble_label.show()
self.bubble_timer.start(3000)
def hide_bubble(self):
if hasattr(self, 'bubble_label'):
self.bubble_label.close()
# --- 【修复】安全动画实现 ---
def _safe_anim(self, target_prop, end_value, duration, easing=QEasingCurve.Linear):
"""创建并持有动画引用,防止GC回收导致崩溃"""
anim = QPropertyAnimation(self, target_prop)
anim.setDuration(duration)
# 用 property() 获取当前值作为起始值,安全且兼容
anim.setStartValue(self.property(target_prop))
anim.setEndValue(end_value)
anim.setEasingCurve(easing)
self._animations.append(anim)
anim.finished.connect(lambda a=anim: self._animations.remove(a) if a in self._animations else None)
return anim
def _run_anim_chain(self, anims, final_callback):
"""链式启动动画序列,最后一个动画结束后回调 final_callback"""
if not anims:
if final_callback:
final_callback()
return
for i in range(len(anims) - 1):
anims[i].finished.connect(anims[i + 1].start)
anims[-1].finished.connect(lambda: self.reset_anim())
anims[0].start()
def animate_jump(self):
self.setUpdatesEnabled(False)
orig_y = self.y()
up = self._safe_anim(b"pos", QPoint(self.x(), orig_y - 50), 150, QEasingCurve.OutQuad)
down = self._safe_anim(b"pos", QPoint(self.x(), orig_y), 150, QEasingCurve.InQuad)
self._run_anim_chain([up, down], self.reset_anim)
def animate_squash(self):
self.setUpdatesEnabled(False)
orig_y = self.y()
down = self._safe_anim(b"pos", QPoint(self.x(), orig_y + 20), 100)
up = self._safe_anim(b"pos", QPoint(self.x(), orig_y - 10), 100)
reset = self._safe_anim(b"pos", QPoint(self.x(), orig_y), 100)
self._run_anim_chain([down, up, reset], self.reset_anim)
def animate_shake(self):
self.setUpdatesEnabled(False)
orig_x = self.x()
right = self._safe_anim(b"pos", QPoint(orig_x + 10, self.y()), 50)
left = self._safe_anim(b"pos", QPoint(orig_x - 10, self.y()), 50)
back = self._safe_anim(b"pos", QPoint(orig_x, self.y()), 50)
self._run_anim_chain([right, left, back], self.reset_anim)
def mouseDoubleClickEvent(self, event):
"""【修复】安全的双击退出 --- 不碰动画,避免 C++ 回调冲突"""
if event.button() == Qt.LeftButton and not self._click_cooling:
# 标记状态,阻止新交互
self.animating = True
# 显示告别语(独立窗口,不影响宠物窗口)
self._show_goodbye()
# 延迟退出,让事件队列自然处理
QTimer.singleShot(500, QApplication.quit)
def _show_goodbye(self):
"""用独立窗口显示告别文字,不修改宠物窗口本身的显示"""
label = QLabel("👋 拜拜~")
label.setWindowFlags(
Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
)
label.setAttribute(Qt.WA_TranslucentBackground)
label.setStyleSheet("""
QLabel {
background-color: rgba(0, 0, 0, 200);
color: white;
font-size: 28px;
font-weight: bold;
padding: 15px 20px;
border-radius: 12px;
}
""")
label.adjustSize()
# 居中显示在宠物窗口位置
label.move(
self.x() + (self.width() - label.width()) // 2,
self.y() - label.height() - 20
)
label.show()
# 500ms 后随程序退出自动销毁
def reset_anim(self):
"""【修复】增加防御性检查,防止动画结束后对象已失效"""
# 如果窗口正在关闭或已被销毁,直接返回
if not self.isVisible() or sip.isdeleted(self):
return
self.setUpdatesEnabled(True)
self.animating = False
# --- 右键菜单 ---
def show_context_menu(self, pos):
menu = QMenu(self)
act_top = QAction("切换置顶", self)
act_top.triggered.connect(self.toggle_topmost)
act_quit = QAction("退出程序", self)
act_quit.triggered.connect(QApplication.quit)
menu.addAction(act_top)
menu.addSeparator()
menu.addAction(act_quit)
menu.exec_(pos)
def toggle_topmost(self):
flags = self.windowFlags()
if flags & Qt.WindowStaysOnTopHint:
self.setWindowFlags(flags & ~Qt.WindowStaysOnTopHint)
else:
self.setWindowFlags(flags | Qt.WindowStaysOnTopHint)
self.show()
if __name__ == "__main__":
app = QApplication(sys.argv)
pet = DesktopPet()
pet.show()
sys.exit(app.exec_())
如何运行
确保安装了这几个库:
bash
pip install PyQt5 pyinstaller
pyinstaller不安装也可以,如果需要打包成exe文件,再安装也不迟
确保有一个pet.png文件在代码的同级目录上

在PyCharm中这里的绿色三角号,点击即可运行;
如何打包成exe
我是执行了下述命令:
bash
pyinstaller --onefile --noconsole zhuochong.py


这里双击就可以运行了,可能需要等待几秒后,会有响应。
最后
毕竟只是临时起意做的demo,难免不够成熟,希望大家多多指正!