Files
BA-VitePress-Pages/.vitepress/theme/components/Spine-Player/index.vue
2025-11-26 15:17:27 +08:00

666 lines
18 KiB
Vue
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.

<!--
todo:
1. 优化暗色描边
2. 优化对话框三角布局
3. 整理代码结构
-->
<template>
<template v-if="state.SpinePlayerEnabled">
<div
ref="playerContainer"
class="playerContainer"
@click="handlePlayerClick"
@touchstart="handlePlayerClick"
></div>
<transition name="fade">
<div v-if="showDialog" class="chatdialog-container">
<div class="chatdialog-triangle"></div>
<div class="chatdialog">{{ currentDialog }}</div>
</div>
</transition>
</template>
</template>
<script setup lang="js">
import { onMounted, ref, watch, computed } from 'vue'
import { useStore } from '../../store'
const { state } = useStore()
import { useData } from 'vitepress'
const themeConfig = useData().theme.value
const spineVoiceLang = themeConfig.spineVoiceLang
import { spine } from './spine-player.js'
// 定义两套spine资产信息
const spineAssets = {
arona: {
skelUrl: "/spine_assets/arona/arona_spr.skel",
atlasUrl: "/spine_assets/arona/arona_spr.atlas",
idleAnimationName: "Idle_01",
eyeCloseAnimationName: "Eye_Close_01",
rightEyeBone: "R_Eye_01",
leftEyeBone: "L_Eye_01",
frontHeadBone: "Head_01",
backHeadBone: "Head_Back",
eyeRotationAngle: 76.307,
voiceConfig: [
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_01.ogg`,
animation: '12',
text: '您回来了?我等您很久啦!'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_02.ogg`,
animation: '03',
text: '嗯,不错,今天也是个好天气。'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_03.ogg`,
animation: '02',
text: '天空真是广啊……\n另一边会有些什么呢'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_04.ogg`,
animation: '18',
text: '偶尔也要为自己的健康着想啊,\n老师我会很担心的。'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_05.ogg`,
animation: '25',
text: '来,加油吧,老师!'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_06.ogg`,
animation: '11',
text: '今天又会有什么事情在等着我呢?'
}
]
},
plana: {
skelUrl: "/spine_assets/plana/plana_spr.skel",
atlasUrl: "/spine_assets/plana/plana_spr.atlas",
idleAnimationName: "Idle_01",
eyeCloseAnimationName: "Eye_Close_01",
rightEyeBone: "R_Eye_01",
leftEyeBone: "L_Eye_01",
frontHeadBone: "Head_Rot",
backHeadBone: "Head_Back",
eyeRotationAngle: 97.331,
voiceConfig: [
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_02.ogg`,
animation: '06',
text: '我明白了,\n老师现在无事可做很无聊。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_01.ogg`,
animation: '13',
text: '混乱,该行动无法理解。\n请不要戳我会出现故障。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_03.ogg`,
animation: '15',
text: '确认连接。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_04.ogg`,
animation: '99',
text: '正在待命,\n需要解决的任务还有很多。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_05.ogg`,
animation: '17',
text: '等您很久了。'
},
]
}
}
const playerContainer = ref(null)
let player = null
let blinkInterval = null
let isEyeControlDisabled = ref(false)
let eyeControlTimer = null
let currentAnimationState = null // 添加动画状态引用
let currentCharacter = ref('arona')
let audioPlayers = []
// 添加客户端就绪状态
const clientReady = ref(false)
// 添加音频上下文管理器
const AudioManager = {
context: null,
buffers: new Map(),
currentSource: null,
gainNode: null,
initialize() {
if (!clientReady.value) return
if (!this.context) {
this.context = new (window.AudioContext || window.webkitAudioContext)();
// 创建增益节点设置音量为50%
this.gainNode = this.context.createGain();
this.gainNode.gain.value = 0.5;
this.gainNode.connect(this.context.destination);
}
},
async loadAudioFile(url) {
if (this.buffers.has(url)) {
const entry = this.buffers.get(url);
entry.lastUsed = Date.now();
return entry.buffer;
}
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.context.decodeAudioData(arrayBuffer);
this.buffers.set(url, { buffer: audioBuffer, lastUsed: Date.now() });
return audioBuffer;
} catch (error) {
console.error('音频加载失败:', error);
return null;
}
},
async playAudio(buffer) {
if (this.currentSource) {
this.currentSource.stop();
}
return new Promise((resolve) => {
const source = this.context.createBufferSource();
source.buffer = buffer;
// 连接到增益节点而不是直接连接到目标
source.connect(this.gainNode);
source.onended = () => {
if (this.currentSource === source) {
this.currentSource = null;
}
resolve();
};
this.currentSource = source;
source.start();
});
},
clear() {
if (this.currentSource) {
this.currentSource.stop();
this.currentSource = null;
}
this.buffers.clear();
},
gc() {
// 清除超过5分钟未使用的音频缓存
const now = Date.now()
for (const [url, entry] of this.buffers.entries()) {
if (now - entry.lastUsed > 300000) { // 5分钟
this.buffers.delete(url)
}
}
}
}
// 修改预加载音频函数
const preloadAudio = async () => {
if (!currentAssets.value) return false
AudioManager.initialize()
AudioManager.gc() // 清理过期缓存
const loadPromises = currentAssets.value.voiceConfig.map(pair =>
AudioManager.loadAudioFile(pair.audio)
)
return Promise.all(loadPromises).catch(error => {
console.error('音频预加载失败:', error)
return false
})
}
const handleScroll = () => {
if (!clientReady.value || !playerContainer.value) return
const bottomReached = window.innerHeight + window.scrollY + 1 >= document.body.offsetHeight
const chatDialog = document.querySelector('.chatdialog')
if (isMobileDevice()) {
if (bottomReached) {
playerContainer.value.style.left = '-50%'
if (chatDialog) {
chatDialog.style.left = '-50%'
}
} else {
playerContainer.value.style.left = '0%'
}
}
}
const isMobileDevice = () => {
if (!clientReady.value) return false
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
let isPlaying = false // 添加播放状态标志
const showDialog = ref(false)
const currentDialog = ref('')
let lastPlayedIndex = -1 // 添加上一次播放的索引记录
// 添加防抖处理
const debounce = (fn, delay) => {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
// 在组件作用域添加重置状态引用
const resetBonesState = ref(null)
// 点击处理函数
const handlePlayerClick = debounce(async (event) => {
event.preventDefault();
event.stopPropagation();
// 检查是否正在播放
if (!isPlaying) {
isPlaying = true
isEyeControlDisabled.value = true
// 点击时重置眼睛位置
resetBonesState.value?.()
const currentConfig = spineAssets[currentCharacter.value].voiceConfig
let randomIndex
do {
randomIndex = Math.floor(Math.random() * currentConfig.length)
} while (randomIndex === lastPlayedIndex && currentConfig.length > 1)
lastPlayedIndex = randomIndex
const selectedPair = currentConfig[randomIndex]
try {
const buffer = await AudioManager.loadAudioFile(selectedPair.audio);
if (!buffer) throw new Error('音频加载失败');
currentDialog.value = selectedPair.text;
showDialog.value = true;
currentAnimationState.addAnimation(2, selectedPair.animation, false, 0);
// 播放音频并等待结束
await AudioManager.playAudio(buffer);
// 音频播放结束后清理状态
isPlaying = false;
isEyeControlDisabled.value = false;
currentAnimationState.setEmptyAnimation(2, 0);
showDialog.value = false;
} catch (error) {
console.error('音频播放失败:', error);
isPlaying = false;
isEyeControlDisabled.value = false;
showDialog.value = false;
}
}
}, 300)
// 提升 moveBones 函数到组件作用域以便在其他地方使用
let moveBonesHandler = null
const initializeSpinePlayer = async (assets) => {
try {
// 清理旧的实例
if (blinkInterval) {
clearTimeout(blinkInterval);
}
// 清理容器内容
if (playerContainer.value) {
playerContainer.value.innerHTML = '';
}
player = new spine.SpinePlayer(playerContainer.value, {
skelUrl: assets.skelUrl,
atlasUrl: assets.atlasUrl,
premultipliedAlpha: true,
backgroundColor: '#00000000',
alpha: true,
showControls: false,
success: function (playerInstance) {
playerInstance.setAnimation(assets.idleAnimationName, true)
const skeleton = playerInstance.skeleton
const animationState = playerInstance.animationState
currentAnimationState = animationState // 保存动画状态引用
const rightEyeBone = skeleton.findBone(assets.rightEyeBone)
const leftEyeBone = skeleton.findBone(assets.leftEyeBone)
const frontHeadBone = skeleton.findBone(assets.frontHeadBone)
const backHeadBone = skeleton.findBone(assets.backHeadBone)
const rightEyeCenterX = rightEyeBone ? rightEyeBone.data.x : 0
const rightEyeCenterY = rightEyeBone ? rightEyeBone.data.y : 0
const leftEyeCenterX = leftEyeBone ? leftEyeBone.data.x : 0
const leftEyeCenterY = leftEyeBone ? leftEyeBone.data.y : 0
const frontHeadCenterX = frontHeadBone ? frontHeadBone.data.x : 0
const frontHeadCenterY = frontHeadBone ? frontHeadBone.data.y : 0
const backHeadCenterX = backHeadBone ? backHeadBone.data.x : 0
const backHeadCenterY = backHeadBone ? backHeadBone.data.y : 0
// 骨骼移动限制
const maxRadius = 15
const frontHeadMaxRadius = 2
const backHeadMaxRadius = 1
function rotateVector(x, y, angle) {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return {
x: x * cos - y * sin,
y: x * sin + y * cos
}
}
function moveBones(event) {
// 如果眼睛控制被禁用,直接返回
if (isEyeControlDisabled.value) return
const containerRect = playerContainer.value.getBoundingClientRect()
const mouseX = event.clientX - (containerRect.right - containerRect.width / 2)
const mouseY = event.clientY - (containerRect.bottom - containerRect.height * 4 / 5)
// 将鼠标坐标偏移量进行逆旋转
const eyeRotation = assets.eyeRotationAngle * (Math.PI / 180) // 眼睛旋转角度
const rotatedMouse = rotateVector(mouseX, mouseY, -eyeRotation)
const offsetX = rotatedMouse.x
const offsetY = rotatedMouse.y
const distance = Math.sqrt(offsetX * offsetX + offsetY * offsetY)
const angle = Math.atan2(offsetY, offsetX)
const maxDistance = Math.min(distance, maxRadius)
const dx = -maxDistance * Math.cos(angle)
const dy = maxDistance * Math.sin(angle)
// 眼睛移动
if (rightEyeBone) {
rightEyeBone.x = rightEyeCenterX + dx
rightEyeBone.y = rightEyeCenterY + dy
}
if (leftEyeBone) {
leftEyeBone.x = leftEyeCenterX + dx
leftEyeBone.y = leftEyeCenterY + dy
}
// 头部轻微移动
const frontHeadDx = Math.min(distance, frontHeadMaxRadius) * Math.cos(angle)
const frontHeadDy = Math.min(distance, frontHeadMaxRadius) * Math.sin(angle)
const backHeadDx = Math.min(distance, backHeadMaxRadius) * Math.cos(angle)
const backHeadDy = Math.min(distance, backHeadMaxRadius) * Math.sin(angle)
if (frontHeadBone) {
frontHeadBone.x = frontHeadCenterX - frontHeadDx
frontHeadBone.y = frontHeadCenterY + frontHeadDy
}
if (backHeadBone) {
backHeadBone.x = backHeadCenterX + backHeadDx
backHeadBone.y = backHeadCenterY - backHeadDy
}
skeleton.updateWorldTransform()
}
function resetBones() {
if (rightEyeBone) {
rightEyeBone.x = rightEyeCenterX
rightEyeBone.y = rightEyeCenterY
}
if (leftEyeBone) {
leftEyeBone.x = leftEyeCenterX
leftEyeBone.y = leftEyeCenterY
}
if (frontHeadBone) {
frontHeadBone.x = frontHeadCenterX
frontHeadBone.y = frontHeadCenterY
}
if (backHeadBone) {
backHeadBone.x = backHeadCenterX
backHeadBone.y = backHeadCenterY
}
skeleton.updateWorldTransform()
}
// 保存重置函数引用
resetBonesState.value = resetBones
function playBlinkAnimation() {
const randomTime = Math.random() * 3 + 3 // 5-8秒的随机间隔
const shouldDoubleBlink = Math.random() > 0.5 // 随机决定是否连续播放两次
animationState.setAnimation(1, assets.eyeCloseAnimationName, false) // 在轨道1上播放眨眼动画
if (shouldDoubleBlink) {
animationState.addAnimation(1, assets.eyeCloseAnimationName, false, 0.1) // 短暂停留后再播放一次
}
// 随机时间后再调用眨眼动画
blinkInterval = setTimeout(playBlinkAnimation, randomTime * 1000)
}
// 修改鼠标移动监听器的添加逻辑
if (!isMobileDevice()) {
moveBonesHandler = moveBones
window.addEventListener('mousemove', moveBonesHandler)
}
playBlinkAnimation()
},
error: function (playerInstance, reason) {
console.error('Spine加载失败: ' + reason)
}
})
} catch(err) {
console.error('Failed to initialize spine player:', err);
}
}
// 将需要监听的状态提取为响应式引用
const isDarkMode = computed(() => state.darkMode === 'dark')
const isEnabled = computed(() => state.SpinePlayerEnabled)
const currentAssets = computed(() => spineAssets[currentCharacter.value])
// 事件委托
const handleEvents = (event) => {
if (event.type === 'scroll') {
handleScroll()
} else if (['mousemove', 'touchmove'].includes(event.type)) {
moveBonesHandler?.(event)
}
}
// 统一的清理函数
const cleanup = () => {
if (blinkInterval) clearTimeout(blinkInterval)
if (eyeControlTimer) clearTimeout(eyeControlTimer)
// 清理监听事件
window.removeEventListener('scroll', handleEvents)
if (moveBonesHandler && !isMobileDevice()) {
window.removeEventListener('mousemove', moveBonesHandler)
moveBonesHandler = null
}
if (playerContainer.value) {
playerContainer.value.innerHTML = ''
}
if (player) {
AudioManager.clear()
player = null
currentAnimationState = null
}
// 使用 WeakRef 来管理音频资源
if (clientReady.value && window.WeakRef) {
audioPlayers = audioPlayers.filter(player => {
const ref = new WeakRef(player)
return ref.deref() !== null
})
}
}
// 初始化函数
const initializeCharacter = async () => {
cleanup()
if (!isEnabled.value || !playerContainer.value) return
currentCharacter.value = isDarkMode.value ? 'plana' : 'arona'
try {
await Promise.all([
preloadAudio(),
initializeSpinePlayer(currentAssets.value)
])
} catch (err) {
console.error('初始化失败:', err)
}
}
const debouncedInitialize = debounce(initializeCharacter, 300)
// 监听主题切换和spine开关
watch([isDarkMode, isEnabled], async ([dark, enabled], [prevDark, prevEnabled]) => {
if (enabled !== prevEnabled) {
if (enabled) {
// 启用时初始化
debouncedInitialize()
} else {
// 禁用时清理资源
cleanup()
}
} else if (enabled && dark !== prevDark) {
// 主题变更且启用状态下重新初始化
debouncedInitialize()
}
}, { immediate: true })
onMounted(() => {
// 设置客户端就绪状态
clientReady.value = true
const options = { passive: true }
window.addEventListener('scroll', handleEvents, options)
if (!isMobileDevice()) {
window.addEventListener('mousemove', handleEvents, options)
}
// 如果启用了Spine播放器初始化
if (state.SpinePlayerEnabled) {
debouncedInitialize()
}
})
</script>
<style scoped lang="less">
.playerContainer {
position: fixed;
bottom: 25px;
left: 0%;
z-index: 100;
width: 12vw;
height: 24vw;
filter: drop-shadow(0 0 3px rgba(40, 42, 44, 0.42));
transition: all 1s;
cursor: pointer;
}
.chatdialog-container {
position: fixed;
bottom: 10vw;
left: 2vw;
z-index: 101;
transition: all 1s;
pointer-events: none;
filter: drop-shadow(0 0 3px rgba(36, 36, 36, 0.6));
}
.chatdialog-triangle {
position: absolute;
left: 2vw;
top: -10px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid rgba(255, 255, 255, 0.9);
z-index: 101;
}
.chatdialog {
background-color: rgba(255, 255, 255, 0.9);
border-radius: 25px;
padding: 12px 24px;
word-wrap: break-word;
white-space: pre-wrap;
line-height: 1.4;
color: #000000;
font-size: 0.8vw;
user-select: none;
pointer-events: auto;
}
// 添加淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.chatdialog-container {
left: 2vh;
bottom: 10vh;
}
.chatdialog {
min-width: auto;
padding: 12px 20px;
font-size: 1vh;
border-radius: 20px;
}
.chatdialog-triangle {
left: 35px;
border-width: 8px;
top: -8px;
}
.playerContainer {
width: 15vh;
height: 30vh;
}
}
</style>