mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
fix(stage-*): many live2d and vrm issues, default values were missing
This commit is contained in:
@@ -11,14 +11,14 @@ import { BackgroundProvider } from '@proj-airi/stage-layouts/components/Backgrou
|
||||
import { useBackgroundThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
|
||||
import { useBackgroundStore } from '@proj-airi/stage-layouts/stores/background'
|
||||
import { IS_DEV } from '@proj-airi/stage-shared'
|
||||
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { ViewControlSlider, WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
|
||||
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
@@ -38,6 +38,7 @@ const isMobile = breakpoints.smaller('md')
|
||||
const backgroundStore = useBackgroundStore()
|
||||
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
|
||||
const backgroundSurface = useTemplateRef<InstanceType<typeof BackgroundProvider>>('backgroundSurface')
|
||||
const { stageModelRenderer } = storeToRefs(useSettings())
|
||||
|
||||
const { syncBackgroundTheme } = useBackgroundThemeColor({ backgroundSurface, selectedOption, sampledColor })
|
||||
onMounted(() => syncBackgroundTheme())
|
||||
@@ -189,15 +190,25 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
|
||||
</div>
|
||||
<!-- page -->
|
||||
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col" min-h-0>
|
||||
<WidgetStage
|
||||
min-w="1/2"
|
||||
min-h-0 flex-1
|
||||
:paused="paused"
|
||||
:focus-at="{
|
||||
x: positionCursor.x.value,
|
||||
y: positionCursor.y.value,
|
||||
}"
|
||||
/>
|
||||
<div relative min-w="1/2" min-h-0 flex-1>
|
||||
<div
|
||||
absolute left-0 z-15 px-3
|
||||
:class="[
|
||||
stageModelRenderer === 'live2d' ? 'top-0 h-full py-[20vh]' : 'top-1/2 -translate-y-1/2',
|
||||
]"
|
||||
>
|
||||
<ViewControlSlider />
|
||||
</div>
|
||||
<WidgetStage
|
||||
h-full w-full
|
||||
:enable-orbit-controls="!isMobile"
|
||||
:paused="paused"
|
||||
:focus-at="{
|
||||
x: positionCursor.x.value,
|
||||
y: positionCursor.y.value,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
|
||||
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen">
|
||||
<template v-if="IS_DEV" #status>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
useElectronRelativeMouse,
|
||||
} from '@proj-airi/electron-vueuse'
|
||||
import { IS_DEV } from '@proj-airi/stage-shared'
|
||||
import { useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
|
||||
import { HoloCoupon } from '@proj-airi/stage-ui/components'
|
||||
import {
|
||||
@@ -456,13 +455,7 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
|
||||
|
||||
// Assistant caption is broadcast from Stage.vue via the same channel
|
||||
|
||||
const { live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
|
||||
live2dEyeTrackingSource.value = computed(() => ({
|
||||
x: relativeMouseX.value,
|
||||
y: relativeMouseY.value,
|
||||
}))
|
||||
const { trackingSource } = storeToRefs(useModelStore())
|
||||
trackingSource.value = computed(() => ({
|
||||
const cursorPosition = computed(() => ({
|
||||
x: relativeMouseX.value,
|
||||
y: relativeMouseY.value,
|
||||
}))
|
||||
@@ -500,6 +493,7 @@ trackingSource.value = computed(() => ({
|
||||
v-model:state="componentStateStage"
|
||||
h-full w-full
|
||||
flex-1
|
||||
:cursor-position="cursorPosition"
|
||||
:paused="stagePaused"
|
||||
/>
|
||||
<HoloCoupon />
|
||||
|
||||
@@ -10,17 +10,15 @@ import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&u
|
||||
import { BackgroundProvider } from '@proj-airi/stage-layouts/components/Backgrounds'
|
||||
import { useBackgroundThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
|
||||
import { useBackgroundStore } from '@proj-airi/stage-layouts/stores/background'
|
||||
import { useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
import { useModelStore } from '@proj-airi/stage-ui-three'
|
||||
import { HoloCoupon } from '@proj-airi/stage-ui/components'
|
||||
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { ViewControlSlider, WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
|
||||
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
@@ -37,6 +35,7 @@ const isMobile = breakpoints.smaller('md')
|
||||
const backgroundStore = useBackgroundStore()
|
||||
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
|
||||
const backgroundSurface = useTemplateRef<InstanceType<typeof BackgroundProvider>>('backgroundSurface')
|
||||
const { stageModelRenderer } = storeToRefs(useSettings())
|
||||
|
||||
const { syncBackgroundTheme } = useBackgroundThemeColor({ backgroundSurface, selectedOption, sampledColor })
|
||||
onMounted(() => syncBackgroundTheme())
|
||||
@@ -149,14 +148,8 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
|
||||
const { x: mouseX, y: mouseY } = useMouse()
|
||||
live2dEyeTrackingSource.value = computed(() => ({
|
||||
x: mouseX.value,
|
||||
y: mouseY.value,
|
||||
}))
|
||||
const { trackingSource } = storeToRefs(useModelStore())
|
||||
trackingSource.value = computed(() => ({
|
||||
const cursorPosition = computed(() => ({
|
||||
x: mouseX.value,
|
||||
y: mouseY.value,
|
||||
}))
|
||||
@@ -177,10 +170,22 @@ trackingSource.value = computed(() => ({
|
||||
</div>
|
||||
<!-- page -->
|
||||
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
|
||||
<WidgetStage
|
||||
flex-1 min-w="1/2"
|
||||
:paused="paused"
|
||||
/>
|
||||
<div relative flex-1 min-w="1/2">
|
||||
<div
|
||||
absolute left-0 z-15 px-3
|
||||
:class="[
|
||||
stageModelRenderer === 'live2d' ? 'top-0 h-full py-[20vh]' : 'top-1/2 -translate-y-1/2',
|
||||
]"
|
||||
>
|
||||
<ViewControlSlider />
|
||||
</div>
|
||||
<WidgetStage
|
||||
h-full w-full
|
||||
:cursor-position="cursorPosition"
|
||||
:enable-orbit-controls="!isMobile"
|
||||
:paused="paused"
|
||||
/>
|
||||
</div>
|
||||
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
|
||||
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
|
||||
</div>
|
||||
|
||||
@@ -165,17 +165,23 @@ live2d:
|
||||
title: Animation
|
||||
focus:
|
||||
title: Enable mouse tracking
|
||||
description: Track current mouse position. May conflict with force idle eye animation
|
||||
description: Look at the cursor while it is moving. Idle eye movement resumes after the cursor stops.
|
||||
offset: Eye offset(%)
|
||||
force-idle-eye-animation:
|
||||
title: Force eye animation when idle
|
||||
description: Randomly look around when idle
|
||||
title: Idle eye movement
|
||||
description: Randomly look around when there is no active cursor focus.
|
||||
blink-enable:
|
||||
title: Enable blink
|
||||
description: Let the model blink.
|
||||
auto-blink:
|
||||
title: Auto blink
|
||||
description: Use default blinking animation of the model
|
||||
force-auto-blink:
|
||||
title: Force auto blink
|
||||
description: Force blinking animation with external timer
|
||||
blink-mode:
|
||||
title: Blink mode
|
||||
description: Choose the model built-in blink or AIRI's forced blink timer.
|
||||
idle-motion:
|
||||
title: Idle animation
|
||||
no-motion: No animation available
|
||||
|
||||
@@ -154,17 +154,23 @@ live2d:
|
||||
title: 动画
|
||||
focus:
|
||||
title: 启用鼠标追踪
|
||||
description: 让模型看向当前光标所在位置。可能与强制眼球动画冲突。
|
||||
description: 光标移动时让模型看向光标;光标停止后恢复待机眼球动画。
|
||||
offset: 眼部偏移量(%)
|
||||
force-idle-eye-animation:
|
||||
title: 强制眼球动画
|
||||
description: 待机时随机看向屏幕各处
|
||||
title: 待机眼球动画
|
||||
description: 没有活跃光标焦点时随机看向屏幕各处。
|
||||
blink-enable:
|
||||
title: 启用眨眼
|
||||
description: 让模型眨眼。
|
||||
auto-blink:
|
||||
title: 使用眨眼动画
|
||||
description: 使用模型默认眨眼动画
|
||||
force-auto-blink:
|
||||
title: 强制眨眼动画
|
||||
description: 强制模型眨眼,模型无眨眼动画时可用。
|
||||
blink-mode:
|
||||
title: 眨眼模式
|
||||
description: 选择模型内置眨眼,或使用 AIRI 的强制眨眼计时器。
|
||||
idle-motion:
|
||||
title: 待机动作
|
||||
no-motion: 无可用动作
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { defaultControlConfig as conf, formatter, useL2dViewControl } from '../../stores'
|
||||
|
||||
const { scale, position, viewControlsEnabled, viewControlMode, set: setValue } = useL2dViewControl()
|
||||
|
||||
const controlledValue = computed({
|
||||
get() {
|
||||
switch (viewControlMode.value) {
|
||||
case 'x':
|
||||
return position.value.x
|
||||
case 'y':
|
||||
return position.value.y
|
||||
case 'scale':
|
||||
return scale.value
|
||||
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
setValue(viewControlMode.value, value)
|
||||
},
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
return formatter[viewControlMode.value](controlledValue.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewControlsEnabled.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade-side-pops-in">
|
||||
<div v-if="viewControlsEnabled">
|
||||
<Transition name="fade-side-pops-in" mode="out-in">
|
||||
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max" :step="conf[viewControlMode].step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ formattedValue }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-side-pops-in-enter-active,
|
||||
.fade-side-pops-in-leave-active {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-side-pops-in-enter-from,
|
||||
.fade-side-pops-in-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%) scale(0.8);
|
||||
}
|
||||
|
||||
.fade-side-pops-in-enter-to,
|
||||
.fade-side-pops-in-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { Live2DEyeFocusSource } from '../../composables/live2d'
|
||||
|
||||
import { Screen } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import SliderControls from '../ViewControls/SliderControls.vue'
|
||||
import Live2DCanvas from './live2d/Canvas.vue'
|
||||
import Live2DModel from './live2d/Model.vue'
|
||||
|
||||
import { useEyeTracking, useSettingsLive2d } from '../../composables/live2d'
|
||||
import { useLive2DEyeFocusFor, useSettingsLive2d } from '../../composables/live2d'
|
||||
|
||||
import '../../utils/live2d-zip-loader'
|
||||
import '../../utils/live2d-opfs-registration'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
cursorPosition?: Live2DEyeFocusSource
|
||||
modelSrc?: string
|
||||
modelId?: string
|
||||
|
||||
@@ -35,6 +37,8 @@ const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('mode
|
||||
|
||||
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
|
||||
const live2dModelRef = ref<InstanceType<typeof Live2DModel>>()
|
||||
const activeCursorPosition = ref<Live2DEyeFocusSource | null>(null)
|
||||
let clearCursorFocusTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const {
|
||||
live2dEyeTracking,
|
||||
@@ -47,14 +51,29 @@ const {
|
||||
live2dRenderScale,
|
||||
live2dShadowEnabled,
|
||||
} = storeToRefs(useSettingsLive2d())
|
||||
const mouseFocus = useEyeTracking(
|
||||
() => live2dCanvasRef.value?.canvasElement(),
|
||||
() => ({
|
||||
const mouseFocus = useLive2DEyeFocusFor({
|
||||
canvas: () => live2dCanvasRef.value?.canvasElement(),
|
||||
model: () => ({
|
||||
normalizedScale: live2dModelRef.value?.modelNormalizeParams.scale ?? 1,
|
||||
modelWidth: live2dModelRef.value?.initialModelWidth ?? 1000,
|
||||
modelHeight: live2dModelRef.value?.initialModelHeight ?? 1000,
|
||||
}),
|
||||
)
|
||||
source: activeCursorPosition,
|
||||
})
|
||||
|
||||
watch(() => props.cursorPosition, (cursorPosition) => {
|
||||
activeCursorPosition.value = cursorPosition ? { ...cursorPosition } : null
|
||||
if (clearCursorFocusTimeout)
|
||||
clearTimeout(clearCursorFocusTimeout)
|
||||
clearCursorFocusTimeout = setTimeout(() => {
|
||||
activeCursorPosition.value = null
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clearCursorFocusTimeout)
|
||||
clearTimeout(clearCursorFocusTimeout)
|
||||
})
|
||||
|
||||
watch([componentStateModel, componentStateCanvas], () => {
|
||||
componentState.value = (componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
|
||||
@@ -74,9 +93,6 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Screen v-slot="{ width, height }" relative>
|
||||
<div absolute top-0 z-15 h-full px-3 py="20vh">
|
||||
<SliderControls />
|
||||
</div>
|
||||
<Live2DCanvas
|
||||
ref="live2dCanvasRef"
|
||||
v-slot="{ app }"
|
||||
@@ -100,6 +116,7 @@ defineExpose({
|
||||
:paused="paused"
|
||||
:focus-at="mouseFocus"
|
||||
:eye-tracking="live2dEyeTracking"
|
||||
:eye-focus-source-active="!!activeCursorPosition"
|
||||
:theme-colors-hue="themeColorsHue"
|
||||
:theme-colors-hue-dynamic="themeColorsHueDynamic"
|
||||
:live2d-idle-animation-enabled="live2dIdleAnimationEnabled"
|
||||
|
||||
@@ -17,7 +17,6 @@ import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from
|
||||
import {
|
||||
createBeatSyncController,
|
||||
useExpressionController,
|
||||
|
||||
useLive2DMotionManagerUpdate,
|
||||
useMotionUpdatePluginAutoEyeBlink,
|
||||
useMotionUpdatePluginBeatSync,
|
||||
@@ -42,6 +41,7 @@ const props = withDefaults(defineProps<{
|
||||
paused?: boolean
|
||||
focusAt?: { x: number, y: number }
|
||||
eyeTracking?: boolean
|
||||
eyeFocusSourceActive?: boolean
|
||||
themeColorsHue?: number
|
||||
themeColorsHueDynamic?: boolean
|
||||
live2dIdleAnimationEnabled?: boolean
|
||||
@@ -56,6 +56,7 @@ const props = withDefaults(defineProps<{
|
||||
paused: false,
|
||||
focusAt: () => ({ x: 0, y: 0 }),
|
||||
eyeTracking: false,
|
||||
eyeFocusSourceActive: false,
|
||||
disableFocusAt: false,
|
||||
scale: 1,
|
||||
themeColorsHue: 220.44,
|
||||
@@ -166,6 +167,8 @@ const {
|
||||
const themeColorsHue = toRef(() => props.themeColorsHue)
|
||||
const themeColorsHueDynamic = toRef(() => props.themeColorsHueDynamic)
|
||||
const live2dIdleAnimationEnabled = toRef(() => props.live2dIdleAnimationEnabled)
|
||||
const live2dEyeTrackingEnabled = toRef(() => props.eyeTracking)
|
||||
const live2dEyeFocusSourceActive = toRef(() => props.eyeFocusSourceActive)
|
||||
const live2dForceIdleEyeAnimation = toRef(() => props.live2dForceIdleEyeAnimation)
|
||||
const live2dAutoBlinkEnabled = toRef(() => props.live2dAutoBlinkEnabled)
|
||||
const live2dForceAutoBlinkEnabled = toRef(() => props.live2dForceAutoBlinkEnabled)
|
||||
@@ -338,6 +341,8 @@ async function loadModel() {
|
||||
internalModel,
|
||||
motionManager,
|
||||
modelParameters,
|
||||
live2dEyeTrackingEnabled,
|
||||
live2dEyeFocusSourceActive,
|
||||
live2dIdleAnimationEnabled,
|
||||
live2dForceIdleEyeAnimation,
|
||||
live2dAutoBlinkEnabled,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
vi.mock('@proj-airi/stage-shared/composables', () => {
|
||||
function useLocalStorageManualReset<T>(_key: string, initialValue: T) {
|
||||
const state = ref(initialValue)
|
||||
return Object.assign(state, {
|
||||
reset: () => {
|
||||
state.value = initialValue
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
useLocalStorageManualReset,
|
||||
useVersionedLocalStorageManualReset: useLocalStorageManualReset,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useLive2DEyeFocusFor', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(focus.value).toEqual({ x: 200, y: 100 })
|
||||
*/
|
||||
it('maps a plain tracking source into Live2D render coordinates', async () => {
|
||||
const { useSettingsLive2d } = await import('./live2d')
|
||||
const { useLive2DEyeFocusFor } = await import('./eye-tracking')
|
||||
|
||||
const settings = useSettingsLive2d()
|
||||
settings.live2dRenderScale = 2
|
||||
|
||||
const canvas = {
|
||||
getBoundingClientRect: () => ({ left: 10, top: 20 }),
|
||||
} as HTMLCanvasElement
|
||||
const focus = useLive2DEyeFocusFor({
|
||||
canvas: () => canvas,
|
||||
model: () => ({ normalizedScale: 1, modelWidth: 1000, modelHeight: 1000 }),
|
||||
source: () => ({ x: 110, y: 70 }),
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(focus.value).toEqual({ x: 200, y: 100 })
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(focus.value).toEqual({ x: 1000, y: 1000 })
|
||||
*/
|
||||
it('returns an off-canvas focus target when tracking source is absent', async () => {
|
||||
const { useLive2DEyeFocusFor } = await import('./eye-tracking')
|
||||
|
||||
const canvas = {
|
||||
getBoundingClientRect: () => ({ left: 10, top: 20 }),
|
||||
} as HTMLCanvasElement
|
||||
const focus = useLive2DEyeFocusFor({
|
||||
canvas: () => canvas,
|
||||
model: () => ({ normalizedScale: 1, modelWidth: 1000, modelHeight: 1000 }),
|
||||
source: () => null,
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(focus.value).toEqual({ x: 1000, y: 1000 })
|
||||
})
|
||||
})
|
||||
@@ -6,29 +6,42 @@ import { computed, toValue } from 'vue'
|
||||
import { useL2dViewControl } from '../../stores'
|
||||
import { useSettingsLive2d } from './live2d'
|
||||
|
||||
const { live2dRenderScale, live2dModelEyeOffset, live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
|
||||
const { scale } = useL2dViewControl()
|
||||
export interface Live2DEyeFocusSource {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the tracking source's position into the model's rendering space. Does not enable tracking by itself.
|
||||
* @param canvas the canvas that draws the avatar
|
||||
* @param model normalized scale and dimensions of the model
|
||||
* @returns a computed ref that maps the tracking source's position into the model's rendering space
|
||||
* Maps a cursor position into the Live2D model driver's eye focus coordinates.
|
||||
*
|
||||
* Use when:
|
||||
* - A Live2D scene owns the canvas and fitted model geometry.
|
||||
* - A parent component provides cursor coordinates in the same client coordinate space as the canvas rect.
|
||||
*
|
||||
* Expects:
|
||||
* - Source coordinates are relative to the browser viewport or Electron window that contains the canvas.
|
||||
* - The canvas element exposes a client rect in that same coordinate space.
|
||||
*
|
||||
* Returns:
|
||||
* - A computed focus point suitable for `Live2DModel.focus(x, y)`.
|
||||
*/
|
||||
export function useEyeTracking(
|
||||
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>,
|
||||
export function useLive2DEyeFocusFor(options: {
|
||||
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>
|
||||
model: MaybeRefOrGetter<{
|
||||
normalizedScale: number
|
||||
modelWidth: number
|
||||
modelHeight: number
|
||||
}>,
|
||||
): ComputedRef<{ x: number, y: number }> {
|
||||
}>
|
||||
source: MaybeRefOrGetter<Live2DEyeFocusSource | null | undefined>
|
||||
}): ComputedRef<{ x: number, y: number }> {
|
||||
const { live2dRenderScale, live2dModelEyeOffset } = storeToRefs(useSettingsLive2d())
|
||||
const { scale } = useL2dViewControl()
|
||||
|
||||
const mouseFocus = computed(() => {
|
||||
const { normalizedScale, modelWidth, modelHeight } = toValue(model)
|
||||
const { normalizedScale, modelWidth, modelHeight } = toValue(options.model)
|
||||
const renderScale = live2dRenderScale.value
|
||||
// does not require further unwrapping for some reason
|
||||
const trackingSource = live2dEyeTrackingSource.value as { x: number, y: number } | null
|
||||
const canvasRect = toValue(canvas)?.getBoundingClientRect()
|
||||
const trackingSource = toValue(options.source)
|
||||
const canvasRect = toValue(options.canvas)?.getBoundingClientRect()
|
||||
if (!trackingSource || !(canvasRect)) {
|
||||
return { x: 1000, y: 1000 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('@proj-airi/stage-shared/composables', () => {
|
||||
function useLocalStorageManualReset<T>(_key: string, initialValue: T) {
|
||||
const state = ref(initialValue)
|
||||
return Object.assign(state, {
|
||||
reset: () => {
|
||||
state.value = initialValue
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
useLocalStorageManualReset,
|
||||
useVersionedLocalStorageManualReset: useLocalStorageManualReset,
|
||||
}
|
||||
})
|
||||
|
||||
describe('useSettingsLive2d', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(settings.live2dEyeTracking).toBe(true)
|
||||
*/
|
||||
it('defaults mouse tracking to enabled to preserve the old focus behavior', async () => {
|
||||
const { useSettingsLive2d } = await import('./live2d')
|
||||
|
||||
const settings = useSettingsLive2d()
|
||||
|
||||
expect(settings.live2dEyeTracking).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(settings.live2dForceIdleEyeAnimation).toBe(true)
|
||||
*/
|
||||
it('defaults idle eye animation to enabled to preserve no-cursor idle behavior', async () => {
|
||||
const { useSettingsLive2d } = await import('./live2d')
|
||||
|
||||
const settings = useSettingsLive2d()
|
||||
|
||||
expect(settings.live2dForceIdleEyeAnimation).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(settings.live2dAutoBlinkEnabled).toBe(true)
|
||||
*/
|
||||
it('defaults blink to enabled with forced blink mode', async () => {
|
||||
const { useSettingsLive2d } = await import('./live2d')
|
||||
|
||||
const settings = useSettingsLive2d()
|
||||
|
||||
expect(settings.live2dAutoBlinkEnabled).toBe(true)
|
||||
expect(settings.live2dForceAutoBlinkEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import { useLocalStorageManualReset, useVersionedLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const live2dEyeTracking = useLocalStorageManualReset<boolean>('settings/live2d/eye-tracking', false)
|
||||
/**
|
||||
* A position to perform eye-tracking on.
|
||||
* Should be a position relative to the application window:
|
||||
* - for browser targets, it should be the top-left corner of the viewport
|
||||
* - for tamagotchi targets, it should be the top-left corner of
|
||||
* the application window that renders the model
|
||||
*/
|
||||
const live2dEyeTrackingSource: Ref<ComputedRef<{ x: number, y: number }> | null> = ref(null)
|
||||
const live2dEyeTracking = useLocalStorageManualReset<boolean>('settings/live2d/eye-tracking', true)
|
||||
/** Offset from model center to the eyes of the model, in percentages of full model width/height */
|
||||
const live2dModelEyeOffset = useLocalStorageManualReset('settings/live2d/model-eye-offset', { x: 0, y: 0 })
|
||||
const live2dIdleAnimationEnabled = useLocalStorageManualReset<boolean>('settings/live2d/idle-animation-enabled', true)
|
||||
/** Force the avatar to look around. May conflict with eye tracking and idle animation. */
|
||||
const live2dForceIdleEyeAnimation = useLocalStorageManualReset<boolean>('settings/live2d/idle-eye-animation-enabled', false)
|
||||
const live2dAutoBlinkEnabled = useVersionedLocalStorageManualReset<boolean>('settings/live2d/auto-blink-enabled', false, {
|
||||
/** Let the avatar look around while no cursor tracking source is active. */
|
||||
const live2dForceIdleEyeAnimation = useLocalStorageManualReset<boolean>('settings/live2d/idle-eye-animation-enabled', true)
|
||||
const live2dAutoBlinkEnabled = useVersionedLocalStorageManualReset<boolean>('settings/live2d/auto-blink-enabled', true, {
|
||||
defaultVersion: '2.0.0',
|
||||
satisfiesVersionBy(beforeVersion, afterVersion) {
|
||||
if (beforeVersion === afterVersion) {
|
||||
@@ -59,7 +48,6 @@ function resetState() {
|
||||
export const useSettingsLive2d = defineStore('settings-live2d', () => {
|
||||
return {
|
||||
live2dEyeTracking,
|
||||
live2dEyeTrackingSource,
|
||||
live2dModelEyeOffset,
|
||||
live2dIdleAnimationEnabled,
|
||||
live2dForceIdleEyeAnimation,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { MotionManagerPluginContext, PixiLive2DInternalModel } from './motion-manager'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
useMotionUpdatePluginAutoEyeBlink,
|
||||
useMotionUpdatePluginIdleDisable,
|
||||
} from './motion-manager'
|
||||
|
||||
vi.mock('./animation', () => ({
|
||||
useLive2DIdleEyeFocus: () => ({ update: vi.fn() }),
|
||||
}))
|
||||
|
||||
function createModel(initialValues: Record<string, number> = {}) {
|
||||
const values = new Map(Object.entries(initialValues))
|
||||
return {
|
||||
getParameterValueById: vi.fn((id: string) => values.get(id) ?? 1),
|
||||
setParameterValueById: vi.fn((id: string, value: number) => {
|
||||
values.set(id, value)
|
||||
}),
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(overrides: Partial<MotionManagerPluginContext> = {}): MotionManagerPluginContext {
|
||||
const model = createModel({
|
||||
ParamEyeLOpen: 1,
|
||||
ParamEyeROpen: 1,
|
||||
})
|
||||
const context = {
|
||||
model,
|
||||
now: 1000,
|
||||
timeDelta: 16,
|
||||
internalModel: {
|
||||
eyeBlink: {
|
||||
updateParameters: vi.fn((targetModel: typeof model) => {
|
||||
targetModel.setParameterValueById('ParamEyeLOpen', 0.5)
|
||||
targetModel.setParameterValueById('ParamEyeROpen', 0.25)
|
||||
}),
|
||||
},
|
||||
coreModel: model,
|
||||
} as unknown as PixiLive2DInternalModel,
|
||||
motionManager: {
|
||||
stopAllMotions: vi.fn(),
|
||||
state: { currentGroup: undefined },
|
||||
groups: { idle: 'Idle' },
|
||||
} as unknown as PixiLive2DInternalModel['motionManager'],
|
||||
modelParameters: ref({
|
||||
leftEyeOpen: 1,
|
||||
rightEyeOpen: 1,
|
||||
}),
|
||||
live2dEyeTrackingEnabled: ref(false),
|
||||
live2dEyeFocusSourceActive: ref(false),
|
||||
live2dIdleAnimationEnabled: ref(true),
|
||||
live2dForceIdleEyeAnimation: ref(false),
|
||||
live2dAutoBlinkEnabled: ref(true),
|
||||
live2dForceAutoBlinkEnabled: ref(false),
|
||||
isIdleMotion: true,
|
||||
handled: false as boolean,
|
||||
markHandled: vi.fn(() => {
|
||||
context.handled = true
|
||||
}),
|
||||
}
|
||||
|
||||
return Object.assign(context, overrides) as unknown as MotionManagerPluginContext
|
||||
}
|
||||
|
||||
describe('live2d motion manager plugins', () => {
|
||||
/**
|
||||
* @example
|
||||
* expect(idleEyeFocus.update).toHaveBeenCalled()
|
||||
*/
|
||||
it('keeps idle eye focus alive when idle motion is disabled', () => {
|
||||
const idleEyeFocus = { update: vi.fn() }
|
||||
const context = createContext({
|
||||
live2dIdleAnimationEnabled: ref(false),
|
||||
live2dForceIdleEyeAnimation: ref(true),
|
||||
})
|
||||
|
||||
useMotionUpdatePluginIdleDisable(idleEyeFocus)(context)
|
||||
|
||||
expect(idleEyeFocus.update).toHaveBeenCalledWith(context.internalModel, context.now)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(idleEyeFocus.update).not.toHaveBeenCalled()
|
||||
*/
|
||||
it('lets mouse tracking own focus while a tracking source is active', () => {
|
||||
const idleEyeFocus = { update: vi.fn() }
|
||||
const context = createContext({
|
||||
live2dEyeTrackingEnabled: ref(true),
|
||||
live2dEyeFocusSourceActive: ref(true),
|
||||
live2dIdleAnimationEnabled: ref(false),
|
||||
live2dForceIdleEyeAnimation: ref(true),
|
||||
})
|
||||
|
||||
useMotionUpdatePluginIdleDisable(idleEyeFocus)(context)
|
||||
|
||||
expect(idleEyeFocus.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(context.internalModel.eyeBlink?.updateParameters).toHaveBeenCalled()
|
||||
*/
|
||||
it('uses the model built-in blink when auto blink is enabled and force blink is disabled', () => {
|
||||
const context = createContext({
|
||||
live2dAutoBlinkEnabled: ref(true),
|
||||
live2dForceAutoBlinkEnabled: ref(false),
|
||||
})
|
||||
|
||||
useMotionUpdatePluginAutoEyeBlink(ref(false))(context)
|
||||
|
||||
expect(context.internalModel.eyeBlink?.updateParameters).toHaveBeenCalled()
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamEyeLOpen', 0.5)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamEyeROpen', 0.25)
|
||||
expect(context.handled).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(context.internalModel.eyeBlink?.updateParameters).not.toHaveBeenCalled()
|
||||
*/
|
||||
it('does not call the model built-in blink when force blink is enabled', () => {
|
||||
const context = createContext({
|
||||
live2dAutoBlinkEnabled: ref(true),
|
||||
live2dForceAutoBlinkEnabled: ref(true),
|
||||
timeDelta: 4000,
|
||||
})
|
||||
|
||||
useMotionUpdatePluginAutoEyeBlink(ref(false))(context)
|
||||
|
||||
expect(context.internalModel.eyeBlink?.updateParameters).not.toHaveBeenCalled()
|
||||
expect(context.handled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,8 @@ export type MotionManagerPluginContext = MotionManagerUpdateContext & {
|
||||
internalModel: PixiLive2DInternalModel
|
||||
motionManager: PixiLive2DInternalModel['motionManager']
|
||||
modelParameters: Ref<any>
|
||||
live2dEyeTrackingEnabled: Ref<boolean>
|
||||
live2dEyeFocusSourceActive: Ref<boolean>
|
||||
live2dIdleAnimationEnabled: Ref<boolean>
|
||||
live2dForceIdleEyeAnimation: Ref<boolean>
|
||||
live2dAutoBlinkEnabled: Ref<boolean>
|
||||
@@ -42,6 +44,8 @@ export interface UseLive2DMotionManagerUpdateOptions {
|
||||
internalModel: PixiLive2DInternalModel
|
||||
motionManager: PixiLive2DInternalModel['motionManager']
|
||||
modelParameters: Ref<any>
|
||||
live2dEyeTrackingEnabled: Ref<boolean>
|
||||
live2dEyeFocusSourceActive: Ref<boolean>
|
||||
live2dIdleAnimationEnabled: Ref<boolean>
|
||||
live2dForceIdleEyeAnimation: Ref<boolean>
|
||||
live2dAutoBlinkEnabled: Ref<boolean>
|
||||
@@ -54,6 +58,8 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
|
||||
internalModel,
|
||||
motionManager,
|
||||
modelParameters,
|
||||
live2dEyeTrackingEnabled,
|
||||
live2dEyeFocusSourceActive,
|
||||
live2dIdleAnimationEnabled,
|
||||
live2dForceIdleEyeAnimation,
|
||||
live2dAutoBlinkEnabled,
|
||||
@@ -97,6 +103,8 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
|
||||
internalModel,
|
||||
motionManager,
|
||||
modelParameters,
|
||||
live2dEyeTrackingEnabled,
|
||||
live2dEyeFocusSourceActive,
|
||||
live2dIdleAnimationEnabled,
|
||||
live2dForceIdleEyeAnimation,
|
||||
live2dAutoBlinkEnabled,
|
||||
@@ -210,7 +218,7 @@ export function useMotionUpdatePluginIdleDisable(idleEyeFocus = useLive2DIdleEye
|
||||
if (!ctx.live2dIdleAnimationEnabled.value && ctx.isIdleMotion) {
|
||||
ctx.motionManager.stopAllMotions()
|
||||
|
||||
if (ctx.live2dForceIdleEyeAnimation.value)
|
||||
if (ctx.live2dForceIdleEyeAnimation.value && (!ctx.live2dEyeTrackingEnabled.value || !ctx.live2dEyeFocusSourceActive.value))
|
||||
idleEyeFocus.update(ctx.internalModel, ctx.now)
|
||||
if (ctx.internalModel.eyeBlink != null) {
|
||||
ctx.internalModel.eyeBlink.updateParameters(ctx.model, ctx.timeDelta / 1000)
|
||||
@@ -229,6 +237,10 @@ export function useMotionUpdatePluginIdleFocus(idleEyeFocus = useLive2DIdleEyeFo
|
||||
return (ctx) => {
|
||||
if (!ctx.isIdleMotion || ctx.handled)
|
||||
return
|
||||
if (!ctx.live2dForceIdleEyeAnimation.value)
|
||||
return
|
||||
if (ctx.live2dEyeTrackingEnabled.value && ctx.live2dEyeFocusSourceActive.value)
|
||||
return
|
||||
|
||||
idleEyeFocus.update(ctx.internalModel, ctx.now)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,9 @@ import {
|
||||
} from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
// From stage-ui-three package
|
||||
import { inject, onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
|
||||
import { onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
|
||||
|
||||
import { useThreeCamera } from '../../stores/camera'
|
||||
import { useThreeViewControl } from '../../stores/view-control'
|
||||
|
||||
/*
|
||||
* Props:
|
||||
@@ -58,8 +57,6 @@ const camera = shallowRef<PerspectiveCamera | null>(null)
|
||||
let disposeControlsChange: (() => void) | undefined
|
||||
|
||||
const { cameraPosition, cameraFOV, cameraDistance } = useThreeCamera()
|
||||
const { viewControlsEnabled } = useThreeViewControl()
|
||||
const isPreviewStage = inject<boolean>('previewStage')
|
||||
|
||||
// Initialisation on onMounted
|
||||
function registerInfoFlow() {
|
||||
@@ -118,11 +115,11 @@ function registerInfoFlow() {
|
||||
camera.value.updateProjectionMatrix()
|
||||
controls.value.update()
|
||||
})
|
||||
watch([controlEnable, viewControlsEnabled], (newEnable) => {
|
||||
watch(controlEnable, (newEnable) => {
|
||||
if (!camera.value || !controls.value)
|
||||
return
|
||||
controls.value.enableRotate = isPreviewStage || newEnable.every(Boolean)
|
||||
controls.value.enableZoom = isPreviewStage || newEnable.every(Boolean)
|
||||
controls.value.enableRotate = newEnable
|
||||
controls.value.enableZoom = newEnable
|
||||
}, { immediate: true })
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { useModelStore } from '../../stores/model-store'
|
||||
import { defaultControlConfig as conf, formatter, useThreeViewControl } from '../../stores/view-control'
|
||||
|
||||
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode, set: setValue } = useThreeViewControl()
|
||||
const { sceneMutationLocked } = storeToRefs(useModelStore())
|
||||
|
||||
const controlledValue = computed({
|
||||
get() {
|
||||
switch (viewControlMode.value) {
|
||||
case 'x':
|
||||
return modelOffset.value.x
|
||||
case 'y':
|
||||
return modelOffset.value.y
|
||||
case 'z':
|
||||
return modelOffset.value.z
|
||||
case 'cameraDistance':
|
||||
return cameraDistance.value
|
||||
case 'cameraFOV':
|
||||
return cameraFOV.value
|
||||
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
if (sceneMutationLocked.value)
|
||||
return
|
||||
setValue(viewControlMode.value, value)
|
||||
},
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
return formatter[viewControlMode.value](controlledValue.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewControlsEnabled.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade-side-pops-in">
|
||||
<fieldset v-if="viewControlsEnabled">
|
||||
<Transition name="fade-side-pops-in" mode="out-in">
|
||||
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max"
|
||||
:disabled="sceneMutationLocked" :step="conf[viewControlMode].step" handle-wheel data-direction="vertical"
|
||||
h="50%" write-vertical-left
|
||||
/>
|
||||
<div
|
||||
class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0
|
||||
transition="all duration-200 ease-in-out"
|
||||
>
|
||||
{{ formattedValue }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</fieldset>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-side-pops-in-enter-active,
|
||||
.fade-side-pops-in-leave-active {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-side-pops-in-enter-from,
|
||||
.fade-side-pops-in-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%) scale(0.8);
|
||||
}
|
||||
|
||||
.fade-side-pops-in-enter-to,
|
||||
.fade-side-pops-in-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,2 +1 @@
|
||||
export { default as OrbitControls } from './OrbitControls.vue'
|
||||
export { default as SliderControls } from './SliderControls.vue'
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
VrmLoadHookContext,
|
||||
VrmMaterialHookContext,
|
||||
} from '../../composables/vrm/hooks'
|
||||
import type { SceneBootstrap, Vec3 } from '../../stores/model-store'
|
||||
import type { SceneBootstrap, TrackingMode, Vec3 } from '../../stores/model-store'
|
||||
import type { VrmLifecycleReason } from '../../trace'
|
||||
import type { ManagedVrmInstance } from './vrm-instance-cache'
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
|
||||
} from 'vue'
|
||||
|
||||
import { useEyeTracking } from '../../composables/eye-tracking'
|
||||
import { useVRMEyeFocusFor } from '../../composables/eye-tracking'
|
||||
import {
|
||||
createIblProbeController,
|
||||
injectDiffuseIBL,
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
currentAudioSource?: AudioBufferSourceNode
|
||||
cursorPosition?: { x: number, y: number }
|
||||
lastCommittedModelSrc?: string
|
||||
modelSrc?: string
|
||||
idleAnimation: string
|
||||
@@ -119,7 +120,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
modelOffset: Vec3
|
||||
modelRotationY: number
|
||||
trackingMode: string
|
||||
trackingMode: TrackingMode
|
||||
eyeHeight: number
|
||||
screenBoundingBox: () => { top: number, left: number, width: number, height: number }
|
||||
cameraPosition: Vec3
|
||||
@@ -895,11 +896,17 @@ async function loadModel() {
|
||||
}
|
||||
}
|
||||
|
||||
const focusPos = useEyeTracking(() => ({
|
||||
camera: camera.value,
|
||||
raycaster,
|
||||
defaultLookAt: defaultTookAt.value,
|
||||
}), props.screenBoundingBox)
|
||||
const focusPos = useVRMEyeFocusFor({
|
||||
cameraPosition: () => props.cameraPosition,
|
||||
context: () => ({
|
||||
camera: camera.value,
|
||||
raycaster,
|
||||
defaultLookAt: defaultTookAt.value,
|
||||
}),
|
||||
screenBoundingBox: props.screenBoundingBox,
|
||||
source: () => props.cursorPosition,
|
||||
trackingMode: () => props.trackingMode,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// watch if the model needs to be reloaded
|
||||
|
||||
@@ -43,18 +43,35 @@ import {
|
||||
stageThreeTraceThreeSceneSubtreeEvent,
|
||||
stageThreeTraceThreeSceneTransactionEvent,
|
||||
} from '../trace'
|
||||
import { OrbitControls, SliderControls } from './Controls'
|
||||
import { OrbitControls } from './Controls'
|
||||
import { SkyBox } from './Environment'
|
||||
import { VRMModel } from './Model'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
currentAudioSource?: AudioBufferSourceNode
|
||||
cursorPosition?: { x: number, y: number }
|
||||
modelSrc?: string
|
||||
skyBoxSrc?: string
|
||||
/**
|
||||
* Enables user-driven OrbitControls camera rotation and zoom.
|
||||
*
|
||||
* This is controlled by the app shell because orbit gestures conflict with
|
||||
* mobile view-control overlays, while desktop/tamagotchi stages still expect
|
||||
* direct scene manipulation.
|
||||
*
|
||||
* | Surface | Expected value | Reason |
|
||||
* | --- | --- | --- |
|
||||
* | stage-web desktop | `true` | Mouse orbit is the primary camera control. |
|
||||
* | stage-web mobile | `false` | Touch input is reserved for mobile view controls and chat UI. |
|
||||
* | stage-pocket mobile | `false` | Touch input is reserved for mobile view controls and chat UI. |
|
||||
* | stage-tamagotchi | `true` | The transparent desktop stage has no mobile view-control overlay. |
|
||||
*/
|
||||
enableOrbitControls?: boolean
|
||||
showAxes?: boolean
|
||||
idleAnimation?: string
|
||||
paused?: boolean
|
||||
}>(), {
|
||||
enableOrbitControls: true,
|
||||
showAxes: false,
|
||||
idleAnimation: new URL('../assets/vrm/animations/idle_loop.vrma', import.meta.url).href,
|
||||
paused: false,
|
||||
@@ -434,7 +451,8 @@ function onOrbitControlsReady() {
|
||||
}
|
||||
|
||||
const controlEnable = computed(() => {
|
||||
return controlsReady.value
|
||||
return props.enableOrbitControls
|
||||
&& controlsReady.value
|
||||
&& modelPhase.value === 'ready'
|
||||
&& !sceneMutationLocked.value
|
||||
})
|
||||
@@ -448,11 +466,6 @@ function onVRMSceneBootstrap(value: SceneBootstrap) {
|
||||
pendingSceneBootstrap.value = value
|
||||
}
|
||||
|
||||
function onVRMModelLookAtTarget(value: Vector3) {
|
||||
lookAtTarget.value.x = value.x
|
||||
lookAtTarget.value.y = value.y
|
||||
lookAtTarget.value.z = value.z
|
||||
}
|
||||
function onVRMModelLoaded(value: string) {
|
||||
activeModelSrc.value = value
|
||||
pendingCommittedModelSrc.value = value
|
||||
@@ -710,9 +723,6 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Screen ref="screenRef" v-slot="{ width, height }" relative>
|
||||
<div top="50%" translate-y="[-50%]" fixed z-15 px-3>
|
||||
<SliderControls />
|
||||
</div>
|
||||
<TresCanvas
|
||||
:width="width"
|
||||
:height="height"
|
||||
@@ -768,6 +778,7 @@ defineExpose({
|
||||
<VRMModel
|
||||
ref="modelRef"
|
||||
:current-audio-source="props.currentAudioSource"
|
||||
:cursor-position="props.cursorPosition"
|
||||
:last-committed-model-src="lastCommittedModelSrc"
|
||||
:model-src="props.modelSrc"
|
||||
:idle-animation="props.idleAnimation"
|
||||
@@ -777,7 +788,6 @@ defineExpose({
|
||||
:npr-irr-s-h="irrSHTex"
|
||||
:model-offset="modelOffset"
|
||||
:model-rotation-y="modelRotationY"
|
||||
:look-at-target="lookAtTarget"
|
||||
:tracking-mode="trackingMode"
|
||||
:eye-height="eyeHeight"
|
||||
:camera-position="cameraPosition"
|
||||
@@ -786,7 +796,6 @@ defineExpose({
|
||||
@loading-progress="(val: number) => emit('loadModelProgress', val)"
|
||||
@load-start="onVRMModelLoadStart"
|
||||
@scene-bootstrap="onVRMSceneBootstrap"
|
||||
@look-at-target="onVRMModelLookAtTarget"
|
||||
@error="onVRMModelError"
|
||||
@loaded="onVRMModelLoaded"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { Raycaster, Vector3 } from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
useBroadcastChannel: vi.fn(() => ({
|
||||
post: vi.fn(),
|
||||
data: vue.ref(null),
|
||||
})),
|
||||
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../stores/camera', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
DEFAULT_CAMERA_DISTANCE: 1,
|
||||
DEFAULT_CAMERA_FOV: 40,
|
||||
DEFAULT_CAMERA_POSITION: { x: 0, y: 0, z: -1 },
|
||||
useThreeCamera: vi.fn(() => ({
|
||||
cameraDistance: vue.ref(1),
|
||||
cameraFOV: vue.ref(40),
|
||||
cameraPosition: vue.ref({ x: 0, y: 0, z: -1 }),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useVRMEyeFocusFor', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.stubGlobal('window', { devicePixelRatio: 1 })
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(focus.value).toBe(defaultLookAt)
|
||||
*/
|
||||
it('returns the default target when mouse mode has no tracking source', async () => {
|
||||
const { useVRMEyeFocusFor } = await import('./eye-tracking')
|
||||
const defaultLookAt = new Vector3(1, 2, 3)
|
||||
|
||||
const focus = useVRMEyeFocusFor({
|
||||
cameraPosition: () => ({ x: 0, y: 0, z: -1 }),
|
||||
context: () => ({
|
||||
raycaster: new Raycaster(),
|
||||
camera: {
|
||||
near: 0.1,
|
||||
} as any,
|
||||
defaultLookAt,
|
||||
}),
|
||||
screenBoundingBox: () => ({ top: 0, left: 0, width: 100, height: 100 }),
|
||||
source: () => null,
|
||||
trackingMode: () => 'mouse',
|
||||
})
|
||||
|
||||
expect(focus.value).toBe(defaultLookAt)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(raycaster.setFromCamera).toHaveBeenCalled()
|
||||
*/
|
||||
it('uses the plain tracking source when mouse tracking is active', async () => {
|
||||
const { useVRMEyeFocusFor } = await import('./eye-tracking')
|
||||
|
||||
const raycaster = {
|
||||
ray: {
|
||||
direction: new Vector3(0, 0, -1),
|
||||
origin: new Vector3(0, 0, 0),
|
||||
},
|
||||
setFromCamera: vi.fn(),
|
||||
}
|
||||
const focus = useVRMEyeFocusFor({
|
||||
cameraPosition: () => ({ x: 0, y: 0, z: -1 }),
|
||||
context: () => ({
|
||||
raycaster: raycaster as unknown as Raycaster,
|
||||
camera: {
|
||||
near: 0.1,
|
||||
} as any,
|
||||
defaultLookAt: new Vector3(1, 2, 3),
|
||||
}),
|
||||
screenBoundingBox: () => ({ top: 0, left: 0, width: 100, height: 100 }),
|
||||
source: () => ({ x: 50, y: 50 }),
|
||||
trackingMode: () => 'mouse',
|
||||
})
|
||||
|
||||
expect(focus.value).toEqual(new Vector3(0, 0, -0.8))
|
||||
expect(raycaster.setFromCamera).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,40 +1,61 @@
|
||||
import type { PerspectiveCamera, Raycaster } from 'three'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { TrackingMode, Vec3 } from '../stores/model-store'
|
||||
|
||||
import { Vector2, Vector3 } from 'three'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { useModelStore } from '../stores/model-store'
|
||||
|
||||
interface ThreeWorldContext {
|
||||
interface VRMWorldContext {
|
||||
raycaster: Raycaster
|
||||
camera: PerspectiveCamera
|
||||
defaultLookAt: Vector3
|
||||
}
|
||||
|
||||
const { trackingSource, trackingMode, cameraPosition } = storeToRefs(useModelStore())
|
||||
export interface VRMEyeFocusSource {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
// look at mouse
|
||||
export function useEyeTracking(
|
||||
context: MaybeRefOrGetter<ThreeWorldContext>,
|
||||
screenBoundingBox: MaybeRefOrGetter<{ top: number, left: number, height: number, width: number }>,
|
||||
) {
|
||||
/**
|
||||
* Maps cursor and camera tracking modes into a VRM world-space eye focus target.
|
||||
*
|
||||
* Use when:
|
||||
* - A VRM model needs a look-at target derived from scene-local cursor coordinates.
|
||||
* - The scene owns the screen bounds used to normalize pointer coordinates.
|
||||
*
|
||||
* Expects:
|
||||
* - Source coordinates are relative to the browser viewport or Electron window that contains the scene.
|
||||
* - Screen bounds are in the same client coordinate space as the source.
|
||||
*
|
||||
* Returns:
|
||||
* - A computed Three.js vector suitable for VRM eye focus updates.
|
||||
*/
|
||||
export function useVRMEyeFocusFor(options: {
|
||||
cameraPosition: MaybeRefOrGetter<Vec3>
|
||||
context: MaybeRefOrGetter<VRMWorldContext>
|
||||
screenBoundingBox: MaybeRefOrGetter<{ top: number, left: number, height: number, width: number }>
|
||||
source: MaybeRefOrGetter<VRMEyeFocusSource | null | undefined>
|
||||
trackingMode: MaybeRefOrGetter<TrackingMode>
|
||||
}) {
|
||||
const focusPos = computed<Vector3>(() => {
|
||||
if (trackingMode.value === 'camera')
|
||||
return new Vector3(cameraPosition.value.x, cameraPosition.value.y, cameraPosition.value.z)
|
||||
const ctx = toValue(context)
|
||||
if (trackingMode.value === 'none' || !trackingSource.value)
|
||||
const trackingMode = toValue(options.trackingMode)
|
||||
if (trackingMode === 'camera') {
|
||||
const cameraPosition = toValue(options.cameraPosition)
|
||||
return new Vector3(cameraPosition.x, cameraPosition.y, cameraPosition.z)
|
||||
}
|
||||
|
||||
const ctx = toValue(options.context)
|
||||
const trackingSource = toValue(options.source)
|
||||
if (trackingMode === 'none' || !trackingSource)
|
||||
return ctx.defaultLookAt
|
||||
const screen = toValue(screenBoundingBox)
|
||||
if (trackingMode.value === 'mouse') {
|
||||
const trackingPos = trackingSource.value as unknown as { x: number, y: number }
|
||||
// from tracking origin to camera center(screen center)
|
||||
const screen = toValue(options.screenBoundingBox)
|
||||
if (trackingMode === 'mouse') {
|
||||
const castedPos = castScreenToCam(
|
||||
ctx,
|
||||
new Vector2(
|
||||
((trackingPos.x - screen.left) / screen.width) * 2 - 1,
|
||||
-((trackingPos.y - screen.top) / screen.height) * 2 + 1,
|
||||
((trackingSource.x - screen.left) / screen.width) * 2 - 1,
|
||||
-((trackingSource.y - screen.top) / screen.height) * 2 + 1,
|
||||
),
|
||||
)
|
||||
return castedPos
|
||||
@@ -45,7 +66,7 @@ export function useEyeTracking(
|
||||
return focusPos
|
||||
}
|
||||
|
||||
function castScreenToCam(ctx: ThreeWorldContext, point: Vector2): Vector3 {
|
||||
function castScreenToCam(ctx: VRMWorldContext, point: Vector2): Vector3 {
|
||||
ctx.raycaster.setFromCamera(point, ctx.camera)
|
||||
const nearPlaneDistance = ctx.camera.near
|
||||
const direction = ctx.raycaster.ray.direction.clone().normalize().multiplyScalar(8)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Vector3 } from 'three'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import { useBroadcastChannel, useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -85,15 +84,7 @@ const { modelOffset, set: setViewControl } = useThreeViewControl()
|
||||
const { cameraDistance, cameraFOV, cameraPosition } = useThreeCamera()
|
||||
|
||||
const modelRotationY = useLocalStorage('settings/stage-ui-three/modelRotationY', 0)
|
||||
const trackingMode = useLocalStorage('settings/stage-ui-three/trackingMode', 'none' as 'camera' | 'mouse' | 'none')
|
||||
/**
|
||||
* A position to perform eye-tracking on.
|
||||
* Should be a position relative to the application window:
|
||||
* - for browser targets, it should be the top-left corner of the viewport
|
||||
* - for tamagotchi targets, it should be the top-left corner of
|
||||
* the application window that renders the model
|
||||
*/
|
||||
const trackingSource: Ref<ComputedRef<{ x: number, y: number }> | null> = ref(null)
|
||||
const trackingMode = useLocalStorage<TrackingMode>('settings/stage-ui-three/trackingMode', 'none')
|
||||
|
||||
export const useModelStore = defineStore('modelStore', () => {
|
||||
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-three-vrm' })
|
||||
@@ -245,7 +236,6 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
|
||||
lookAtTarget,
|
||||
trackingMode,
|
||||
trackingSource,
|
||||
eyeHeight,
|
||||
renderScale,
|
||||
multisampling,
|
||||
|
||||
@@ -87,6 +87,27 @@ const fpsOptions = computed(() => [
|
||||
{ value: 60, label: '60' },
|
||||
{ value: 0, label: t('settings.live2d.parameters.fps.options.unlimited') },
|
||||
])
|
||||
const blinkModeOptions = computed(() => [
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('settings.live2d.animation.auto-blink.title'),
|
||||
description: t('settings.live2d.animation.auto-blink.description'),
|
||||
},
|
||||
{
|
||||
value: 'force',
|
||||
label: t('settings.live2d.animation.force-auto-blink.title'),
|
||||
description: t('settings.live2d.animation.force-auto-blink.description'),
|
||||
},
|
||||
])
|
||||
const live2dBlinkMode = computed<'auto' | 'force'>({
|
||||
get() {
|
||||
return live2dForceAutoBlinkEnabled.value ? 'force' : 'auto'
|
||||
},
|
||||
set(mode) {
|
||||
live2dAutoBlinkEnabled.value = true
|
||||
live2dForceAutoBlinkEnabled.value = mode === 'force'
|
||||
},
|
||||
})
|
||||
|
||||
watch(() => live2d.availableMotions, (motions) => {
|
||||
runtimeMotions.value = motions.map(m => ({
|
||||
@@ -346,16 +367,21 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
|
||||
/>
|
||||
<FieldCheckbox
|
||||
v-model="live2dAutoBlinkEnabled"
|
||||
:label="t('settings.live2d.animation.auto-blink.title')"
|
||||
:description="t('settings.live2d.animation.auto-blink.description')"
|
||||
placement="right"
|
||||
/>
|
||||
<FieldCheckbox
|
||||
v-model="live2dForceAutoBlinkEnabled"
|
||||
:label="t('settings.live2d.animation.force-auto-blink.title')"
|
||||
:description="t('settings.live2d.animation.force-auto-blink.description')"
|
||||
:label="t('settings.live2d.animation.blink-enable.title')"
|
||||
:description="t('settings.live2d.animation.blink-enable.description')"
|
||||
placement="right"
|
||||
/>
|
||||
<label v-if="live2dAutoBlinkEnabled" class="flex flex-wrap gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
{{ t('settings.live2d.animation.blink-mode.title') }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{{ t('settings.live2d.animation.blink-mode.description') }}
|
||||
</div>
|
||||
</div>
|
||||
<SelectTab v-model="live2dBlinkMode" :options="blinkModeOptions" size="sm" :class="['shrink-0']" />
|
||||
</label>
|
||||
<FieldCombobox
|
||||
v-model="selectedRuntimeMotion"
|
||||
:label="t('settings.live2d.animation.idle-motion.title')"
|
||||
|
||||
+5
-12
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { Live2DScene, useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
import { Live2DScene } from '@proj-airi/stage-ui-live2d'
|
||||
import { SpineScene } from '@proj-airi/stage-ui-spine'
|
||||
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
|
||||
import { useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, provide, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useSettings } from '../../../../stores/settings'
|
||||
import {
|
||||
@@ -33,8 +33,6 @@ const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const spineComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
provide('previewStage', true)
|
||||
|
||||
const {
|
||||
stageModelSelected,
|
||||
stageModelSelectedUrl,
|
||||
@@ -152,14 +150,8 @@ defineExpose({
|
||||
capturePreviewFrame,
|
||||
})
|
||||
|
||||
const { live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
|
||||
const { x: mouseX, y: mouseY } = useMouse()
|
||||
live2dEyeTrackingSource.value = computed(() => ({
|
||||
x: mouseX.value,
|
||||
y: mouseY.value,
|
||||
}))
|
||||
const { trackingSource } = storeToRefs(useModelStore())
|
||||
trackingSource.value = computed(() => ({
|
||||
const cursorPosition = computed(() => ({
|
||||
x: mouseX.value,
|
||||
y: mouseY.value,
|
||||
}))
|
||||
@@ -173,6 +165,7 @@ trackingSource.value = computed(() => ({
|
||||
v-model:state="live2dComponentState"
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:model-id="stageModelSelected"
|
||||
:cursor-position="cursorPosition"
|
||||
:theme-colors-hue="themeColorsHue"
|
||||
:theme-colors-hue-dynamic="themeColorsHueDynamic"
|
||||
/>
|
||||
@@ -180,7 +173,7 @@ trackingSource.value = computed(() => ({
|
||||
</template>
|
||||
<template v-if="stageModelRenderer === 'vrm'">
|
||||
<div :class="vrmSceneClassList">
|
||||
<ThreeScene ref="vrmSceneRef" :model-src="stageModelSelectedUrl" />
|
||||
<ThreeScene ref="vrmSceneRef" :cursor-position="cursorPosition" :model-src="stageModelSelectedUrl" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="stageModelRenderer === 'spine'">
|
||||
|
||||
@@ -45,8 +45,13 @@ import { useSettings } from '../../stores/settings'
|
||||
import { useSpeechRuntimeStore } from '../../stores/speech-runtime'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
cursorPosition?: { x: number, y: number }
|
||||
enableOrbitControls?: boolean
|
||||
paused?: boolean
|
||||
}>(), { paused: false, scale: 1 })
|
||||
}>(), {
|
||||
enableOrbitControls: true,
|
||||
paused: false,
|
||||
})
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
|
||||
@@ -867,6 +872,7 @@ defineExpose({
|
||||
h-full w-full flex-1
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:model-id="stageModelSelected"
|
||||
:cursor-position="cursorPosition"
|
||||
:mouth-open-size="mouthOpenSize"
|
||||
:now-speaking="nowSpeaking"
|
||||
:paused="paused"
|
||||
@@ -882,9 +888,11 @@ defineExpose({
|
||||
v-model:state="componentState"
|
||||
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:cursor-position="cursorPosition"
|
||||
:idle-animation="animations.idleLoop.toString()"
|
||||
:paused="paused"
|
||||
:show-axes="stageViewControlsEnabled"
|
||||
:enable-orbit-controls="props.enableOrbitControls"
|
||||
:current-audio-source="currentAudioSource"
|
||||
@error="console.error"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defaultControlConfig as threeControlConfig,
|
||||
formatter as threeFormatter,
|
||||
useModelStore,
|
||||
useThreeViewControl,
|
||||
} from '@proj-airi/stage-ui-three'
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import {
|
||||
defaultControlConfig as live2dControlConfig,
|
||||
formatter as live2dFormatter,
|
||||
useL2dViewControl,
|
||||
} from '../../stores/live2d'
|
||||
import { useSettingsStageModel } from '../../stores/settings/stage-model'
|
||||
|
||||
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
|
||||
const live2d = useL2dViewControl()
|
||||
const three = useThreeViewControl()
|
||||
const { sceneMutationLocked } = storeToRefs(useModelStore())
|
||||
|
||||
const activeRenderer = computed<'live2d' | 'vrm' | null>(() => {
|
||||
if (stageModelRenderer.value === 'live2d')
|
||||
return 'live2d'
|
||||
if (stageModelRenderer.value === 'vrm')
|
||||
return 'vrm'
|
||||
return null
|
||||
})
|
||||
|
||||
const controlEnabled = computed(() => {
|
||||
if (activeRenderer.value === 'live2d')
|
||||
return live2d.viewControlsEnabled.value
|
||||
if (activeRenderer.value === 'vrm')
|
||||
return three.viewControlsEnabled.value
|
||||
return false
|
||||
})
|
||||
|
||||
const activeControlKey = computed(() => {
|
||||
if (activeRenderer.value === 'live2d')
|
||||
return live2d.viewControlMode.value
|
||||
if (activeRenderer.value === 'vrm')
|
||||
return three.viewControlMode.value
|
||||
return null
|
||||
})
|
||||
|
||||
const activeControlConfig = computed(() => {
|
||||
if (activeRenderer.value === 'live2d')
|
||||
return live2dControlConfig[live2d.viewControlMode.value]
|
||||
if (activeRenderer.value === 'vrm')
|
||||
return threeControlConfig[three.viewControlMode.value]
|
||||
return null
|
||||
})
|
||||
|
||||
const controlledValue = computed({
|
||||
get() {
|
||||
if (activeRenderer.value === 'live2d') {
|
||||
switch (live2d.viewControlMode.value) {
|
||||
case 'x':
|
||||
return live2d.position.value.x
|
||||
case 'y':
|
||||
return live2d.position.value.y
|
||||
case 'scale':
|
||||
return live2d.scale.value
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRenderer.value === 'vrm') {
|
||||
switch (three.viewControlMode.value) {
|
||||
case 'x':
|
||||
return three.modelOffset.value.x
|
||||
case 'y':
|
||||
return three.modelOffset.value.y
|
||||
case 'z':
|
||||
return three.modelOffset.value.z
|
||||
case 'cameraDistance':
|
||||
return three.cameraDistance.value
|
||||
case 'cameraFOV':
|
||||
return three.cameraFOV.value
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
},
|
||||
set(value) {
|
||||
if (activeRenderer.value === 'live2d') {
|
||||
live2d.set(live2d.viewControlMode.value, value)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeRenderer.value === 'vrm') {
|
||||
if (sceneMutationLocked.value)
|
||||
return
|
||||
three.set(three.viewControlMode.value, value)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (activeRenderer.value === 'live2d')
|
||||
return live2dFormatter[live2d.viewControlMode.value](controlledValue.value)
|
||||
if (activeRenderer.value === 'vrm')
|
||||
return threeFormatter[three.viewControlMode.value](controlledValue.value)
|
||||
return ''
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
live2d.viewControlsEnabled.value = false
|
||||
three.viewControlsEnabled.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade-side-pops-in">
|
||||
<fieldset v-if="controlEnabled && activeControlConfig">
|
||||
<Transition name="fade-side-pops-in" mode="out-in">
|
||||
<div :key="activeControlKey ?? 'none'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="controlledValue"
|
||||
:min="activeControlConfig.min"
|
||||
:max="activeControlConfig.max"
|
||||
:disabled="activeRenderer === 'vrm' && sceneMutationLocked"
|
||||
:step="activeControlConfig.step"
|
||||
handle-wheel
|
||||
data-direction="vertical"
|
||||
h="50%"
|
||||
write-vertical-left
|
||||
/>
|
||||
<div
|
||||
class="round-range-tooltip"
|
||||
absolute left-10 top="50%"
|
||||
translate-y="[-50%]"
|
||||
font-mono op-0
|
||||
transition="all duration-200 ease-in-out"
|
||||
>
|
||||
{{ formattedValue }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</fieldset>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-side-pops-in-enter-active,
|
||||
.fade-side-pops-in-leave-active {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-side-pops-in-enter-from,
|
||||
.fade-side-pops-in-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%) scale(0.8);
|
||||
}
|
||||
|
||||
.fade-side-pops-in-enter-to,
|
||||
.fade-side-pops-in-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as WidgetStage } from './Stage.vue'
|
||||
export { default as ViewControlSlider } from './ViewControlSlider.vue'
|
||||
export {
|
||||
Live2DCanvas,
|
||||
Live2DModel,
|
||||
|
||||
Reference in New Issue
Block a user