feat(stage-ui,stage-pages): idle animation selector (#723)

This commit is contained in:
Mediocore
2025-11-23 15:34:48 +08:00
committed by GitHub
parent b60e0d59c5
commit 3db6315879
2 changed files with 199 additions and 9 deletions
@@ -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)