diff --git a/packages/stage-ui-live2d/package.json b/packages/stage-ui-live2d/package.json index d1e3da058..1de04df31 100644 --- a/packages/stage-ui-live2d/package.json +++ b/packages/stage-ui-live2d/package.json @@ -52,6 +52,7 @@ "@proj-airi/stage-shared": "workspace:^", "@proj-airi/ui": "workspace:^", "@vueuse/core": "^14.2.1", + "animejs": "^4.3.6", "culori": "^4.0.2", "es-toolkit": "catalog:", "jszip": "^3.10.1", diff --git a/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue b/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue index 721c1e2ec..49bc5ceec 100644 --- a/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue +++ b/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue @@ -5,7 +5,8 @@ import type { PixiLive2DInternalModel } from '../../../composables/live2d' import { listenBeatSyncBeatSignal } from '@proj-airi/stage-shared/beat-sync' import { useTheme } from '@proj-airi/ui' -import { breakpointsTailwind, until, useBreakpoints, useDebounceFn } from '@vueuse/core' +import { breakpointsTailwind, until, useBreakpoints } from '@vueuse/core' +import { animate } from 'animejs' import { formatHex } from 'culori' import { Mutex } from 'es-toolkit' import { storeToRefs } from 'pinia' @@ -116,10 +117,9 @@ function getCoreModel() { return model.value!.internalModel.coreModel as any } -function setScaleAndPosition() { - if (!model.value) - return +let resizeAnimation: ReturnType | undefined +function computeScaleAndPosition() { let offsetFactor = 2.2 if (isMobile.value) { offsetFactor = 2.2 @@ -129,15 +129,52 @@ function setScaleAndPosition() { const widthScale = (props.width * 0.95 / initialModelWidth.value * offsetFactor) let scale = Math.min(heightScale, widthScale) - // Prevent zero or NaN values to fix the "headless" model issue. if (Number.isNaN(scale) || scale <= 0) { scale = 1e-6 } - model.value.scale.set(scale * props.scale, scale * props.scale) + return { + scale: scale * props.scale, + x: (props.width / 2) + offset.value.xOffset, + y: props.height + offset.value.yOffset, + } +} - model.value.x = (props.width / 2) + offset.value.xOffset - model.value.y = props.height + offset.value.yOffset +function setScaleAndPosition(animated = false) { + if (!model.value) + return + + const target = computeScaleAndPosition() + + if (!animated) { + model.value.scale.set(target.scale, target.scale) + model.value.x = target.x + model.value.y = target.y + return + } + + resizeAnimation?.pause() + + const current = { + scale: model.value.scale.x, + x: model.value.x, + y: model.value.y, + } + + resizeAnimation = animate(current, { + scale: target.scale, + x: target.x, + y: target.y, + duration: 200, + ease: 'outQuad', + onUpdate: () => { + if (!model.value) + return + model.value.scale.set(current.scale, current.scale) + model.value.x = current.x + model.value.y = current.y + }, + }) } const live2dStore = useLive2d() @@ -398,7 +435,9 @@ async function setMotion(motionName: string, index?: number) { } } -const handleResize = useDebounceFn(setScaleAndPosition, 100) +function handleResize() { + setScaleAndPosition(true) +} const dropShadowColorComputer = ref() const dropShadowAnimationId = ref(0) @@ -425,8 +464,8 @@ watch(modelSrcRef, async () => await loadModel(), { immediate: true }) watch(dark, updateDropShadowFilter, { immediate: true }) watch([model, themeColorsHue], updateDropShadowFilter) watch(live2dShadowEnabled, updateDropShadowFilter) -watch(offset, setScaleAndPosition) -watch(() => props.scale, setScaleAndPosition) +watch(offset, () => setScaleAndPosition()) +watch(() => props.scale, () => setScaleAndPosition()) // TODO: This is hacky! function updateDropShadowFilterLoop() { @@ -625,6 +664,7 @@ onMounted(async () => { onUnmounted(() => { isUnmounted = true + resizeAnimation?.pause() disposeShouldUpdateView?.() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b4683285..b6d4f04c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2910,6 +2910,9 @@ importers: '@vueuse/core': specifier: ^14.2.1 version: 14.2.1(vue@3.5.29(typescript@5.9.3)) + animejs: + specifier: ^4.3.6 + version: 4.3.6 culori: specifier: ^4.0.2 version: 4.0.2