feat(stage-ui-live2d): offset model with cursor

This commit is contained in:
Rin
2026-08-25 01:48:03 +08:00
parent b3c506a98b
commit c511bdead9
4 changed files with 64 additions and 4 deletions
@@ -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"
@@ -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)
@@ -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 })
})
})
@@ -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<DOMRect, 'left' | 'top' | 'width' | 'height'> | 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.
*