From c511bdead9940cedf4d3fb361d6f49acc28b2e9a Mon Sep 17 00:00:00 2001 From: Rin Date: Tue, 25 Aug 2026 01:48:03 +0800 Subject: [PATCH] feat(stage-ui-live2d): offset model with cursor --- .../src/components/scenes/Live2D.vue | 14 +++++++++-- .../src/components/scenes/live2d/Model.vue | 6 +++-- .../composables/live2d/eye-tracking.test.ts | 23 +++++++++++++++++ .../src/composables/live2d/eye-tracking.ts | 25 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue index 989206826..7d2527ec5 100644 --- a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue +++ b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue @@ -3,12 +3,12 @@ import type { Live2DEyeFocusSource } from '../../composables/live2d' import { Screen } from '@proj-airi/ui' import { storeToRefs } from 'pinia' -import { onUnmounted, ref, watch } from 'vue' +import { computed, onUnmounted, ref, watch } from 'vue' import Live2DCanvas from './live2d/Canvas.vue' import Live2DModel from './live2d/Model.vue' -import { useLive2DEyeFocusFor, useSettingsLive2d } from '../../composables/live2d' +import { getLive2DModelMouseOffset, useLive2DEyeFocusFor, useSettingsLive2d } from '../../composables/live2d' import '../../utils/live2d-zip-loader' import '../../utils/live2d-opfs-registration' @@ -64,6 +64,15 @@ const mouseFocus = useLive2DEyeFocusFor({ }), source: activeCursorPosition, }) +const mouseModelOffset = computed(() => { + if (!live2dEyeTracking.value) + return { x: 0, y: 0 } + + return getLive2DModelMouseOffset( + activeCursorPosition.value, + live2dCanvasRef.value?.canvasElement()?.getBoundingClientRect(), + ) +}) watch(() => props.cursorPosition, (cursorPosition) => { activeCursorPosition.value = cursorPosition ? { ...cursorPosition } : null @@ -120,6 +129,7 @@ defineExpose({ :height="height" :paused="paused" :focus-at="mouseFocus" + :mouse-offset="mouseModelOffset" :eye-tracking="live2dEyeTracking" :eye-focus-source-active="!!activeCursorPosition" :theme-colors-hue="themeColorsHue" 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 c367c5ecf..733742776 100644 --- a/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue +++ b/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue @@ -42,6 +42,7 @@ const props = withDefaults(defineProps<{ height: number paused?: boolean focusAt?: { x: number, y: number } + mouseOffset?: { x: number, y: number } eyeTracking?: boolean eyeFocusSourceActive?: boolean themeColorsHue?: number @@ -57,6 +58,7 @@ const props = withDefaults(defineProps<{ nowSpeaking: false, paused: false, focusAt: () => ({ x: 0, y: 0 }), + mouseOffset: () => ({ x: 0, y: 0 }), eyeTracking: false, eyeFocusSourceActive: false, disableFocusAt: false, @@ -88,8 +90,8 @@ let isUnmounted = false const modelLoadMutex = new Mutex() const offset = computed(() => ({ - x: (position.value.x / 100) * props.width, - y: -(position.value.y / 100) * props.height, + x: (position.value.x / 100) * props.width + props.mouseOffset.x, + y: -(position.value.y / 100) * props.height + props.mouseOffset.y, })) const pixiApp = toRef(() => props.app) diff --git a/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.test.ts b/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.test.ts index 2e3f5d30b..6520d00c0 100644 --- a/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.test.ts +++ b/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.test.ts @@ -76,4 +76,27 @@ describe('useLive2DEyeFocusFor', () => { expect(focus.value).toEqual({ x: 1000, y: 1000 }) }) + + it('maps cursor displacement to a small model offset with twice the vertical rate', async () => { + const { getLive2DModelMouseOffset } = await import('./eye-tracking') + + expect(getLive2DModelMouseOffset( + { x: 750, y: 750 }, + { left: 0, top: 0, width: 1000, height: 1000 }, + )).toEqual({ x: -10, y: 20 }) + expect(getLive2DModelMouseOffset( + { x: 500, y: 500 }, + { left: 0, top: 0, width: 1000, height: 1000 }, + )).toEqual({ x: 0, y: 0 }) + }) + + it('does not offset the model without valid tracking geometry', async () => { + const { getLive2DModelMouseOffset } = await import('./eye-tracking') + + expect(getLive2DModelMouseOffset(null, undefined)).toEqual({ x: 0, y: 0 }) + expect(getLive2DModelMouseOffset( + { x: 500, y: 500 }, + { left: 0, top: 0, width: 0, height: 1000 }, + )).toEqual({ x: 0, y: 0 }) + }) }) diff --git a/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.ts b/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.ts index 60a0febd2..abce749b2 100644 --- a/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.ts +++ b/packages/stage-ui-live2d/src/composables/live2d/eye-tracking.ts @@ -11,6 +11,31 @@ export interface Live2DEyeFocusSource { y: number } +const live2dModelMouseOffsetRatio = 0.04 + +/** + * Maps cursor displacement from the canvas center to a small model offset. + * + * @example + * getLive2DModelMouseOffset({ x: 750, y: 750 }, { left: 0, top: 0, width: 1000, height: 1000 }) + * // => { x: -10, y: 20 } + */ +export function getLive2DModelMouseOffset( + source: Live2DEyeFocusSource | null | undefined, + canvasRect: Pick | undefined, +): { x: number, y: number } { + if (!source || !canvasRect || canvasRect.width <= 0 || canvasRect.height <= 0) + return { x: 0, y: 0 } + + const horizontalOffset = source.x - canvasRect.left - canvasRect.width / 2 + const verticalOffset = source.y - canvasRect.top - canvasRect.height / 2 + + return { + x: horizontalOffset === 0 ? 0 : -horizontalOffset * live2dModelMouseOffsetRatio, + y: verticalOffset * live2dModelMouseOffsetRatio * 2, + } +} + /** * Maps a cursor position into the Live2D model driver's eye focus coordinates. *