feat(stage-ui,stage-pages): idle animation selector (#723)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Checkbox, FieldRange } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { defaultModelParameters, useLive2d } from '../../../../stores/live2d'
|
||||
@@ -13,7 +13,6 @@ import { ColorPalette } from '../../../widgets'
|
||||
defineProps<{
|
||||
palette: string[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'extractColorsFromModel'): void
|
||||
}>()
|
||||
@@ -28,13 +27,87 @@ const {
|
||||
scale,
|
||||
position,
|
||||
modelParameters,
|
||||
currentMotion,
|
||||
} = storeToRefs(live2d)
|
||||
|
||||
const selectedRuntimeMotion = ref<string>('')
|
||||
const selectedRuntimeMotionName = ref<string>('')
|
||||
const runtimeMotions = ref<Array<{ name: string, fullPath: string, displayPath: string, group: string, index: number }>>([])
|
||||
const showMotionSelector = ref(false)
|
||||
|
||||
// Get available runtime motions from the model
|
||||
onMounted(() => {
|
||||
// Listen for available motions updates
|
||||
watch(() => live2d.availableMotions, (motions) => {
|
||||
// Show all motions with their full paths
|
||||
runtimeMotions.value = motions.map(m => ({
|
||||
name: m.fileName.split('/').pop() || m.fileName,
|
||||
fullPath: m.fileName, // Full path like "hiyori_free_zh/runtime/motions/idle.motion3.json"
|
||||
displayPath: m.fileName, // Show full path for clarity
|
||||
group: m.motionName,
|
||||
index: m.motionIndex,
|
||||
}))
|
||||
|
||||
console.info('Available motions:', runtimeMotions.value)
|
||||
}, { immediate: true })
|
||||
|
||||
// Restore selected motion
|
||||
const savedPath = localStorage.getItem('selected-runtime-motion')
|
||||
const savedName = localStorage.getItem('selected-runtime-motion-name')
|
||||
if (savedPath) {
|
||||
selectedRuntimeMotion.value = savedPath
|
||||
}
|
||||
if (savedName) {
|
||||
selectedRuntimeMotionName.value = savedName
|
||||
}
|
||||
|
||||
// Add click outside handler
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// Function to reset all parameters to default values
|
||||
function resetToDefaultParameters() {
|
||||
modelParameters.value = { ...defaultModelParameters }
|
||||
}
|
||||
|
||||
// Runtime motion selection handlers
|
||||
function handleMotionSelect(motion: any) {
|
||||
selectedRuntimeMotion.value = motion.displayPath // Store full path
|
||||
selectedRuntimeMotionName.value = motion.name // Store just the filename for display
|
||||
localStorage.setItem('selected-runtime-motion', motion.displayPath)
|
||||
localStorage.setItem('selected-runtime-motion-name', motion.name)
|
||||
localStorage.setItem('selected-runtime-motion-group', motion.group)
|
||||
localStorage.setItem('selected-runtime-motion-index', motion.index.toString())
|
||||
|
||||
// Enable idle animation
|
||||
live2dIdleAnimationEnabled.value = true
|
||||
|
||||
// Set the current motion to the selected runtime motion
|
||||
currentMotion.value = { group: motion.group, index: motion.index }
|
||||
|
||||
showMotionSelector.value = false
|
||||
|
||||
console.info('✅ Selected runtime motion:', motion.name)
|
||||
console.info('Full path:', motion.displayPath)
|
||||
console.info('Group:', motion.group, 'Index:', motion.index)
|
||||
}
|
||||
|
||||
function toggleMotionSelector() {
|
||||
showMotionSelector.value = !showMotionSelector.value
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('[data-motion-selector]')) {
|
||||
showMotionSelector.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// Auto blink slider animation
|
||||
let blinkAnimationId: number | null = null
|
||||
let nextBlinkTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -390,7 +463,51 @@ onUnmounted(() => {
|
||||
>
|
||||
<div flex items-center justify-between>
|
||||
<span text-sm text-neutral-600 dark:text-neutral-400>Idle Animation</span>
|
||||
<Checkbox v-model="live2dIdleAnimationEnabled" />
|
||||
<div data-motion-selector relative flex flex-col items-end gap-1>
|
||||
<button
|
||||
|
||||
:title="selectedRuntimeMotion"
|
||||
flex items-center gap-2 border rounded bg-neutral-100 px-4 py-2 text-sm text-neutral-700 font-medium transition-colors dark:border-neutral-700 dark:bg-neutral-800 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700
|
||||
@click="toggleMotionSelector"
|
||||
>
|
||||
<span max-w-32 truncate>{{ selectedRuntimeMotionName || 'Select Motion' }}</span>
|
||||
<div
|
||||
:class="showMotionSelector ? 'i-solar:alt-arrow-up-line-duotone' : 'i-solar:alt-arrow-down-line-duotone'"
|
||||
text-xs transition-transform
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showMotionSelector"
|
||||
|
||||
bg="white dark:neutral-800"
|
||||
border="1 neutral-200 dark:neutral-700"
|
||||
absolute right-0 top-10 z-50 max-h-80 min-w-64 overflow-y-auto rounded-lg shadow-lg
|
||||
>
|
||||
<div v-if="runtimeMotions.length === 0" p-4 text-sm text-neutral-500 dark:text-neutral-400>
|
||||
No motions available
|
||||
</div>
|
||||
<button
|
||||
v-for="motion in runtimeMotions"
|
||||
:key="motion.fullPath"
|
||||
w-full px-4 py-2.5 text-left
|
||||
hover:bg="neutral-100 dark:neutral-700"
|
||||
transition-colors
|
||||
:class="{
|
||||
'bg-neutral-100 dark:bg-neutral-700': selectedRuntimeMotion === motion.displayPath,
|
||||
}"
|
||||
@click="handleMotionSelect(motion)"
|
||||
>
|
||||
<div text-sm text-neutral-900 font-medium dark:text-neutral-100>
|
||||
{{ motion.name }}
|
||||
</div>
|
||||
<div truncate text-xs text-neutral-500 dark:text-neutral-400>
|
||||
{{ motion.displayPath }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div mt-4 flex items-center justify-between>
|
||||
|
||||
@@ -123,12 +123,13 @@ function setScaleAndPosition() {
|
||||
model.value.y = props.height + offset.value.yOffset
|
||||
}
|
||||
|
||||
const live2dStore = useLive2d()
|
||||
const {
|
||||
currentMotion,
|
||||
availableMotions,
|
||||
motionMap,
|
||||
modelParameters,
|
||||
} = storeToRefs(useLive2d())
|
||||
} = storeToRefs(live2dStore)
|
||||
|
||||
const {
|
||||
themeColorsHue,
|
||||
@@ -139,6 +140,11 @@ const {
|
||||
|
||||
const localCurrentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 })
|
||||
|
||||
// Listen for model reload requests (e.g., when runtime motion is uploaded)
|
||||
live2dStore.onShouldUpdateView(() => {
|
||||
loadModel()
|
||||
})
|
||||
|
||||
async function loadModel() {
|
||||
await until(modelLoading).not.toBeTruthy()
|
||||
|
||||
@@ -151,9 +157,14 @@ async function loadModel() {
|
||||
return
|
||||
}
|
||||
|
||||
if (model.value) {
|
||||
pixiApp.value.stage.removeChild(model.value)
|
||||
model.value.destroy()
|
||||
if (model.value && pixiApp.value.stage) {
|
||||
try {
|
||||
pixiApp.value.stage.removeChild(model.value)
|
||||
model.value.destroy()
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Error removing old model:', error)
|
||||
}
|
||||
model.value = undefined
|
||||
}
|
||||
if (!modelSrcRef.value) {
|
||||
@@ -215,6 +226,34 @@ async function loadModel() {
|
||||
})) || []))
|
||||
.filter(Boolean)
|
||||
|
||||
// Check if user has selected a runtime motion to play as idle
|
||||
const selectedMotionGroup = localStorage.getItem('selected-runtime-motion-group')
|
||||
const selectedMotionIndex = localStorage.getItem('selected-runtime-motion-index')
|
||||
|
||||
// Configure the selected motion to loop
|
||||
if (selectedMotionGroup && selectedMotionIndex) {
|
||||
const groupIndex = (motionManager.groups as Record<string, any>)[selectedMotionGroup]
|
||||
if (groupIndex !== undefined && motionManager.motionGroups[groupIndex]) {
|
||||
const motionIndex = Number.parseInt(selectedMotionIndex)
|
||||
const motion = motionManager.motionGroups[groupIndex][motionIndex]
|
||||
if (motion && motion._looper) {
|
||||
// Force the motion to loop
|
||||
motion._looper.loopDuration = 0 // 0 means infinite loop
|
||||
console.info('Configured motion to loop infinitely:', selectedMotionGroup, motionIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedMotionGroup && selectedMotionIndex && live2dIdleAnimationEnabled.value) {
|
||||
setTimeout(() => {
|
||||
console.info('Playing selected runtime motion:', selectedMotionGroup, selectedMotionIndex)
|
||||
currentMotion.value = {
|
||||
group: selectedMotionGroup,
|
||||
index: Number.parseInt(selectedMotionIndex),
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Remove eye ball movements from idle motion group to prevent conflicts
|
||||
// This is too hacky
|
||||
// FIXME: it cannot blink if loading a model only have idle motion
|
||||
@@ -282,7 +321,11 @@ async function loadModel() {
|
||||
|
||||
lastUpdateTime.value = now
|
||||
|
||||
const isIdleMotion = !motionManager.state.currentGroup || motionManager.state.currentGroup === motionManager.groups.idle
|
||||
// Check if current motion is an idle motion (including user-selected runtime motion)
|
||||
const selectedMotionGroup = localStorage.getItem('selected-runtime-motion-group')
|
||||
const isIdleMotion = !motionManager.state.currentGroup
|
||||
|| motionManager.state.currentGroup === motionManager.groups.idle
|
||||
|| (selectedMotionGroup && motionManager.state.currentGroup === selectedMotionGroup)
|
||||
|
||||
// Stop idle motions if they're disabled
|
||||
if (!live2dIdleAnimationEnabled.value && isIdleMotion) {
|
||||
@@ -345,6 +388,24 @@ async function loadModel() {
|
||||
localCurrentMotion.value = { group, index }
|
||||
})
|
||||
|
||||
// Listen for motion finish to restart runtime motion for looping
|
||||
motionManager.on('motionFinish', () => {
|
||||
const selectedMotionGroup = localStorage.getItem('selected-runtime-motion-group')
|
||||
const selectedMotionIndex = localStorage.getItem('selected-runtime-motion-index')
|
||||
|
||||
if (selectedMotionGroup && selectedMotionIndex && live2dIdleAnimationEnabled.value) {
|
||||
// Restart the selected runtime motion immediately for seamless looping
|
||||
console.info('Motion finished, restarting runtime motion:', selectedMotionGroup, selectedMotionIndex)
|
||||
// Use requestAnimationFrame to restart on the next frame for smooth transition
|
||||
requestAnimationFrame(() => {
|
||||
currentMotion.value = {
|
||||
group: selectedMotionGroup,
|
||||
index: Number.parseInt(selectedMotionIndex),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Apply all stored parameters to the model
|
||||
coreModel.setParameterValueById('ParamAngleX', modelParameters.value.angleX)
|
||||
coreModel.setParameterValueById('ParamAngleY', modelParameters.value.angleY)
|
||||
@@ -378,7 +439,19 @@ async function loadModel() {
|
||||
|
||||
async function setMotion(motionName: string, index?: number) {
|
||||
// TODO: motion? Not every Live2D model has motion, we do need to help users to set motion
|
||||
await model.value?.motion(motionName, index, MotionPriority.FORCE)
|
||||
if (!model.value) {
|
||||
console.warn('Cannot set motion: model not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
console.info('Setting motion:', motionName, 'index:', index)
|
||||
try {
|
||||
await model.value.motion(motionName, index, MotionPriority.FORCE)
|
||||
console.info('Motion started successfully:', motionName)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to start motion:', motionName, error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = useDebounceFn(setScaleAndPosition, 100)
|
||||
|
||||
Reference in New Issue
Block a user