
简介Python桌面宠物源代码是一份基于PyQt5开发的互动桌面宠物项目。资源包共2949个文件整体大小41.69MB其中2944张PNG图片用于宠物形象与动画帧2个py源码文件包含程序入口DesktopPet.py另有README说明文档和配置文件便于快速上手。项目以DesktopPet.py为核心演示了如何用QApplication与QWidget构建窗口使用QLabel、QPixmap加载图像并通过事件处理、槽函数与QPropertyAnimation实现移动、跳跃等交互动画还支持从64种宠物中随机选择。已有1377人学习下载内容结合Python基础、GUI设计与事件驱动编程适合初学者及PyQt5学习者深入研究。通过分析源码可以掌握桌面应用动画控制的关键方法并在此基础上自定义更多宠物形象与行为提升项目趣味性与扩展性是一份颇具学习价值的Python GUI实践资源。1. 从「Python 桌面宠物源代码」说起透明小窗口撑起的完整工程桌面宠物在 python 教程里常被当成好玩的小项目但拆开看它背后是桌面 GUI 最不玩具的三个问题无边框窗口怎么做、背景怎么透明、一组精灵图如何按帧率流畅播放。这三个问题同样会出现在仪表盘挂件、直播助手、录屏水印这些常驻桌面的程序里只是换了层壳。下面这套 Python 桌面宠物源代码从透明窗口讲到帧动画再到拖拽交互和 exe 打包每一步都给可运行代码和参数说明做完就是一份能直接收藏的免费 Python 源码。适合刚配好 pycharm 环境、想找 python 项目练手的人跟做也适合写过几年桌面程序的开发者看看 Tkinter 在透明窗口上的边界。2. 用 Tkinter 搭骨架Python 桌面宠物的透明无边框窗口2.1 三条路线里为什么先选 Tkinter桌面宠物最常见的实现路线有三条Tkinter、PyQt/PySide、Pygame。选型直接决定身后透明窗口和动画两条路好不好走先把这个表看明白再动手。方案透明方案依赖成本适合场景Tkinter-transparentcolor色键透明仅 Windows标准库零安装精灵图 Label 轮换PyQt5 / PySide6WA_TranslucentBackground逐像素 Alpha运行时几十 MB毛玻璃、软阴影、复杂界面Pygame / Arcade需要配合窗口混色透明实现琐碎中等粒子特效、物理模拟提示素材如果带半透明羽化边缘、需要投影或玻璃质感直接选 PySide6不要和色键较劲。我一般先选 Tkinter原因是这份源代码要让人拿起来就能跑python 装好就能直接执行少一层依赖就少一层环境问题。对 5 年以上经验的开发者来说Tk 的透明能力确实不够看但它的色键透明在资源受限的常驻场景里反而是最省的一种方式整窗只有一个 Label 在换图没有合成器负担。Pygame 那套要自己做窗口混色在 Windows 上表现不稳定除非要做大量角色物理否则不推荐绕这个远路。2.2 最小可运行代码置顶、无边框、色键透明装好 python 之后新建pet.py跑下面这段就能看到一个透明窗口的雏形import tkinter as tk root tk.Tk() root.overrideredirect(True) # 去掉标题栏与系统边框 root.attributes(-topmost, True) # 始终保持置顶 root.attributes(-transparentcolor, #FF00FF) # 纯品红作为透明色键 root.geometry(220x220200200) # 宽x高屏幕X屏幕Y root.configure(bg#FF00FF) # 窗口背景与色键一致 label tk.Label(root, bg#FF00FF) # 占位控件之后换成动画帧 label.pack() root.mainloop()逻辑说明overrideredirect(True)去掉窗口装饰后Windows 任务栏里不会再出现这个窗口这是桌面宠物区别于普通窗口的第一个关键点-topmost让窗口压在其它应用上面-transparentcolor是 Windows 平台 Tk 提供的色键透明窗口里所有与该颜色完全一致的像素都会被镂空它是严格的颜色匹配#FE00FF都不会生效。参数说明geometry里的200200表示窗口左上角在屏幕坐标系中的位置单位像素后续拖拽逻辑要反复用到这两条坐标色键建议固定用#FF00FF这是图像处理里约定俗成的品红去背色。素材制作时要避开这个颜色否则角色身上也会被镂空出洞。2.3 色键透明的三个坑锯齿、误透明与平台差异第一坑是边缘锯齿。精灵图从 Photoshop 直接导出 PNG透明边缘往往会带半透明像素Tk 的色键只认完全一致的颜色半透明像素既不是前景也不是品红显示出来会变成一圈粉边。常见做法是让素材导出时保留硬边像素风天然没问题如果素材只有软边版本就用 PIL 把半透明像素向品红预合成能救回大部分from PIL import Image def flatten_to_key(image: Image.Image, key_hex: str #FF00FF) - Image.Image: 把 RGBA 图铺到纯色底上透明部分变成色键色。 r, g, b int(key_hex[1:3], 16), int(key_hex[3:5], 16), int(key_hex[5:7], 16) bg Image.new(RGBA, image.size, (r, g, b, 255)) bg.alpha_composite(image) # 半透明像素与底色做一次混合 return bg.convert(RGB) # 丢掉 alpha交给色键去处理参数说明key_hex必须和-transparentcolor完全一致alpha_composite会把半透明像素和品红混合混合结果不再等于品红所以它救不了羽化边缘只能让全透明像素正确落到色键上。这也是为什么像素风素材在 Tk 方案里总是优先项。第二坑是误透明。素材一旦经过 JPEG 压缩品红区域会变成#FE00FE这类近似值色键匹配立刻失效桌面出现紫红色方框。所以素材只允许 PNG调试时如果看到整块色块盖在桌面上先去查素材是不是被转码过。第三坑是平台差异。-transparentcolor是 Windows 特有属性Linux 上即使装了python3-tk也不生效。在 Linux 系统装 python 想跑桌面宠物要么换 PySide6 的逐像素 Alpha要么退而求其次用root.attributes(-alpha, 0.9)做整窗半透明但角色本身也会跟着变透明。macOS 上overrideredirect对窗口层级支持不稳定这套骨架建议以 Windows 为主要运行环境。3. 帧动画子系统让桌面宠物从静态图变成活角色3.1 精灵表切帧与 PhotoImage 的内存模型透明窗口只是地基角色的活全来自帧动画。常见做法是把所有动画帧拼成一张精灵表sprite sheet行是不同动作列是同一动作的连续帧程序按坐标切出来逐帧播放。为了控制内存和加载速度一次性把整张表切成 PhotoImage 列表而不是每帧去读磁盘from PIL import Image, ImageTk class Sprite: def __init__(self, sheet_path: str, cols: int, rows: int, scale: float 1.0): sheet Image.open(sheet_path).convert(RGBA) fw, fh sheet.size[0] // cols, sheet.size[1] // rows # 每帧宽高 self.action_rows: dict[str, list[ImageTk.PhotoImage]] {} for r in range(rows): frames [] for c in range(cols): cell sheet.crop((c * fw, r * fh, (c 1) * fw, (r 1) * fh)) if scale ! 1.0: cell cell.resize((int(fw * scale), int(fh * scale)), Image.LANCZOS) cell flatten_to_key(cell) # 铺品红底见上一章 frames.append(ImageTk.PhotoImage(cell)) self.action_rows[frow_{r}] frames逻辑说明crop的左、上、右、下四个坐标完全由cols/rows均分得出所以素材表在设计时必须保证每帧等宽等高且帧与帧之间无留白否则画面会整体跳帧。action_rows用字典保存动作名 → 帧列表比二维数组直观后面状态机直接按名取帧。ImageTk.PhotoImage会把 PIL 图像拷贝进 Tk 的图像系统拷贝后原 PIL 对象可以丢弃但 PhotoImage 对象必须由 Python 侧持有引用否则会被垃圾回收典型表现是动画播几帧后画面消失。参数说明scale用于素材整体缩放1.0 表示原尺寸放大缩小都用 LANCZOS 保证边缘质量。对像素风素材运行时缩放会糊掉轮廓建议直接请美术按目标尺寸出图程序里不做缩放。3.2 用 after() 搭帧播放器别开线程动画定时器最容易写错的地方是开threading.Timer每秒刷新十几次。Tk 的 UI 只能由主线程更新另开线程去改 Label 会在动画面板里随机闪崩。正确做法是用 Tk 事件循环自带的after()自调度class Pet(tk.Label): def __init__(self, master, sprite: Sprite, fps: int 12): super().__init__(master, bg#FF00FF) self.sprite sprite self.fps fps # 每秒播放帧数 self.frames [] self.frame_idx 0 self.playing False self.loop True def play(self, frames: list, loop: bool True): self.frames frames self.loop loop self.frame_idx 0 self.playing True def _tick(self): if self.playing and self.frames: self.config(imageself.frames[self.frame_idx]) self.image self.frames[self.frame_idx] # 保持引用防 GC self.frame_idx 1 if self.frame_idx len(self.frames): self.frame_idx 0 if not self.loop: self.playing False self.after(1000 // self.fps, self._tick) # 重新调度自己 def start(self): self.after(1000 // self.fps, self._tick)逻辑说明_tick每次只做两件事把当前帧挂到 Label 上再用after把自己排回事件循环。这种自调度写法比外部while True time.sleep干净得多因为它天然让出主循环拖拽、菜单、动画互不阻塞。1000 // self.fps是每次回调的间隔毫秒数整除能避免浮点误差累积。参数说明fps直接决定 CPU 占用和流畅度桌面宠物场景有张经验表可以直接抄fpsafter 间隔推荐用途8125 ms省电模式、笔记本常驻1283 ms默认待机动画2441 ms走位、反应动作3033 ms验证素材用不建议常驻after不是硬实时定时器。如果主线程里跑了耗时操作如加载大文件回调会堆积表现为动画卡顿后突然快进。此时不要改线程应该把耗时操作拆小或挪到after_idle里分批执行。3.3 状态机idle / react 两种动作的切换参数有了 Sprite 和 Pet动作管理就该上状态机了。桌面宠物最少需要两组动作待机循环确idle和一次性反应react点击、被拖动时都可以触发后者。常见做法是把动作名映射到精灵表的行号ACTIONS { idle: 0, # 精灵表第 0 行待机 react: 1, # 精灵表第 1 行点击反应 walk: 2, # 精灵表第 2 行走位巡游可选 } def set_action(pet: Pet, sprite: Sprite, name: str, loop: bool True): row ACTIONS[name] pet.play(sprite.action_rows[frow_{row}], looploop)逻辑说明动作切换的本质是换掉Pet.frames并把frame_idx归零动画时长等于帧数 ÷ fps。比如 react 组有 8 帧、fps 12那么 0.67 秒播完一次之后playing自动置 False 停在最后一帧外部再用set_action(..., idle)切回待机。参数loop决定动作是否循环待机永远loopTrue反应类动作loopFalse否则宠物会一直抽搐。切动作时还有一个容易忽略的细节上一动作的帧没播完就硬切会造成体位跳变。常见做法是保留上一动作最后一帧做过渡新动作在下一个 tick 才生效。像素风素材这种跳变肉眼能接受平滑动画素材则需要加 2~3 帧插值过渡复杂度明显上升这也是很多人最终选择直接切帧的原因。4. 桌面宠物的交互层拖拽、点击反应与随机巡游4.1 鼠标拖拽窗口与点击/拖拽判别阈值桌面宠物最重要的交互是能用手拎起来换个位置。实现思路是按下时记录鼠标在窗口内的偏移拖动时用鼠标屏幕坐标减去偏移得到新窗口位置持续更新geometry。但按下和释放之间到底是点击还是拖拽必须用阈值判断DRAG_THRESHOLD 5 # 移动超过 5px 才认为是拖拽 def on_press(event): pet._offset (event.x, event.y) # 按下点在窗口内的偏移 pet._press_xy (event.x_root, event.y_root) pet._dragging False def on_motion(event): if pet._offset is None: return dx abs(event.x_root - pet._press_xy[0]) dy abs(event.y_root - pet._press_xy[1]) if dx DRAG_THRESHOLD or dy DRAG_THRESHOLD: pet._dragging True nx event.x_root - pet._offset[0] ny event.y_root - pet._offset[1] pet.geometry(f{nx}{ny}) def on_release(event): if pet._offset is None: return if not pet._dragging: set_action(pet, sprite, react, loopFalse) # 判定为点击 pet.after(700, lambda: set_action(pet, sprite, idle, loopTrue)) pet._offset None label.bind(ButtonPress-1, on_press) label.bind(B1-Motion, on_motion) label.bind(ButtonRelease-1, on_release)逻辑说明event.x / event.y是相对控件左上角的坐标event.x_root / event.y_root是屏幕坐标。拖拽计算必须用x_root误用event.x的话窗口一动偏移量就错宠物会跑在鼠标后面。阈值判断放在on_motion里而不是on_release这样拖拽过程中就已经区分语义避免拖完还误触发一次点击反应。参数说明DRAG_THRESHOLD取 3~8 都行值越大点击容错越高太小的话手抖一下就变拖拽。700是 react 播完切回 idle 的延迟毫秒数需要和 react 动画时长帧数 ÷ fps对齐播到一半切走会显得角色被打断。三种鼠标事件的分工可以记成一张表事件Tk 序列判定语义按下ButtonPress-1记录偏移进入待判定移动B1-Motion超过阈值后移动窗口释放ButtonRelease-1未拖拽则触发点击反应还有一个很多人踩过的坑-transparentcolor只负责看起来透明点击命中区域仍然是整个矩形窗口。如果素材四周留白很多宠物周围会出现一块看不见的墙挡住桌面图标和窗口。常见做法是把 Label 和窗口尺寸裁剪到角色紧贴的边界框留白控制在 2~4px这也是精灵表切帧时要顺手去掉冗余留白的原因。4.2 右键菜单与退出路径overrideredirect(True)会连同关闭按钮一起去掉右键菜单成了宠物唯一的退出入口必须在交互层显式实现def show_menu(event): menu tk.Menu(root, tearoff0) menu.add_command(label打招呼, commandlambda: set_action(pet, sprite, react, False)) menu.add_command(label切换素材, commandopen_file_selector) menu.add_separator() menu.add_command(label退出, commandroot.destroy) menu.tk_popup(event.x_root, event.y_root) label.bind(Button-3, show_menu)参数说明tk_popup弹出后菜单对象必须继续存活否则菜单一闪而过Windows 上右键是Button-3macOS 触控板右键事件序列不同按平台绑定即可。新手常在这里把commandroot.destroy写成commandroot.quit前者清理窗口资源后结束事件循环后者直接退出进程开发阶段看不出差别打包成 exe 后偶发残留进程排查时要先看这里。4.3 随机巡游让宠物自己找位置纯静态宠物看一眼就没意思了加上随机巡游角色才会活。常见做法是每隔几秒在屏幕的下半部分随机选目标点宠物按固定步长走过去到达后暂停几秒再换目标。巡游器仍是一个 after 自调度与动画播放器互不干扰import random class Wanderer: def __init__(self, pet: Pet, area_bottom_ratio: float 0.6, pause_range(4, 12), step_ms: int 50): self.pet pet self.area_bottom_ratio area_bottom_ratio # 只在下 60% 屏幕区域活动 self.pause_range pause_range # 到达后暂停的周期数范围 self.step_ms step_ms # 每步间隔毫秒 self.target None self.steps_left 0 self.pause_left random.randint(*pause_range) def _tick(self): if self.steps_left 0: x, y self.pet.winfo_x(), self.pet.winfo_y() tx, ty self.target nx x (1 if tx x else -1) * 2 # 每步移动 2px ny y (1 if ty y else -1) * 2 self.pet.geometry(f{nx}{ny}) self.steps_left - 1 elif self.pause_left 0: self.pause_left - 1 # 原地休整保留待机动画 else: sw, sh root.winfo_screenwidth(), root.winfo_screenheight() limit_y int(sh * self.area_bottom_ratio) self.target (random.randint(0, sw - 220), random.randint(limit_y, sh - 220)) self.steps_left 40 self.pause_left random.randint(*self.pause_range) pet.after(self.step_ms, self._tick)逻辑说明巡游器完全复用 after 自调度不碰主循环里的其它事steps_left和pause_left组成一个极简状态机比维护行走中/暂停中两个布尔值清晰。目标点限制在屏幕下 60% 区域避免宠物爬到标题栏附近干扰操作sw - 220和sh - 220把窗口完整约束在屏幕内220 对应窗口宽度窗口尺寸改动时这里要同步改。参数说明step_ms相当于巡游帧率50ms 一步、每步 2px40 步约 2 秒移动 80px手感是悬空漂浮适合桌面宠物想模拟走路节奏步长加大到 4px、间隔放到 80~100ms。pause_range的单位是周期而不是秒真实暂停时间要乘以step_ms设计参数时务必换算别把 4~12 直接当秒用。5. 打包 exe 与性能调优让这份 Python 桌面宠物源代码真正落地5.1 PyInstaller 打包与资源路径修正供自己跑还只是脚本能双击运行才算一个桌面宠物项目。常见做法是用 PyInstaller 打单文件 exe一条命令加一个函数就能搞定pyinstaller --onefile --windowed --name desktop_pet ^ --add-data assets;assets --icon pet.ico main.py参数说明--windowed去掉控制台黑框--add-data把素材目录拷进包Windows 用分号分隔源和目标Linux/macOS 用冒号--icon给 exe 换图标。打包后素材不会和 exe 平级躺在磁盘上而是解压进_MEIPASS临时目录所以代码里所有资源路径都要走同一个修正函数import sys, os def resource_path(rel: str) - str: base getattr(sys, _MEIPASS, os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, rel) sprite Sprite(resource_path(assets/pet.png), cols4, rows3)逻辑说明源码运行时_MEIPASS不存在getattr回退到脚本所在目录onefile 模式运行时PyInstaller 把打包内容解压到临时目录并通过sys._MEIPASS暴露两行代码兼容两种形态。没用这个函数的最典型症状是本机能跑、打包后报找不到图片。打包完建议拿到没装 python 的机器上双击一次验证自解压链路和资源路径都正常。5.2 三处调优与一个验证技巧第一处是限帧。任务管理器里发现宠物 CPU 占用超过 3%绝大多数是 fps 开太高待机 8~12fps 足够只在走位和反应动作临时升到 24fps。第二处是减少无谓重绘Tk 里config(image...)即使内容相同也会触发重绘帧播放器可以先比较当前帧对象相同就跳过。第三处是素材体积单帧 220x220 的 PNG 拉到 30fps内存和重绘开销都不小像素风素材控制在 128x128 以内更划算。验证动画流畅度不能只看任务管理器要自己加一个回调间隔监测把事件循环的心跳量化出来import time _last time.monotonic() def _tick(self): global _last now time.monotonic() drift_ms (now - _last) * 1000 - 1000 / self.fps _last now if drift_ms 100: # 比预期多出 100ms说明主线程被占 print(fevent loop stall: {drift_ms:.0f}ms) # 原有切帧逻辑照常执行drift_ms持续大于 0 说明主线程里有阻塞点偶尔一次超过 100ms 通常是系统调度抖动可以忽略如果动画卡顿和 drift 尖峰同时出现去查素材解码、日志写入这类同步 IO把它挪到后台线程或用after_idle分批处理。本文还有配套的精品资源点击获取