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

736 lines
27 KiB
Python
Raw 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娘 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 版本的渲染方式:纹理上传时预乘 alphashader 不再做预乘
# 修复 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")
# 预乘 alphaRGB = 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 maskglReadPixels 在 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'>MicGanChindows</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 找到模型最上方的非透明像素(窗口坐标 yNone 表示无数据"""
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()