From c9d166cfe8db8107974f2f0baaf591001932d7cc Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Sat, 16 Aug 2025 22:00:03 +0800 Subject: [PATCH] refactor(stage-ui): VRM & Live2D view load --- .../Settings/ModelSettings/Live2D.vue | 1 + .../Scenarios/Settings/ModelSettings/VRM.vue | 1 + .../src/components/Scenes/Live2D/Model.vue | 9 ++- packages/stage-ui/src/composables/vrm/core.ts | 8 ++- packages/stage-ui/src/stores/live2d.ts | 61 ++++++++++++++++++- packages/stage-ui/src/stores/vrm.ts | 39 ++++++++++-- 6 files changed, 108 insertions(+), 11 deletions(-) diff --git a/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/Live2D.vue b/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/Live2D.vue index 354891ffb..075702683 100644 --- a/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/Live2D.vue +++ b/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/Live2D.vue @@ -50,6 +50,7 @@ modelFileDialog.onChange((files) => { motionMap.value = {} modelFile.value = files[0] loadingModel.value = true + live2d.shouldUpdateView() } }) diff --git a/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue b/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue index 7876e8358..9ac4d3afa 100644 --- a/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue +++ b/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue @@ -64,6 +64,7 @@ const trackingOptions = computed(() => [ modelFileDialog.onChange((files) => { if (files && files.length > 0) { modelFile.value = files[0] + vrm.shouldUpdateView() } }) diff --git a/packages/stage-ui/src/components/Scenes/Live2D/Model.vue b/packages/stage-ui/src/components/Scenes/Live2D/Model.vue index 30388e9cd..5091813a5 100644 --- a/packages/stage-ui/src/components/Scenes/Live2D/Model.vue +++ b/packages/stage-ui/src/components/Scenes/Live2D/Model.vue @@ -151,7 +151,12 @@ async function loadModel() { } const modelInstance = new Live2DModel() - await Live2DFactory.setupLive2DModel(modelInstance, modelSrcNormalized.value, { autoInteract: false }) + if (modelFile.value) { + await Live2DFactory.setupLive2DModel(modelInstance, [modelFile.value], { autoInteract: false }) + } + else { + await Live2DFactory.setupLive2DModel(modelInstance, modelSrcNormalized.value, { autoInteract: false }) + } model.value = modelInstance pixiApp.value.stage.addChild(model.value) @@ -270,7 +275,7 @@ function updateDropShadowFilter() { } watch([() => props.width, () => props.height], () => handleResize()) -watch(modelSrcNormalized, () => loadModel(), { immediate: true }) +watch(modelSrcNormalized, () => loadModel()) watch(dark, updateDropShadowFilter, { immediate: true }) watch([model, themeColorsHue], updateDropShadowFilter) watch(offset, setScaleAndPosition) diff --git a/packages/stage-ui/src/composables/vrm/core.ts b/packages/stage-ui/src/composables/vrm/core.ts index db809a50f..a7d5d1d0d 100644 --- a/packages/stage-ui/src/composables/vrm/core.ts +++ b/packages/stage-ui/src/composables/vrm/core.ts @@ -15,7 +15,13 @@ export async function loadVrm(model: string, options?: { scene?: Scene lookAt?: boolean onProgress?: (progress: ProgressEvent) => void | Promise -}): Promise<{ _vrm: VRMCore, _vrmGroup: Group, modelCenter: Vector3, modelSize: Vector3, initialCameraOffset: Vector3 } | undefined> { +}): Promise<{ + _vrm: VRMCore + _vrmGroup: Group + modelCenter: Vector3 + modelSize: Vector3 + initialCameraOffset: Vector3 +} | undefined> { const loader = useVRMLoader() const gltf = await loader.loadAsync(model, progress => options?.onProgress?.(progress)) diff --git a/packages/stage-ui/src/stores/live2d.ts b/packages/stage-ui/src/stores/live2d.ts index e241e1d83..51125d006 100644 --- a/packages/stage-ui/src/stores/live2d.ts +++ b/packages/stage-ui/src/stores/live2d.ts @@ -1,13 +1,65 @@ import localforage from 'localforage' -import { computedAsync, useLocalStorage } from '@vueuse/core' +import { useBroadcastChannel, useLocalStorage } from '@vueuse/core' import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' + +type BroadcastChannelEvents + = | BroadcastChannelEventShouldUpdateView + +interface BroadcastChannelEventShouldUpdateView { + type: 'should-update-view' +} export const useLive2d = defineStore('live2d', () => { + const { post, data } = useBroadcastChannel({ name: 'airi-stores-live2d' }) + const shouldUpdateViewHooks = ref void>>([]) + + const onShouldUpdateView = (hook: () => void) => { + shouldUpdateViewHooks.value.push(hook) + } + + function shouldUpdateView() { + post({ type: 'should-update-view' }) + shouldUpdateViewHooks.value.forEach(hook => hook()) + } + + watch(data, (event) => { + if (event.type === 'should-update-view') { + shouldUpdateViewHooks.value.forEach(hook => hook()) + } + }) + + const indexedDbModelFile = ref(null) + + onMounted(async () => await loadModelFileFromIndexedDb()) + onShouldUpdateView(async () => await loadModelFileFromIndexedDb()) + + async function loadModelFileFromIndexedDb() { + const file = await localforage.getItem('assets-models-live2d') + if (file) { + indexedDbModelFile.value = file + } + } + + const modelFile = computed({ + get: () => { + return indexedDbModelFile.value + }, + set: (file: File | null) => { + if (file) { + localforage.setItem('assets-models-live2d', file) + } + else { + localforage.removeItem('assets-models-live2d') + } + + indexedDbModelFile.value = file + }, + }) + const defaultModelUrl = '/assets/live2d/models/hiyori_pro_zh.zip' const modelUrl = useLocalStorage('settings/live2d/model-src', defaultModelUrl) - const modelFile = computedAsync(async () => localforage.getItem('assets-models-live2d')) const loadingModel = ref(false) // if set to true, the model will be loaded const position = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are % @@ -30,5 +82,8 @@ export const useLive2d = defineStore('live2d', () => { availableMotions, motionMap, scale, + + onShouldUpdateView, + shouldUpdateView, } }) diff --git a/packages/stage-ui/src/stores/vrm.ts b/packages/stage-ui/src/stores/vrm.ts index 9f6e257c2..de68ffb25 100644 --- a/packages/stage-ui/src/stores/vrm.ts +++ b/packages/stage-ui/src/stores/vrm.ts @@ -1,20 +1,48 @@ import localforage from 'localforage' -import { useLocalStorage } from '@vueuse/core' +import { useBroadcastChannel, useLocalStorage } from '@vueuse/core' import { defineStore } from 'pinia' -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' import defaultSkyBoxSrc from '../components/Scenes/Tres/assets/sky_linekotsi_23_HDRI.hdr?url' +type BroadcastChannelEvents + = | BroadcastChannelEventShouldUpdateView + +interface BroadcastChannelEventShouldUpdateView { + type: 'should-update-view' +} + export const useVRM = defineStore('vrm', () => { + const { post, data } = useBroadcastChannel({ name: 'airi-stores-vrm' }) + const shouldUpdateViewHooks = ref void | Promise>>([]) + + const onShouldUpdateView = (hook: () => void | Promise) => { + shouldUpdateViewHooks.value.push(hook) + } + + function shouldUpdateView() { + post({ type: 'should-update-view' }) + shouldUpdateViewHooks.value.forEach(hook => hook()) + } + + watch(data, (event) => { + if (event.type === 'should-update-view') { + shouldUpdateViewHooks.value.forEach(hook => hook()) + } + }) + const indexedDbModelFile = ref(null) - onMounted(async () => { + async function loadModelFileFromIndexedDb() { const file = await localforage.getItem('assets-models-vrm') if (file) { indexedDbModelFile.value = file } - }) + } + + onMounted(async () => loadModelFileFromIndexedDb()) + // onShouldUpdateView(() => loadModelFileFromIndexedDb()) const modelFile = computed({ get: () => { @@ -121,9 +149,10 @@ export const useVRM = defineStore('vrm', () => { isTracking, trackingMode, eyeHeight, - envSelect, skyBoxSrc, + shouldUpdateView, + onShouldUpdateView, } })