feat(stage-*): dramatically improved mobile

This commit is contained in:
Neko Ayaka
2025-07-21 01:52:48 +08:00
parent f041ec0ac0
commit 0db4fa3f7c
31 changed files with 1016 additions and 593 deletions
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { ref } from 'vue'
const minValue = ref(0)
const midValue = ref(50)
const maxValue = ref(100)
</script>
<template>
<Story title="Round Range" group="form" :layout="{ type: 'grid', width: '100%' }">
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant id="default-min" title="Default (min)">
<RoundRange v-model="minValue" w-full />
</Variant>
<Variant id="default-mid" title="Default (mid)">
<RoundRange v-model="midValue" w-full />
</Variant>
<Variant id="default-max" title="Default (max)">
<RoundRange v-model="maxValue" w-full />
</Variant>
</Story>
</template>
@@ -14,7 +14,7 @@ const themeClasses: Record<ThemeVariant, {
}> = {
primary: {
container: [
'bg-primary-400/10 dark:bg-primary-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-primary-100/60 dark:bg-primary-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-primary-400 dark:bg-primary-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-primary-900`,
],
label: [
@@ -23,7 +23,7 @@ const themeClasses: Record<ThemeVariant, {
},
lime: {
container: [
'bg-lime-400/10 dark:bg-lime-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-lime-100/60 dark:bg-lime-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-lime-400 dark:bg-lime-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-lime-900`,
],
label: [
@@ -32,7 +32,7 @@ const themeClasses: Record<ThemeVariant, {
},
violet: {
container: [
'bg-violet-400/10 dark:bg-violet-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-violet-100/60 dark:bg-violet-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-violet-400 dark:bg-violet-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-violet-900`,
],
label: [
@@ -41,7 +41,7 @@ const themeClasses: Record<ThemeVariant, {
},
orange: {
container: [
'bg-orange-400/10 dark:bg-orange-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-orange-100/60 dark:bg-orange-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-orange-400 dark:bg-orange-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-orange-900`,
],
label: [
@@ -4,7 +4,7 @@ import { computed } from 'vue'
import { BidirectionalTransition } from '../Misc'
// Define button variants for better type safety and maintainability
type ButtonVariant = 'primary' | 'secondary' | 'danger'
type ButtonVariant = 'primary' | 'secondary' | 'secondary-muted' | 'danger'
type ButtonTheme
= | 'default'
@@ -15,6 +15,7 @@ type ButtonTheme
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps {
toggled?: boolean // Optional toggled state for toggle buttons
icon?: string // Icon class name
label?: string // Button text label
disabled?: boolean // Disabled state
@@ -26,6 +27,7 @@ interface ButtonProps {
}
const props = withDefaults(defineProps<ButtonProps>(), {
toggled: false,
variant: 'primary',
disabled: false,
loading: false,
@@ -38,18 +40,33 @@ const isDisabled = computed(() => props.disabled || props.loading)
// Extract variant styles for better organization
const variantClasses: Record<ButtonVariant, {
default: string
// dimmed: string
// lightened: string
default: {
default: string
nonToggled?: string
toggled?: string
}
}> = {
primary: {
default: 'bg-primary-500/15 hover:bg-primary-500/20 active:bg-primary-500/30 dark:bg-primary-700/30 dark:hover:bg-primary-700/40 dark:active:bg-primary-700/30 focus:ring-primary-300/60 dark:focus:ring-primary-600/30 border-2 border-solid border-primary-500/5 dark:border-primary-900/40 text-primary-950 dark:text-primary-100',
'primary': {
default: {
default: 'bg-primary-500/15 hover:bg-primary-500/20 active:bg-primary-500/30 dark:bg-primary-700/30 dark:hover:bg-primary-700/40 dark:active:bg-primary-700/30 focus:ring-primary-300/60 dark:focus:ring-primary-600/30 border-2 border-solid border-primary-500/5 dark:border-primary-900/40 text-primary-950 dark:text-primary-100',
},
},
secondary: {
default: 'bg-neutral-400/15 hover:bg-neutral-400/20 active:bg-neutral-400/30 dark:bg-neutral-700/60 dark:hover:bg-neutral-700/80 dark:active:bg-neutral-700/60 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30 border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30 text-neutral-950 dark:text-neutral-100',
'secondary': {
default: {
default: 'bg-neutral-100/55 hover:bg-neutral-400/20 active:bg-neutral-400/30 dark:bg-neutral-700/60 dark:hover:bg-neutral-700/80 dark:active:bg-neutral-700/60 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30 border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30 text-neutral-950 dark:text-neutral-100',
},
},
danger: {
default: 'bg-red-500/15 hover:bg-red-500/20 active:bg-red-500/30 dark:bg-red-700/30 dark:hover:bg-red-700/40 dark:active:bg-red-700/30 focus:ring-red-300/30 dark:focus:ring-red-600/60 dark:focus:ring-red-600/30 border-2 border-solid border-red-200/30 dark:border-red-900/30 text-red-950 dark:text-red-100',
'secondary-muted': {
default: {
default: 'hover:bg-neutral-50/50 active:bg-neutral-50/90 hover:dark:bg-neutral-800/50 active:dark:bg-neutral-800/90 border-2 border-solid border-neutral-100/60 dark:border-neutral-800/30 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30',
nonToggled: 'bg-neutral-50/70 dark:bg-neutral-800/70 text-neutral-500 dark:text-neutral-400',
toggled: 'bg-white/90 dark:bg-neutral-500/70 ring-neutral-300/30 dark:ring-neutral-600/60 ring-2 dark:ring-neutral-600/30 text-primary-500 dark:text-primary-100',
},
},
'danger': {
default: {
default: 'bg-red-500/15 hover:bg-red-500/20 active:bg-red-500/30 dark:bg-red-700/30 dark:hover:bg-red-700/40 dark:active:bg-red-700/30 focus:ring-red-300/30 dark:focus:ring-red-600/60 dark:focus:ring-red-600/30 border-2 border-solid border-red-200/30 dark:border-red-900/30 text-red-950 dark:text-red-100',
},
},
}
@@ -62,14 +79,16 @@ const sizeClasses: Record<ButtonSize, string> = {
// Base classes that are always applied
const baseClasses = computed(() => [
'rounded-lg font-medium outline-none',
'rounded-xl font-medium outline-none',
'transition-all duration-200 ease-in-out',
'disabled:cursor-not-allowed disabled:opacity-50',
'backdrop-blur-md',
props.block ? 'w-full' : '',
sizeClasses[props.size],
variantClasses[props.variant][props.theme],
variantClasses[props.variant][props.theme].default,
props.toggled ? variantClasses[props.variant][props.theme].toggled || '' : variantClasses[props.variant][props.theme].nonToggled || '',
{ 'opacity-50 cursor-not-allowed': isDisabled.value },
'focus:ring-2 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-neutral-900',
'focus:ring-2',
])
</script>
@@ -1,42 +0,0 @@
<script setup lang="ts">
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useLive2d } from '../../../../stores'
import { Screen } from '../../../Misc'
import { Live2DCanvas, Live2DModel } from '../../../Scenes'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { positionInPercentageString, scale } = storeToRefs(useLive2d())
const positionCursor = useMouse()
</script>
<template>
<Screen v-slot="{ width, height }" relative min-h-70dvh>
<Live2DCanvas
ref="live2dCanvasRef"
v-slot="{ app }"
:width="width"
:height="height"
:resolution="3"
rounded-full
>
<Live2DModel
:app="app"
:mouth-open-size="0"
:width="width"
:height="height"
:paused="false"
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
</Live2DCanvas>
</Screen>
</template>
@@ -31,6 +31,7 @@ const {
loadSource,
loadingModel,
modelUrl,
scale,
modelSize,
modelOffset,
} = storeToRefs(vrm)
@@ -59,6 +60,122 @@ modelFileDialog.onChange((files) => {
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Callout
:label="t('settings.vrm.scale-and-position.model-info-title')"
w-full
>
<div>
<div class="text-sm text-neutral-600 space-y-1">
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-x') }}</span>
<span>{{ modelSize.x.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-y') }}</span>
<span>{{ modelSize.y.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-z') }}</span>
<span>{{ modelSize.z.toFixed(4) }}</span>
</div>
</div>
</div>
</Callout>
<Callout
theme="lime"
label="Tips!"
w-full
>
<div class="text-sm text-neutral-600 space-y-1">
{{ t('settings.vrm.scale-and-position.tips') }}
</div>
</Callout>
<FieldRange
v-model="scale"
as="div"
:min="0"
:max="5"
:step="scale / 100"
:label="t('settings.vrm.scale-and-position.x')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.scale') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.x"
as="div"
:min="-modelSize.x - 10"
:max="modelSize.x + 10"
:step="modelSize.x / 100"
:label="t('settings.vrm.scale-and-position.x')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.x') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.y"
as="div"
:min="-modelSize.y - 10"
:max="modelSize.y + 10"
:step="modelSize.y / 100"
:label="t('settings.vrm.scale-and-position.y')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.z"
as="div"
:min="-modelSize.z - 10"
:max="modelSize.z + 10"
:step="modelSize.z / 100"
:label="t('settings.vrm.scale-and-position.z')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.z') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.z = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
<Section
:title="t('settings.vrm.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
@@ -99,96 +216,4 @@ modelFileDialog.onChange((files) => {
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Callout :label="t('settings.vrm.scale-and-position.model-info-title')">
<div>
<div class="text-sm text-neutral-600 space-y-1">
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-x') }}</span>
<span>{{ modelSize.x.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-y') }}</span>
<span>{{ modelSize.y.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-z') }}</span>
<span>{{ modelSize.z.toFixed(4) }}</span>
</div>
</div>
</div>
</Callout>
<Callout
theme="lime"
label="Tips!"
>
<div class="text-sm text-neutral-600 space-y-1">
{{ t('settings.vrm.scale-and-position.tips') }}
</div>
</Callout>
<FieldRange
:model-value="Number(modelOffset.x.toFixed(4))"
as="div"
:min="-modelSize.x"
:max="modelSize.x"
:step="modelSize.x / 100"
:label="t('settings.vrm.scale-and-position.x')"
@update:model-value="val => modelOffset.x = val"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.x') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
:model-value="Number(modelOffset.y.toFixed(4))"
as="div"
:min="-modelSize.y"
:max="modelSize.y"
:step="modelSize.y / 100"
:label="t('settings.vrm.scale-and-position.y')"
@update:model-value="val => modelOffset.y = val"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
:model-value="Number(modelOffset.z.toFixed(4))"
as="div"
:min="-modelSize.z"
:max="modelSize.z"
:step="modelSize.z / 100"
:label="t('settings.vrm.scale-and-position.z')"
@update:model-value="val => modelOffset.z = val"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.z') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.z = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
</template>
@@ -1,53 +0,0 @@
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { useElementBounding } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useVRM } from '../../../../stores'
import { VRMModel } from '../../../Scenes'
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
const VRMContainerRef = ref<HTMLDivElement>()
const { width, height } = useElementBounding(VRMContainerRef)
const { selectedModel } = storeToRefs(useVRM())
const cameraPositionX = ref(0.17)
const cameraPositionY = ref(0.5)
const cameraPositionZ = ref(-1)
const modelRef = ref<{
setExpression: (expression: string) => void
}>()
defineExpose({
setExpression: (expression: string) => {
modelRef.value?.setExpression(expression)
},
})
</script>
<template>
<div ref="VRMContainerRef" w="100%" h="100%">
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
<OrbitControls />
<TresAxesHelper :size="1" />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<VRMModel
ref="modelRef"
:key="selectedModel"
:model="selectedModel"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:paused="false"
@load-model-progress="(val) => emit('loadModelProgress', val)"
@error="(val) => emit('error', val)"
/>
</TresCanvas>
</div>
</template>
@@ -1,5 +1 @@
export { default as ModelSettings } from './index.vue'
export { default as ModelSettingsLive2D } from './Live2D.vue'
export { default as ModelSettingsLive2DScene } from './Live2DScene.vue'
export { default as ModelSettingsVRM } from './VRM.vue'
export { default as ModelSettingsVRMScene } from './VRMScene.vue'
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Live2DScene from '../../../Scenes/Live2D.vue'
import VRMScene from '../../../Scenes/VRM.vue'
import Live2D from './Live2D.vue'
import Live2DScene from './Live2DScene.vue'
import VRM from './VRM.vue'
import VRMScene from './VRMScene.vue'
import { useSettings } from '../../../../stores/settings'
@@ -12,6 +12,8 @@ const props = defineProps<{
palette: string[]
live2dSceneClass?: string | string[]
live2dSettingsClass?: string | string[]
vrmSceneClass?: string | string[]
vrmSettingsClass?: string | string[]
}>()
defineEmits<{
@@ -24,13 +26,15 @@ const { stageView } = storeToRefs(useSettings())
<template>
<!-- Live2D component for 2D stage view -->
<template v-if="stageView === '2d'">
<Live2DScene
<div
:class="[
...(props.live2dSceneClass
? (typeof props.live2dSceneClass === 'string' ? [props.live2dSceneClass] : props.live2dSceneClass)
: []),
]"
/>
>
<Live2DScene />
</div>
<div
flex="~ col gap-2" :class="[
...(props.live2dSettingsClass
@@ -43,9 +47,26 @@ const { stageView } = storeToRefs(useSettings())
</template>
<!-- VRM component for 3D stage view -->
<template v-if="stageView === '3d'">
<VRMScene />
<div flex="~ col gap-2">
<VRM :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-live-2-d="stageView = '2d'" />
<div
:class="[
...(props.vrmSceneClass
? (typeof props.vrmSceneClass === 'string' ? [props.vrmSceneClass] : props.vrmSceneClass)
: []),
]"
>
<VRMScene />
</div>
<div h-full w-full p-2>
<div
flex="~ col gap-2"
:class="[
...(props.vrmSettingsClass
? (typeof props.vrmSettingsClass === 'string' ? [props.vrmSettingsClass] : props.vrmSettingsClass)
: []),
]"
>
<VRM :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-live-2-d="stageView = '2d'" />
</div>
</div>
</template>
</template>
@@ -1,32 +1,23 @@
<script setup lang="ts">
import { TransitionVertical } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Screen from '../Misc/Screen.vue'
import Live2DCanvas from './Live2D/Canvas.vue'
import Live2DModel from './Live2D/Model.vue'
import { useLive2d } from '../../stores'
import '../../utils/live2d-zip-loader'
withDefaults(defineProps<{
paused: boolean
paused?: boolean
mouthOpenSize?: number
focusAt: { x: number, y: number }
focusAt?: { x: number, y: number }
xOffset?: number | string
yOffset?: number | string
scale?: number
}>(), {
paused: false,
focusAt: () => ({ x: 0, y: 0 }),
mouthOpenSize: 0,
scale: 1,
})
const { t } = useI18n()
const show = ref(false)
const { currentMotion } = storeToRefs(useLive2d())
</script>
<template>
@@ -50,80 +41,5 @@ const { currentMotion } = storeToRefs(useLive2d())
:scale="scale"
/>
</Live2DCanvas>
<div absolute bottom="3" right="3">
<div flex="~ row" cursor-pointer>
<label
:class="[show ? 'bg-neutral-300 dark:bg-neutral-200' : 'bg-neutral-100 dark:bg-neutral-700']"
transition="all ease-in-out duration-500"
text="lg neutral-500 dark:neutral-400"
m-1 h-fit w-fit cursor-pointer appearance-none gap-1 rounded-lg rounded-md border-none p-2 outline-none
>
<input
v-model="show"
:checked="show"
:aria-checked="show"
name="showLive2DViewerInspector"
type="checkbox"
appearance-none outline-none hidden
>
<div select-none>
<div i-solar:bug-bold-duotone />
</div>
</label>
</div>
<TransitionVertical>
<div v-if="show" min-w="50vw" z="<md:20" class="bottom-11 right-0" absolute m-1 w-full>
<div bg="neutral-200/20 dark:black/20" rounded-lg p-2 backdrop-blur-sm>
<div font-mono>
<span>{{ t('stage.viewers.debug-menu.emotions') }}</span>
</div>
<div flex="~ row" flex-wrap gap-2>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Surprise', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.surprised') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Sad', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.sad') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Angry', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.angry') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Happy', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.happy') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Awkward', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.awkward') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Question', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.question') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Think', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.think') }}
</button>
</div>
</div>
</div>
</TransitionVertical>
</div>
</Screen>
</template>
@@ -41,7 +41,7 @@ const db = ref<DuckDBWasmDrizzleDatabase>()
const vrmViewerRef = ref<{ setExpression: (expression: string) => void }>()
const { stageView } = storeToRefs(useSettings())
const { stageView, stageViewControlsEnabled } = storeToRefs(useSettings())
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { audioContext, calculateVolume } = useAudioContext()
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatStore()
@@ -245,6 +245,7 @@ onMounted(async () => {
idle-animation="/assets/vrm/animations/idle_loop.vrma"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:paused="paused"
:show-axes="stageViewControlsEnabled"
@error="console.error"
/>
</div>
+17 -163
View File
@@ -1,44 +1,35 @@
<script setup lang="ts">
import { TransitionVertical } from '@proj-airi/ui'
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DataGuiRange from '../DataGui/Range.vue'
import Collapsable from '../Misc/Collapsable.vue'
import Screen from '../Misc/Screen.vue'
import VRMModel from './VRM/Model.vue'
import { useVRM } from '../../stores'
import { Screen } from '../Misc'
import { VRMModel } from '../Scenes'
const props = defineProps<{
paused: boolean
}>()
const props = withDefaults(defineProps<{
paused?: boolean
showAxes?: boolean
}>(), {
paused: false,
showAxes: false,
})
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
const { selectedModel, scale } = storeToRefs(useVRM())
const show = ref(false)
const { selectedModel } = storeToRefs(useVRM())
const cameraPositionX = ref(-0.17)
const cameraPositionY = ref(0)
const cameraPositionX = ref(0.17)
const cameraPositionY = ref(0.5)
const cameraPositionZ = ref(-1)
const vrmModelPositionX = ref(-0.18)
const vrmModelPositionY = ref(-1.32)
const vrmModelPositionZ = ref(-0.24)
const modelRef = ref<{
setExpression: (expression: string) => void
}>()
const { t } = useI18n()
defineExpose({
setExpression: (expression: string) => {
modelRef.value?.setExpression(expression)
@@ -47,14 +38,16 @@ defineExpose({
</script>
<template>
<Screen v-slot="{ height, width }" relative>
<Screen v-slot="{ width, height }" relative>
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
<OrbitControls />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
<OrbitControls :enable-zoom="false" />
<TresAxesHelper v-if="props.showAxes" :size="1" />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" :zoom="scale" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<VRMModel
ref="modelRef"
:key="selectedModel"
:model="selectedModel"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:paused="props.paused"
@@ -62,144 +55,5 @@ defineExpose({
@error="(val) => emit('error', val)"
/>
</TresCanvas>
<div absolute bottom="3" right="3">
<div flex="~ row" cursor-pointer>
<label
:class="[show ? 'bg-neutral-300 dark:bg-neutral-200' : 'bg-neutral-100 dark:bg-neutral-700']"
transition="all ease-in-out duration-500"
text="lg neutral-500 dark:neutral-400"
m-1 h-fit w-fit cursor-pointer appearance-none gap-1 rounded-lg rounded-md border-none p-2 outline-none
>
<input
v-model="show"
:checked="show"
:aria-checked="show"
name="showLive2DViewerInspector"
type="checkbox"
appearance-none outline-none hidden
>
<div select-none>
<div i-solar:bug-bold-duotone />
</div>
</label>
</div>
<TransitionVertical>
<div v-if="show" min-w="50vw" z="<md:20" class="bottom-11 right-0" absolute m-1 w-full>
<div bg="neutral-200/20 dark:black/20" flex="~ col" gap-2 rounded-lg p-2 backdrop-blur-sm>
<div font-mono>
<span>{{ t('stage.viewers.debug-menu.vrm.model.title') }}</span>
</div>
<Collapsable h-fit w-full flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" of-clip shadow-sm>
<template #label>
<span font-mono>{{ t('stage.viewers.debug-menu.vrm.camera') }}</span>
</template>
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
<div text="neutral-400 dark:neutral-500">
<span>X</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionX" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ cameraPositionX }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Y</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionY" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ cameraPositionY }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Z</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionZ" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ cameraPositionZ }}</span>
</div>
</div>
</Collapsable>
<Collapsable h-fit w-full flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" of-clip shadow-sm>
<template #label>
<span font-mono>{{ t('stage.viewers.debug-menu.vrm.model.title') }}</span>
</template>
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
<div text="neutral-400 dark:neutral-500">
<span>X</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionX" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ vrmModelPositionX }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Y</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionY" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ vrmModelPositionY }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Z</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionZ" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ vrmModelPositionZ }}</span>
</div>
</div>
</Collapsable>
<div font-mono>
<span>{{ t('stage.viewers.debug-menu.emotions') }}</span>
</div>
<div flex="~ row" w-full flex-wrap gap-2>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('neutral')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.neutral') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('surprised')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.surprised') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('sad')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.sad') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('angry')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.angry') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('happy')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.happy') }}
</button>
</div>
</div>
</div>
</TransitionVertical>
</div>
</Screen>
</template>
@@ -40,15 +40,16 @@ const {
modelOffset,
modelOrigin,
modelSize,
modelPosition,
position,
scale,
} = storeToRefs(vrmStore)
watch(modelOffset, () => {
if (vrm.value) {
vrm.value.scene.position.set(
modelPosition.value.x,
modelPosition.value.y,
modelPosition.value.z,
position.value.x,
position.value.y,
position.value.z,
)
}
}, { deep: true })
@@ -69,9 +70,10 @@ onMounted(async () => {
console.warn('No VRM model loaded')
return
}
const { _vrm, modelCenter: vrmModelCenter, modelSize: vrmModelSize } = _vrmInfo
// Set initial postions for model
// Set initial positions for model
modelOrigin.value = {
x: vrmModelCenter.x,
y: vrmModelCenter.y,
@@ -83,7 +85,7 @@ onMounted(async () => {
z: vrmModelSize.z,
}
// Set initial positons for animation
// Set initial positions for animation
function removeRootPositionTrack(clip: AnimationClip) {
clip.tracks = clip.tracks.filter((track) => {
return !track.name.endsWith('.position')
@@ -96,6 +98,7 @@ onMounted(async () => {
console.warn('No VRM animation loaded')
return
}
removeRootPositionTrack(clip)
// play animation
@@ -105,6 +108,7 @@ onMounted(async () => {
vrmEmote.value = useVRMEmote(_vrm)
vrm.value = _vrm
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
disposeBeforeRenderLoop = onBeforeRender(({ delta }) => {
vrmAnimationMixer.value?.update(delta)
@@ -138,6 +142,12 @@ const { pause, resume } = useLoop()
watch(() => props.paused, (value) => {
value ? pause() : resume()
})
watch(scale, () => {
if (vrm.value) {
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
}
}, { immediate: true })
</script>
<template>
+2
View File
@@ -13,6 +13,7 @@ export const useSettings = defineStore('settings', () => {
const language = useLocalStorage('settings/language', 'en')
const stageView = useLocalStorage('settings/stage/view/model-renderer', '2d')
const stageViewControlsEnabled = ref(false)
const isAudioInputOn = useLocalStorage('settings/audio/input', 'false')
const selectedAudioDeviceId = computed(() => selectedAudioDevice.value?.deviceId)
@@ -77,6 +78,7 @@ export const useSettings = defineStore('settings', () => {
usePageSpecificTransitions,
language,
stageView,
stageViewControlsEnabled,
themeColorsHue,
themeColorsHueDynamic,
isAudioInputOn,
+9 -5
View File
@@ -7,18 +7,21 @@ export const useVRM = defineStore('vrm', () => {
const modelUrl = ref<string>('/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm')
const loadSource = ref<'file' | 'url'>('url')
const loadingModel = ref(false)
const scale = useLocalStorage('settings/live2d/cameraScale', 1)
const modelSize = useLocalStorage('settings/vrm/modelSize', { x: 0, y: 0, z: 0 })
const modelOrigin = useLocalStorage('settings/vrm/modelOrigin', { x: 0, y: 0, z: 0 })
const modelOffset = useLocalStorage('settings/vrm/modelOffset', { x: 0, y: 0, z: 0 })
const modelPosition = computed(() => ({
const position = computed(() => ({
x: modelOrigin.value.x + modelOffset.value.x,
y: modelOrigin.value.y + modelOffset.value.y,
z: modelOrigin.value.z + modelOffset.value.z,
}))
const positionInPercentageString = computed(() => ({
x: `${modelPosition.value.x}%`,
y: `${modelPosition.value.y}%`,
z: `${modelPosition.value.z}%`,
x: `${position.value.x}%`,
y: `${position.value.y}%`,
z: `${position.value.z}%`,
}))
const modelObjectUrl = ref<string>()
@@ -52,9 +55,10 @@ export const useVRM = defineStore('vrm', () => {
loadSource,
loadingModel,
modelSize,
scale,
modelOrigin,
modelOffset,
modelPosition,
position,
positionInPercentageString,
selectedModel, // Expose the new computed property
}
@@ -153,6 +153,7 @@ https://toughengineer.github.io/demo/slider-styler*/
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(8px);
box-shadow: var(--track-box-shadow);
position: relative;
cursor: col-resize;
@@ -225,6 +226,7 @@ https://toughengineer.github.io/demo/slider-styler*/
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(8px);
box-shadow: var(--track-box-shadow);
cursor: col-resize;
/* Trim left and right paddings of track */
@@ -307,6 +309,7 @@ https://toughengineer.github.io/demo/slider-styler*/
height: var(--track-height);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(8px);
border: var(--track-border);
box-shadow: var(--track-box-shadow);
box-sizing: border-box;
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
min?: number
max?: number
step?: number
disabled?: boolean
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
})
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 sliderValue = computed({
get: () => modelValue.value * 10000,
set: (value: number) => {
modelValue.value = value / 10000
updateTrackColor()
},
})
onMounted(() => updateTrackColor())
watch(sliderValue, () => updateTrackColor(), { immediate: true })
watch([scaledMin, scaledMax, scaledStep], () => updateTrackColor(), { immediate: true })
function updateTrackColor() {
if (!sliderRef.value) {
return
}
sliderRef.value.style.setProperty('--value', sliderValue.value.toString())
sliderRef.value.style.setProperty('--min', !sliderRef.value.min ? props.min.toString() : sliderRef.value.min)
sliderRef.value.style.setProperty('--max', !sliderRef.value.max ? props.max.toString() : sliderRef.value.max)
}
function handleInput(e: Event) {
const target = e.target as HTMLInputElement
target.style.setProperty('--value', target.value)
}
</script>
<template>
<input
ref="sliderRef"
v-model.number="sliderValue"
type="range"
:min="scaledMin"
:max="scaledMax"
:step="scaledStep"
class="slider-progress form_input-round-range"
@input="handleInput"
>
</template>
<style scoped>
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
.form_input-round-range {
--height: 2em;
min-height: var(--height);
appearance: none;
background: transparent;
border-radius: 4px;
transition: background-color 0.2s ease;
--thumb-width: var(--height);
--thumb-height: 0px;
--thumb-box-shadow: none;
--thumb-border: none;
--thumb-border-radius: 0px;
--thumb-background: rgb(255, 255, 255);
--track-height: calc(var(--height) - var(--track-value-padding) * 2);
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
--track-border: none;
--track-border-radius: 10px;
--track-background: rgba(0, 0, 0, 0.4);
--track-value-background: rgb(255, 255, 255);
--track-value-padding: 0px;
}
[data-direction="vertical"].form_input-round-range {
transform: rotate(180deg);
}
.dark .form_input-round-range {
--thumb-background: rgb(238, 238, 238);
--track-border: none;
--track-background: rgba(99, 99, 99, 0.7);
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
--track-value-background: rgb(238, 238, 238);
}
/*progress support*/
.form_input-round-range.slider-progress {
--range: calc(var(--max) - var(--min));
--ratio: calc((var(--value) - var(--min)) / var(--range));
--sx: calc(0.5 * 0em + var(--ratio) * (100% - 0em));
}
.form_input-round-range:focus {
outline: none;
}
/*webkit*/
.form_input-round-range::-webkit-slider-thumb {
appearance: none;
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: var(--thumb-border);
box-shadow: var(--thumb-box-shadow);
margin-top: 0px;
margin-left: calc(0 - var(--track-value-padding));
cursor: col-resize;
transition:
background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out,
transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1),
width 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
[data-direction="vertical"]::-webkit-slider-thumb {
cursor: ns-resize;
}
.form_input-round-range::-webkit-slider-runnable-track {
height: var(--track-height);
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(4px);
box-shadow: var(--track-box-shadow);
position: relative;
cursor: col-resize;
overflow: hidden;
transition:
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out;
}
[data-direction="vertical"]::-webkit-slider-runnable-track {
cursor: ns-resize;
}
.form_input-round-range.slider-progress::-webkit-slider-runnable-track {
background:
linear-gradient(var(--track-value-background), var(--track-value-background)) 0 / var(--sx) 100% no-repeat,
var(--track-background);
}
[data-direction="vertical"].form_input-round-range.slider-progress::-webkit-slider-runnable-track {
background: linear-gradient(var(--track-value-background) var(--sx), var(--track-background) var(--sx)) no-repeat;
}
/*mozilla*/
.form_input-round-range::-moz-range-thumb {
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: none;
box-shadow: var(--thumb-box-shadow);
cursor: col-resize;
margin-left: calc(0 - var(--track-value-padding));
transition:
background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out,
transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1),
width 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
[data-direction="vertical"]::-moz-range-thumb {
cursor: ns-resize;
}
.form_input-round-range::-moz-range-track {
height: var(--track-height);
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(4px);
box-shadow: var(--track-box-shadow);
cursor: col-resize;
overflow: hidden;
/* Trim left and right paddings of track */
width: calc(100% - var(--track-value-padding) * 2);
}
[data-direction="vertical"]::-moz-range-track {
cursor: ns-resize;
}
.form_input-round-range.slider-progress::-moz-range-track {
background:
linear-gradient(var(--track-value-background), var(--track-value-background)) 0 / var(--sx) 100% no-repeat,
var(--track-background);
}
[data-direction="vertical"].form_input-round-range.slider-progress::-moz-range-track {
background: linear-gradient(var(--track-value-background) var(--sx), var(--track-background) var(--sx)) no-repeat;
}
/*ms*/
.form_input-round-range::-ms-fill-upper {
background: transparent;
border-color: transparent;
}
.form_input-round-range::-ms-fill-lower {
background: transparent;
border-color: transparent;
}
.form_input-round-range::-ms-thumb {
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: var(--thumb-border);
box-shadow: var(--thumb-box-shadow);
box-sizing: border-box;
cursor: col-resize;
transition:
background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out,
transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1),
width 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
[data-direction="vertical"]::-ms-thumb {
cursor: ns-resize;
}
.form_input-round-range::-ms-track {
height: var(--track-height);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(4px);
border: var(--track-border);
box-shadow: var(--track-box-shadow);
box-sizing: border-box;
cursor: col-resize;
overflow: hidden;
}
[data-direction="vertical"]::-ms-track {
cursor: ns-resize;
}
.form_input-round-range.slider-progress::-ms-fill-lower {
height: var(--track-height);
border-radius: var(--track-border-radius) 0 0 var(--track-border-radius);
margin: 0;
background: var(--track-value-background);
border: none;
border-right-width: 0;
}
</style>
@@ -1,2 +1,3 @@
export { default as ColorHueRange } from './ColorHueRange.vue'
export { default as Range } from './Range.vue'
export { default as RoundRange } from './RoundRange.vue'