Files
DesktopPet/DesktopPet.py
2026-07-15 21:24:12 +08:00

512 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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('<ButtonPress-1>', self._on_press)
self.canvas.bind('<B1-Motion>', self._on_drag)
self.canvas.bind('<ButtonRelease-1>', self._on_release)
self.canvas.bind('<ButtonPress-3>', self._on_right)
self.canvas.bind('<Double-Button-1>', self._on_double)
self.root.bind('<Escape>', 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()