feat(stage): change scale and position of live2d model (#275)

This commit is contained in:
LemonNeko
2025-07-16 16:19:26 +08:00
committed by GitHub
parent 3d0600cc6c
commit fffe3d1932
12 changed files with 101 additions and 51 deletions
@@ -1,3 +1,4 @@
<!-- TODO: merge to stage-ui -->
<script setup lang="ts">
import JSZip from 'jszip'
import localforage from 'localforage'
@@ -5,6 +6,7 @@ import localforage from 'localforage'
import { Section } from '@proj-airi/stage-ui/components'
import { Emotion, EmotionNeutralMotionName } from '@proj-airi/stage-ui/constants'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { FieldRange } from '@proj-airi/ui'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
@@ -165,6 +167,11 @@ const exportObjectUrl = useObjectUrl(live2dModelFile)
<Button w-full>Export</button>
</a>
</Section>
<Section :title="t('settings.live2d.scale-and-position.title')" icon="i-solar:scale-bold-duotone">
<FieldRange v-model="settings.live2dScale" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')" />
<FieldRange v-model="settings.live2dPosition.x" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')" />
<FieldRange v-model="settings.live2dPosition.y" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')" />
</Section>
</div>
</template>
+9 -2
View File
@@ -2,7 +2,7 @@
import type { AiriTamagotchiEvents, Point } from '../composables/tauri'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useMcpStore } from '@proj-airi/stage-ui/stores'
import { useMcpStore, useSettings } from '@proj-airi/stage-ui/stores'
import { connectServer } from '@proj-airi/tauri-plugin-mcp'
import { useWindowSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -34,6 +34,8 @@ const resourcesStore = useResourcesStore()
const mcpStore = useMcpStore()
const { connected, serverCmd, serverArgs } = storeToRefs(mcpStore)
const { live2dScale, live2dPositionInPercentageString } = storeToRefs(useSettings())
watch([live2dLookAtX, live2dLookAtY], ([x, y]) => live2dFocusAt.value = { x, y }, { immediate: true })
const modeIndicatorClass = computed(() => {
@@ -127,7 +129,12 @@ if (import.meta.hot) { // For better DX
transition="opacity duration-500 ease-in-out"
>
<div relative h-full w-full items-end gap-2 class="view">
<WidgetStage h-full w-full flex-1 :focus-at="live2dFocusAt" mb="<md:18" />
<WidgetStage
h-full w-full flex-1
:focus-at="live2dFocusAt" :scale="live2dScale"
:x-offset="live2dPositionInPercentageString.x"
:y-offset="live2dPositionInPercentageString.y" mb="<md:18"
/>
<ResourceStatusIsland />
<div
absolute bottom-4 left-4 flex gap-1 op-0 transition="opacity duration-500"
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useMouse } from '@vueuse/core'
import { Vibrant } from 'node-vibrant/browser'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import IconAnimation from '../../../components/IconAnimation.vue'
@@ -12,6 +14,7 @@ import { useIconAnimation } from '../../../composables/icon-animation'
const live2dContainerRef = ref<HTMLDivElement>()
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { width, height } = useElementBounding(live2dContainerRef)
const { live2dPositionInPercentageString, live2dScale } = storeToRefs(useSettings())
const palette = ref<string[]>([])
@@ -47,8 +50,8 @@ const positionCursor = useMouse()
</script>
<template>
<div flex class="flex-col-reverse sm:flex-row">
<div ref="live2dContainerRef" w="100% sm:50%" h="50dvh sm:80dvh">
<div flex class="h-[calc(100dvh-8rem)] flex-col-reverse sm:flex-row">
<div ref="live2dContainerRef" w="100% sm:50%" h="50% sm:80%">
<Live2DCanvas
v-slot="{ app }"
ref="live2dCanvasRef"
@@ -67,10 +70,13 @@ const positionCursor = useMouse()
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="live2dPositionInPercentageString.x"
:y-offset="live2dPositionInPercentageString.y"
:scale="live2dScale"
/>
</Live2DCanvas>
</div>
<Live2DSettings w="100% sm:50%" h="50dvh sm:80dvh" :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
<Live2DSettings w="100% sm:50%" h="50% sm:80%" overflow-y-scroll :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
</div>
<IconAnimation
@@ -1,3 +1,4 @@
<!-- TODO: merge to stage-ui -->
<script setup lang="ts">
import JSZip from 'jszip'
import localforage from 'localforage'
@@ -5,6 +6,7 @@ import localforage from 'localforage'
import { Section } from '@proj-airi/stage-ui/components'
import { Emotion, EmotionNeutralMotionName } from '@proj-airi/stage-ui/constants'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { FieldRange } from '@proj-airi/ui'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
@@ -165,6 +167,11 @@ const exportObjectUrl = useObjectUrl(live2dModelFile)
<Button w-full>Export</button>
</a>
</Section>
<Section :title="t('settings.live2d.scale-and-position.title')" icon="i-solar:scale-bold-duotone">
<FieldRange v-model="settings.live2dScale" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')" />
<FieldRange v-model="settings.live2dPosition.x" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')" />
<FieldRange v-model="settings.live2dPosition.y" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')" />
</Section>
</div>
</template>
+6 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { breakpointsTailwind, useBreakpoints, useDark, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import Cross from '../components/Backgrounds/Cross.vue'
@@ -19,6 +21,7 @@ function handleSettingsOpen(open: boolean) {
}
const positionCursor = useMouse()
const { live2dScale, live2dPosition, live2dPositionInPercentageString } = storeToRefs(useSettings())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
@@ -49,7 +52,9 @@ onMounted(() => updateThemeColor())
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="isMobile ? undefined : '-10%'"
:x-offset="`${isMobile ? live2dPosition.x : live2dPosition.x - 10}%`"
:y-offset="live2dPositionInPercentageString.y"
:scale="live2dScale"
/>
<InteractiveArea class="flex <md:hidden" absolute h="85dvh" right-4 flex-1 max-w="500px" min-w="30%" />
<MobileInteractiveArea class="hidden <md:block" mx2 mb2 @settings-open="handleSettingsOpen" />
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useMouse } from '@vueuse/core'
import { Vibrant } from 'node-vibrant/browser'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import IconAnimation from '../../../components/IconAnimation.vue'
@@ -12,6 +14,7 @@ import { useIconAnimation } from '../../../composables/icon-animation'
const live2dContainerRef = ref<HTMLDivElement>()
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { width, height } = useElementBounding(live2dContainerRef)
const { live2dPositionInPercentageString, live2dScale } = storeToRefs(useSettings())
const palette = ref<string[]>([])
@@ -47,8 +50,8 @@ const positionCursor = useMouse()
</script>
<template>
<div flex class="flex-col-reverse sm:flex-row">
<div ref="live2dContainerRef" w="100% sm:50%" h="50dvh sm:80dvh">
<div flex class="h-[calc(100dvh-8rem)] flex-col-reverse sm:flex-row">
<div ref="live2dContainerRef" w="100% sm:50%" h="50% sm:80%">
<Live2DCanvas
v-slot="{ app }"
ref="live2dCanvasRef"
@@ -67,10 +70,13 @@ const positionCursor = useMouse()
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="live2dPositionInPercentageString.x"
:y-offset="live2dPositionInPercentageString.y"
:scale="live2dScale"
/>
</Live2DCanvas>
</div>
<Live2DSettings w="100% sm:50%" h="50dvh sm:80dvh" :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
<Live2DSettings w="100% sm:50%" h="50% sm:80%" overflow-y-scroll :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
</div>
<IconAnimation
@@ -50,6 +50,11 @@ live2d:
play: Play Motion
title: Map Motions
title: Live2D Settings
scale-and-position:
title: Scale And Position
scale: Scale
x: X
'y': 'Y'
microphone: Microphone
models: Model
pages:
@@ -46,6 +46,11 @@ live2d:
play: 播放动作
title: 映射动作
title: Live2D 设置
scale-and-position:
title: 缩放与位置
scale: 缩放
x: X
'y': 'Y'
microphone: 麦克风
models: 模型
pages:
@@ -18,8 +18,10 @@ withDefaults(defineProps<{
focusAt: { x: number, y: number }
xOffset?: number | string
yOffset?: number | string
scale?: number
}>(), {
mouthOpenSize: 0,
scale: 1,
})
const { t } = useI18n()
@@ -45,6 +47,7 @@ const { live2dCurrentMotion } = storeToRefs(useSettings())
:focus-at="focusAt"
:x-offset="xOffset"
:y-offset="yOffset"
:scale="scale"
/>
</Live2DCanvas>
<div absolute bottom="3" right="3">
@@ -1,7 +1,5 @@
<script setup lang="ts">
import type { Application } from '@pixi/app'
import type { InternalModel } from 'pixi-live2d-display/cubism4'
import type { Ref } from 'vue'
import localforage from 'localforage'
@@ -28,17 +26,38 @@ const props = withDefaults(defineProps<{
disableFocusAt?: boolean
xOffset?: number | string
yOffset?: number | string
scale?: number
}>(), {
mouthOpenSize: 0,
paused: false,
focusAt: () => ({ x: 0, y: 0 }),
disableFocusAt: false,
scale: 1,
})
const emits = defineEmits<{
(e: 'modelLoaded'): void
}>()
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
}
return {
xOffset,
yOffset,
}
}
const offset = computed(() => parsePropsOffset())
const pixiApp = toRef(() => props.app)
const paused = toRef(() => props.paused)
const focusAt = toRef(() => props.focusAt)
@@ -62,7 +81,7 @@ function getCoreModel() {
return model.value!.internalModel.coreModel as any
}
function setScale(model: Ref<Live2DModel<InternalModel> | undefined>) {
function setScaleAndPosition() {
if (!model.value)
return
@@ -75,7 +94,10 @@ function setScale(model: Ref<Live2DModel<InternalModel> | undefined>) {
const widthScale = (props.width * 0.95 / initialModelWidth.value * offsetFactor)
const scale = Math.min(heightScale, widthScale)
model.value.scale.set(scale, scale)
model.value.scale.set(scale * props.scale, scale * props.scale)
model.value.x = (props.width / 2) + offset.value.xOffset
model.value.y = props.height + offset.value.yOffset
}
const {
@@ -90,27 +112,6 @@ const {
} = storeToRefs(useSettings())
const currentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 })
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
}
if (Number.isNaN(xOffset))
xOffset = 0
if (Number.isNaN(yOffset))
yOffset = 0
return {
xOffset,
yOffset,
}
}
async function loadModel() {
if (!pixiApp.value)
return
@@ -135,12 +136,8 @@ async function loadModel() {
initialModelWidth.value = model.value.width
initialModelHeight.value = model.value.height
const { xOffset, yOffset } = parsePropsOffset()
model.value.x = (props.width / 2) + (xOffset || 0)
model.value.y = props.height + (yOffset || 0)
model.value.anchor.set(0.5, 0.5)
setScale(model)
setScaleAndPosition()
model.value.on('hit', (hitAreas) => {
if (model.value && hitAreas.includes('body'))
@@ -234,16 +231,7 @@ async function setMotion(motionName: string, index?: number) {
await model.value?.motion(motionName, index, MotionPriority.FORCE)
}
const handleResize = useDebounceFn(() => {
if (model.value) {
const { xOffset, yOffset } = parsePropsOffset()
model.value.x = props.width / 2 + xOffset
model.value.y = props.height + yOffset
setScale(model)
}
}, 100)
const handleResize = useDebounceFn(setScaleAndPosition, 100)
const dropShadowColorComputer = ref<HTMLDivElement>()
const dropShadowAnimationId = ref(0)
@@ -259,6 +247,8 @@ function updateDropShadowFilter() {
watch([() => props.width, () => props.height], () => handleResize())
watch(dark, updateDropShadowFilter, { immediate: true })
watch([model, themeColorsHue], updateDropShadowFilter)
watch(offset, setScaleAndPosition)
watch(() => props.scale, setScaleAndPosition)
// TODO: This is hacky!
function updateDropShadowFilterLoop() {
@@ -32,7 +32,8 @@ withDefaults(defineProps<{
focusAt: { x: number, y: number }
xOffset?: number | string
yOffset?: number | string
}>(), { paused: false })
scale?: number
}>(), { paused: false, scale: 1 })
const db = ref<DuckDBWasmDrizzleDatabase>()
// const transformersProvider = createTransformers({ embedWorkerURL })
@@ -234,6 +235,7 @@ onMounted(async () => {
:paused="paused"
:x-offset="xOffset"
:y-offset="yOffset"
:scale="scale"
/>
<VRMScene
v-else-if="stageView === '3d'"
+8 -1
View File
@@ -23,10 +23,15 @@ export const useSettings = defineStore('settings', () => {
const live2dModelUrl = ref<string>('/assets/live2d/models/hiyori_pro_zh.zip')
const live2dLoadSource = ref<'file' | 'url'>('url')
const loadingLive2dModel = ref(false) // if set to true, the model will be loaded
const live2dPosition = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen
const live2dPosition = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
const live2dPositionInPercentageString = computed(() => ({
x: `${live2dPosition.value.x}%`,
y: `${live2dPosition.value.y}%`,
}))
const live2dCurrentMotion = ref<{ group: string, index?: number }>({ group: 'Idle', index: 0 })
const availableLive2dMotions = ref<{ motionName: string, motionIndex: number, fileName: string }[]>([])
const live2dMotionMap = useLocalStorage<Record<string, string>>('settings/live2d/motion-map', {})
const live2dScale = useLocalStorage('settings/live2d/scale', 1)
const disableTransitions = useLocalStorage('settings/disable-transitions', true)
const usePageSpecificTransitions = useLocalStorage('settings/use-page-specific-transitions', true)
@@ -88,6 +93,8 @@ export const useSettings = defineStore('settings', () => {
live2dLoadSource,
live2dCurrentMotion,
live2dPosition,
live2dPositionInPercentageString,
live2dScale,
availableLive2dMotions,
live2dMotionMap,
loadingLive2dModel,