制作一个简单的桌宠

导入

利用python和QT实现一个简单的桌宠程序。效果如下(先鼠标拖拽,点击,然后自主移动):

特性

  • 点击,拖拽回应,气泡框显示回应文本

  • 自主探索,可以在整个桌面上自行移动

正文

项目结构

├─config # 对话文本
│ └─dialogues.json 
├─main.py  # 主程序
└─pets  # 桌宠图片资源
  └─girl  # 小女孩桌宠
    ├─idle.png  # 待机
    ├─walk.png  # 向左移动
    ├─walk_down.png  # 向下移动
    ├─walk_right.png  # 向右移动
    └─walk_up.png  # 向上移动

图片资源可以随意从网上寻找,或者自己用Aseprite软件制作(像素风格),网上可以用OpenGameArt之类的免费资源网站,我的这个资源就是在这里随便选的,样式大概如下:

这就是walk.png,是按帧顺序排列的一系列动作组成的图片,使用时候按照动作的帧数进行切割即可。我选择的资源比较简单,只有待机行走,读者可以自由选择更多复杂动作的资源,比如睡觉,玩耍,吃饭等等。

对话文本类似:

{
  "click": [
    "你好呀!点击我有惊喜~",
    "不要突然戳我啦!",
    "你在看我吗?",
    "嘿嘿,被你发现啦!",
    "摸摸头可以,但要轻一点哦~"
  ],
  "idle": [
    "我在发呆...好无聊呀",
    "主人在忙什么呢?",
    "发呆时间到~",
    "呼...呼...(打盹中)",
    "等你好久啦!"
  ],
  "walk": [
    "我去左边看看!",
    "左边有什么好玩的?",
    "向左探索去咯!"
  ],
  "walk_right": [
    "右边有什么呢?",
    "我去右边逛逛!",
    "右边好像有宝藏!"
  ],
  "walk_down": [
    "下面好像有好玩的!",
    "我往下走走~",
    "下面的世界真有趣!"
  ],
  "walk_up": [
    "上面的风景真好!",
    "我往上爬爬!",
    "高处看风景最棒了!"
  ],
  "greet": [
    "主人好!我来陪你啦!",
    "今天也要开心哦!",
    "嘿嘿,见到你真好!",
    "新的一天开始啦!"
  ],
  "drag_start": [
    "哎呀!要带我去哪里?",
    "等等!我还没准备好~",
    "要搬家了吗?",
    "带我去好玩的地方吧!"
  ],
  "drag_end": [
    "到啦!这里不错~",
    "新家好漂亮!",
    "这里视野真好!",
    "谢谢你带我来这儿!"
  ]
}

行为+行为触发的文本列表即可,读者可以根据自己实际使用的资源进行随意自定义。

完整代码

# main.py
import sys
import os
import random
import json
from PyQt5.QtWidgets import QApplication, QLabel, QWidget
from PyQt5.QtCore import Qt, QTimer, QRect, QPoint, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import QPixmap, QPainter, QFont


class BubbleLabel(QLabel):
    """独立的气泡标签类,避免透明窗口的渲染问题"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowFlags(
            Qt.FramelessWindowHint |
            Qt.WindowStaysOnTopHint |
            Qt.Tool
        )
        self.setAttribute(Qt.WA_TranslucentBackground)
        self.setWordWrap(True)
        # 改进气泡样式:更醒目的背景和文字
        self.setStyleSheet("""
            background-color: rgba(255, 248, 220, 240); /* 淡黄色背景,带透明度 */
            border: 2px solid #FFA500; /* 橙色边框 */
            border-radius: 12px;
            padding: 10px;
            color: #2F4F4F; /* 深灰色文字 */
            font-family: "Microsoft YaHei", "SimHei", sans-serif;
            font-size: 11pt;
            font-weight: bold; /* 加粗文字 */
        """)
        self.setFont(QFont("Microsoft YaHei", 11, QFont.Bold))
        self.setAlignment(Qt.AlignCenter)
        self.hide()
        self.pet_ref = None  # 引用桌宠对象,用于位置更新

    def set_pet_reference(self, pet):
        """设置桌宠引用"""
        self.pet_ref = pet

    def update_position(self):
        """更新气泡位置,跟随桌宠"""
        if not self.pet_ref or not self.isVisible():
            return
            
        # 计算气泡位置(在宠物头顶上方)
        pet_x = self.pet_ref.x()
        pet_y = self.pet_ref.y()
        pet_width = self.pet_ref.width()
     
        bubble_width = self.width()
        bubble_height = self.height()
        
        bubble_x = pet_x + (pet_width - bubble_width) // 2
        bubble_y = pet_y - bubble_height - 15
        
        # 获取屏幕尺寸进行边界检查
        screen = QApplication.primaryScreen().geometry()
        bubble_x = max(5, min(bubble_x, screen.width() - bubble_width - 5))
        bubble_y = max(5, bubble_y)
        
        self.move(bubble_x, bubble_y)


class DesktopPet(QWidget):
    def __init__(self):
        super().__init__()
        # 移动相关参数 
        self.edge_margin = 50  # 距离屏幕边缘的安全距离(像素)
        self.moving = False  # 当前是否正在移动
        self.move_animation = None  # 移动动画对象
        
        self.init_ui()
        self.load_config()
        self.setup_behavior()
        self.setup_interaction()
        self.setup_animation()

    def init_ui(self):
        """初始化界面:设置窗口为无边框、透明、置顶"""
        self.setWindowFlags(
            Qt.FramelessWindowHint |        # 无边框窗口
            Qt.WindowStaysOnTopHint |       # 始终置顶
            Qt.SubWindow                    # 子窗口(不在任务栏显示)
        )
        self.setAttribute(Qt.WA_TranslucentBackground)  # 背景透明
        self.setAttribute(Qt.WA_TransparentForMouseEvents, False)  # 接收鼠标事件

        # 创建标签用于显示宠物图像
        self.pet_label = QLabel(self)
        self.pet_label.setAlignment(Qt.AlignCenter)

        # 创建独立的气泡窗口
        self.bubble_label = BubbleLabel()
        self.bubble_label.set_pet_reference(self)  # 设置桌宠引用
        
        # 设置宠物类型为小女孩
        self.current_pet = "girl"
        self.load_pet_sprites()

        # 设置初始大小和位置
        if self.sprite_frames.get("idle"):
            first_frame = self.sprite_frames["idle"][0]
            self.resize(first_frame.width(), first_frame.height())
            self.pet_label.setPixmap(first_frame)
            self.pet_label.resize(first_frame.width(), first_frame.height())
        else:
            self.resize(36, 44)
            self.pet_label.resize(36, 44)
            print("警告:未找到宠物图片,使用默认尺寸")

        # 初始位置:屏幕右下角
        screen_geo = QApplication.primaryScreen().geometry()
        initial_x = screen_geo.width() - self.width() - self.edge_margin
        initial_y = screen_geo.height() - self.height() - self.edge_margin
        self.move(initial_x, initial_y)

    def load_pet_sprites(self):
        """加载宠物精灵图并切割成单帧动画"""
        self.frame_counts = {
            "idle": 8,
            "walk": 6,        # 向左走
            "walk_down": 8,   # 向下走
            "walk_right": 6,  # 向右走
            "walk_up": 8      # 向上走
        }
        
        pet_dir = f"pets/{self.current_pet}"
        if not os.path.exists(pet_dir):
            print(f"警告:宠物资源目录 {pet_dir} 不存在!")
            self.sprite_frames = {}
            return

        self.sprite_frames = {}
        
        for action, frame_count in self.frame_counts.items():
            img_path = os.path.join(pet_dir, f"{action}.png")
            if not os.path.exists(img_path):
                print(f"警告:动作图片 {img_path} 不存在!")
                continue
            
            full_sprite = QPixmap(img_path)
            if full_sprite.isNull():
                print(f"警告:无法加载图片 {img_path}!")
                continue
            
            frame_width = full_sprite.width() // frame_count
            frame_height = full_sprite.height()
            
            frames = []
            for i in range(frame_count):
                frame = QPixmap(frame_width, frame_height)
                frame.fill(Qt.transparent)
                
                painter = QPainter(frame)
                painter.drawPixmap(0, 0, full_sprite, 
                                 i * frame_width, 0,
                                 frame_width, frame_height)
                painter.end()
                
                frames.append(frame)
            
            self.sprite_frames[action] = frames
            print(f"成功加载动作 '{action}':{frame_count} 帧")

    def load_config(self):
        """加载互动文本配置"""
        config_path = "config/dialogues.json"
        if os.path.exists(config_path):
            with open(config_path, "r", encoding="utf-8") as f:
                self.dialogues = json.load(f)
        else:
            self.dialogues = {
                "click": ["你好呀!点击我有惊喜~"],
                "idle": ["我在发呆...好无聊呀"],
                "walk": ["我去左边看看!"],
                "walk_right": ["右边有什么呢?"],
                "walk_down": ["下面好像有好玩的!"],
                "walk_up": ["上面的风景真好!"],
                "greet": ["主人好!我来陪你啦!"],
                "drag_start": ["哎呀!要带我去哪里?"],
                "drag_end": ["到啦!这里不错~"]
            }

    def setup_interaction(self):
        """设置用户交互:点击、拖拽"""
        self.is_dragging = False
        self.drag_start_pos = QPoint()
        self.mouse_press_pos = QPoint()

    def mousePressEvent(self, event):
        """鼠标按下"""
        if event.button() == Qt.LeftButton:
            # 如果正在自动移动,停止移动
            if self.moving:
                if self.move_animation:
                    self.move_animation.stop()
                self.moving = False
                self.state = "idle"
                self.current_frame_index = 0
            
            self.drag_start_pos = event.globalPos() - self.frameGeometry().topLeft()
            self.mouse_press_pos = event.pos()
            self.is_dragging = False
            event.accept()

    def mouseMoveEvent(self, event):
        """鼠标移动:实现拖拽"""
        if event.buttons() & Qt.LeftButton:
            if not self.is_dragging:
                if (event.pos() - self.mouse_press_pos).manhattanLength() > 5:
                    self.is_dragging = True
                    # 停止自动移动
                    if self.moving:
                        if self.move_animation:
                            self.move_animation.stop()
                        self.moving = False
                        self.state = "idle"
                        self.current_frame_index = 0
                    
                    # 使用随机选择拖拽开始对话
                    drag_start_options = self.dialogues.get("drag_start", ["哎呀!要带我去哪里?"])
                    self.show_bubble(random.choice(drag_start_options))
            
            if self.is_dragging:
                self.move(event.globalPos() - self.drag_start_pos)
                # 更新气泡位置(如果气泡正在显示)
                self.bubble_label.update_position()
                event.accept()

    def mouseReleaseEvent(self, event):
        """鼠标释放:结束拖拽或处理点击"""
        if event.button() == Qt.LeftButton:
            if self.is_dragging:
                self.is_dragging = False
                # 使用随机选择拖拽结束对话
                drag_end_options = self.dialogues.get("drag_end", ["到啦!这里不错~"])
                self.show_bubble(random.choice(drag_end_options))
            else:
                self.show_bubble(random.choice(self.dialogues.get("click", ["不要戳我啦!"])))
            event.accept()

    def setup_behavior(self):
        """设置自主行为"""
        self.state = "idle"
        self.state_timer = QTimer(self)
        self.state_timer.timeout.connect(self.update_state)
        self.state_timer.start(6000)  # 每6秒切换状态

        # 行为权重调整,增加移动行为的概率
        self.behavior_weights = {
            "idle": 25,
            "walk": 20,         # 向左
            "walk_down": 15,    # 向下
            "walk_right": 20,   # 向右
            "walk_up": 20       # 向上
        }

    def setup_animation(self):
        """设置帧动画定时器"""
        self.current_frame_index = 0
        self.animation_timer = QTimer(self)
        self.animation_timer.timeout.connect(self.update_animation_frame)
        self.animation_timer.start(120)  # 约8帧/秒

        # 添加位置更新定时器,用于自动移动时更新气泡位置
        self.position_update_timer = QTimer(self)
        self.position_update_timer.timeout.connect(self.update_bubble_position)
        self.position_update_timer.start(50)  # 每50ms更新一次位置

    def update_bubble_position(self):
        """定时更新气泡位置(用于动画移动时)"""
        if self.bubble_label.isVisible():
            self.bubble_label.update_position()

    def update_state(self):
        """根据权重随机切换状态,并触发移动"""
        if self.moving or self.is_dragging:
            return  # 如果正在移动或被拖拽,不切换状态
            
        states = list(self.behavior_weights.keys())
        weights = list(self.behavior_weights.values())
        new_state = random.choices(states, weights=weights, k=1)[0]
        
        if new_state != self.state:
            self.state = new_state
            self.current_frame_index = 0
            
            # 显示状态对话
            if self.state == "idle":
                self.show_bubble(random.choice(self.dialogues.get("idle", ["我在发呆..."])))
            elif self.state in self.dialogues:
                self.show_bubble(random.choice(self.dialogues[self.state]))
            else:
                # 如果没有特定对话,使用通用walk对话
                self.show_bubble(random.choice(self.dialogues.get("walk", ["我去散步啦!"])))
            
            # 如果是移动状态,开始移动
            if self.state != "idle":
                self.start_moving()

    def start_moving(self):
        """开始向指定方向移动"""
        if self.moving:
            return
            
        self.moving = True
        screen = QApplication.primaryScreen().geometry()
        
        # 获取当前位置
        current_x = self.x()
        current_y = self.y()
        pet_width = self.width()
        pet_height = self.height()
        
        # 计算移动目标位置
        move_distance = random.randint(80, 150)  # 随机移动距离
        
        if self.state == "walk":  # 向左
            target_x = current_x - move_distance
            target_y = current_y
        elif self.state == "walk_right":  # 向右
            target_x = current_x + move_distance
            target_y = current_y
        elif self.state == "walk_down":  # 向下
            target_x = current_x
            target_y = current_y + move_distance
        elif self.state == "walk_up":  # 向上
            target_x = current_x
            target_y = current_y - move_distance
        else:
            self.moving = False
            return
        
        # 边界检查:确保目标位置在安全范围内
        min_x = self.edge_margin
        max_x = screen.width() - pet_width - self.edge_margin
        min_y = self.edge_margin
        max_y = screen.height() - pet_height - self.edge_margin
        
        target_x = max(min_x, min(target_x, max_x))
        target_y = max(min_y, min(target_y, max_y))
        
        # 如果目标位置和当前位置相同,说明被边界限制了,切换回idle
        if abs(target_x - current_x) < 5 and abs(target_y - current_y) < 5:
            self.moving = False
            self.state = "idle"
            self.current_frame_index = 0
            return
        
        # 创建移动动画
        self.move_animation = QPropertyAnimation(self, b"geometry")
        self.move_animation.setDuration(2000)  # 2秒移动完成
        self.move_animation.setStartValue(QRect(current_x, current_y, pet_width, pet_height))
        self.move_animation.setEndValue(QRect(target_x, target_y, pet_width, pet_height))
        self.move_animation.setEasingCurve(QEasingCurve.InOutQuad)
        
        # 动画结束时的回调
        self.move_animation.finished.connect(self.on_move_finished)
        self.move_animation.start()

    def on_move_finished(self):
        """移动完成后的回调"""
        self.moving = False
        self.move_animation = None
        # 移动完成后自动切换回idle状态
        self.state = "idle"
        self.current_frame_index = 0

    def update_animation_frame(self):
        """更新当前显示的动画帧"""
        frames = self.sprite_frames.get(self.state, [])
        if not frames:
            frames = self.sprite_frames.get("idle", [])
            if not frames:
                return
        
        self.current_frame_index = (self.current_frame_index + 1) % len(frames)
        current_frame = frames[self.current_frame_index]
        
        self.pet_label.setPixmap(current_frame)
        self.pet_label.resize(current_frame.width(), current_frame.height())

    def show_bubble(self, text):
        """显示对话气泡"""
        if not text or not text.strip():
            return

        # 设置气泡文本
        self.bubble_label.setText(text)
        self.bubble_label.adjustSize()
        
        # 确保气泡尺寸合理
        bubble_width = max(80, min(self.bubble_label.width(), 220))
        bubble_height = self.bubble_label.height()
        
        # 重新设置尺寸
        self.bubble_label.resize(bubble_width, bubble_height)
        self.bubble_label.setText(text)
        
        # 初始位置设置
        self.bubble_label.update_position()
        self.bubble_label.show()
        
        # 2秒后隐藏
        QTimer.singleShot(2000, self.bubble_label.hide)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setQuitOnLastWindowClosed(False)

    pet = DesktopPet()
    pet.show()

    # 启动欢迎语
    QTimer.singleShot(1000, lambda: pet.show_bubble(
        random.choice(pet.dialogues.get("greet", ["你好!"]))
    ))

    sys.exit(app.exec_())


其中跟我这个特定资源相关的代码修改成自己资源相关即可,代码不长也不复杂,很好修改,主要展示如何设计制作一个桌宠,如果不想修改,就去我上面提到的网站,搜索girl应该就能找到我这个素材。如果不想桌宠实际移动,就想要在原地不动,删除start_moving相关逻辑即可,毕竟大部分资源也没有上下左右的移动动作。总之就是在这个设计框架下,根据自己实际使用资源修改。

改进方向

实现的是比较纯粹的桌宠,不带有功能性,除了上面提到的使用更加丰富动作的素材之外,还可以添加功能性作用,比如操作文件管理器,网络搜索,等等工具菜单的添加。

其次,桌宠当前是2D的,使用PyQt5配合PyOpenGL可以实现3D渲染,因此可以改进2D桌宠为3D桌宠,更加生动立体。