feat(stage): change scale and position of live2d model (#275)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user