feat(stage-ui): add live2d parameters controls and idle animation toggle (#680)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user