# -*- coding: utf-8 -*- """ 2333娘 Live2D 桌宠 基于 live2d-py (v2cpp) + PyQt5 的透明窗口 Live2D 桌面宠物。 使用 2233live2d 模型(Cubism 2 格式:.moc / .mtn / model.json)。 - 左键拖动移动窗口;松开(未拖动)= 触发点击动作 - 右键:菜单(随机动作 / 随机表情 / 切换角色·服装 / 置顶 / 退出) - 鼠标移动:视线跟随 - 滚轮:缩放 - 自动眨眼、呼吸、空闲随机动作 - 透明区域点击穿透(动态切换 Win32 WS_EX_TRANSPARENT + glReadPixels 检测像素 alpha) """ import ctypes import os import random import sys import glob import live2d.v2 as live2d from OpenGL import GL from PyQt5.QtCore import Qt, QTimer, QPoint from PyQt5.QtGui import QCursor, QImage, QSurfaceFormat from PyQt5.QtWidgets import ( QApplication, QLabel, QMenu, QMessageBox, QOpenGLWidget, QVBoxLayout, QWidget, ) # ============== Monkey-patch: 预乘 alpha 纹理 + 修正 shader ============== # 匹配 HTML 版本的渲染方式:纹理上传时预乘 alpha,shader 不再做预乘 # 修复 mipmap 边缘颜色渗透导致的边线问题 from PIL import Image as _PILImage, ImageChops as _ImageChops from live2d.v2.platform_manager import PlatformManager as _PlatformManager from live2d.v2.core.graphics.draw_param_opengl import DrawParamOpenGL as _DrawParamOpenGL import OpenGL.GL as _gl def _PatchedLoadTexture(self, live2DModel, no, path): """预乘 alpha 的纹理加载,匹配 WebGL 的 UNPACK_PREMULTIPLY_ALPHA_WEBGL""" image = _PILImage.open(path) if image.mode != 'RGBA': image = image.convert("RGBA") # 预乘 alpha:RGB = RGB * alpha / 255 r, g, b, a = image.split() a_rgb = _PILImage.merge("RGB", (a, a, a)) rgb = _PILImage.merge("RGB", (r, g, b)) rgb_pm = _ImageChops.multiply(rgb, a_rgb) image = _PILImage.merge("RGBA", (*rgb_pm.split(), a)) image_data = image.tobytes() width, height = image.size _gl.glEnable(_gl.GL_TEXTURE_2D) texture = _gl.glGenTextures(1) _gl.glBindTexture(_gl.GL_TEXTURE_2D, texture) _gl.glTexImage2D( _gl.GL_TEXTURE_2D, 0, _gl.GL_RGBA, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, image_data ) _gl.glTexParameteri(_gl.GL_TEXTURE_2D, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_LINEAR_MIPMAP_NEAREST) _gl.glTexParameteri(_gl.GL_TEXTURE_2D, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_LINEAR) _gl.glGenerateMipmap(_gl.GL_TEXTURE_2D) _gl.glBindTexture(_gl.GL_TEXTURE_2D, 0) live2DModel.setTexture(no, texture) _PlatformManager.loadTexture = _PatchedLoadTexture # 修改 shader 去掉预乘行(因为纹理已预乘,避免双重预乘) _OriginalDrawParamInit = _DrawParamOpenGL.__init__ def _PatchedDrawParamInit(self, version): _OriginalDrawParamInit(self, version) self.aM = self.aM.replace("smpColor.rgb = smpColor.rgb * smpColor.a;", "") self.aJ = self.aJ.replace("col_formask.rgb = col_formask.rgb * col_formask.a;", "") _DrawParamOpenGL.__init__ = _PatchedDrawParamInit # ============== Monkey-patch 结束 ============== # ============== 路径与配置 ============== SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) MODEL_DIR = os.path.join(SCRIPT_DIR, "model") # 角色目录(22 / 33) CHARACTERS = ["22", "33"] ALPHA_THRESHOLD = 10 # 像素 alpha < 此值视为透明(可穿透) def ScanModelFiles(): """扫描 model/ 下所有角色目录的 model.*.json,返回 {(角色, 服装名): 路径}""" result = {} for char in CHARACTERS: char_dir = os.path.join(MODEL_DIR, char) if not os.path.isdir(char_dir): continue for path in glob.glob(os.path.join(char_dir, "model.*.json")): fname = os.path.basename(path) # model.default.json costume = fname[len("model."):-len(".json")] # default result[(char, costume)] = path return result # 服装中文名映射(常见的几个) COSTUME_CN = { "default": "默认", "2016.xmas.1": "2016圣诞(1)", "2016.xmas.2": "2016圣诞(2)", "2017.cba-normal": "2017应援(普)", "2017.cba-super": "2017应援(超)", "2017.newyear": "2017新年", "2017.school": "2017校服", "2017.summer.normal.1": "2017夏装(普1)", "2017.summer.normal.2": "2017夏装(普2)", "2017.summer.super.1": "2017夏装(超1)", "2017.summer.super.2": "2017夏装(超2)", "2017.tomo-bukatsu.high": "2017社团(高)", "2017.tomo-bukatsu.low": "2017社团(低)", "2017.valley": "2017山谷", "2017.vdays": "2017情人节", "2018.bls-summer": "2018BLS夏", "2018.bls-winter": "2018BLS冬", "2018.lover": "2018恋人", "2018.spring": "2018春装", } DEFAULT_KEY = ("22", "default") WIN_W = 450 WIN_H = 650 DRAG_THRESHOLD = 5 # 像素,超过则判定为拖动 DRAG_SCALE = 0.4 # 视线跟随缩放比例(0=不动, 1=全幅跟随),减小转头幅度 # 随机字幕(2333娘风格台词) SUBTITLES = [ "2333~", "主人好呀!", "今天也元气满满呢!", "陪我玩一会儿嘛~", "嘻嘻嘻~", "想吃饭团了...", "主人在看什么呀?", "摸鱼中,请勿打扰~", "困了...Zzz", "今天直播看了吗?", "投币点赞收藏~", "主人最可爱了!", "哔哩哔哩干杯!", "悄悄摸一下~", "诶嘿嘿~", "要不要一起看番?", "我的发卡好看吗?", "尾巴给你摸一下~", "今天也要好好吃饭哦", ] SUBTITLE_INTERVAL = (15000, 30000) # 字幕随机间隔(毫秒) SUBTITLE_DURATION = 6000 # 字幕显示时长(毫秒) class SubtitleBubble(QWidget): """字幕气泡:独立透明窗口,显示在桌宠上方""" BUBBLE_QSS = """ QLabel { background: rgba(35, 35, 42, 220); color: #e8e8ec; padding: 10px 20px; border-radius: 14px; font-family: "Microsoft YaHei", "微软雅黑", sans-serif; font-size: 15px; } """ def __init__(self): super().__init__() self.setWindowFlags( Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool ) self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_ShowWithoutActivating, True) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self.label = QLabel() self.label.setStyleSheet(self.BUBBLE_QSS) layout.addWidget(self.label) self.hide() self.hide_timer = QTimer(self) self.hide_timer.setSingleShot(True) self.hide_timer.timeout.connect(self.hide) def show_text(self, text, pos): """在指定位置显示字幕,一段时间后自动隐藏""" self.label.setText(text) self.label.adjustSize() self.adjustSize() self.move(pos) self.show() self.hide_timer.start(SUBTITLE_DURATION) class Live2DPet(QOpenGLWidget): """Live2D 桌宠主窗口""" def __init__(self, model_path: str): super().__init__() self.setWindowFlags( Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool ) self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_AlwaysStackOnTop, True) self.setMouseTracking(True) self.resize(WIN_W, WIN_H) self.setWindowTitle("2333娘 Live2D 桌宠") # 初始位置:屏幕右下角 screen = QApplication.primaryScreen().geometry() self.move(screen.width() - WIN_W - 40, screen.height() - WIN_H - 90) # 模型状态 self.model_path = model_path self.model = None self.scale = 1.05 self.offset_y = 0.1 # 模型竖直偏移 self._model_cache = ScanModelFiles() # 缓存模型列表,避免每次右键扫描 self._menu = None # 预创建菜单缓存,避免首次右键卡顿 self._desktop_pet = None # 搞笑模式 DesktopPet 实例 self._tk_timer = None # 驱动 tkinter 事件的定时器 self._alpha_mask = None # 缓存窗口 alpha 通道,用于点击穿透判定 self._alpha_frame = 0 # alpha mask 更新帧计数 self._click_through_state = None # 缓存当前 WS_EX_TRANSPARENT 状态 # 拖动 / 点击判定 self._drag_offset = None self._press_pos = None self._moved = False # 渲染定时器(~60fps)+ 全局鼠标跟踪 self.render_timer = QTimer(self) self.render_timer.timeout.connect(self._tick) self.render_timer.start(16) # 空闲随机动作定时器 self.idle_timer = QTimer(self) self.idle_timer.timeout.connect(self._idle_action) self._reset_idle_timer() # 随机字幕气泡 self.bubble = SubtitleBubble() self.subtitle_timer = QTimer(self) self.subtitle_timer.setSingleShot(True) self.subtitle_timer.timeout.connect(self._show_subtitle) self.subtitle_timer.start(random.randint(*SUBTITLE_INTERVAL)) # ============== 渲染 + 全局鼠标跟踪 + 点击穿透 ============== def _tick(self): """每帧:全局鼠标位置跟踪视线 + alpha mask 更新 + 点击穿透检测 + 触发重绘""" global_pos = QCursor.pos() local = self.mapFromGlobal(global_pos) # 视线跟随(非拖动状态) if self.model and self._drag_offset is None: try: cx, cy = self.width() / 2, self.height() / 2 dx = (local.x() - cx) * DRAG_SCALE + cx dy = (local.y() - cy) * DRAG_SCALE + cy self.model.Drag(dx, dy) except Exception: pass # 每 6 帧用 grabFramebuffer 更新一次 alpha mask(glReadPixels 在 v2 下失败) self._alpha_frame += 1 if self._alpha_frame >= 6: self._alpha_frame = 0 try: img = self.grabFramebuffer() if img is not None and not img.isNull(): img = img.convertToFormat(QImage.Format_RGBA8888) ptr = img.constBits() ptr.setsize(img.byteCount()) raw = bytes(ptr) self._alpha_mask = bytes(raw[i] for i in range(3, len(raw), 4)) except Exception: self._alpha_mask = None # 点击穿透检测:透明像素 → 穿透;不透明像素 / 拖动中 / 窗口外 → 不穿透 if self._drag_offset is not None: self._set_click_through(False) elif 0 <= local.x() < self.width() and 0 <= local.y() < self.height(): self._set_click_through(self._is_pixel_transparent(local.x(), local.y())) else: self._set_click_through(False) self.update() # ============== OpenGL 生命周期 ============== def initializeGL(self): live2d.glInit() self._load_model(self.model_path) def resizeGL(self, w, h): if self.model: self.model.Resize(w, h) self._apply_transform() def paintGL(self): live2d.clearBuffer() if self.model: self.model.Update() self.model.Draw() def _load_model(self, path: str): self.makeCurrent() try: self.model = live2d.LAppModel() self.model.LoadModelJson(path) self.model.SetAutoBlinkEnable(True) self.model.SetAutoBreathEnable(True) self.model.Resize(self.width(), self.height()) self._apply_transform() # 进场 idle try: self.model.StartRandomMotion("idle", live2d.MotionPriority.IDLE) except Exception: pass finally: self.doneCurrent() def _apply_transform(self): if not self.model: return try: self.model.SetScale(self.scale) except Exception: pass try: self.model.SetOffset(0, self.offset_y) except Exception: pass # ============== 透明区域点击穿透 ============== # Win32 常量 GWL_EXSTYLE = -20 WS_EX_TRANSPARENT = 0x00000020 def _set_click_through(self, through: bool): """动态设置 WS_EX_TRANSPARENT,让透明区域点击穿透到桌面。 带状态缓存,仅在状态变化时调用 Win32 API,避免每帧开销。""" if self._click_through_state == through: return try: hwnd = int(self.winId()) ex = ctypes.windll.user32.GetWindowLongW(hwnd, self.GWL_EXSTYLE) if through: ex |= self.WS_EX_TRANSPARENT else: ex &= ~self.WS_EX_TRANSPARENT ctypes.windll.user32.SetWindowLongW(hwnd, self.GWL_EXSTYLE, ex) self._click_through_state = through except Exception: pass def _is_pixel_transparent(self, x: int, y: int) -> bool: """从缓存的 alpha mask 中查询 (x, y) 是否透明""" if not self.model: return True if self._alpha_mask is None: return False # 无缓存时不穿透(避免误穿透导致无法交互) w, h = self.width(), self.height() if x < 0 or x >= w or y < 0 or y >= h: return True # OpenGL y 轴向上,窗口 y 轴向下,需翻转 gl_y = h - 1 - y idx = gl_y * w + x if idx < len(self._alpha_mask): return self._alpha_mask[idx] < ALPHA_THRESHOLD return False # ============== 交互 ============== def mousePressEvent(self, e): if e.button() == Qt.LeftButton: self._press_pos = e.globalPos() self._drag_offset = e.globalPos() - self.frameGeometry().topLeft() self._moved = False elif e.button() == Qt.RightButton: self._show_menu(e.globalPos()) def mouseMoveEvent(self, e): # 拖动窗口(视线跟随由 _tick 全局处理) if self._drag_offset is not None and self._press_pos is not None: if (e.globalPos() - self._press_pos).manhattanLength() > DRAG_THRESHOLD: self._moved = True self.move(e.globalPos() - self._drag_offset) def mouseReleaseEvent(self, e): if e.button() == Qt.LeftButton: if not self._moved and self.model: # 视为点击模型 → 触发动作 try: self.model.StartRandomMotion("tap_body", live2d.MotionPriority.NORMAL) except Exception: try: self.model.StartRandomMotion("idle", live2d.MotionPriority.IDLE) except Exception: pass self._drag_offset = None self._press_pos = None self._moved = False def wheelEvent(self, e): delta = e.angleDelta().y() if delta > 0: self.scale = min(self.scale * 1.08, 3.0) else: self.scale = max(self.scale / 1.08, 0.3) self._apply_transform() self.update() def leaveEvent(self, e): # 鼠标离开窗口时无需处理(_tick 全局跟踪会继续跟随) pass def keyPressEvent(self, e): if e.key() == Qt.Key_Escape: QApplication.quit() def showEvent(self, e): super().showEvent(e) # 窗口显示后预创建菜单,触发字体/QSS 首次加载,避免首次右键卡顿 if self._menu is None: self._build_menu() # ============== 菜单 ============== MENU_QSS = """ QMenu { background: rgba(35, 35, 42, 245); border: 1px solid rgba(120, 120, 140, 60); border-radius: 10px; padding: 6px; color: #e8e8ec; font-family: "Microsoft YaHei", "微软雅黑", sans-serif; font-size: 15px; } QMenu::item { padding: 7px 28px 7px 22px; border-radius: 6px; margin: 1px 4px; } QMenu::item:selected { background: rgba(86, 122, 232, 200); color: #ffffff; } QMenu::item:checked { background: rgba(86, 232, 144, 80); color: #ffffff; } QMenu::item:checked:selected { background: rgba(86, 232, 144, 160); } QMenu::separator { height: 1px; background: rgba(255, 255, 255, 35); margin: 5px 10px; } QMessageBox { background: rgba(35, 35, 42, 245); border: 1px solid rgba(120, 120, 140, 60); border-radius: 10px; font-family: "Microsoft YaHei", "微软雅黑", sans-serif; } QMessageBox QLabel { color: #e8e8ec; font-family: "Microsoft YaHei", "微软雅黑", sans-serif; font-size: 15px; } QPushButton { background: rgba(86, 122, 232, 200); color: #ffffff; border: none; border-radius: 5px; padding: 6px 18px; font-family: "Microsoft YaHei", "微软雅黑", sans-serif; } QPushButton:hover { background: rgba(106, 142, 252, 230); } """ def _build_menu(self): """预创建并缓存菜单,避免每次右键重建导致卡顿""" menu = QMenu(self) menu.setStyleSheet(self.MENU_QSS) self._act_motion = menu.addAction("随机动作 ♪") self._act_expr = menu.addAction("随机表情 ☆") self._act_thank = menu.addAction("感谢动作 ♥") self._act_say = menu.addAction("说句话 ✦") menu.addSeparator() # 切换角色·服装(两级子菜单:22 / 33 → 服装列表) self._switch_menu = menu.addMenu("切换角色·服装") models = self._model_cache self._costume_actions = {} for char in CHARACTERS: char_menu = self._switch_menu.addMenu(f"{char}娘") char_menu.setStyleSheet(self.MENU_QSS) for (c, costume), path in sorted(models.items()): if c != char: continue label = COSTUME_CN.get(costume, costume) act = char_menu.addAction(label) act.setData(path) act.setCheckable(True) self._costume_actions[path] = act menu.addSeparator() self._act_top = menu.addAction("置顶 / 取消置顶") self._act_fun = menu.addAction("搞笑模式 🤪") self._act_about = menu.addAction("开发者名单 ℹ") self._act_quit = menu.addAction("退出 (。•́︿•̀。)") self._menu = menu def _show_menu(self, global_pos): if self._menu is None: self._build_menu() # 更新服装选中状态 for path, act in self._costume_actions.items(): act.setChecked(path == self.model_path) action = self._menu.exec_(global_pos) if action is None: return if action == self._act_motion: self._random_motion() elif action == self._act_expr: self._random_expression() elif action == self._act_thank: self._thank_motion() elif action == self._act_say: self._show_subtitle() self.subtitle_timer.stop() self.subtitle_timer.start(random.randint(*SUBTITLE_INTERVAL)) elif action == self._act_top: flags = self.windowFlags() self.setWindowFlags(flags ^ Qt.WindowStaysOnTopHint) self.show() elif action == self._act_fun: self._switch_to_desktop_pet() elif action == self._act_about: self._show_about() elif action == self._act_quit: QApplication.quit() elif action in self._costume_actions.values(): path = action.data() if path and os.path.isfile(path): self.model_path = path self._load_model(path) def _switch_to_desktop_pet(self): """切换到 tkinter 版搞笑模式:隐藏 Live2D 窗口,内嵌运行 DesktopPet""" from DesktopPet import DesktopPet self.hide() self.render_timer.stop() self.bubble.hide() # 非阻塞运行 DesktopPet,退出时回调恢复 Live2D self._desktop_pet = DesktopPet( blocking=False, on_exit=self._restore_from_desktop_pet, ) # 用 QTimer 驱动 tkinter 事件循环 self._tk_timer = QTimer(self) self._tk_timer.timeout.connect(self._pump_tk) self._tk_timer.start(16) def _pump_tk(self): """驱动 tkinter 事件处理""" try: if self._desktop_pet and not self._desktop_pet._exited: self._desktop_pet.root.update() else: self._tk_timer.stop() except Exception: # root 已销毁 self._tk_timer.stop() def _restore_from_desktop_pet(self): """DesktopPet 退出后恢复 Live2D""" self._tk_timer.stop() self._desktop_pet = None self.render_timer.start(16) self.show() def _show_about(self): """显示开发者名单""" msg = QMessageBox(self) msg.setWindowTitle("开发者名单") msg.setText( "
" "

2333娘 Live2D 桌宠

" "" "" "" "" "" "" "
Mincrohard_1724
Dinganzhi
MicGan(Chindows)
TermiNexus
悠然自得
" "

谨献给每一个需要陪伴的人 ♡

" "
" ) msg.setStyleSheet(self.MENU_QSS) msg.exec_() # ============== 行为 ============== def _random_motion(self): if not self.model: return # 2233 模型的动作组:idle / tap_body / thanking group = random.choice(["idle", "tap_body", "thanking"]) try: self.model.StartRandomMotion(group, live2d.MotionPriority.NORMAL) except Exception: pass def _random_expression(self): if not self.model: return try: self.model.SetRandomExpression() except Exception: pass def _thank_motion(self): if not self.model: return try: self.model.StartRandomMotion("thanking", live2d.MotionPriority.FORCE) except Exception: try: self.model.StartRandomMotion("idle", live2d.MotionPriority.NORMAL) except Exception: pass def _idle_action(self): if not self.model: return # 随机:动作 或 表情 if random.random() < 0.6: self._random_motion() else: self._random_expression() self._reset_idle_timer() def _model_top_y(self): """从 alpha mask 找到模型最上方的非透明像素(窗口坐标 y),None 表示无数据""" if not self._alpha_mask: return None w, h = self.width(), self.height() for y in range(h): gl_y = h - 1 - y row_start = gl_y * w if max(self._alpha_mask[row_start:row_start + w]) >= ALPHA_THRESHOLD: return y return None def _show_subtitle(self): """随机显示一条字幕气泡,紧贴模型头部上方""" text = random.choice(SUBTITLES) self.bubble.show_text(text, QPoint(0, 0)) # 先 show 再算尺寸 fg = self.frameGeometry() bx = fg.center().x() - self.bubble.width() // 2 # 用模型实际顶部定位,回退到窗口顶部 top_y = self._model_top_y() anchor_y = top_y if top_y is not None else 0 by = fg.top() + anchor_y - self.bubble.height() - 12 # 屏幕上方不够时放到模型下方 screen_top = QApplication.primaryScreen().availableGeometry().top() if by < screen_top: bottom_y = self.height() by = fg.top() + bottom_y + 12 self.bubble.move(bx, by) self.subtitle_timer.start(random.randint(*SUBTITLE_INTERVAL)) def _reset_idle_timer(self): self.idle_timer.start(random.randint(7000, 15000)) # ============== 退出清理 ============== def closeEvent(self, e): try: self.render_timer.stop() self.idle_timer.stop() self.subtitle_timer.stop() self.bubble.close() except Exception: pass # 清理搞笑模式 try: if self._tk_timer: self._tk_timer.stop() if self._desktop_pet and not self._desktop_pet._exited: self._desktop_pet._do_exit() except Exception: pass try: self.makeCurrent() self.model = None self.doneCurrent() except Exception: pass try: live2d.dispose() except Exception: pass e.accept() # Qt.Tool 窗口关闭不会自动退出事件循环,需显式 quit QApplication.quit() def main(): # 确保 OpenGL 上下文带 alpha 通道(透明窗口必需)+ MSAA 抗锯齿 fmt = QSurfaceFormat() fmt.setAlphaBufferSize(8) fmt.setDepthBufferSize(24) fmt.setSamples(4) # MSAA 4x 抗锯齿,减少模型边缘锯齿 fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer) QSurfaceFormat.setDefaultFormat(fmt) # 关闭 live2d-py 日志刷屏 try: live2d.setLogLevel(live2d.Live2DLogLevels.Off) except Exception: pass live2d.init() app = QApplication(sys.argv) app.setApplicationName("2333娘 Live2D 桌宠") models = ScanModelFiles() model_path = models.get(DEFAULT_KEY) if not model_path or not os.path.isfile(model_path): # 回退:取第一个可用的 if models: model_path = list(models.values())[0] else: print(f"[错误] 在 {MODEL_DIR} 下找不到任何模型文件") print("请确认 model/22 或 model/33 目录存在且包含 model.*.json") sys.exit(1) pet = Live2DPet(model_path) pet.show() sys.exit(app.exec_()) if __name__ == "__main__": main()