feat(stage-ui): add live2d parameters controls and idle animation toggle (#680)

This commit is contained in:
Mediocore
2025-11-05 05:36:44 +08:00
committed by GitHub
parent 51081dc4df
commit ecaee565c6
4 changed files with 700 additions and 3 deletions
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Checkbox, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useLive2d } from '../../../../stores/live2d'
import { defaultModelParameters, useLive2d } from '../../../../stores/live2d'
import { useSettings } from '../../../../stores/settings'
import { Section } from '../../../layouts'
import { Button } from '../../../misc'
@@ -20,14 +21,201 @@ defineEmits<{
const { t } = useI18n()
const settings = useSettings()
const { live2dDisableFocus } = storeToRefs(settings)
const { live2dDisableFocus, live2dIdleAnimationEnabled, live2dAutoBlinkEnabled } = storeToRefs(settings)
const live2d = useLive2d()
const {
scale,
position,
modelParameters,
} = storeToRefs(live2d)
// Function to reset all parameters to default values
function resetToDefaultParameters() {
modelParameters.value = { ...defaultModelParameters }
}
// Auto blink slider animation
let blinkAnimationId: number | null = null
let nextBlinkTimeout: ReturnType<typeof setTimeout> | null = null
const blinkCloseDuration = 200 // 0.2 seconds to close (1 to 0)
const blinkOpenDuration = 200 // 0.2 seconds to open (0 to 1)
const totalBlinkDuration = blinkCloseDuration + blinkOpenDuration
let blinkStartTime = 0
let blinkStartLeft = 1
let blinkStartRight = 1
let isAnimating = false
let isUpdatingFromAnimation = false
let userInteractionTimeout: ReturnType<typeof setTimeout> | null = null
function stopBlinkAnimation() {
if (blinkAnimationId !== null) {
cancelAnimationFrame(blinkAnimationId)
blinkAnimationId = null
isAnimating = false
}
if (nextBlinkTimeout !== null) {
clearTimeout(nextBlinkTimeout)
nextBlinkTimeout = null
}
if (userInteractionTimeout !== null) {
clearTimeout(userInteractionTimeout)
userInteractionTimeout = null
}
}
function animateEyeBlink() {
// Prevent multiple animations from running simultaneously
if (isAnimating || nextBlinkTimeout !== null) {
return
}
stopBlinkAnimation()
if (!live2dAutoBlinkEnabled.value) {
return
}
isAnimating = true
blinkStartTime = Date.now()
blinkStartLeft = modelParameters.value.leftEyeOpen
blinkStartRight = modelParameters.value.rightEyeOpen
function animate() {
if (!live2dAutoBlinkEnabled.value) {
stopBlinkAnimation()
return
}
const elapsed = Date.now() - blinkStartTime
const progress = Math.min(elapsed / totalBlinkDuration, 1)
let newLeft: number
let newRight: number
if (elapsed < blinkCloseDuration) {
// Closing phase: 1 -> 0
const closeProgress = elapsed / blinkCloseDuration
const eased = 1 - (1 - closeProgress) ** 2 // Ease out
newLeft = blinkStartLeft + (0 - blinkStartLeft) * eased
newRight = blinkStartRight + (0 - blinkStartRight) * eased
}
else {
// Opening phase: 0 -> 1
const openProgress = (elapsed - blinkCloseDuration) / blinkOpenDuration
const eased = openProgress * openProgress // Ease in
newLeft = 0 + (blinkStartLeft - 0) * eased
newRight = 0 + (blinkStartRight - 0) * eased
}
isUpdatingFromAnimation = true
modelParameters.value.leftEyeOpen = Math.round(newLeft * 100) / 100
modelParameters.value.rightEyeOpen = Math.round(newRight * 100) / 100
// Use nextTick to ensure watchers see the flag before it's cleared
setTimeout(() => {
isUpdatingFromAnimation = false
}, 0)
if (progress < 1) {
blinkAnimationId = requestAnimationFrame(animate)
}
else {
// Animation complete, eyes back to starting value
isUpdatingFromAnimation = true
modelParameters.value.leftEyeOpen = Math.round(blinkStartLeft * 100) / 100
modelParameters.value.rightEyeOpen = Math.round(blinkStartRight * 100) / 100
setTimeout(() => {
isUpdatingFromAnimation = false
}, 0)
isAnimating = false
blinkAnimationId = null
// Trigger next blink after a delay
if (live2dAutoBlinkEnabled.value) {
const nextBlinkDelay = 5000 + Math.random() * 5000 // 5-10 seconds
nextBlinkTimeout = setTimeout(() => {
nextBlinkTimeout = null
if (live2dAutoBlinkEnabled.value && !isAnimating) {
animateEyeBlink()
}
}, nextBlinkDelay)
}
}
}
blinkAnimationId = requestAnimationFrame(animate)
}
// Watch for auto blink enabled/disabled
watch(live2dAutoBlinkEnabled, (enabled) => {
if (enabled) {
// Start animation when enabled, but only if not already running
if (!isAnimating && nextBlinkTimeout === null) {
animateEyeBlink()
}
}
else {
// Stop animation when disabled
stopBlinkAnimation()
}
})
// Track user interaction with sliders - only react if not currently animating
watch(() => modelParameters.value.leftEyeOpen, (newValue, oldValue) => {
if (!live2dAutoBlinkEnabled.value || isAnimating || isUpdatingFromAnimation || nextBlinkTimeout !== null) {
return
}
if (oldValue == null && newValue > 0) {
animateEyeBlink()
return
}
// If user manually changed the value while auto blink is enabled
if (oldValue !== undefined && Math.abs(newValue - oldValue) > 0.01) {
stopBlinkAnimation()
// Resume animation after user stops interacting
if (userInteractionTimeout !== null) {
clearTimeout(userInteractionTimeout)
}
userInteractionTimeout = setTimeout(() => {
if (live2dAutoBlinkEnabled.value && !isAnimating && nextBlinkTimeout === null) {
animateEyeBlink()
}
userInteractionTimeout = null
}, 1000)
}
}, { immediate: true })
watch(() => modelParameters.value.rightEyeOpen, (newValue, oldValue) => {
if (!live2dAutoBlinkEnabled.value || isAnimating || isUpdatingFromAnimation || nextBlinkTimeout !== null) {
return
}
if (oldValue == null && newValue > 0) {
animateEyeBlink()
return
}
// If user manually changed the value while auto blink is enabled
if (oldValue !== undefined && Math.abs(newValue - oldValue) > 0.01) {
stopBlinkAnimation()
// Resume animation after user stops interacting
if (userInteractionTimeout !== null) {
clearTimeout(userInteractionTimeout)
}
userInteractionTimeout = setTimeout(() => {
if (live2dAutoBlinkEnabled.value && !isAnimating && nextBlinkTimeout === null) {
animateEyeBlink()
}
userInteractionTimeout = null
}, 1000)
}
}, { immediate: true })
onUnmounted(() => {
stopBlinkAnimation()
})
// async function patchMotionMap(source: File, motionMap: Record<string, string>): Promise<File> {
// if (!Object.keys(motionMap).length)
// return source
@@ -189,4 +377,283 @@ const {
:label="t('settings.live2d.focus.button-disable.title')"
/>
</Section>
<Section
title="Parameters"
icon="i-solar:settings-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<div flex items-center justify-between>
<span text-sm text-neutral-600 dark:text-neutral-400>Idle Animation</span>
<Checkbox v-model="live2dIdleAnimationEnabled" />
</div>
<div mt-4 flex items-center justify-between>
<span text-sm text-neutral-600 dark:text-neutral-400>Auto Blink</span>
<Checkbox v-model="live2dAutoBlinkEnabled" />
</div>
<button
mt-4 w-full border rounded bg-neutral-100 px-4 py-2 text-sm text-neutral-700 font-medium transition-colors dark:border-neutral-700 dark:bg-neutral-800 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700
@click="resetToDefaultParameters"
>
Reset To Default Parameters
</button>
<!-- Head Rotation -->
<div mb-2 mt-4 text-xs text-neutral-500 font-semibold dark:text-neutral-400>
Head Rotation
</div>
<FieldRange v-model="modelParameters.angleX" as="div" :min="-30" :max="30" :step="0.1" label="Angle X">
<template #label>
<div flex items-center>
<div>Angle X</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.angleX = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.angleY" as="div" :min="-30" :max="30" :step="0.1" label="Angle Y">
<template #label>
<div flex items-center>
<div>Angle Y</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.angleY = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.angleZ" as="div" :min="-30" :max="30" :step="0.1" label="Angle Z">
<template #label>
<div flex items-center>
<div>Angle Z</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.angleZ = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<!-- Eyes -->
<div mb-2 mt-4 text-xs text-neutral-500 font-semibold dark:text-neutral-400>
Eyes
</div>
<FieldRange v-model="modelParameters.leftEyeOpen" as="div" :min="0" :max="1" :step="0.01" label="Left Eye Open/Close">
<template #label>
<div flex items-center>
<div>Left Eye Open/Close</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.leftEyeOpen = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.rightEyeOpen" as="div" :min="0" :max="1" :step="0.01" label="Right Eye Open/Close">
<template #label>
<div flex items-center>
<div>Right Eye Open/Close</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.rightEyeOpen = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.leftEyeSmile" as="div" :min="0" :max="1" :step="0.01" label="Left Eye Smiling">
<template #label>
<div flex items-center>
<div>Left Eye Smiling</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.leftEyeSmile = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.rightEyeSmile" as="div" :min="0" :max="1" :step="0.01" label="Right Eye Smiling">
<template #label>
<div flex items-center>
<div>Right Eye Smiling</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.rightEyeSmile = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<!-- Eyebrows -->
<div mb-2 mt-4 text-xs text-neutral-500 font-semibold dark:text-neutral-400>
Eyebrows
</div>
<FieldRange v-model="modelParameters.leftEyebrowLR" as="div" :min="-1" :max="1" :step="0.01" label="Left eyebrow Left/Right">
<template #label>
<div flex items-center>
<div>Left eyebrow Left/Right</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.leftEyebrowLR = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.rightEyebrowLR" as="div" :min="-1" :max="1" :step="0.01" label="Right eyebrow Left/Right">
<template #label>
<div flex items-center>
<div>Right eyebrow Left/Right</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.rightEyebrowLR = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.leftEyebrowY" as="div" :min="-1" :max="1" :step="0.01" label="Left Eyebrow Y (Up/Down)">
<template #label>
<div flex items-center>
<div>Left Eyebrow Y</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.leftEyebrowY = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.rightEyebrowY" as="div" :min="-1" :max="1" :step="0.01" label="Right Eyebrow Y (Up/Down)">
<template #label>
<div flex items-center>
<div>Right Eyebrow Y</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.rightEyebrowY = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.leftEyebrowAngle" as="div" :min="-1" :max="1" :step="0.01" label="Left Eyebrow Angle">
<template #label>
<div flex items-center>
<div>Left Eyebrow Angle</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.leftEyebrowAngle = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.rightEyebrowAngle" as="div" :min="-1" :max="1" :step="0.01" label="Right Eyebrow Angle">
<template #label>
<div flex items-center>
<div>Right Eyebrow Angle</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.rightEyebrowAngle = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.leftEyebrowForm" as="div" :min="-1" :max="1" :step="0.01" label="Left Eyebrow Form (Deformation)">
<template #label>
<div flex items-center>
<div>Left Eyebrow Form</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.leftEyebrowForm = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.rightEyebrowForm" as="div" :min="-1" :max="1" :step="0.01" label="Right Eyebrow Form (Deformation)">
<template #label>
<div flex items-center>
<div>Right Eyebrow Form</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.rightEyebrowForm = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<!-- Mouth -->
<div mb-2 mt-4 text-xs text-neutral-500 font-semibold dark:text-neutral-400>
Mouth
</div>
<FieldRange v-model="modelParameters.mouthOpen" as="div" :min="0" :max="1" :step="0.01" label="Mouth Open/Close">
<template #label>
<div flex items-center>
<div>Mouth Open/Close</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.mouthOpen = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.mouthForm" as="div" :min="-1" :max="1" :step="0.01" label="Mouth Form (Deformation)">
<template #label>
<div flex items-center>
<div>Mouth Form</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.mouthForm = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<!-- Face -->
<div mb-2 mt-4 text-xs text-neutral-500 font-semibold dark:text-neutral-400>
Face
</div>
<FieldRange v-model="modelParameters.cheek" as="div" :min="0" :max="1" :step="0.01" label="Cheek">
<template #label>
<div flex items-center>
<div>Cheek</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.cheek = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<!-- Body -->
<div mb-2 mt-4 text-xs text-neutral-500 font-semibold dark:text-neutral-400>
Body
</div>
<FieldRange v-model="modelParameters.bodyAngleX" as="div" :min="-10" :max="10" :step="0.1" label="Body rotation X">
<template #label>
<div flex items-center>
<div>Body rotation X</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.bodyAngleX = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.bodyAngleY" as="div" :min="-10" :max="10" :step="0.1" label="Body rotation Y">
<template #label>
<div flex items-center>
<div>Body rotation Y</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.bodyAngleY = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.bodyAngleZ" as="div" :min="-10" :max="10" :step="0.1" label="Body rotation Z">
<template #label>
<div flex items-center>
<div>Body rotation Z</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.bodyAngleZ = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="modelParameters.breath" as="div" :min="0" :max="1" :step="0.01" label="Breath">
<template #label>
<div flex items-center>
<div>Breath</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelParameters.breath = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
</template>
@@ -126,11 +126,13 @@ const {
currentMotion,
availableMotions,
motionMap,
modelParameters,
} = storeToRefs(useLive2d())
const {
themeColorsHue,
themeColorsHueDynamic,
live2dIdleAnimationEnabled,
} = storeToRefs(useSettings())
const localCurrentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 })
@@ -278,11 +280,27 @@ async function loadModel() {
lastUpdateTime.value = now
const isIdleMotion = !motionManager.state.currentGroup || motionManager.state.currentGroup === motionManager.groups.idle
// Stop idle motions if they're disabled
if (!live2dIdleAnimationEnabled.value && isIdleMotion) {
motionManager.stopAllMotions()
// Still update eye focus and blink even if idle motion is stopped
idleEyeFocus.update(internalModel, now)
if (internalModel.eyeBlink != null) {
internalModel.eyeBlink.updateParameters(model, timeDelta / 1000)
}
// Apply manual eye parameters after auto eye blink
coreModel.setParameterValueById('ParamEyeLOpen', modelParameters.value.leftEyeOpen)
coreModel.setParameterValueById('ParamEyeROpen', modelParameters.value.rightEyeOpen)
return true
}
hookedUpdate?.call(this, model, now)
// Possibility 1: Only update eye focus when the model is idle
// Possibility 2: For models having no motion groups, currentGroup will be undefined while groups can be { idle: ... }
if (!motionManager.state.currentGroup || motionManager.state.currentGroup === motionManager.groups.idle) {
if (isIdleMotion) {
idleEyeFocus.update(internalModel, now)
// If the model has eye blink parameters
@@ -310,6 +328,10 @@ async function loadModel() {
internalModel.eyeBlink.updateParameters(model, (now - lastUpdateTime.value) / 1000)
}
// Apply manual eye parameters after auto eye blink
coreModel.setParameterValueById('ParamEyeLOpen', modelParameters.value.leftEyeOpen)
coreModel.setParameterValueById('ParamEyeROpen', modelParameters.value.rightEyeOpen)
// still, mark the motion as updated
return true
}
@@ -321,6 +343,29 @@ async function loadModel() {
localCurrentMotion.value = { group, index }
})
// Apply all stored parameters to the model
coreModel.setParameterValueById('ParamAngleX', modelParameters.value.angleX)
coreModel.setParameterValueById('ParamAngleY', modelParameters.value.angleY)
coreModel.setParameterValueById('ParamAngleZ', modelParameters.value.angleZ)
coreModel.setParameterValueById('ParamEyeLOpen', modelParameters.value.leftEyeOpen)
coreModel.setParameterValueById('ParamEyeROpen', modelParameters.value.rightEyeOpen)
coreModel.setParameterValueById('ParamEyeSmile', modelParameters.value.leftEyeSmile)
coreModel.setParameterValueById('ParamBrowLX', modelParameters.value.leftEyebrowLR)
coreModel.setParameterValueById('ParamBrowRX', modelParameters.value.rightEyebrowLR)
coreModel.setParameterValueById('ParamBrowLY', modelParameters.value.leftEyebrowY)
coreModel.setParameterValueById('ParamBrowRY', modelParameters.value.rightEyebrowY)
coreModel.setParameterValueById('ParamBrowLAngle', modelParameters.value.leftEyebrowAngle)
coreModel.setParameterValueById('ParamBrowRAngle', modelParameters.value.rightEyebrowAngle)
coreModel.setParameterValueById('ParamBrowLForm', modelParameters.value.leftEyebrowForm)
coreModel.setParameterValueById('ParamBrowRForm', modelParameters.value.rightEyebrowForm)
coreModel.setParameterValueById('ParamMouthOpenY', modelParameters.value.mouthOpen)
coreModel.setParameterValueById('ParamMouthForm', modelParameters.value.mouthForm)
coreModel.setParameterValueById('ParamCheek', modelParameters.value.cheek)
coreModel.setParameterValueById('ParamBodyAngleX', modelParameters.value.bodyAngleX)
coreModel.setParameterValueById('ParamBodyAngleY', modelParameters.value.bodyAngleY)
coreModel.setParameterValueById('ParamBodyAngleZ', modelParameters.value.bodyAngleZ)
coreModel.setParameterValueById('ParamBreath', modelParameters.value.breath)
emits('modelLoaded')
}
finally {
@@ -374,6 +419,158 @@ watch(mouthOpenSize, value => getCoreModel().setParameterValueById('ParamMouthOp
watch(currentMotion, value => setMotion(value.group, value.index))
watch(paused, value => value ? pixiApp.value?.stop() : pixiApp.value?.start())
// Watch and apply model parameters
watch(() => modelParameters.value.angleX, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamAngleX', value)
}
})
watch(() => modelParameters.value.angleY, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamAngleY', value)
}
})
watch(() => modelParameters.value.angleZ, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamAngleZ', value)
}
})
watch(() => modelParameters.value.leftEyeOpen, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamEyeLOpen', value)
}
})
watch(() => modelParameters.value.rightEyeOpen, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamEyeROpen', value)
}
})
watch(() => modelParameters.value.mouthOpen, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamMouthOpenY', value)
}
})
watch(() => modelParameters.value.mouthForm, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamMouthForm', value)
}
})
watch(() => modelParameters.value.cheek, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamCheek', value)
}
})
watch(() => modelParameters.value.bodyAngleX, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBodyAngleX', value)
}
})
watch(() => modelParameters.value.bodyAngleY, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBodyAngleY', value)
}
})
watch(() => modelParameters.value.bodyAngleZ, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBodyAngleZ', value)
}
})
watch(() => modelParameters.value.breath, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBreath', value)
}
})
// Watch eyebrow parameters
watch(() => modelParameters.value.leftEyebrowLR, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowLX', value)
}
})
watch(() => modelParameters.value.rightEyebrowLR, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowRX', value)
}
})
watch(() => modelParameters.value.leftEyebrowY, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowLY', value)
}
})
watch(() => modelParameters.value.rightEyebrowY, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowRY', value)
}
})
watch(() => modelParameters.value.leftEyebrowAngle, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowLAngle', value)
}
})
watch(() => modelParameters.value.rightEyebrowAngle, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowRAngle', value)
}
})
watch(() => modelParameters.value.leftEyebrowForm, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowLForm', value)
}
})
watch(() => modelParameters.value.rightEyebrowForm, (value) => {
if (model.value) {
const internalModel = model.value.internalModel
internalModel.coreModel.setParameterValueById('ParamBrowRForm', value)
}
})
// Watch for idle animation setting changes and stop motions if disabled
watch(live2dIdleAnimationEnabled, (enabled) => {
if (!enabled && model.value) {
const internalModel = model.value.internalModel
if (internalModel?.motionManager) {
internalModel.motionManager.stopAllMotions()
}
}
})
watch(focusAt, (value) => {
if (!model.value)
return
+29
View File
@@ -9,6 +9,31 @@ interface BroadcastChannelEventShouldUpdateView {
type: 'should-update-view'
}
export const defaultModelParameters = {
angleX: 0,
angleY: 0,
angleZ: 0,
leftEyeOpen: 1,
rightEyeOpen: 1,
leftEyeSmile: 0,
rightEyeSmile: 0,
leftEyebrowLR: 0,
rightEyebrowLR: 0,
leftEyebrowY: 0,
rightEyebrowY: 0,
leftEyebrowAngle: 0,
rightEyebrowAngle: 0,
leftEyebrowForm: 0,
rightEyebrowForm: 0,
mouthOpen: 0,
mouthForm: 0,
cheek: 0,
bodyAngleX: 0,
bodyAngleY: 0,
bodyAngleZ: 0,
breath: 0,
}
export const useLive2d = defineStore('live2d', () => {
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-live2d' })
const shouldUpdateViewHooks = ref<Array<() => void>>([])
@@ -38,6 +63,9 @@ export const useLive2d = defineStore('live2d', () => {
const motionMap = useLocalStorage<Record<string, string>>('settings/live2d/motion-map', {})
const scale = useLocalStorage('settings/live2d/scale', 1)
// Live2D model parameters
const modelParameters = useLocalStorage('settings/live2d/parameters', defaultModelParameters)
return {
position,
positionInPercentageString,
@@ -45,6 +73,7 @@ export const useLive2d = defineStore('live2d', () => {
availableMotions,
motionMap,
scale,
modelParameters,
onShouldUpdateView,
shouldUpdateView,
+4
View File
@@ -100,6 +100,8 @@ export const useSettings = defineStore('settings', () => {
const stageViewControlsEnabled = ref(false)
const live2dDisableFocus = useLocalStorage('settings/live2d/disable-focus', false)
const live2dIdleAnimationEnabled = useLocalStorage('settings/live2d/idle-animation-enabled', true)
const live2dAutoBlinkEnabled = useLocalStorage('settings/live2d/auto-blink-enabled', true)
const disableTransitions = useLocalStorage('settings/disable-transitions', true)
const usePageSpecificTransitions = useLocalStorage('settings/use-page-specific-transitions', true)
@@ -170,6 +172,8 @@ export const useSettings = defineStore('settings', () => {
stageViewControlsEnabled,
live2dDisableFocus,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
themeColorsHue,
themeColorsHueDynamic,