736 lines
27 KiB
Python
736 lines
27 KiB
Python
# -*- 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(
|
||
"<div style='line-height:1.6'>"
|
||
"<h3 style='margin:0 0 8px'>2333娘 Live2D 桌宠</h3>"
|
||
"<table style='white-space:pre'>"
|
||
"<tr><td style='color:#888'>Mincrohard_1724</td></tr>"
|
||
"<tr><td style='color:#888'>Dinganzhi</td></tr>"
|
||
"<tr><td style='color:#888'>MicGan(Chindows)</td></tr>"
|
||
"<tr><td style='color:#888'>TermiNexus</td></tr>"
|
||
"<tr><td style='color:#888'>悠然自得</td></tr>"
|
||
"</table>"
|
||
"<p style='color:#aaa;margin-top:10px'>谨献给每一个需要陪伴的人 ♡</p>"
|
||
"</div>"
|
||
)
|
||
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()
|