fix(stage-ui): live2d auto blink with better control
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user