初次上传
This commit is contained in:
735
Live2DPet.py
Normal file
735
Live2DPet.py
Normal file
@@ -0,0 +1,735 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user