# -*- coding: utf-8 -*- """ 2333娘桌宠 基于 tkinter 的透明窗口桌面宠物,无需第三方依赖。 - 左键拖动移动 - 双击:开心跳跃 + "2333!" 气泡 - 右键:弹出菜单(说话 / 切换表情 / 置顶 / 退出) - 自动呼吸、眨眼、偶尔随机吐槽 """ import math import random import sys import tkinter as tk # ============== 颜色配置 ============== COLOR_BG = '#FF00FF' # 透明色(纯品红,绘制中不使用) COLOR_HAIR = '#00AEEC' # B站蓝 COLOR_HAIR_DARK = '#0090C8' COLOR_HAIR_LIGHT = '#5FC8F2' COLOR_SKIN = '#FFE6D5' COLOR_SKIN_SHADOW = '#FAD0BC' COLOR_BLUSH = '#FF9FB2' COLOR_EYE = '#2EA9DF' COLOR_EYE_DARK = '#1B7FB8' COLOR_WHITE = '#FFFFFF' COLOR_DRESS = '#F2FAFF' COLOR_DRESS_BLUE = '#00AEEC' COLOR_DRESS_DARK = '#0090C8' COLOR_MOUTH = '#C8485A' COLOR_MOUTH_INNER = '#FF8A9A' COLOR_TONGUE = '#FFB6C1' COLOR_ACCENT = '#FB7299' # B站粉 COLOR_DARK = '#3A3A3A' COLOR_SCREEN = '#1E2233' # 额头成像仪屏幕 # ============== 画布尺寸 ============== WIDTH = 240 HEIGHT = 310 CX = WIDTH // 2 FRAME_MS = 33 # ~30fps # ============== 随机吐槽语 ============== IDLE_MESSAGES = [ '2333~', '哈哈哈~', '干杯!(´・ω・`)', '哔哩哔哩☆', '今天也要元气满满哦!', '戳我干嘛 (`ε´)', '最喜欢姐姐了~', '需要充电...zzZ', '2233娘最强!', '233333333', '再看就把你吃掉!', '帮我插个插座?', '咕~ 咕~', '今天视频审核完了吗?', '点击这里投喂电池 →', ] class DesktopPet: """2333娘桌宠主类""" def __init__(self, blocking=True, on_exit=None): """blocking=True 时调用 mainloop 阻塞;False 时由外部驱动 root.update()。 on_exit: 退出时的回调(用于通知调用者恢复)。""" self._blocking = blocking self._on_exit = on_exit self._exited = False self.root = tk.Tk() self.root.title('2333娘') # 初始位置:屏幕右下角 sx = self.root.winfo_screenwidth() - WIDTH - 30 sy = self.root.winfo_screenheight() - HEIGHT - 80 self.root.geometry(f'{WIDTH}x{HEIGHT}+{sx}+{sy}') self.root.overrideredirect(True) self.root.attributes('-topmost', True) self.root.config(bg=COLOR_BG) try: self.root.attributes('-transparentcolor', COLOR_BG) except tk.TclError: pass self.canvas = tk.Canvas( self.root, width=WIDTH, height=HEIGHT, bg=COLOR_BG, highlightthickness=0, bd=0 ) self.canvas.pack() # ----- 动画状态 ----- self.t = 0.0 self.dt = FRAME_MS / 1000.0 self.bob_y = 0.0 self.jump_y = 0.0 self.jump_vel = 0.0 # 眨眼 self.blink_phase = 0.0 # 0=睁眼;>0=正在眨眼 self.next_blink = random.uniform(2.0, 5.0) self.eye_open = 1.0 # 表情与说话 self.expression = 'laugh' # laugh / happy / shy / wink self.speech_text = '' self.speech_timer = 0.0 self.idle_countdown = random.uniform(6.0, 12.0) # 拖动 self._drag_dx = 0 self._drag_dy = 0 self._dragging = False # 互动粒子(爱心/星星) self.particles = [] # list of dict self._build_menu() self._bind_events() self._schedule_idle_speech() self._tick() if self._blocking: self.root.mainloop() # ================= 菜单 ================= def _build_menu(self): self.menu = tk.Menu(self.root, tearoff=0) self.menu.add_command(label='说句话 (2333~)', command=self._say_random) self.menu.add_separator() self.menu.add_command(label='表情:大笑', command=lambda: self._set_expr('laugh')) self.menu.add_command(label='表情:开心', command=lambda: self._set_expr('happy')) self.menu.add_command(label='表情:害羞', command=lambda: self._set_expr('shy')) self.menu.add_command(label='表情:眨眼', command=lambda: self._set_expr('wink')) self.menu.add_separator() self.menu.add_command(label='跳一下!', command=self._jump) self.menu.add_command(label='置顶 / 取消置顶', command=self._toggle_topmost) self.menu.add_separator() self.menu.add_command(label='Live2D 模式 ✨', command=self._switch_to_live2d) self.menu.add_separator() self.menu.add_command(label='退出 (。•́︿•̀。)', command=self._quit) # ================= 事件绑定 ================= def _bind_events(self): self.canvas.bind('', self._on_press) self.canvas.bind('', self._on_drag) self.canvas.bind('', self._on_release) self.canvas.bind('', self._on_right) self.canvas.bind('', self._on_double) self.root.bind('', lambda e: self._quit()) # ----- 鼠标交互 ----- def _on_press(self, e): self._dragging = True self._drag_dx = e.x_root - self.root.winfo_x() self._drag_dy = e.y_root - self.root.winfo_y() def _on_drag(self, e): if self._dragging: x = e.x_root - self._drag_dx y = e.y_root - self._drag_dy self.root.geometry(f'+{x}+{y}') def _on_release(self, e): self._dragging = False def _on_right(self, e): try: self.menu.tk_popup(e.x_root, e.y_root) finally: self.menu.grab_release() def _on_double(self, e): self._jump() self._say('2333!') # ----- 菜单动作 ----- def _set_expr(self, expr): self.expression = expr self._say({'laugh': '2333~', 'happy': '嘿嘿~', 'shy': '呜...>//<', 'wink': '★~'}[expr]) def _say(self, text): self.speech_text = text self.speech_timer = 2.4 def _say_random(self): self._say(random.choice(IDLE_MESSAGES)) def _jump(self): if self.jump_y == 0 and self.jump_vel == 0: self.jump_vel = -13.0 def _toggle_topmost(self): self.root.attributes('-topmost', not self.root.attributes('-topmost')) def _switch_to_live2d(self): """切换回 Live2D 模式(非阻塞模式下通知调用者恢复)""" self._do_exit() def _do_exit(self): """统一退出处理""" if self._exited: return self._exited = True try: self.root.destroy() except Exception: pass if self._on_exit: self._on_exit() elif self._blocking: sys.exit(0) def _quit(self): self._do_exit() def _schedule_idle_speech(self): # 用随机定时器在 _tick 中处理,这里不需要额外线程 pass # ================= 主循环 ================= def _tick(self): self.t += self.dt # 呼吸浮动 self.bob_y = math.sin(self.t * 2.2) * 3.0 # 跳跃物理 if self.jump_y < 0 or self.jump_vel < 0: self.jump_vel += 1.1 # 重力 self.jump_y += self.jump_vel if self.jump_y >= 0: self.jump_y = 0.0 self.jump_vel = 0.0 # 眨眼计时 if self.blink_phase == 0.0: self.next_blink -= self.dt if self.next_blink <= 0: self.blink_phase = 0.0001 else: self.blink_phase += self.dt # 0~0.05 闭眼,0.05~0.13 闭着,0.13~0.20 睁眼 p = self.blink_phase if p < 0.05: self.eye_open = 1.0 - (p / 0.05) elif p < 0.13: self.eye_open = 0.0 elif p < 0.20: self.eye_open = (p - 0.13) / 0.07 else: self.eye_open = 1.0 self.blink_phase = 0.0 self.next_blink = random.uniform(2.5, 6.5) self.eye_open = max(0.0, min(1.0, self.eye_open)) # wink 表情强制一只眼闭着 if self.expression == 'wink': self._wink_left_open = True # 说话气泡计时 if self.speech_timer > 0: self.speech_timer -= self.dt if self.speech_timer <= 0: self.speech_text = '' # 随机吐槽 self.idle_countdown -= self.dt if self.idle_countdown <= 0: self.idle_countdown = random.uniform(10.0, 20.0) if not self.speech_text and random.random() < 0.7: self._say(random.choice(IDLE_MESSAGES)) # 粒子更新 self._update_particles() self._draw() self.root.after(FRAME_MS, self._tick) # ================= 绘制 ================= def _draw(self): c = self.canvas c.delete('all') oy = self.bob_y + self.jump_y self._draw_hair_back(oy) self._draw_body(oy) self._draw_face(oy) self._draw_forehead_device(oy) self._draw_blush(oy) self._draw_eyes(oy) self._draw_mouth(oy) self._draw_hair_front(oy) self._draw_ahoge(oy) self._draw_particles() self._draw_speech(oy) # ----- 后侧头发 ----- def _draw_hair_back(self, oy): c = self.canvas # 大块蓝色后发 c.create_oval(CX - 96, 36 + oy, CX + 96, 196 + oy, fill=COLOR_HAIR, outline=COLOR_HAIR_DARK, width=2) # 两侧散发 c.create_oval(CX - 100, 120 + oy, CX - 60, 230 + oy, fill=COLOR_HAIR, outline=COLOR_HAIR_DARK, width=2) c.create_oval(CX + 60, 120 + oy, CX + 100, 230 + oy, fill=COLOR_HAIR, outline=COLOR_HAIR_DARK, width=2) # ----- 脸 ----- def _draw_face(self, oy): c = self.canvas c.create_oval(CX - 80, 44 + oy, CX + 80, 196 + oy, fill=COLOR_SKIN, outline=COLOR_SKIN_SHADOW, width=2) # ----- 额头成像仪(33娘特征)----- def _draw_forehead_device(self, oy): c = self.canvas cx, cy = CX, 80 + oy # 外圈 c.create_oval(cx - 16, cy - 16, cx + 16, cy + 16, fill=COLOR_DRESS_BLUE, outline=COLOR_DRESS_DARK, width=2) # 屏幕 c.create_oval(cx - 11, cy - 11, cx + 11, cy + 11, fill=COLOR_SCREEN, outline='') # 屏幕上的小光点(呼吸) glow = 0.5 + 0.5 * math.sin(self.t * 3) col = self._mix(COLOR_HAIR_LIGHT, COLOR_WHITE, glow) c.create_oval(cx - 4, cy - 4, cx + 4, cy + 4, fill=col, outline='') # ----- 腮红 ----- def _draw_blush(self, oy): c = self.canvas alpha = 1.0 if self.expression == 'shy': alpha = 1.4 col = self._mix(COLOR_SKIN, COLOR_BLUSH, min(1.0, 0.6 * alpha)) c.create_oval(CX - 58, 138 + oy, CX - 28, 156 + oy, fill=col, outline='') c.create_oval(CX + 28, 138 + oy, CX + 58, 156 + oy, fill=col, outline='') # ----- 眼睛 ----- def _draw_eyes(self, oy): c = self.canvas lex, rex = CX - 34, CX + 34 ey = 120 + oy open_f = self.eye_open wink = (self.expression == 'wink') left_open = open_f right_open = 0.0 if wink else open_f for (ex, op) in ((lex, left_open), (rex, right_open)): if op > 0.15: self._draw_open_eye(ex, ey, op) else: # 闭眼弧线 c.create_arc(ex - 14, ey - 6, ex + 14, ey + 8, start=20, extent=140, style='arc', outline=COLOR_DARK, width=2.5) def _draw_open_eye(self, ex, ey, op): c = self.canvas # 眼白 ry = 22 * op c.create_oval(ex - 16, ey - ry, ex + 16, ey + ry, fill=COLOR_WHITE, outline=COLOR_DARK, width=1.5) # 虹膜 ir = 12 c.create_oval(ex - ir, ey - ir, ex + ir, ey + ir, fill=COLOR_EYE, outline='') # 瞳孔 c.create_oval(ex - 6, ey - 6, ex + 6, ey + 6, fill=COLOR_DARK, outline='') # 高光 c.create_oval(ex - 9, ey - 9, ex - 3, ey - 3, fill=COLOR_WHITE, outline='') c.create_oval(ex + 3, ey + 2, ex + 7, ey + 6, fill=COLOR_WHITE, outline='') # ----- 嘴 ----- def _draw_mouth(self, oy): c = self.canvas mx, my = CX, 158 + oy expr = self.expression if expr == 'laugh': # 大笑:张开嘴 w = 20 + 3 * math.sin(self.t * 8) c.create_oval(mx - w, my - 6, mx + w, my + 14, fill=COLOR_MOUTH_INNER, outline=COLOR_MOUTH, width=2) # 舌头 c.create_oval(mx - 10, my + 2, mx + 10, my + 14, fill=COLOR_TONGUE, outline='') # 上唇线 c.create_arc(mx - w, my - 12, mx + w, my + 6, start=200, extent=140, style='arc', outline=COLOR_MOUTH, width=2) elif expr == 'happy': # 微笑弧 c.create_arc(mx - 14, my - 8, mx + 14, my + 10, start=20, extent=140, style='arc', outline=COLOR_MOUTH, width=2.5) elif expr == 'shy': # 小抿嘴 c.create_line(mx - 8, my, mx + 8, my, fill=COLOR_MOUTH, width=2.5, smooth=True) c.create_arc(mx - 6, my - 4, mx + 6, my + 4, start=200, extent=140, style='arc', outline=COLOR_MOUTH, width=2) else: # wink c.create_arc(mx - 12, my - 6, mx + 12, my + 12, start=20, extent=140, style='arc', outline=COLOR_MOUTH, width=2.5) # ----- 前侧头发(刘海)----- def _draw_hair_front(self, oy): c = self.canvas # 头顶发盖 c.create_oval(CX - 86, 30 + oy, CX + 86, 120 + oy, fill=COLOR_HAIR, outline=COLOR_HAIR_DARK, width=2) # 刘海锯齿 bang_pts = [] tips = [ (CX - 80, 96), (CX - 64, 70), (CX - 48, 96), (CX - 30, 74), (CX - 12, 98), (CX + 6, 76), (CX + 24, 96), (CX + 44, 72), (CX + 62, 96), (CX + 80, 78), ] top_y = 44 + oy # 沿头顶弧线生成上沿 for i, (tx, ty) in enumerate(tips): bang_pts.append((tx, ty + oy)) # 用多边形画几缕刘海 for i in range(0, len(tips) - 1, 2): x1, y1 = tips[i] x2, y2 = tips[i + 1] xm, ym = (x1 + x2) / 2, max(y1, y2) + 18 c.create_polygon(x1, top_y, x1, y1 + oy, xm, ym + oy, x2, y2 + oy, x2, top_y, fill=COLOR_HAIR, outline=COLOR_HAIR_DARK, width=1.5) # 两侧鬓发 c.create_polygon(CX - 86, 60 + oy, CX - 92, 170 + oy, CX - 64, 150 + oy, CX - 60, 70 + oy, fill=COLOR_HAIR, outline=COLOR_HAIR_DARK, width=1.5) c.create_polygon(CX + 86, 60 + oy, CX + 92, 170 + oy, CX + 64, 150 + oy, CX + 60, 70 + oy, fill=COLOR_HAIR, outline=COLOR_HAIR_DARK, width=1.5) # ----- 呆毛 ----- def _draw_ahoge(self, oy): c = self.canvas sway = math.sin(self.t * 3) * 4 # 一根弯曲呆毛 c.create_line(CX, 30 + oy, CX - 6 + sway, 6 + oy, CX + 6 + sway, 2 + oy, fill=COLOR_HAIR_DARK, width=4, smooth=True, capstyle='round') # ----- 身体 / 裙子 ----- def _draw_body(self, oy): c = self.canvas # 脖子 c.create_rectangle(CX - 14, 186 + oy, CX + 14, 210 + oy, fill=COLOR_SKIN, outline=COLOR_SKIN_SHADOW, width=1.5) # 裙子上半(白色) c.create_polygon(CX - 44, 210 + oy, CX + 44, 210 + oy, CX + 54, 270 + oy, CX - 54, 270 + oy, fill=COLOR_DRESS, outline=COLOR_DRESS_DARK, width=2) # 蓝色领结/缎带 c.create_polygon(CX - 24, 206 + oy, CX + 24, 206 + oy, CX + 18, 224 + oy, CX - 18, 224 + oy, fill=COLOR_DRESS_BLUE, outline=COLOR_DRESS_DARK, width=1.5) c.create_oval(CX - 6, 208 + oy, CX + 6, 220 + oy, fill=COLOR_ACCENT, outline='') # 裙摆蓝色边 c.create_polygon(CX - 54, 262 + oy, CX + 54, 262 + oy, CX + 58, 276 + oy, CX - 58, 276 + oy, fill=COLOR_DRESS_BLUE, outline=COLOR_DRESS_DARK, width=1.5) # 手臂 c.create_oval(CX - 60, 214 + oy, CX - 40, 250 + oy, fill=COLOR_DRESS, outline=COLOR_DRESS_DARK, width=1.5) c.create_oval(CX + 40, 214 + oy, CX + 60, 250 + oy, fill=COLOR_DRESS, outline=COLOR_DRESS_DARK, width=1.5) # 手 c.create_oval(CX - 62, 244 + oy, CX - 44, 262 + oy, fill=COLOR_SKIN, outline=COLOR_SKIN_SHADOW, width=1.5) c.create_oval(CX + 44, 244 + oy, CX + 62, 262 + oy, fill=COLOR_SKIN, outline=COLOR_SKIN_SHADOW, width=1.5) # ----- 粒子(爱心)----- def _spawn_hearts(self, n=6): for _ in range(n): self.particles.append({ 'x': CX + random.uniform(-30, 30), 'y': 110 + random.uniform(-10, 10), 'vx': random.uniform(-1.2, 1.2), 'vy': random.uniform(-2.5, -1.0), 'life': 1.0, 'size': random.uniform(8, 14), }) def _update_particles(self): for p in self.particles: p['x'] += p['vx'] p['y'] += p['vy'] p['vy'] += 0.04 p['life'] -= self.dt * 0.8 self.particles = [p for p in self.particles if p['life'] > 0] def _draw_particles(self): c = self.canvas for p in self.particles: s = p['size'] * max(0.2, p['life']) x, y = p['x'], p['y'] c.create_text(x, y, text='♥', font=('Segoe UI Symbol', int(s)), fill=COLOR_ACCENT) # ----- 说话气泡 ----- def _draw_speech(self, oy): if not self.speech_text: return c = self.canvas text = self.speech_text font = ('微软雅黑', 12, 'bold') # 估算宽度 w = max(70, len(text) * 14 + 26) h = 36 x = CX y = 6 + oy # 气泡圆角矩形 pad = 8 c.create_rectangle(x - w / 2, y, x + w / 2, y + h, fill=COLOR_WHITE, outline=COLOR_ACCENT, width=2) # 尾巴 c.create_polygon(x - 8, y + h - 1, x + 8, y + h - 1, x, y + h + 12, fill=COLOR_WHITE, outline=COLOR_ACCENT, width=2) # 遮掉尾巴上沿的边线 c.create_line(x - 7, y + h, x + 7, y + h, fill=COLOR_WHITE, width=3) c.create_text(x, y + h / 2 + 1, text=text, fill=COLOR_ACCENT, font=font) # ================= 工具 ================= @staticmethod def _mix(c1, c2, t): """简单颜色线性混合,t in [0,1]""" t = max(0.0, min(1.0, t)) def parse(h): h = h.lstrip('#') return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) r1, g1, b1 = parse(c1) r2, g2, b2 = parse(c2) r = int(r1 + (r2 - r1) * t) g = int(g1 + (g2 - g1) * t) b = int(b1 + (b2 - b1) * t) return f'#{r:02x}{g:02x}{b:02x}' def _on_double_click_heart(self): """双击时额外撒爱心(供扩展)""" self._spawn_hearts(8) if __name__ == '__main__': DesktopPet()