feat(stage-*): allow setting character offset in landscape (#1696)
This commit is contained in:
@@ -15,7 +15,6 @@ import { 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 { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
|
||||
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'
|
||||
@@ -33,7 +32,6 @@ function handleSettingsOpen(open: boolean) {
|
||||
}
|
||||
|
||||
const positionCursor = useMouse()
|
||||
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isMobile = breakpoints.smaller('md')
|
||||
|
||||
@@ -199,9 +197,6 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
|
||||
x: positionCursor.x.value,
|
||||
y: positionCursor.y.value,
|
||||
}"
|
||||
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
|
||||
:y-offset="positionInPercentageString.y"
|
||||
:scale="scale"
|
||||
/>
|
||||
<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">
|
||||
|
||||
@@ -25,7 +25,6 @@ import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
|
||||
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
|
||||
import { useCanvasPixelIsTransparentAtPoint } from '@proj-airi/stage-ui/composables/canvas-alpha'
|
||||
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
@@ -113,8 +112,6 @@ const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
|
||||
|
||||
const setIgnoreMouseEvents = useElectronEventaInvoke(electron.window.setIgnoreMouseEvents)
|
||||
|
||||
const live2dStore = useLive2d()
|
||||
const { scale, positionInPercentageString } = storeToRefs(live2dStore)
|
||||
const { live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
|
||||
|
||||
const { pause, resume } = watch(isTransparent, (transparent) => {
|
||||
@@ -484,9 +481,6 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
|
||||
flex-1
|
||||
:paused="stagePaused"
|
||||
:focus-at="{ x: live2dLookAtX, y: live2dLookAtY }"
|
||||
:scale="scale"
|
||||
:x-offset="positionInPercentageString.x"
|
||||
:y-offset="positionInPercentageString.y"
|
||||
/>
|
||||
<HoloCoupon />
|
||||
<ControlsIsland
|
||||
|
||||
@@ -15,7 +15,6 @@ import { 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 { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
|
||||
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'
|
||||
@@ -31,7 +30,6 @@ function handleSettingsOpen(open: boolean) {
|
||||
}
|
||||
|
||||
const positionCursor = useMouse()
|
||||
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isMobile = breakpoints.smaller('md')
|
||||
|
||||
@@ -173,9 +171,6 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
|
||||
x: positionCursor.x.value,
|
||||
y: positionCursor.y.value,
|
||||
}"
|
||||
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
|
||||
:y-offset="positionInPercentageString.y"
|
||||
:scale="scale"
|
||||
/>
|
||||
<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" />
|
||||
|
||||
+36
-21
@@ -1,42 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'reset'): void
|
||||
}>()
|
||||
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
|
||||
const { viewControlsEnabled: l2dViewCtrlEnabled, viewControlMode: l2dCtrlMode, reset: l2dReset } = useL2dViewControl()
|
||||
const { viewControlsEnabled: threePlainCtrlEnabled, viewControlMode: threeCtrlMode, reset: threeReset } = useThreeViewControl()
|
||||
const controlEnabled = computed(() => {
|
||||
if (stageModelRenderer.value === 'live2d')
|
||||
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode, supported: l2dSupportedCtrl, reset: l2dReset }
|
||||
if (stageModelRenderer.value === 'vrm')
|
||||
return { enabled: threePlainCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, reset: threeReset }
|
||||
return null
|
||||
})
|
||||
|
||||
const { stageModelRenderer, stageViewControlsEnabled } = storeToRefs(useSettings())
|
||||
|
||||
const mode = defineModel<'x' | 'y' | 'z' | 'scale'>({ required: true })
|
||||
|
||||
function handleViewControlsToggle(targetMode: 'x' | 'y' | 'z' | 'scale') {
|
||||
if (mode.value === targetMode) {
|
||||
emits('reset')
|
||||
function handleViewControlsToggle(targetMode: string) {
|
||||
if (!controlEnabled.value || !controlEnabled.value.supported.includes(targetMode as any))
|
||||
return
|
||||
if (controlEnabled.value.mode.value === targetMode) {
|
||||
controlEnabled.value.reset(controlEnabled.value.mode.value as any)
|
||||
return
|
||||
}
|
||||
|
||||
mode.value = targetMode
|
||||
controlEnabled.value.mode.value = targetMode as any
|
||||
}
|
||||
|
||||
// watch()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div w-full flex flex-1 items-center self-end justify-end gap-2>
|
||||
<Transition name="fade">
|
||||
<div v-if="stageViewControlsEnabled" w-full flex justify-between gap-2>
|
||||
<Button variant="secondary-muted" :toggled="mode === 'x'" w-full @click="handleViewControlsToggle('x')">
|
||||
<div v-if="controlEnabled?.enabled.value" w-full flex justify-between gap-2>
|
||||
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'x'" w-full @click="handleViewControlsToggle('x')">
|
||||
X
|
||||
</Button>
|
||||
<Button variant="secondary-muted" :toggled="mode === 'y'" w-full @click="handleViewControlsToggle('y')">
|
||||
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'y'" w-full @click="handleViewControlsToggle('y')">
|
||||
Y
|
||||
</Button>
|
||||
<Button v-if="stageModelRenderer === 'vrm'" variant="secondary-muted" :toggled="mode === 'z'" w-full @click="handleViewControlsToggle('z')">
|
||||
<Button v-if="controlEnabled.supported.includes('z' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'z'" w-full @click="handleViewControlsToggle('z')">
|
||||
Z
|
||||
</Button>
|
||||
<Button variant="secondary-muted" :toggled="mode === 'scale'" w-full @click="handleViewControlsToggle('scale')">
|
||||
<Button v-if="controlEnabled.supported.includes('cameraFOV' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraFOV'" w-full @click="handleViewControlsToggle('cameraFOV')">
|
||||
FOV
|
||||
</Button>
|
||||
<Button v-if="controlEnabled.supported.includes('scale' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'scale'" w-full @click="handleViewControlsToggle('scale')">
|
||||
Scale
|
||||
</Button>
|
||||
<Button v-if="controlEnabled.supported.includes('cameraDistance' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraDistance'" w-full @click="handleViewControlsToggle('cameraDistance')">
|
||||
Dis
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
<button
|
||||
@@ -45,11 +60,11 @@ function handleViewControlsToggle(targetMode: 'x' | 'y' | 'z' | 'scale') {
|
||||
bg="neutral-50/70 dark:neutral-800/70"
|
||||
title="View"
|
||||
text="neutral-500 dark:neutral-400"
|
||||
@click="stageViewControlsEnabled = !stageViewControlsEnabled"
|
||||
@click="controlEnabled && (controlEnabled.enabled.value = !controlEnabled.enabled.value)"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="!stageViewControlsEnabled" i-solar:tuning-outline size-5 />
|
||||
<div v-else i-solar:alt-arrow-right-outline size-5 />
|
||||
<div v-if="controlEnabled?.enabled.value" i-solar:alt-arrow-right-outline size-5 />
|
||||
<div v-else i-solar:tuning-outline size-5 />
|
||||
</Transition>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
|
||||
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
|
||||
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
||||
@@ -9,20 +10,20 @@ import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { BasicTextarea, useTheme } from '@proj-airi/ui'
|
||||
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
|
||||
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
|
||||
import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
|
||||
import ViewControlInputs from './ViewControls/Inputs.vue'
|
||||
|
||||
import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
@@ -41,9 +42,6 @@ function handleDeleteMessage(index: number) {
|
||||
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
|
||||
}
|
||||
|
||||
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
|
||||
const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInputs>>('viewControlsInputs')
|
||||
|
||||
const messageInput = ref('')
|
||||
const isComposing = ref(false)
|
||||
const backgroundDialogOpen = ref(false)
|
||||
@@ -53,7 +51,9 @@ const providersStore = useProvidersStore()
|
||||
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
|
||||
|
||||
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
||||
const { themeColorsHueDynamic, stageViewControlsEnabled } = storeToRefs(useSettings())
|
||||
const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
|
||||
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
|
||||
const settingsAudioDevice = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
|
||||
const { ingest, onAfterMessageComposed } = chatOrchestrator
|
||||
@@ -149,7 +149,7 @@ onMounted(() => {
|
||||
<KeepAlive>
|
||||
<Transition name="fade">
|
||||
<ChatHistory
|
||||
v-if="!stageViewControlsEnabled"
|
||||
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
|
||||
variant="mobile"
|
||||
:messages="historyMessages"
|
||||
:sending="sending"
|
||||
@@ -165,9 +165,6 @@ onMounted(() => {
|
||||
</Transition>
|
||||
</KeepAlive>
|
||||
<div relative w-full self-end>
|
||||
<div top="50%" translate-y="[-50%]" fixed z-15 px-3>
|
||||
<ViewControlInputs ref="viewControlsInputs" :mode="viewControlsActiveMode" />
|
||||
</div>
|
||||
<div translate-y="[-100%]" absolute left-0 px-3 pb-3 font-sans>
|
||||
<div flex="~ col" gap-1>
|
||||
<slot name="status" />
|
||||
@@ -223,7 +220,7 @@ onMounted(() => {
|
||||
>
|
||||
<div class="i-solar:trash-bin-2-bold-duotone" />
|
||||
</button>
|
||||
<ActionViewControls v-model="viewControlsActiveMode" @reset="() => viewControlsInputsRef?.resetOnMode()" />
|
||||
<ViewControls />
|
||||
</div>
|
||||
</div>
|
||||
<div bg="white dark:neutral-800" max-h-100dvh max-w-100dvw w-full flex gap-1 overflow-auto px-3 pt-2 :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 12)}px` }">
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useModelStore } from '@proj-airi/stage-ui-three'
|
||||
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'x' | 'y' | 'z' | 'scale'
|
||||
}>()
|
||||
|
||||
const { stageModelRenderer, stageViewControlsEnabled } = storeToRefs(useSettings())
|
||||
const { modelOffset: vrmPosition, modelSize: vrmModelSize, cameraDistance: vrmCameraDistance, sceneMutationLocked: vrmSceneMutationLocked } = storeToRefs(useModelStore())
|
||||
const { scale: live2dScale, position: live2dPosition } = storeToRefs(useLive2d())
|
||||
|
||||
const vrmControlsDisabled = computed(() => {
|
||||
return stageModelRenderer.value === 'vrm' && vrmSceneMutationLocked.value
|
||||
})
|
||||
|
||||
const viewControlsValueX = computed({
|
||||
get: () => {
|
||||
switch (stageModelRenderer.value) {
|
||||
case 'live2d':
|
||||
return live2dPosition.value.x
|
||||
case 'vrm':
|
||||
return vrmPosition.value.x * 100
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
switch (stageModelRenderer.value) {
|
||||
case 'live2d':
|
||||
live2dPosition.value.x = value
|
||||
break
|
||||
case 'vrm':
|
||||
if (vrmControlsDisabled.value)
|
||||
break
|
||||
vrmPosition.value.x = value / 100
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const viewControlsValueXMin = computed(() => {
|
||||
return stageModelRenderer.value === 'live2d' ? -1000 : (-vrmModelSize.value.x - 10) * 10
|
||||
})
|
||||
|
||||
const viewControlsValueXMax = computed(() => {
|
||||
return stageModelRenderer.value === 'live2d' ? 1000 : (vrmModelSize.value.x + 10) * 10
|
||||
})
|
||||
|
||||
const viewControlsValueY = computed({
|
||||
get: () => {
|
||||
switch (stageModelRenderer.value) {
|
||||
case 'live2d':
|
||||
return -live2dPosition.value.y
|
||||
case 'vrm':
|
||||
return vrmPosition.value.y * 100
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
switch (stageModelRenderer.value) {
|
||||
case 'live2d':
|
||||
live2dPosition.value.y = -value
|
||||
break
|
||||
case 'vrm':
|
||||
if (vrmControlsDisabled.value)
|
||||
break
|
||||
vrmPosition.value.y = value / 100
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const viewControlsValueYMin = computed(() => {
|
||||
return stageModelRenderer.value === 'live2d' ? -1000 : (-vrmModelSize.value.y - 10) * 10
|
||||
})
|
||||
|
||||
const viewControlsValueYMax = computed(() => {
|
||||
return stageModelRenderer.value === 'live2d' ? 1000 : (vrmModelSize.value.y + 10) * 10
|
||||
})
|
||||
|
||||
const viewControlsValueZ = computed({
|
||||
get: () => {
|
||||
switch (stageModelRenderer.value) {
|
||||
case 'live2d':
|
||||
return 0
|
||||
case 'vrm':
|
||||
return vrmPosition.value.z
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
switch (stageModelRenderer.value) {
|
||||
case 'live2d':
|
||||
break
|
||||
case 'vrm':
|
||||
if (vrmControlsDisabled.value)
|
||||
break
|
||||
vrmPosition.value.z = value
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const viewControlsValueZMin = computed(() => {
|
||||
return stageModelRenderer.value === 'live2d' ? -500 : -vrmModelSize.value.z - 10
|
||||
})
|
||||
|
||||
const viewControlsValueZMax = computed(() => {
|
||||
return stageModelRenderer.value === 'live2d' ? 500 : vrmModelSize.value.z + 10
|
||||
})
|
||||
|
||||
const viewControlsValueScale = computed({
|
||||
get: () => {
|
||||
if (stageModelRenderer.value === 'live2d') {
|
||||
return live2dScale.value
|
||||
}
|
||||
|
||||
return vrmCameraDistance.value
|
||||
},
|
||||
set: (value) => {
|
||||
if (stageModelRenderer.value === 'live2d') {
|
||||
live2dScale.value = value
|
||||
}
|
||||
else if (!vrmControlsDisabled.value) {
|
||||
vrmCameraDistance.value = value
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function resetOnMode() {
|
||||
switch (props.mode) {
|
||||
case 'x':
|
||||
viewControlsValueX.value = 0
|
||||
break
|
||||
case 'y':
|
||||
viewControlsValueY.value = 0
|
||||
break
|
||||
case 'z':
|
||||
viewControlsValueZ.value = 0
|
||||
break
|
||||
case 'scale':
|
||||
viewControlsValueScale.value = 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetOnMode,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade-side-pops-in">
|
||||
<div v-if="stageViewControlsEnabled" :class="vrmControlsDisabled ? ['opacity-60', 'pointer-events-none'] : []">
|
||||
<Transition name="fade-side-pops-in" mode="out-in">
|
||||
<div v-if="props.mode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange v-model="viewControlsValueX" :min="viewControlsValueXMin" :max="viewControlsValueXMax" :step="0.01" :disabled="vrmControlsDisabled" 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">
|
||||
{{ viewControlsValueX.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.mode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange v-model="viewControlsValueY" :min="viewControlsValueYMin" :max="viewControlsValueYMax" :step="0.01" :disabled="vrmControlsDisabled" write-vertical-left h="50%" data-direction="vertical" />
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ viewControlsValueY.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="stageModelRenderer === 'vrm' && props.mode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange v-model="viewControlsValueZ" :min="viewControlsValueZMin" :max="viewControlsValueZMax" :step="0.01" :disabled="vrmControlsDisabled" write-vertical-left h="50%" data-direction="vertical" />
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ viewControlsValueZ.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.mode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange v-model="viewControlsValueScale" :min="0" :max="3" :step="0.0001" :disabled="vrmControlsDisabled" write-vertical-left h="50%" data-direction="vertical" />
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ viewControlsValueScale.toFixed(2) }}
|
||||
</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>
|
||||
@@ -3,6 +3,8 @@ import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/mainten
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
|
||||
|
||||
import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
const { cleanupMessages } = useChatMaintenanceStore()
|
||||
@@ -14,6 +16,7 @@ const backgroundDialogOpen = ref(false)
|
||||
<template>
|
||||
<BackgroundDialogPicker v-model="backgroundDialogOpen" />
|
||||
<div absolute bottom--8 right-0 flex gap-2>
|
||||
<ViewControls />
|
||||
<button
|
||||
class="max-h-[10lh] min-h-[1lh]"
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
import { controlConfig, useL2dViewControl } from '../../stores'
|
||||
|
||||
const { scale, position, viewControlsEnabled, viewControlMode } = useL2dViewControl()
|
||||
|
||||
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 v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="position.x" :min="controlConfig.x.min" :max="controlConfig.x.max" :step="controlConfig.x.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">
|
||||
{{ controlConfig.x.format(position.x) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="position.y" :min="controlConfig.y.min" :max="controlConfig.y.max" :step="controlConfig.y.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">
|
||||
{{ controlConfig.y.format(position.y) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="scale" :min="controlConfig.scale.min" :max="controlConfig.scale.max" :step="controlConfig.scale.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">
|
||||
{{ controlConfig.scale.format(scale) }}
|
||||
</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,13 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Screen } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import SliderControls from '../ViewControls/SliderControls.vue'
|
||||
import Live2DCanvas from './live2d/Canvas.vue'
|
||||
import Live2DModel from './live2d/Model.vue'
|
||||
|
||||
import { useLive2d } from '../../stores/live2d'
|
||||
|
||||
import '../../utils/live2d-zip-loader'
|
||||
import '../../utils/live2d-opfs-registration'
|
||||
|
||||
@@ -19,7 +17,6 @@ withDefaults(defineProps<{
|
||||
mouthOpenSize?: number
|
||||
focusAt?: { x: number, y: number }
|
||||
disableFocusAt?: boolean
|
||||
scale?: number
|
||||
themeColorsHue?: number
|
||||
themeColorsHueDynamic?: boolean
|
||||
live2dIdleAnimationEnabled?: boolean
|
||||
@@ -33,7 +30,6 @@ withDefaults(defineProps<{
|
||||
paused: false,
|
||||
focusAt: () => ({ x: 0, y: 0 }),
|
||||
mouthOpenSize: 0,
|
||||
scale: 1,
|
||||
themeColorsHue: 220.44,
|
||||
themeColorsHueDynamic: false,
|
||||
live2dIdleAnimationEnabled: true,
|
||||
@@ -51,9 +47,6 @@ const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('mode
|
||||
|
||||
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
|
||||
|
||||
const live2d = useLive2d()
|
||||
const { position } = storeToRefs(live2d)
|
||||
|
||||
watch([componentStateModel, componentStateCanvas], () => {
|
||||
componentState.value = (componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
|
||||
? 'mounted'
|
||||
@@ -72,6 +65,9 @@ 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 }"
|
||||
@@ -92,9 +88,6 @@ defineExpose({
|
||||
:height="height"
|
||||
:paused="paused"
|
||||
:focus-at="focusAt"
|
||||
:x-offset="position.x"
|
||||
:y-offset="position.y"
|
||||
:scale="scale"
|
||||
:disable-focus-at="disableFocusAt"
|
||||
:theme-colors-hue="themeColorsHue"
|
||||
:theme-colors-hue-dynamic="themeColorsHueDynamic"
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useMotionUpdatePluginIdleFocus,
|
||||
} from '../../../composables/live2d'
|
||||
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
|
||||
import { useLive2d } from '../../../stores/live2d'
|
||||
import { useL2dViewControl, useLive2d } from '../../../stores/live2d'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelSrc?: string
|
||||
@@ -39,9 +39,6 @@ const props = withDefaults(defineProps<{
|
||||
paused?: boolean
|
||||
focusAt?: { x: number, y: number }
|
||||
disableFocusAt?: boolean
|
||||
xOffset?: number | string
|
||||
yOffset?: number | string
|
||||
scale?: number
|
||||
themeColorsHue?: number
|
||||
themeColorsHueDynamic?: boolean
|
||||
live2dIdleAnimationEnabled?: boolean
|
||||
@@ -70,17 +67,11 @@ const emits = defineEmits<{
|
||||
}>()
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
const { position, scale } = useL2dViewControl()
|
||||
|
||||
function parsePropsOffset() {
|
||||
let xOffset = Number.parseFloat(String(props.xOffset)) || 0
|
||||
let yOffset = Number.parseFloat(String(props.yOffset)) || 0
|
||||
|
||||
if (String(props.xOffset).endsWith('%')) {
|
||||
xOffset = (Number.parseFloat(String(props.xOffset).replace('%', '')) / 100) * props.width
|
||||
}
|
||||
if (String(props.yOffset).endsWith('%')) {
|
||||
yOffset = (Number.parseFloat(String(props.yOffset).replace('%', '')) / 100) * props.height
|
||||
}
|
||||
const xOffset = (position.value.x / 100) * props.width
|
||||
const yOffset = -(position.value.y / 100) * props.height
|
||||
|
||||
return {
|
||||
xOffset,
|
||||
@@ -131,14 +122,14 @@ function computeScaleAndPosition() {
|
||||
|
||||
const heightScale = (props.height * 0.95 / initialModelHeight.value * offsetFactor)
|
||||
const widthScale = (props.width * 0.95 / initialModelWidth.value * offsetFactor)
|
||||
let scale = Math.min(heightScale, widthScale)
|
||||
let minScale = Math.min(heightScale, widthScale)
|
||||
|
||||
if (Number.isNaN(scale) || scale <= 0) {
|
||||
scale = 1e-6
|
||||
if (Number.isNaN(minScale) || minScale <= 0) {
|
||||
minScale = 1e-6
|
||||
}
|
||||
|
||||
return {
|
||||
scale: scale * props.scale,
|
||||
scale: minScale * scale.value,
|
||||
x: (props.width / 2) + offset.value.xOffset,
|
||||
y: props.height + offset.value.yOffset,
|
||||
}
|
||||
@@ -549,8 +540,7 @@ 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, scale], () => setScaleAndPosition())
|
||||
|
||||
// TODO: This is hacky!
|
||||
function updateDropShadowFilterLoop() {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './expression-store'
|
||||
export * from './live2d'
|
||||
export * from './view-control'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { supportedControl, useL2dViewControl } from './view-control'
|
||||
|
||||
type BroadcastChannelEvents
|
||||
= | BroadcastChannelEventShouldUpdateView
|
||||
@@ -57,32 +59,25 @@ export const useLive2d = defineStore('live2d', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const position = useLocalStorageManualReset<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
|
||||
const positionInPercentageString = computed(() => ({
|
||||
x: `${position.value.x}%`,
|
||||
y: `${position.value.y}%`,
|
||||
}))
|
||||
const currentMotion = useLocalStorageManualReset<{ group: string, index?: number }>('settings/live2d/current-motion', () => ({ group: 'Idle', index: 0 }))
|
||||
const availableMotions = useLocalStorageManualReset<{ motionName: string, motionIndex: number, fileName: string }[]>('settings/live2d/available-motions', () => [])
|
||||
const motionMap = useLocalStorageManualReset<Record<string, string>>('settings/live2d/motion-map', {})
|
||||
const scale = useLocalStorageManualReset('settings/live2d/scale', 1)
|
||||
const { position, scale, reset: resetViewControl } = useL2dViewControl()
|
||||
|
||||
// Live2D model parameters
|
||||
const modelParameters = useLocalStorageManualReset<Record<string, number>>('settings/live2d/parameters', defaultModelParameters)
|
||||
|
||||
function resetState() {
|
||||
position.reset()
|
||||
supportedControl.forEach(c => resetViewControl(c))
|
||||
currentMotion.reset()
|
||||
availableMotions.reset()
|
||||
motionMap.reset()
|
||||
scale.reset()
|
||||
modelParameters.reset()
|
||||
shouldUpdateView()
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
positionInPercentageString,
|
||||
currentMotion,
|
||||
availableMotions,
|
||||
motionMap,
|
||||
@@ -94,3 +89,4 @@ export const useLive2d = defineStore('live2d', () => {
|
||||
resetState,
|
||||
}
|
||||
})
|
||||
export { useL2dViewControl }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const supportedControl = ['x', 'y', 'scale'] as const
|
||||
type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
|
||||
|
||||
/** show or hide the control element(slider) on stage */
|
||||
const viewControlsEnabled = ref(false)
|
||||
/** what value to control for the control element */
|
||||
const viewControlMode = ref<SupportedControl>('scale')
|
||||
/** model position relative to the center of the screen, in pixels */
|
||||
const position = useLocalStorage<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 })
|
||||
/** model scaling. `1` means no scaling. */
|
||||
const scale = useLocalStorage('settings/live2d/scale', 1)
|
||||
|
||||
const formatPercentD1 = (val: number) => `${val.toFixed(1)}%`
|
||||
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
|
||||
|
||||
export const controlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
// TODO: calculate the min and max value dynamically according to window height/width, or allow user to set it
|
||||
x: {
|
||||
min: -500,
|
||||
max: 500,
|
||||
step: 0.1,
|
||||
default: 0,
|
||||
format: formatPercentD1,
|
||||
},
|
||||
y: {
|
||||
min: -500,
|
||||
max: 500,
|
||||
step: 0.1,
|
||||
default: 0,
|
||||
format: formatPercentD1,
|
||||
},
|
||||
scale: {
|
||||
min: 0.01,
|
||||
max: 3,
|
||||
step: 0.01,
|
||||
default: 1,
|
||||
format: formatToPercent,
|
||||
},
|
||||
}
|
||||
|
||||
export function useL2dViewControl() {
|
||||
/**
|
||||
* reset the given control to its default value.
|
||||
* @param key the control to reset
|
||||
*/
|
||||
function reset(key: SupportedControl) {
|
||||
switch (key) {
|
||||
case 'x':
|
||||
position.value.x = controlConfig.x.default
|
||||
break
|
||||
case 'y':
|
||||
position.value.y = controlConfig.y.default
|
||||
break
|
||||
case 'scale':
|
||||
scale.value = controlConfig.scale.default
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** model position relative to the center of the screen, in pixels */
|
||||
position,
|
||||
/** model scaling in percentages. `100` means no scaling. */
|
||||
scale,
|
||||
/** reset the given control to its default value. */
|
||||
reset,
|
||||
/** show or hide the control element(slider) on stage */
|
||||
viewControlsEnabled,
|
||||
/** what value to control for the control element */
|
||||
viewControlMode,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
/*
|
||||
* - Extend OrbitControls from three
|
||||
* - Define camera behavior
|
||||
* - TODO: implement the control elements and replace the <slot/>
|
||||
*/
|
||||
|
||||
import type { Vec3 } from '../../stores/model-store'
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
import { controlConfig as conf, useThreeViewControl } from '../../stores/view-control'
|
||||
|
||||
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode } = useThreeViewControl()
|
||||
|
||||
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">
|
||||
<!-- TODO: generate the controls programmatically, while preserving the transition -->
|
||||
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="modelOffset.x" :min="conf.x.min" :max="conf.x.max" :step="conf.x.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">
|
||||
{{ conf.x.format(modelOffset.x) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="modelOffset.y" :min="conf.y.min" :max="conf.y.max" :step="conf.y.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">
|
||||
{{ conf.y.format(modelOffset.y) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="modelOffset.z" :min="conf.z.min" :max="conf.z.max" :step="conf.z.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">
|
||||
{{ conf.z.format(modelOffset.z) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'cameraDistance'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="cameraDistance" :min="conf.cameraDistance.min" :max="conf.cameraDistance.max" :step="conf.cameraDistance.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">
|
||||
{{ conf.cameraDistance.format(cameraDistance) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'cameraFOV'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="cameraFOV" :min="conf.cameraFOV.min" :max="conf.cameraFOV.max" :step="conf.cameraFOV.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">
|
||||
{{ conf.cameraFOV.format(cameraFOV) }}
|
||||
</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 +1,2 @@
|
||||
export { default as OrbitControls } from './OrbitControls.vue'
|
||||
export { default as SliderControls } from './SliderControls.vue'
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
stageThreeTraceThreeSceneSubtreeEvent,
|
||||
stageThreeTraceThreeSceneTransactionEvent,
|
||||
} from '../trace'
|
||||
import { OrbitControls } from './Controls'
|
||||
import { OrbitControls, SliderControls } from './Controls'
|
||||
import { SkyBox } from './Environment'
|
||||
import { VRMModel } from './Model'
|
||||
|
||||
@@ -708,7 +708,10 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Screen v-slot="{ width, height }">
|
||||
<Screen v-slot="{ width, height }" relative>
|
||||
<div top="50%" translate-y="[-50%]" fixed z-15 px-3>
|
||||
<SliderControls />
|
||||
</div>
|
||||
<TresCanvas
|
||||
:width="width"
|
||||
:height="height"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { default as ThreeScene } from './components/ThreeScene.vue'
|
||||
|
||||
export * from './composables/hit-test'
|
||||
export * from './composables/render-target'
|
||||
export { useModelStore } from './stores/model-store'
|
||||
export * from './stores/view-control'
|
||||
export * from './trace'
|
||||
export * from './utils/vrm-preview'
|
||||
|
||||
@@ -6,6 +6,8 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import defaultSkyBoxSrc from '../components/Environment/assets/sky_linekotsi_23_HDRI.hdr?url'
|
||||
|
||||
import { supportedControl, useThreeViewControl } from './view-control'
|
||||
|
||||
// TODO: this is for future type injection features
|
||||
// TODO: make a separate type.ts
|
||||
export interface Vec3 { x: number, y: number, z: number }
|
||||
@@ -77,6 +79,7 @@ interface BroadcastChannelEventShouldUpdateView {
|
||||
|
||||
const vrmViewUpdateRuntimeInstanceId = Math.random().toString(36).slice(2, 10)
|
||||
let vrmViewUpdateMessageSequence = 0
|
||||
const { cameraFOV, cameraDistance, modelOffset, reset: resetViewControl } = useThreeViewControl()
|
||||
|
||||
export const useModelStore = defineStore('modelStore', () => {
|
||||
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-three-vrm' })
|
||||
@@ -131,9 +134,6 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
}
|
||||
|
||||
// === Legacy / shared controls ===
|
||||
// REVIEW: `scale` is still shared with non-VRM view controls. The VRM path should
|
||||
// gradually move to `cameraDistance` as the primary user-facing zoom concept.
|
||||
const scale = useLocalStorage('settings/stage-ui-three/scale', 1)
|
||||
const lastCommittedModelSrc = useLocalStorage('settings/stage-ui-three/lastModelSrc', '')
|
||||
|
||||
// === Model lifecycle / bootstrap ===
|
||||
@@ -145,9 +145,7 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
|
||||
// === User scene settings ===
|
||||
// These values are intended to survive model reloads and direct edits from settings UI.
|
||||
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: 0, y: 0, z: 0 })
|
||||
const modelRotationY = useLocalStorage('settings/stage-ui-three/modelRotationY', 0)
|
||||
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', 40)
|
||||
const trackingMode = useLocalStorage('settings/stage-ui-three/trackingMode', 'none' as 'camera' | 'mouse' | 'none')
|
||||
|
||||
// === View state ===
|
||||
@@ -155,7 +153,6 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
// `lookAtTarget` represent the current runtime pose and may be recalculated when
|
||||
// a new model bootstrap is applied.
|
||||
const cameraPosition = useLocalStorage('settings/stage-ui-three/camera-position', { x: 0, y: 0, z: -1 })
|
||||
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', 0)
|
||||
const lookAtTarget = useLocalStorage('settings/stage-ui-three/lookAtTarget', { x: 0, y: 0, z: 0 })
|
||||
|
||||
function resetModelStore() {
|
||||
@@ -165,12 +162,10 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
lastCommittedModelSrc.value = ''
|
||||
modelSize.value = { x: 0, y: 0, z: 0 }
|
||||
modelOrigin.value = { x: 0, y: 0, z: 0 }
|
||||
modelOffset.value = { x: 0, y: 0, z: 0 }
|
||||
modelRotationY.value = 0
|
||||
|
||||
cameraFOV.value = 40
|
||||
cameraPosition.value = { x: 0, y: 0, z: 0 }
|
||||
cameraDistance.value = 0
|
||||
supportedControl.forEach(c => resetViewControl(c))
|
||||
|
||||
lookAtTarget.value = { x: 0, y: 0, z: 0 }
|
||||
trackingMode.value = 'none'
|
||||
@@ -218,7 +213,6 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
sceneTransactionDepth,
|
||||
sceneMutationLocked,
|
||||
|
||||
scale,
|
||||
lastCommittedModelSrc,
|
||||
|
||||
modelSize,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const supportedControl = ['x', 'y', 'z', 'cameraDistance', 'cameraFOV'] as const
|
||||
type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
|
||||
|
||||
const formatDecimal2Meters = (val: number) => `${val.toFixed(2)}m`
|
||||
|
||||
export const controlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
// TODO: allow user to set the min/max value
|
||||
x: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 0,
|
||||
format: formatDecimal2Meters,
|
||||
},
|
||||
y: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 0,
|
||||
format: formatDecimal2Meters,
|
||||
},
|
||||
z: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 0,
|
||||
format: formatDecimal2Meters,
|
||||
},
|
||||
cameraDistance: {
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 1,
|
||||
format: formatDecimal2Meters,
|
||||
},
|
||||
cameraFOV: {
|
||||
min: 10,
|
||||
max: 120,
|
||||
step: 1,
|
||||
default: 40,
|
||||
format: (val: number) => `${val.toFixed(0)}°`,
|
||||
},
|
||||
}
|
||||
|
||||
/** camera field of view, in degrees. */
|
||||
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', 40)
|
||||
/**
|
||||
* euclidean distance between the model center and the camera center, in meters.
|
||||
* setting this value will move the camera along the axis.
|
||||
*/
|
||||
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', 1)
|
||||
/** model position from the scene origin, in meters. */
|
||||
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: 0, y: 0, z: 0 })
|
||||
/** show or hide the control element(slider) on HUD. */
|
||||
const viewControlsEnabled = ref(false)
|
||||
/** what value to control for the control element */
|
||||
const viewControlMode = ref<SupportedControl>('cameraDistance')
|
||||
|
||||
/**
|
||||
* reset the given control to its default value.
|
||||
* @param key the control to reset
|
||||
*/
|
||||
function reset(key: SupportedControl) {
|
||||
switch (key) {
|
||||
case 'x':
|
||||
modelOffset.value.x = controlConfig.x.default
|
||||
break
|
||||
case 'y':
|
||||
modelOffset.value.y = controlConfig.y.default
|
||||
break
|
||||
case 'z':
|
||||
modelOffset.value.z = controlConfig.z.default
|
||||
break
|
||||
case 'cameraDistance':
|
||||
cameraDistance.value = controlConfig.cameraDistance.default
|
||||
break
|
||||
case 'cameraFOV':
|
||||
cameraFOV.value = controlConfig.cameraFOV.default
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export function useThreeViewControl() {
|
||||
return {
|
||||
/** camera field of view, in degrees. */
|
||||
cameraFOV,
|
||||
/** euclidean distance between the model center and the camera center, in meters. */
|
||||
cameraDistance,
|
||||
/** model position from the scene origin, in meters. */
|
||||
modelOffset,
|
||||
/** show or hide the control element(slider) on HUD. */
|
||||
viewControlsEnabled,
|
||||
/** what value to control for the control element */
|
||||
viewControlMode,
|
||||
|
||||
/** reset the given control to its default value. */
|
||||
reset,
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { Live2DScene, useLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
import { Live2DScene } from '@proj-airi/stage-ui-live2d'
|
||||
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
|
||||
import { useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -24,7 +24,6 @@ const emit = defineEmits<{
|
||||
|
||||
const positionCursor = useMouse()
|
||||
const settingsStore = useSettings()
|
||||
const live2dStore = useLive2d()
|
||||
const modelStore = useModelStore()
|
||||
const live2dSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
@@ -45,7 +44,6 @@ const {
|
||||
live2dMaxFps,
|
||||
live2dRenderScale,
|
||||
} = storeToRefs(settingsStore)
|
||||
const { scale: live2dScale } = storeToRefs(live2dStore)
|
||||
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
|
||||
|
||||
const live2dSceneClassList = computed(() => normalizeClassList(props.live2dSceneClass))
|
||||
@@ -141,7 +139,6 @@ defineExpose({
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:model-id="stageModelSelected"
|
||||
:disable-focus-at="live2dDisableFocus"
|
||||
:scale="live2dScale"
|
||||
:theme-colors-hue="themeColorsHue"
|
||||
:theme-colors-hue-dynamic="themeColorsHueDynamic"
|
||||
:live2d-idle-animation-enabled="live2dIdleAnimationEnabled"
|
||||
|
||||
@@ -42,9 +42,6 @@ import { shouldRunLive2dLipSyncLoop } from './runtime'
|
||||
const props = withDefaults(defineProps<{
|
||||
paused?: boolean
|
||||
focusAt: { x: number, y: number }
|
||||
xOffset?: number | string
|
||||
yOffset?: number | string
|
||||
scale?: number
|
||||
}>(), { paused: false, scale: 1 })
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
@@ -652,9 +649,6 @@ defineExpose({
|
||||
:focus-at="focusAt"
|
||||
:mouth-open-size="mouthOpenSize"
|
||||
:paused="paused"
|
||||
:x-offset="xOffset"
|
||||
:y-offset="yOffset"
|
||||
:scale="scale"
|
||||
:disable-focus-at="live2dDisableFocus"
|
||||
:theme-colors-hue="themeColorsHue"
|
||||
:theme-colors-hue-dynamic="themeColorsHueDynamic"
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChatSessionsExport } from '../types/chat-session'
|
||||
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { useLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
import { useModelStore } from '@proj-airi/stage-ui-three'
|
||||
|
||||
import { useChatOrchestratorStore } from '../stores/chat'
|
||||
import { useChatSessionStore } from '../stores/chat/session-store'
|
||||
@@ -27,6 +28,7 @@ export function useDataMaintenance() {
|
||||
const settingsStore = useSettings()
|
||||
const audioSettingsStore = useSettingsAudioDevice()
|
||||
const live2dStore = useLive2d()
|
||||
const threeStore = useModelStore()
|
||||
const hearingStore = useHearingStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
@@ -84,6 +86,7 @@ export function useDataMaintenance() {
|
||||
await settingsStore.resetState()
|
||||
audioSettingsStore.resetState()
|
||||
live2dStore.resetState()
|
||||
threeStore.resetModelStore()
|
||||
mcpStore.resetState()
|
||||
onboardingStore.resetSetupState()
|
||||
airiCardStore.resetState()
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from '@proj-airi/stage-ui-live2d/stores/live2d'
|
||||
export * from '@proj-airi/stage-ui-live2d/stores'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { supportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useElementHover, useKeyModifier } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -6,6 +7,7 @@ const props = withDefaults(defineProps<{
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
handleWheel?: boolean
|
||||
}>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
@@ -13,17 +15,22 @@ const props = withDefaults(defineProps<{
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const smoothingFactor = 10000
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const scaledMin = computed(() => props.min * 10000)
|
||||
const scaledMax = computed(() => props.max * 10000)
|
||||
const scaledStep = computed(() => props.step * 10000)
|
||||
|
||||
const sliderRef = ref<HTMLInputElement>()
|
||||
|
||||
const scaledMin = computed(() => props.min * smoothingFactor)
|
||||
const scaledMax = computed(() => props.max * smoothingFactor)
|
||||
const scaledStep = computed(() => props.step * smoothingFactor)
|
||||
const shiftPressed = useKeyModifier('Shift')
|
||||
const isHovered = useElementHover(sliderRef)
|
||||
|
||||
const sliderValue = computed({
|
||||
get: () => modelValue.value * 10000,
|
||||
get: () => modelValue.value * smoothingFactor,
|
||||
set: (value: number) => {
|
||||
modelValue.value = value / 10000
|
||||
modelValue.value = value / smoothingFactor
|
||||
updateTrackColor()
|
||||
},
|
||||
})
|
||||
@@ -31,6 +38,15 @@ const sliderValue = computed({
|
||||
onMounted(() => updateTrackColor())
|
||||
watch(sliderValue, () => updateTrackColor(), { immediate: true })
|
||||
watch([scaledMin, scaledMax, scaledStep], () => updateTrackColor(), { immediate: true })
|
||||
watch(isHovered, (hovered: boolean) => {
|
||||
if (!props.handleWheel)
|
||||
return
|
||||
if (hovered) {
|
||||
sliderRef.value?.addEventListener('wheel', onWheelInput)
|
||||
return
|
||||
}
|
||||
sliderRef.value?.removeEventListener('wheel', onWheelInput)
|
||||
})
|
||||
|
||||
function updateTrackColor() {
|
||||
if (!sliderRef.value) {
|
||||
@@ -46,6 +62,15 @@ function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
target.style.setProperty('--value', target.value)
|
||||
}
|
||||
|
||||
function onWheelInput(ev: WheelEvent) {
|
||||
let valueAfter = 0
|
||||
if (ev.deltaY < 0)
|
||||
valueAfter = modelValue.value += props.step * (shiftPressed.value ? 50 : 1)
|
||||
if (ev.deltaY > 0)
|
||||
valueAfter = modelValue.value -= props.step * (shiftPressed.value ? 50 : 1)
|
||||
modelValue.value = Math.min(Math.max(valueAfter, props.min), props.max)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,21 +91,23 @@ function handleInput(e: Event) {
|
||||
/*generated with Input range slider CSS style generator (version 20211225)
|
||||
https://toughengineer.github.io/demo/slider-styler*/
|
||||
.form_input-round-range {
|
||||
--height: 2em;
|
||||
--height: 100%;
|
||||
--width: 2rem;
|
||||
|
||||
min-height: var(--height);
|
||||
min-height: var(--width);
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
--thumb-width: var(--height);
|
||||
--thumb-height: var(--height);
|
||||
--thumb-width: var(--width);
|
||||
--thumb-height: var(--width);
|
||||
--thumb-box-shadow: none;
|
||||
--thumb-border: none;
|
||||
--thumb-border-radius: 0px;
|
||||
--thumb-background: transparent;
|
||||
|
||||
--track-height: calc(var(--height) - var(--track-value-padding) * 2);
|
||||
--track-width: var(--width);
|
||||
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
|
||||
--track-border: none;
|
||||
--track-border-radius: 16px;
|
||||
@@ -95,8 +122,6 @@ https://toughengineer.github.io/demo/slider-styler*/
|
||||
}
|
||||
|
||||
.dark .form_input-round-range {
|
||||
--thumb-background: rgb(238, 238, 238);
|
||||
|
||||
--track-border: none;
|
||||
--track-background: rgba(64, 64, 64, 0.7);
|
||||
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
|
||||
|
||||
Reference in New Issue
Block a user