fix(stage-ui): live2d auto blink with better control

This commit is contained in:
Neko Ayaka
2025-12-27 16:40:41 +08:00
parent 3dcc9a0677
commit b8810943f8
4 changed files with 126 additions and 186 deletions
@@ -19,7 +19,7 @@ defineEmits<{
const { t } = useI18n()
const settings = useSettings()
const { live2dDisableFocus, live2dIdleAnimationEnabled, live2dAutoBlinkEnabled, live2dShadowEnabled } = storeToRefs(settings)
const { live2dDisableFocus, live2dIdleAnimationEnabled, live2dAutoBlinkEnabled, live2dForceAutoBlinkEnabled, live2dShadowEnabled } = storeToRefs(settings)
const live2d = useLive2d()
const {
@@ -107,187 +107,6 @@ onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 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
@@ -355,7 +174,7 @@ onUnmounted(() => {
</div>
</template>
</FieldRange>
<FieldRange v-model="position.x" as="div" :min="-1000" :max="1000" :step="1" :label="t('settings.live2d.scale-and-position.x')">
<FieldRange v-model="position.x" as="div" :min="-3000" :max="3000" :step="1" :label="t('settings.live2d.scale-and-position.x')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.x') }}</div>
@@ -365,7 +184,7 @@ onUnmounted(() => {
</div>
</template>
</FieldRange>
<FieldRange v-model="position.y" as="div" :min="-1000" :max="1000" :step="1" :label="t('settings.live2d.scale-and-position.y')">
<FieldRange v-model="position.y" as="div" :min="-3000" :max="3000" :step="1" :label="t('settings.live2d.scale-and-position.y')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.y') }}</div>
@@ -514,6 +333,11 @@ onUnmounted(() => {
<Checkbox v-model="live2dAutoBlinkEnabled" />
</div>
<div mt-3 flex items-center justify-between>
<span text-sm text-neutral-600 dark:text-neutral-400>Force Auto Blink (fallback timer)</span>
<Checkbox v-model="live2dForceAutoBlinkEnabled" />
</div>
<div mt-4 flex items-center justify-between>
<span text-sm text-neutral-600 dark:text-neutral-400>Shadow</span>
<Checkbox v-model="live2dShadowEnabled" />
@@ -140,6 +140,8 @@ const {
themeColorsHue,
themeColorsHueDynamic,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
live2dShadowEnabled,
} = storeToRefs(useSettings())
@@ -296,6 +298,8 @@ async function loadModel() {
motionManager,
modelParameters,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
lastUpdateTime,
})
@@ -25,6 +25,8 @@ export type MotionManagerPluginContext = MotionManagerUpdateContext & {
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dIdleAnimationEnabled: Ref<boolean>
live2dAutoBlinkEnabled: Ref<boolean>
live2dForceAutoBlinkEnabled: Ref<boolean>
isIdleMotion: boolean
handled: boolean
markHandled: () => void
@@ -37,6 +39,8 @@ export interface UseLive2DMotionManagerUpdateOptions {
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dIdleAnimationEnabled: Ref<boolean>
live2dAutoBlinkEnabled: Ref<boolean>
live2dForceAutoBlinkEnabled: Ref<boolean>
lastUpdateTime: Ref<number>
}
@@ -46,6 +50,8 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
motionManager,
modelParameters,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
lastUpdateTime,
} = options
@@ -83,6 +89,8 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
motionManager,
modelParameters,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
isIdleMotion,
handled: false,
markHandled: () => {
@@ -212,12 +220,110 @@ export function useMotionUpdatePluginIdleFocus(idleEyeFocus = useLive2DIdleEyeFo
}
export function useMotionUpdatePluginAutoEyeBlink(): MotionManagerPlugin {
const blinkState = {
phase: 'idle' as 'idle' | 'closing' | 'opening',
progress: 0,
startLeft: 1,
startRight: 1,
delayMs: 0,
}
const blinkCloseDuration = 200 // ms
const blinkOpenDuration = 200 // ms
const minDelay = 3000
const maxDelay = 8000
const clamp01 = (value: number) => Math.min(1, Math.max(0, value))
function resetBlinkState() {
blinkState.phase = 'idle'
blinkState.progress = 0
blinkState.delayMs = minDelay + Math.random() * (maxDelay - minDelay)
}
resetBlinkState()
function easeOutQuad(t: number) {
return 1 - (1 - t) * (1 - t)
}
function easeInQuad(t: number) {
return t * t
}
function updateForcedBlink(dt: number, baseLeft: number, baseRight: number) {
// Idle: count down delay to next blink.
if (blinkState.phase === 'idle') {
blinkState.delayMs = Math.max(0, blinkState.delayMs - dt)
if (blinkState.delayMs === 0) {
blinkState.phase = 'closing'
blinkState.progress = 0
blinkState.startLeft = baseLeft
blinkState.startRight = baseRight
}
return { eyeLOpen: baseLeft, eyeROpen: baseRight }
}
// Closing: move toward zero with ease-out.
if (blinkState.phase === 'closing') {
blinkState.progress = Math.min(1, blinkState.progress + dt / blinkCloseDuration)
const eased = easeOutQuad(blinkState.progress)
const eyeLOpen = clamp01(blinkState.startLeft * (1 - eased))
const eyeROpen = clamp01(blinkState.startRight * (1 - eased))
if (blinkState.progress >= 1) {
blinkState.phase = 'opening'
blinkState.progress = 0
}
return { eyeLOpen, eyeROpen }
}
// Opening: move back to the base with ease-in.
blinkState.progress = Math.min(1, blinkState.progress + dt / blinkOpenDuration)
const eased = easeInQuad(blinkState.progress)
const eyeLOpen = clamp01(blinkState.startLeft * eased)
const eyeROpen = clamp01(blinkState.startRight * eased)
if (blinkState.progress >= 1) {
resetBlinkState()
}
return { eyeLOpen, eyeROpen }
}
return (ctx) => {
// 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 (!ctx.isIdleMotion || ctx.handled)
return
const baseLeft = clamp01(ctx.modelParameters.value.leftEyeOpen)
const baseRight = clamp01(ctx.modelParameters.value.rightEyeOpen)
// If the user disabled auto blink entirely, keep manual values and bail. Reset state so re-enabling starts fresh.
if (!ctx.live2dAutoBlinkEnabled.value) {
resetBlinkState()
ctx.model.setParameterValueById('ParamEyeLOpen', baseLeft)
ctx.model.setParameterValueById('ParamEyeROpen', baseRight)
ctx.markHandled()
return
}
// Option 1: Force auto blink via our own timer (for models without eyeBlink or when forced in settings).
if (ctx.live2dForceAutoBlinkEnabled.value || !ctx.internalModel.eyeBlink) {
// timeDelta can be seconds or milliseconds depending on source; normalize to ms.
const rawDelta = Math.max(ctx.timeDelta ?? 0, 0)
const dt = rawDelta < 5 ? rawDelta * 1000 : rawDelta // If less than 5, treat as seconds (e.g., 0.016s -> 16ms).
const safeDt = dt || 16 // Fallback to ~1 frame to avoid getting stuck when timeDelta is 0 on first tick.
const { eyeLOpen, eyeROpen } = updateForcedBlink(safeDt, baseLeft, baseRight)
ctx.model.setParameterValueById('ParamEyeLOpen', eyeLOpen)
ctx.model.setParameterValueById('ParamEyeROpen', eyeROpen)
ctx.markHandled()
return
}
// Option 2: Let Cubism drive the blink, but scale it with the user-provided base.
// If the model has eye blink parameters
if (ctx.internalModel.eyeBlink != null) {
// For the part of the auto eye blink implementation in pixi-live2d-display
@@ -244,8 +350,11 @@ export function useMotionUpdatePluginAutoEyeBlink(): MotionManagerPlugin {
}
// Apply manual eye parameters after auto eye blink
ctx.model.setParameterValueById('ParamEyeLOpen', ctx.modelParameters.value.leftEyeOpen)
ctx.model.setParameterValueById('ParamEyeROpen', ctx.modelParameters.value.rightEyeOpen)
const blinkLeft = ctx.model.getParameterValueById('ParamEyeLOpen') as number
const blinkRight = ctx.model.getParameterValueById('ParamEyeROpen') as number
ctx.model.setParameterValueById('ParamEyeLOpen', clamp01(blinkLeft * baseLeft))
ctx.model.setParameterValueById('ParamEyeROpen', clamp01(blinkRight * baseRight))
ctx.markHandled()
}
+3
View File
@@ -102,6 +102,7 @@ export const useSettings = defineStore('settings', () => {
const [live2dDisableFocus, resetLive2dDisableFocus] = createResettableLocalStorage('settings/live2d/disable-focus', false)
const [live2dIdleAnimationEnabled, resetLive2dIdleAnimationEnabled] = createResettableLocalStorage('settings/live2d/idle-animation-enabled', true)
const [live2dAutoBlinkEnabled, resetLive2dAutoBlinkEnabled] = createResettableLocalStorage('settings/live2d/auto-blink-enabled', true)
const [live2dForceAutoBlinkEnabled, resetLive2dForceAutoBlinkEnabled] = createResettableLocalStorage('settings/live2d/force-auto-blink-enabled', false)
const [live2dShadowEnabled, resetLive2dShadowEnabled] = createResettableLocalStorage('settings/live2d/shadow-enabled', true)
const [disableTransitions, resetDisableTransitions] = createResettableLocalStorage('settings/disable-transitions', true)
@@ -173,6 +174,7 @@ export const useSettings = defineStore('settings', () => {
resetLive2dDisableFocus()
resetLive2dIdleAnimationEnabled()
resetLive2dAutoBlinkEnabled()
resetLive2dForceAutoBlinkEnabled()
resetLive2dShadowEnabled()
resetDisableTransitions()
@@ -202,6 +204,7 @@ export const useSettings = defineStore('settings', () => {
live2dDisableFocus,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
live2dShadowEnabled,
themeColorsHue,
themeColorsHueDynamic,