Files
2025-10-01 18:40:05 +08:00

775 lines
28 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.
from config import Global
from PyQt5.QtGui import (QFontDatabase, QIcon, QCursor, QPainter, QColor, QFontDatabase, QRadialGradient,
QBrush, QFontMetrics, QFont, QPen
)
from PyQt5.QtCore import Qt, QPoint, QTimer, QPropertyAnimation, QEasingCurve, pyqtProperty
from PyQt5.QtWidgets import (QSystemTrayIcon, QMenu, QAction, QOpenGLWidget, QApplication, QLabel, QWidget,
QLineEdit, QVBoxLayout
)
import live2d.v3 as live2d
from animator.GUIAnimator import *
from animator.animator import *
from gui.style import style
from utils.other import terminate_thread, capture_screen, check_send_over, sounds_player
from utils.func_queue import FuncQueue
from threading import Thread
import os
class Live2DCanvas(QOpenGLWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Ciallo~(∠・ω< )⌒★")
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
Global.send_audio_text = None
self.double_hit = [0, '']
# 缩放相关属性
self.scale_factor = 1.0
self.scale_step = 0.1
win_width, win_height = Global.character['win_width'], Global.character['win_height']
self.resize(win_width, win_height)
screen = QApplication.primaryScreen().geometry()
self.screen_width, self.screen_height = screen.width(), screen.height()
x = (self.screen_width - win_width) // 2
y = (self.screen_height - win_height) // 2
self.move(x, y)
self.model: None | live2d.LAppModel = None
self.dragging = False
self.drag_position = QPoint()
# 字体
Global.font = 'Microsoft YaHei'
font_db = QFontDatabase()
font_id = font_db.addApplicationFont("assets\\fonts\\字体.ttf")
if font_id != -1:
font_families = font_db.applicationFontFamilies(font_id)
if font_families:
Global.font = font_families[0]
style.input_box = style.input_box % (Global.font)
style.tray_menu = style.tray_menu % (Global.font)
style.context_menu = style.context_menu % (Global.font)
# 创建双击特效
self.click_effect = ClickEffect()
# 添加输入框
if Global.input_box:
self.input_box = QLineEdit(self)
self.input_box.setPlaceholderText("请输入内容")
self.input_box.returnPressed.connect(self.on_input_enter)
self.input_box.setStyleSheet(style.input_box)
self.input_box.hide()
# 字幕控件
self.subtitle_window = SubtitleWindow()
self.subtitle_window.show()
self.raise_timer = QTimer()
self.raise_timer.timeout.connect(self.raise_subtitle)
self.raise_timer.start(100)
# 主动对话
if Global.initiative_wait_range != 'none':
self.initiative_reset()
self.initiative_timer = QTimer()
self.initiative_timer.timeout.connect(self.initiative_check)
self.initiative_timer.start(1000)
# 气泡
Global.bubble_widget = BubbleWidget(self)
# 音效
Global.sounds_player = sounds_player()
Global.sounds_player.load('click.wav', "assets\sounds\click.wav", 0.5)
Global.sounds_player.load('exp.wav', "assets\sounds\exp.wav", 0.5)
self.enter_mouse = False
self.timer1 = QTimer()
self.timer1.timeout.connect(self.check_mouse_pos)
self.timer1.start(100)
Global.animator1 = SubtitleAnimator1(self.subtitle_window.subtitle_label)
Global.animator2 = SubtitleAnimator2(self.subtitle_window.subtitle_label)
Global.func_queue1 = FuncQueue(max_t=2)
Global.func_queue2 = FuncQueue()
self.create_tray_icon()
def raise_subtitle(self):
self.subtitle_window.raise_()
def initiative_check(self):
current_time = (time.time() - self.initiative_t) / 60
if current_time > self.initiative_wait and check_send_over():
if Global.exist:
img = capture_screen(Global.required['max_pixels'])
Global.send_text_thread = Thread(target=Global.send_audio_text, args=(str({'系统': f'用户已经{self.initiative_wait}分钟没与你对话了,请根据当前屏幕内容,找个不重复的、用户感兴趣的话题'}), img), daemon=True)
Global.send_text_thread.start()
self.initiative_reset()
def initiative_reset(self):
if Global.initiative_wait_range != 'none':
self.initiative_t = time.time()
self.initiative_wait = random.randint(Global.initiative_wait_range[0], Global.initiative_wait_range[1])
def wheelEvent(self, event):
if self.model is None:
return
angle_delta = event.angleDelta().y()
old_scale = self.scale_factor
if angle_delta < 0:
self.scale_factor = self.scale_factor + self.scale_step
else:
self.scale_factor = self.scale_factor - self.scale_step
if old_scale != self.scale_factor:
if not self.update_scale():
self.scale_factor = old_scale
super().wheelEvent(event)
def update_scale(self):
new_width = int(Global.character['win_width'] * self.scale_factor)
new_height = int(Global.character['win_height'] * self.scale_factor)
if new_width < 420:
return False
current_rect = self.geometry()
current_x = current_rect.x()
current_y = current_rect.y()
self.setGeometry(current_x, current_y, new_width, new_height)
self.model.Resize(new_width, new_height)
self.position_input_box()
return True
def create_context_menu(self, pos):
context_menu = QMenu(self)
context_menu.setAttribute(Qt.WA_TranslucentBackground, True)
context_menu.setWindowFlags(Qt.FramelessWindowHint | Qt.Popup | Qt.NoDropShadowWindowHint)
context_menu.setStyleSheet(style.context_menu)
def sleep():
Global.sounds_player.play('click.wav')
Global.exist = not Global.exist
def clear_history():
Global.sounds_player.play('click.wav')
Global.my_agent.messages = Global.my_agent.messages[:1]
def animate_menu_show(menu):
self.menu_animation = QPropertyAnimation(menu, b"windowOpacity")
self.menu_animation.setDuration(200)
self.menu_animation.setStartValue(0.0)
self.menu_animation.setEndValue(1.0)
self.menu_animation.setEasingCurve(QEasingCurve.OutCubic)
menu.setWindowOpacity(0.0)
self.menu_animation.start()
if Global.exist:
sleep_action = QAction("睡眠", self)
sleep_action.triggered.connect(sleep)
else:
sleep_action = QAction("唤醒", self)
sleep_action.triggered.connect(sleep)
context_menu.addAction(sleep_action)
context_menu.addSeparator()
clear_action = QAction("清空历史", self)
clear_action.triggered.connect(clear_history)
context_menu.addAction(clear_action)
animate_menu_show(context_menu)
context_menu.exec_(pos)
def create_tray_icon(self):
self.tray_icon = QSystemTrayIcon(self)
icon = QIcon("assets\icons\icon.ico")
self.tray_icon.setIcon(icon)
tray_menu = QMenu()
tray_menu.setStyleSheet(style.tray_menu)
tray_menu.setAttribute(Qt.WA_TranslucentBackground, True)
tray_menu.setWindowFlags(Qt.FramelessWindowHint | Qt.Popup)
self.penetrate_action = QAction("启用穿透", self)
self.penetrate_action.setCheckable(True)
self.penetrate_action.triggered.connect(self.toggle_penetrate)
tray_menu.addAction(self.penetrate_action)
tray_menu.addSeparator()
quit_action = QAction("退出", self)
quit_action.setCheckable(True)
quit_action.triggered.connect(self.quit_app)
tray_menu.addAction(quit_action)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.setToolTip("Ciallo~(∠・ω< )⌒★")
self.tray_icon.show()
def on_input_enter(self):
text = self.input_box.text().strip()
if text:
self.input_box.clear()
Global.animator2.schedule_fade_out()
if Global.func_queue1.t:
for t0 in Global.func_queue1.t:
terminate_thread(t0)
Global.func_queue1.__init__()
if Global.audio_queue.q:
Global.audio_queue.q = {}
if Global.send_text_thread:
terminate_thread(Global.send_text_thread)
Global.send_text_thread = None
if Global.sing_song_thread and Global.rvc.playing:
terminate_thread(Global.sing_song_thread)
Global.sing_song_thread = None
img = None
if Global.always_screenshot:
img = capture_screen(Global.required['max_pixels'])
else:
if time.time() - Global.screenshot_t < 30:
img = capture_screen(Global.required['max_pixels'])
Global.screenshot_t = time.time()
else:
for i in Global.shot_word:
if i in text:
img = capture_screen(Global.required['max_pixels'])
Global.screenshot_t = time.time()
break
Global.send_text_thread = Thread(target=Global.send_audio_text, args=(text, img), daemon=True)
Global.send_text_thread.start()
def toggle_penetrate(self):
if self.penetrate_action.isChecked():
flags = self.windowFlags()
self.setWindowFlags(flags | Qt.WindowType.WindowTransparentForInput)
self.penetrate_action.setText("关闭穿透")
self.show()
else:
flags = self.windowFlags()
self.setWindowFlags(flags & ~Qt.WindowType.WindowTransparentForInput)
self.penetrate_action.setText("启用穿透")
self.show()
def quit_app(self):
self.tray_icon.hide()
os._exit(0)
def initializeGL(self):
live2d.glInit()
self.model = live2d.LAppModel()
self.model.LoadModelJson(Global.character["live2d_model"])
self.model.Resize(self.width(), self.height())
self.model.SetAutoBlinkEnable(False)
self.model.SetAutoBreathEnable(True)
Global.model = self.model
Global.live2d_animator = Live2dAnimator(self.model)
Global.blink_animator = BlinkAnimator()
Global.live2d_animator.add(100, Global.blink_animator)
Global.eyeball_animator = EyeBallAnimator()
Global.live2d_animator.add(5, Global.eyeball_animator)
Global.emotion_animator = EmotionAnimator()
Global.live2d_animator.add(5, Global.emotion_animator)
Global.expression_animator = ExpressionAnimator()
Global.live2d_animator.add(5, Global.expression_animator)
Global.appearance_animator = AppearanceAnimator(self)
Global.live2d_animator.add(5, Global.appearance_animator)
if Global.character["Exp"]["mixanimator_wait_range"] != 'none':
exp_mix_animator = ExpMixAnimator()
Global.live2d_animator.add(6, exp_mix_animator)
if Global.character["Body"]["mixanimator_wait_range"] != 'none':
body_mix_animator = BodyMixAnimator()
Global.live2d_animator.add(6, body_mix_animator)
self.position_input_box()
self.startTimer(int(1000 / 60)) # 渲染帧率
def position_input_box(self):
if not hasattr(self, 'input_box'):
return
box_width = 400
box_height = 50
center_x = (self.width() - box_width) // 2
center_y = (self.height() - box_height) // 2
win_pos = self.pos()
screen_x = win_pos.x() + center_x
screen_y = win_pos.y() + center_y
screen = QApplication.primaryScreen().geometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_x < 0:
center_x = -win_pos.x()
elif screen_x + box_width > screen_width:
center_x = screen_width - win_pos.x() - box_width
if screen_y < 0:
center_y = -win_pos.y()
elif screen_y + box_height > screen_height:
center_y = screen_height - win_pos.y() - box_height
center_x = max(0, min(center_x, self.width() - box_width))
center_y = max(0, min(center_y, self.height() - box_height))
self.input_box.setGeometry(center_x, center_y, box_width, box_height)
def check_mouse_pos(self):
global_pos = QCursor.pos()
win_pos = self.pos()
win_w, win_h = self.width(), self.height()
x1, x2, y1, y2 = win_pos.x(), win_pos.x()+win_w, win_pos.y(), win_pos.y()+win_h
if x1 < global_pos.x() < x2 and y1 < global_pos.y() < y2:
if not self.enter_mouse:
self.enter_mouse = True
if hasattr(self, 'input_box') and self.penetrate_action.text() == '启用穿透':
self.position_input_box()
self.input_box.show()
else:
if self.enter_mouse:
self.enter_mouse = False
if hasattr(self, 'input_box'):
self.input_box.hide()
def mouseMoveEvent(self, event):
if event.buttons() == Qt.MouseButton.LeftButton and self.dragging:
self.position_input_box()
self.move(event.globalPos() - self.drag_position)
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
if self.model is None:
return
if event.button() == Qt.MouseButton.RightButton:
self.create_context_menu(event.globalPos())
return
if event.button() == Qt.MouseButton.LeftButton:
x, y = event.x(), event.y()
hit_parts = self.model.HitPart(x, y)
if hit_parts:
hit_part = Global.parts[hit_parts[0]]
if hit_part == self.double_hit[1] and time.time() - self.double_hit[0] < 0.4:
print('触摸了', hit_part)
global_pos = self.mapToGlobal(QPoint(x, y))
self.click_effect.start_effect(global_pos.x(), global_pos.y())
Global.sounds_player.play('click.wav')
Global.bubble_widget.show_bubble(f"触摸了 {hit_part} ♡")
Global.animator2.schedule_fade_out()
if Global.func_queue1.t:
for t0 in Global.func_queue1.t:
terminate_thread(t0)
Global.func_queue1.__init__()
if Global.audio_queue.q:
Global.audio_queue.q = {}
if Global.send_text_thread:
terminate_thread(Global.send_text_thread)
Global.send_text_thread = None
if Global.sing_song_thread and Global.rvc.playing:
terminate_thread(Global.sing_song_thread)
Global.sing_song_thread = None
Global.send_text_thread = Thread(target=Global.send_audio_text, args=(f'[触摸了你的{hit_part}]',), daemon=True)
Global.send_text_thread.start()
self.double_hit = [time.time(), hit_part]
self.drag_position = event.globalPos() - self.frameGeometry().topLeft()
self.dragging = True
else:
self.dragging = False
return
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.dragging = False
super().mouseReleaseEvent(event)
def timerEvent(self, _):
Global.live2d_animator.update()
self.update()
def on_draw(self):
live2d.clearBuffer()
self.model.Draw()
def paintGL(self):
self.model.Update()
self.on_draw()
def closeEvent(self, event):
os._exit(0)
class SubtitleWindow(QWidget):
def __init__(self):
super().__init__()
self.setup_window()
self.setup_subtitle()
def setup_window(self):
self.setWindowTitle("字幕窗口")
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
screen = QApplication.primaryScreen().geometry()
self.setGeometry(screen)
def setup_subtitle(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(100, 0, 100, 100)
# 添加弹性空间,让字幕靠底部显示
main_layout.addStretch()
self.subtitle_label = QLabel()
self.subtitle_label.setTextFormat(Qt.TextFormat.RichText)
self.subtitle_label.setWordWrap(True)
font_id = QFontDatabase.addApplicationFont(r"assets\fonts\字体.ttf")
if font_id != -1:
font_family = QFontDatabase.applicationFontFamilies(font_id)[0]
else:
font_family = "Arial"
self.subtitle_label.setStyleSheet(f'''
font-size: 96px;
font-family: "{font_family}";
color: white;
background-color: transparent;
padding: 20px;
''')
self.subtitle_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.subtitle_label.setMaximumHeight(400) # 限制最大高度
main_layout.addWidget(self.subtitle_label)
self.subtitle_label.setText("")
class ClickEffect(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
self._radius = 0
self._opacity = 1.0
self.center_x = 0
self.center_y = 0
self.radius_animation = QPropertyAnimation(self, b"radius")
self.opacity_animation = QPropertyAnimation(self, b"opacity")
self.radius_animation.finished.connect(self.hide)
@pyqtProperty(float)
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._radius = value
self.update()
@pyqtProperty(float)
def opacity(self):
return self._opacity
@opacity.setter
def opacity(self, value):
self._opacity = value
self.update()
def start_effect(self, x, y):
self.center_x = x
self.center_y = y
self._radius = 0
self._opacity = 1.0
effect_size = 120
self.setGeometry(
x - effect_size // 2,
y - effect_size // 2,
effect_size,
effect_size
)
self.center_x = effect_size // 2
self.center_y = effect_size // 2
self.show()
self.radius_animation.setDuration(500)
self.radius_animation.setStartValue(0)
self.radius_animation.setEndValue(60)
self.radius_animation.setEasingCurve(QEasingCurve.OutQuad)
self.opacity_animation.setDuration(500)
self.opacity_animation.setStartValue(1.0)
self.opacity_animation.setEndValue(0.0)
self.opacity_animation.setEasingCurve(QEasingCurve.OutQuad)
self.radius_animation.start()
self.opacity_animation.start()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
color_rgb = Global.character["ttf_rgb"]
base_color = QColor(color_rgb[0], color_rgb[1], color_rgb[2])
if self._radius > 0:
gradient = QRadialGradient(self.center_x, self.center_y, self._radius)
inner_color = QColor(base_color)
inner_color.setAlphaF(0.0)
gradient.setColorAt(0.0, inner_color)
gradient.setColorAt(0.85, inner_color)
mid_color = QColor(base_color)
mid_color.setAlphaF(0.8 * self._opacity)
gradient.setColorAt(0.7, mid_color)
outer_color = QColor(base_color)
outer_color.setAlphaF(0.3 * self._opacity)
gradient.setColorAt(1.0, outer_color)
painter.setBrush(QBrush(gradient))
painter.setPen(Qt.NoPen)
painter.drawEllipse(
int(self.center_x - self._radius),
int(self.center_y - self._radius),
int(self._radius * 2),
int(self._radius * 2)
)
class BubbleWidget(QWidget):
show_bubble_signal = pyqtSignal(str, int)
hide_bubble_signal = pyqtSignal()
def __init__(self, root):
super().__init__()
self.root = root
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
self._opacity = 0.0
self._scale = 0.0
self.text = ""
self.padding = 15
self.border_radius = 20
self.arrow_size = 12
self.opacity_animation = QPropertyAnimation(self, b"opacity")
self.scale_animation = QPropertyAnimation(self, b"scale")
self.opacity_animation.finished.connect(self._on_hide_animation_finished)
self._is_hiding = False
self.font_family = Global.font
self.font_size = 18
self.auto_hide_timer = QTimer()
self.auto_hide_timer.timeout.connect(self._hide_bubble_impl)
self.show_bubble_signal.connect(self._show_bubble_impl)
self.hide_bubble_signal.connect(self._hide_bubble_impl)
@pyqtProperty(float)
def opacity(self):
return self._opacity
@opacity.setter
def opacity(self, value):
self._opacity = value
self.update()
@pyqtProperty(float)
def scale(self):
return self._scale
@scale.setter
def scale(self, value):
self._scale = value
self.update()
def _on_hide_animation_finished(self):
"""动画结束回调"""
if self._is_hiding:
self.hide()
self._is_hiding = False
def show_bubble(self, text, duration=2000):
self.show_bubble_signal.emit(text, duration)
def hide_bubble(self):
self.hide_bubble_signal.emit()
def _show_bubble_impl(self, text, duration):
self.text = text
self._opacity = 0.0
self._scale = 0.0
self._is_hiding = False
font = self.get_bubble_font()
font_metrics = QFontMetrics(font)
max_width = 300
text_rect = font_metrics.boundingRect(0, 0, max_width, 0, Qt.AlignCenter | Qt.TextWordWrap, text)
extra_margin = 10
bubble_width = min(max(text_rect.width() + self.padding * 2 + extra_margin, 100), max_width + self.padding * 2)
bubble_height = text_rect.height() + self.padding * 2 + self.arrow_size + extra_margin
window_pos = self.root.pos()
window_size = self.root.size()
bubble_x = window_pos.x() + (window_size.width() - bubble_width) // 2
bubble_y = window_pos.y() + (window_size.height() - bubble_height) // 2 + int(Global.character["bubble_height"] * self.root.scale_factor)
self.setGeometry(bubble_x, bubble_y, bubble_width, bubble_height)
self.show()
self.opacity_animation.setDuration(300)
self.opacity_animation.setStartValue(0.0)
self.opacity_animation.setEndValue(1.0)
self.opacity_animation.setEasingCurve(QEasingCurve.OutCubic)
self.scale_animation.setDuration(300)
self.scale_animation.setStartValue(0.0)
self.scale_animation.setEndValue(1.0)
self.scale_animation.setEasingCurve(QEasingCurve.OutBack)
self.opacity_animation.start()
self.scale_animation.start()
if duration > 0:
self.auto_hide_timer.start(duration)
def _hide_bubble_impl(self):
self.auto_hide_timer.stop()
self._is_hiding = True
self.opacity_animation.setDuration(200)
self.opacity_animation.setStartValue(self._opacity)
self.opacity_animation.setEndValue(0.0)
self.opacity_animation.setEasingCurve(QEasingCurve.InCubic)
self.scale_animation.setDuration(200)
self.scale_animation.setStartValue(self._scale)
self.scale_animation.setEndValue(0.8)
self.scale_animation.setEasingCurve(QEasingCurve.InCubic)
self.opacity_animation.start()
self.scale_animation.start()
def get_bubble_font(self):
font = QFont(self.font_family, self.font_size)
font.setBold(True)
return font
def paintEvent(self, event):
if self._opacity <= 0:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setOpacity(self._opacity)
center_x = self.width() / 2
center_y = self.height() / 2
painter.translate(center_x, center_y)
painter.scale(self._scale, self._scale)
painter.translate(-center_x, -center_y)
margin = 3
bubble_rect = self.rect().adjusted(margin, margin, -margin, -self.arrow_size - margin)
gradient = QRadialGradient(bubble_rect.center(), max(bubble_rect.width(), bubble_rect.height()) / 3)
color_rgb = Global.character["ttf_rgb"]
accent_color = QColor(color_rgb[0], color_rgb[1], color_rgb[2])
bg_color1 = QColor(0, 0, 0, 200)
bg_color2 = QColor(0, 0, 0, 160)
gradient.setColorAt(0.0, bg_color1)
gradient.setColorAt(1.0, bg_color2)
painter.setBrush(QBrush(gradient))
painter.setPen(QPen(accent_color, 2))
painter.drawRoundedRect(bubble_rect, self.border_radius, self.border_radius)
arrow_x = bubble_rect.center().x()
arrow_y = bubble_rect.bottom()
arrow_points = [
QPoint(arrow_x - self.arrow_size // 2, arrow_y),
QPoint(arrow_x + self.arrow_size // 2, arrow_y),
QPoint(arrow_x, arrow_y + self.arrow_size)
]
painter.drawPolygon(arrow_points)
painter.setPen(QPen(QColor(255, 255, 255)))
painter.setFont(self.get_bubble_font())
text_padding = self.padding - 2
text_rect = bubble_rect.adjusted(text_padding, text_padding, -text_padding, -text_padding)
painter.drawText(text_rect, Qt.AlignCenter | Qt.TextWordWrap, self.text)