尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
PyQt5打造智能桌面宠物挂件:透明窗口、帧动画与状态机实践
简介这套资料是面向创客、电子爱好者与嵌入式初学者的智能桌面宠物完整开发包旨在帮用户绕过“没有编译器”“不会串口下载”等障碍从零搭出可交互桌宠的软硬件。包内共82个文件、约48.21MB类型覆盖源码、固件与硬件设计hex固件可直接烧录串口工具负责程序传输bmp/png图片存放表情与模型素材PCB板文件及PDF图纸便于打样mp4视频演示蓝牙配置txt和pdf提供教程、物料清单与更新说明。学习时可按“代码-硬件-教程”模块查阅初学者能逐步完成烧录与调试有经验者也可利用源码和板卡设计进行二次创作。目前已有670人学习下载适合作为桌面宠物项目的入门参考或改造底本。1. 智能桌面宠物一个把琐碎提醒收进屏幕角落的 PyQt5 挂件电脑右下角每天要弹的提醒少说七八个日历、喝水、待办各弹各的关掉怕错过留着又嫌吵。这份智能桌面宠物资料就是冲着这个场景来的把各种琐碎提醒收进一个有透明背景的小宠物身上它蹲在屏幕边缘到点吐一句气泡右键还能直接执行你提前放好的动作。拆开这份资源核心是一套 Python PyQt5 桌面挂件覆盖透明无边框窗口、帧动画状态机、拖拽吸附和定时气泡对话四块功能动画素材和台词全部做成外部配置不写代码也能换皮。适合两类人想给自用工具加一个看得见交互入口的开发者和想搞懂 GUI 状态机怎么写、透明窗口怎么避开黑底坑的 Python 学习者。2. 整体架构拆解素材、渲染、行为三层的协作方式2.1 技术选型复盘PyQt5、Tkinter、Electron 之间的取舍拆这份资料之前我先确认了一件事它到底选了什么技术栈。答案比预想的干脆——Python 3 配 PyQt5动画素材和台词全部外置成 JSON 与 PNG主程序不写死任何一套皮。为什么不是 Tkinter工具类小窗控件倒是齐全但要做一个带透明背景、边缘吸附、随时置顶的挂件Tkinter 在无边框窗口和透明通道上能用的方案少样式又难调。Electron 呢包体大、启动慢一个常驻桌面小物件动辄吃掉一两百 MB 内存属于杀鸡用牛刀。Unity 做 3D 宠物当然漂亮但要为它单独维护一个引擎项目素材和逻辑的改造成本都太高。PyQt5 这一层恰好给出三个关键能力Qt.FramelessWindowHint去掉系统边框配合WA_TranslucentBackground让窗口底色真正透明QSS 可以给气泡、菜单换肤QTimer驱动帧动画和 Qt 事件循环天然同频。这套组合在桌面挂件里属于常见方案结构简单、素材迭代快、打包半径小一个宠物项目从改皮到加行为都能在这个框架内完成不依赖任何黑匣子组件。2.2 目录结构与职责划分core、ui、resources 三层资料里的目录拆得比较干净我把它整理成下面这份结构基本和你解压后看到的一致petshell/ ├── main.py # 程序入口创建 QApplication 和主窗口 ├── config.json # 运行时配置窗口尺寸、台词、提醒项 ├── core/ # 不依赖 UI 类型的行为逻辑 │ ├── animator.py # 帧动画播放器 │ ├── state_machine.py # 状态机与转移表 │ ├── bubble.py # 气泡对话控件 │ └── scheduler.py # 定时提醒调度 ├── ui/ │ ├── pet_window.py # 透明无边框主窗口 │ ├── tray.py # 系统托盘图标 │ └── menu.py # 右键菜单 ├── resources/ │ └── pets/ │ ├── idle/ # 待机帧动画序列帧 │ ├── drag/ # 拖拽中动画 │ ├── click/ # 点击动画 │ └── eat/ # 投喂动画 └── docs/ # 素材制作规范与配置说明core里放的是不依赖 UI 类型的行为逻辑。animator不管窗口是谁只接收一个 QLabel 和一份动画配置就把帧播起来state_machine记录当前状态收到事件后查转移表bubble负责台词展示scheduler管定时提醒。ui层正好反过来只负责窗口、托盘、菜单这些和系统桌面打交道的东西不写业务判断。resources是纯素材目录按状态名分文件夹每个文件夹里是 01.png、02.png 这样的序列帧animations.json描述每套动画的帧数和帧间隔。这种拆法有一个直接的好处团队里美术同学只动resources不碰 Python写逻辑的人只动core和ui不用关心图片。自己单干也一样换一套新皮通常就是复制一个文件夹、改几行 JSON 的事不用去读主窗口代码。2.3 启动链路与事件循环从 main.py 到宠物上屏main.py的启动顺序是一条直线没有复杂的依赖注入框架import sys from PyQt5.QtWidgets import QApplication from ui.pet_window import PetWindow from utils.config import load_config def main(): app QApplication(sys.argv) app.setApplicationName(petshell) cfg load_config(config.json) win PetWindow(cfg) win.show() sys.exit(app.exec_()) if __name__ __main__: main()exec_()进入 Qt 事件循环后QTimer、鼠标事件、托盘回调都由这个循环统一调度。需要注意 PyQt5 里窗口对象必须在exec_()之前创建show()之后由事件循环接管很多人把win写成局部变量后以为会被回收其实 Qt 对象生命周期由 C 侧托管Python 侧引用不断就不会被销毁。挂件类程序还建议在main()里包一层异常捕获避免某个 QTimer 回调抛异常后整个进程无提示退出。2.4 配置中心config.json 与 animations.json 的参数约定这套设计的另一层用意是把「改参数」和「改代码」彻底分开。config.json是这样一份结构{ window: {width: 160, height: 160, x: null, y: null}, animations_file: resources/animations.json, bubble: { interval_sec: 300, lines: [该起来走两步了, 喝口水吧, 文件记得提交] }, reminders: [ {hour: 10, minute: 0, text: 站会} ], startup: false }几个关键字段的作用如下配置项示例值作用window.width/height160挂件窗口大小必须与素材单帧尺寸一致window.x/ynull上次退出时的位置null 表示首次启动居中bubble.interval_sec300主动气泡的最小间隔单位秒reminders10:00 站会每天定时提醒匹配到点后吐指定台词startupfalse是否写入开机自启项animations.json单独放在resources下描述每套动画怎么播字段解释直接看 JSON 就足够{ idle: {frames: idle/%02d.png, count: 8, interval_ms: 120, loop: true}, click: {frames: click/%02d.png, count: 6, interval_ms: 90, loop: false}, eat: {frames: eat/%02d.png, count: 6, interval_ms: 150, loop: false} }interval_ms是单帧间隔120ms 约等于每秒 8 帧待机合适点击这类瞬发动作用 90ms 会更有「跟手」的感觉。loop决定播放完是否回到第一帧循环非循环动画播完会停在最后一帧等待状态机给出下一个切换指令。3. 核心代码解析从透明窗口到行为调度的完整链路3.1 透明无边框窗口WA_TranslucentBackground 的正确打开方式挂件程序第一个要解决的问题是「窗口不能看起来像个窗口」。pet_window.py里的初始化代码是这一套from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QWidget class PetWindow(QWidget): def __init__(self, cfg): super().__init__() self.setWindowFlags( Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool ) self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_ShowWithoutActivating, True) self.resize(cfg[window][width], cfg[window][height])三个 Flags 配合两个 Attribute 是透明挂件的固定配方。FramelessWindowHint去掉系统标题栏WindowStaysOnTopHint保证宠物始终在其他窗口上层Tool让这个窗口不占任务栏位置少一个碍眼的图标。真正决定背景透明的是WA_TranslucentBackground它告诉 Qt 不要填充默认窗口背景色否则你会得到一个带白底或黑底的方块。WA_ShowWithoutActivating是按需加的——挂件不应该因为被点了一下就抢走输入焦点比如你在编辑器里打字宠物冒泡后不该把焦点带走。注意setWindowFlags之后再调用setAttribute的顺序不是关键关键是两者都必须在show()之前设置完成。如果在show()之后再改 flags窗口会被隐式重建一次透明设置容易失效这是 Qt 一个老规矩几乎每个版本都有人踩。3.2 帧动画播放器QTimer 驱动序列帧的缓存策略有了透明窗口下一步是把 PNG 序列帧播起来。animator.py的核心是一个 QTimer 加一个帧索引from PyQt5.QtCore import QObject, QTimer from PyQt5.QtGui import QPixmap class Animator(QObject): def __init__(self, label, image_dir, anim_cfg): super().__init__() self._label label self._image_dir image_dir self._anim_cfg anim_cfg self._frames [] self._frame_index 0 self._loop True self._cache {} self._timer QTimer(self) self._timer.timeout.connect(self._next_frame) def play(self, anim_name, loopTrue): cfg self._anim_cfg[anim_name] self._timer.stop() self._frames self._load_frames(cfg) self._frame_index 0 self._loop loop self._timer.setInterval(cfg[interval_ms]) self._timer.start() self._next_frame() def _load_frames(self, cfg): frames [] for i in range(1, cfg[count] 1): path f{self._image_dir}/{cfg[frames] % i} frames.append(self._cache.setdefault(path, QPixmap(path))) return framesplay()接受动画名和是否循环两个参数每次切换动画前先stop()旧 timer避免上一轮动画还在跑、新动画已经开始两个 QTimer 同时触发导致帧序错乱。_load_frames里做了 QPixmap 缓存同一张 PNG 第一次读盘后驻留内存后面只是指针替换拖拽时的快速切换不会反复走磁盘 IO。非循环动画播完后_next_frame里判断_loop为假就停下并停在最后一帧等状态机发下一个指令。这里有一个细节interval_ms是理想帧间隔但 QTimer 在窗口高频移动时会被事件循环延后触发按固定计数推进帧会出现播放不均匀。更稳的做法是记录每帧的时间戳用真实流逝时间换算当前帧具体写法我放在第 4 章排查记录里。3.3 状态机动画互斥与切换回调的集中管理动画状态多了以后最怕各处的if散弹式判断拖拽中能不能吃东西点击动画没播完能不能直接切拖拽与其在每个事件回调里防这防那不如把规则收进一张状态转移表。state_machine.py的实现思路是这样from enum import Enum from PyQt5.QtCore import QObject class State(Enum): IDLE idle DRAG drag CLICK click EAT eat class StateMachine(QObject): def __init__(self): super().__init__() self._state State.IDLE self._transitions {} self._callbacks {} def add_transition(self, src_state, event_name, dst_state): self._transitions.setdefault(src_state, {})[event_name] dst_state def on_state(self, state, callback): self._callbacks[state] callback def trigger(self, event_name): mapping self._transitions.get(self._state, {}) target mapping.get(event_name) if target is None: return False self._state target cb self._callbacks.get(target) if cb: cb() return Truetrigger()返回布尔值表示这次事件是否触发了一次有效转移。状态机不负责执行业务真正干活的是on_state注册的回调状态切到 DRAG 时外部会改鼠标光标并按拖拽位移动画切到 EAT 时播放吃食物动画。这种做法的意义在于把「允许从当前状态做什么」集中到一张转移表里而不是散落在鼠标事件、菜单事件各处以后新增行为先问一句「从哪些状态能进来、能去哪」比到处加if好维护得多。3.4 拖拽与边缘吸附三个鼠标事件怎么配合拖拽是挂件最高频的交互实现要点是三个鼠标事件按顺序接力。窗口坐标和全局坐标的换算一旦出错宠物会跟着鼠标「遛狗」一样乱跑def mousePressEvent(self, event): if event.button() Qt.LeftButton: gpos event.globalPosition().toPoint() self._drag_offset gpos - self.frameGeometry().topLeft() self._state_machine.trigger(drag_start) def mouseMoveEvent(self, event): if self._drag_offset is not None: gpos event.globalPosition().toPoint() self.move(gpos - self._drag_offset) def mouseReleaseEvent(self, event): if event.button() Qt.LeftButton and self._drag_offset is not None: self._drag_offset None self._snap_to_edge() self._state_machine.trigger(drag_end)_drag_offset记录按下瞬间鼠标全局坐标和窗口左上角的差值移动时用全局坐标减掉这个差值得到目标位置窗口就不会「跳」到鼠标正下方。松手后做边缘吸附逻辑是取屏幕可用区域算出窗口中心到左右上三条边的距离小于 30 像素就把窗口贴过去def _snap_to_edge(self): screen QApplication.primaryScreen().availableGeometry() pos self.frameGeometry() candidates { left: abs(screen.left() - pos.left()), right: abs(screen.right() - pos.right()), top: abs(screen.top() - pos.top()), } edge min(candidates, keycandidates.get) if candidates[edge] 30: if edge left: self.move(screen.left(), pos.top()) elif edge right: self.move(screen.right() - pos.width(), pos.top()) else: self.move(pos.left(), screen.top())注意availableGeometry()返回的是去掉任务栏后的可用区域而不是screenGeometry()的物理全屏否则宠物会被任务栏遮住一半。3.5 气泡对话与定时提醒为什么调度必须走 QTimer气泡对话本质是一个自动隐藏的 QLabel关键是隐藏时机不能写死在调用方class Bubble(QLabel): def __init__(self, parentNone): super().__init__(parent) self.setWordWrap(True) self._hide_timer QTimer(self) self._hide_timer.setSingleShot(True) self._hide_timer.timeout.connect(self.hide) def show_message(self, text, duration_ms3000): self.setText(text) self.adjustSize() self.show() self._hide_timer.start(duration_ms)定时提醒的调度器同样用 QTimer而不是threading.Timer。原因很实际UI 刷新必须回到主线程threading里直接setText会触发跨线程调用告警甚至崩溃QTimer 由事件循环驱动天然在主线程执行回调里可以直接操作任何控件。分钟级的提醒用Qt.CoarseTimer类型还能减少无谓的唤醒次数挂件常驻桌面省电是正经事。class Scheduler(QObject): def __init__(self, reminders, on_fire): super().__init__() for item in reminders: timer QTimer(self) timer.setTimerType(Qt.CoarseTimer) secs (item[hour] * 60 item[minute]) * 60 timer.timeout.connect(lambda titem: on_fire(t)) timer.start(secs * 1000)定时器只计算今天内的偏移跨天循环需要借助QDateTime计算到下一个目标时间点的差值这部分属于可扩展项资料里预留了接口但没有展开。4. 运行与避坑环境准备、首次启动和 5 类高频问题排查4.1 环境准备Python 版本、依赖安装与虚拟环境运行这份资料的环境要求很简单Python 3.8 及以上依赖只有一个PyQt5。建议不要全局安装先建虚拟环境python -m venv .venv # Windows .venv\Scripts\activate # macOS / Linux source .venv/bin/activate pip install PyQt5 python main.pypython main.py启动后桌面边缘应该出现一个透明背景的宠物可以按住左键拖到任意位置。首次启动日志里如果出现QWidget: Must construct a QApplication before a QWidget说明入口文件顺序写错了QApplication一定要在所有控件创建之前实例化。如果看到的是闪退无报错优先检查resources目录和animations.json是不是在当前工作目录下。4.2 首次启动前的配置检查高清屏、动画 JSON 与素材目录启动前建议花两分钟核对三件事能省去后面排查的功夫。第一config.json里window.width/height要和素材单帧尺寸一致比如素材是 160x160 的 PNG窗口配 160x160不然透明区域会多出一块空白或者素材被裁掉。第二animations.json里的frames路径模板要能对应到真实文件idle/%02d.png的意思是找idle/01.png、idle/02.png这种两位数字的文件名素材命名必须是01.png而不是1.png。第三高分屏下建议在创建QApplication之前开启缩放属性QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)这两个 Attribute 必须在QApplication构造之前设置写在后面不生效。开了之后窗口逻辑尺寸不用改系统会自动按缩放比把素材放大代价是 1 倍图会轻微发虚想要锐利就得准备 2 倍图。4.3 五类高频问题排查记录下面五条是这份资料运行和二次开发时最容易遇到的坑我按「现象 → 原因 → 解决」的顺序记录。第一条窗口黑底或者白底宠物像是贴在一个不透明的方块上。原因基本是WA_TranslucentBackground设置时机晚了或者某处 QSS 给窗口设置了background-color。解决方法是确认这个 Attribute 写在show()之前并且不要在PetWindow的 QSS 里写任何不透明底色气泡、菜单这些子控件可以有自己的背景主窗口必须保持透明。第二条拖拽时宠物跑偏鼠标和宠物之间有一段越来越大的位移。原因是把event.globalPosition()直接传给了move()全局坐标和窗口坐标混用了。解决方法是按 3.4 的做法按下时记录偏移移动时用全局坐标减偏移再move不要用event.pos()这种相对窗口的坐标做移动计算。第三条动画在拖拽期间明显掉帧或者播放速度忽快忽慢。原因是 QTimer 在窗口高频move时事件循环被压缩固定帧号推进就产生了不匀速。解决方法是改成按真实时间算帧def _next_frame(self): now time.monotonic() elapsed now - self._last_frame_time if elapsed self._interval_ms / 1000: return self._last_frame_time now frame_pos int(elapsed / (self._interval_ms / 1000)) self._frame_index (self._frame_index frame_pos) % len(self._frames)这样即使某次刷新晚了 30ms也能一次性跳过该跳过的帧动画时间轴始终和真实时间对齐。第四条用 PyInstaller 打包后启动报找不到素材或者直接闪退。原因是打包时resources目录没有被带进去运行时路径也不是开发时的相对路径。解决方法是打包命令加--add-data并在代码里统一走一个资源路径函数def resource_path(rel): base getattr(sys, _MEIPASS, os.path.dirname(__file__)) return os.path.join(base, rel)开发环境下sys._MEIPASS不存在回退到当前文件目录打包后用--add-data resources;resourcesWindows或--add-data resources:resourcesmacOS/Linux把素材塞进解包目录。第五条高清屏下宠物边缘发虚特别是圆角轮廓一圈毛边。原因是没开缩放属性且素材只有 1 倍图系统把 160px 的图强行放大到 2 倍边缘自然糊。解决方法是素材目录准备idle2x这类 2 倍图加载时按devicePixelRatio()选择对应资源如果只是自用不开缩放也可以接受毛边但对外发布建议补一套 2 倍素材。5. 二次开发路径给宠物新增一个「打个滚」行为的完整示例这套设计真正舒服的地方在扩展性上。我给个可以直接抄的例子给宠物加一个右键菜单项「打个滚」点一下宠物原地滚一圈播完自动回到待机。整个过程只改三处不碰主窗口逻辑。第一步在resources/pets/roll/下放 8 张序列帧命名01.png到08.png然后在animations.json里注册{ roll: {frames: roll/%02d.png, count: 8, interval_ms: 100, loop: false} }第二步给状态机加两个转移规则。从待机能进入翻滚翻滚播完回待机state_machine.add_transition(State.IDLE, roll, State.ROLL) state_machine.add_transition(State.ROLL, roll_end, State.IDLE) state_machine.on_state(State.ROLL, lambda: animator.play(roll, loopFalse))第三步右键菜单挂一个动作。菜单回调不直接播动画而是触发状态机事件这样「从哪个状态能不能滚」由状态机说了算roll_action menu.addAction(打个滚) roll_action.triggered.connect(lambda: state_machine.trigger(roll)) # animator 需要在非循环动画播完后发退出事件 animator.finished.connect(lambda: state_machine.trigger(roll_end))animator.finished在非循环动画走到最后一帧时发出状态机收到后切回 IDLE待机动画接管。这套「动画 状态 菜单入口」的三件套可以复用到任何新行为上加一个睡觉动画就注册sleep状态加一个喂食动画就注册feed状态规则完全一致。改完之后的验证我习惯按固定顺序做一遍回归。先逐个触发所有动画确认帧序没有跳帧、非循环动画能停在最后一帧而不是闪回第一帧。再拖拽到屏幕四个角和四条边确认吸附距离正常松手动画能正确回 IDLE。最后开着任务管理器观察空闲 CPU 占用QTimer 驱动的挂件通常应该在 1% 到 3% 以内超过 5% 就要检查是不是有定时器没停或者每帧都在重新加载素材。我从那以后每改一次这类挂件都会强制走一遍这套回归别嫌麻烦动画状态机最怕的就是新加的状态和旧状态之间出现没人管的漏洞。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

快速排序面试必考:首元素枢轴的Lomuto与Hoare分区详解

快速排序面试必考:首元素枢轴的Lomuto与Hoare分区详解

面试手写排序算法时,快速排序几乎是必考项。但很多人写出来的代码,在小数组或已经有序时能跑,一上强度就翻车。今天要聊的这个版本——以第一个元素为枢轴的快速排序——正是最常见也最容易写错的一个变体。之所以拿它开刀,是因为…

📅 2026/10/20 3:39:23
前端选项卡与折叠面板:从交互语义到组件化封装完整指南

前端选项卡与折叠面板:从交互语义到组件化封装完整指南

做到第十四节,前面积累的选择器、事件、DOM 操作这些基本功,终于要派上真正的用场了。选项卡效果和可折叠功能,看起来就是两个常见的 UI 小效果——一个负责点标签切内容,一个负责展开收起段落——但把它们从头完整实现一遍&#…

📅 2026/10/20 3:39:23
用ByteBuddy动态代理实现第三方SDK调用日志拦截与可观测性增强

用ByteBuddy动态代理实现第三方SDK调用日志拦截与可观测性增强

做支付业务那阵子,最让人头疼的不是业务逻辑本身,而是排查第三方 SDK 内部的调用问题。报错信息到了业务层往往只剩一句“调用失败”或者“签名错误”,真正出问题的网络请求参数、响应原文、内部重试过程,全被 SDK 的封装层吃掉了…

📅 2026/10/20 3:34:23
MORE NEWS

更多资讯

📰

Skills项目:将技能封装为可执行单元,实现能力复用与编排

1. 当“skills”成为一个项目标题:我看到的不是技能清单,而是一套能力封装系统第一次看到“skills”这个标题时,我下意识以为又是一个罗列“程序员必会的十项技能”之类的清单文。但翻完项目正文、关键词和摘要描述,发现全是空的—…

📰

上下文模式实战:告别参数透传,优雅管理多场景代码

1. 从“上下文模式”这个命名说起:它到底在解决什么问题第一次看到“context-mode”这个词,很多人会下意识地把它当成某个框架里的配置项,或者某个库的开关参数。但如果你真的在工程里跟“上下文”打过几年交道,就会明白这个词背后…

📰

BEVFormer中英文对照精读:从网格查询到空间与时序注意力

前阵子整理资料,又把BEVFormer翻出来从头精读了一遍,顺手做了一份中英文对照笔记。这篇工作在BEV感知方向算是绕不开的经典,无论是刚入门自动驾驶感知的新人,还是已经在做多传感器融合方案的老手,都会在某个时间点撞上…

📰

REA事件建模实战:用Resource-Event-Agent重构订单与库存系统

我第一次认真研究REA,不是在看理论文章的时候,而是在一个订单系统改到想拍桌子的时候。那时候线上订单的状态字段越来越多,待支付、已支付、待发货、已发货、已签收、售后中,每一个状态背后还挂着一堆标志位和流水记录&#xff0c…

📰

银河麒麟上装达梦和人大金仓,踩过的坑都在这

一、部署前的环境准备 上周在麒麟 V10 SP3(aarch64)上装达梦 DM8,静默安装跑到一半直接退出: $ ./DMInstall.bin -i [INFO] 正在解压安装包,请稍候... Segmentation fault (core dumped)换人大金仓,安装程序过了,初始化实例又停住: $ initdb -D /opt/kingbase/data …

📰

人脸灰度图像彩色复原:从原理到工程实践的深度学习方案

你有没有翻到过家里的老照片?黑白照片里,爷爷的头发、外婆的裙子、街角那扇门是什么颜色,往往只能靠想象。我一直觉得,给灰度图像做彩色复原是一件"看着简单、做起来极麻烦"的事——它不是填色游戏,而是一个…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬