feat(stage-ui): shuffle idle motions for Live2D model (#820)

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
Rin
2026-01-03 18:04:50 +00:00
committed by GitHub
co-authored by Neko Ayaka
parent 1f6db3111e
commit 4e553b7a76
4 changed files with 97 additions and 40 deletions
@@ -27,6 +27,7 @@ const {
position,
modelParameters,
currentMotion,
selectedRuntimeIdleMotion,
} = storeToRefs(live2d)
const selectedRuntimeMotion = ref<string>('')
@@ -50,20 +51,33 @@ onMounted(() => {
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
}
watch(selectedRuntimeIdleMotion, (motion) => {
selectedRuntimeMotion.value = motion?.path || ''
selectedRuntimeMotionName.value = motion?.name || ''
}, { immediate: true })
// Add click outside handler
document.addEventListener('click', handleClickOutside)
})
function handleMotionShuffle() {
selectedRuntimeMotion.value = 'Shuffle All'
selectedRuntimeMotionName.value = 'Shuffle All'
selectedRuntimeIdleMotion.value = {
path: 'Shuffle All',
name: 'Shuffle All',
group: 'Idle',
}
// Enable idle animation
live2dIdleAnimationEnabled.value = true
// Set the current motion to shuffle (no index)
currentMotion.value = { group: 'Idle' }
showMotionSelector.value = false
}
// Function to reset all parameters to default values
function resetToDefaultParameters() {
modelParameters.value = { ...defaultModelParameters }
@@ -73,10 +87,12 @@ function resetToDefaultParameters() {
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())
selectedRuntimeIdleMotion.value = {
path: motion.displayPath,
name: motion.name,
group: motion.group,
index: motion.index,
}
// Enable idle animation
live2dIdleAnimationEnabled.value = true
@@ -306,6 +322,22 @@ onUnmounted(() => {
<div v-if="runtimeMotions.length === 0" p-4 text-sm text-neutral-500 dark:text-neutral-400>
No motions available
</div>
<button
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 === 'Shuffle All',
}"
@click="handleMotionShuffle"
>
<div text-sm text-neutral-900 font-medium dark:text-neutral-100>
Shuffle All (Idle)
</div>
<div truncate text-xs text-neutral-500 dark:text-neutral-400>
Randomly play motions from the Idle group
</div>
</button>
<button
v-for="motion in runtimeMotions"
:key="motion.fullPath"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Application } from '@pixi/app'
import type { MotionManager } from 'pixi-live2d-display/cubism4'
import type { PixiLive2DInternalModel } from '../../../composables/live2d'
@@ -26,6 +27,17 @@ import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
import { useLive2d } from '../../../stores/live2d'
import { useSettings } from '../../../stores/settings'
type ExtendedMotionManager = MotionManager<any, any> & {
idleMotionGroup?: string
definitions: Record<string, any[]>
groups: {
idle?: string
Idle?: string
[key: string]: any
}
motionGroups: Record<string, any[]>
}
const props = withDefaults(defineProps<{
modelSrc?: string
modelId?: string
@@ -134,6 +146,7 @@ const {
availableMotions,
motionMap,
modelParameters,
selectedRuntimeIdleMotion,
} = storeToRefs(live2dStore)
const {
@@ -238,7 +251,8 @@ async function loadModel() {
const internalModel = model.value.internalModel
const coreModel = internalModel.coreModel
const motionManager = internalModel.motionManager
const motionManager = internalModel.motionManager as ExtendedMotionManager
coreModel.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
availableMotions.value = Object
@@ -250,30 +264,28 @@ 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')
const selectedMotionGroup = selectedRuntimeIdleMotion.value?.group
const selectedMotionIndex = selectedRuntimeIdleMotion.value?.index
// Configure the selected motion to loop
if (selectedMotionGroup !== null && selectedMotionIndex) {
if (selectedMotionGroup != null && selectedMotionIndex != null) {
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]
const motion = motionManager.motionGroups[groupIndex][selectedMotionIndex]
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)
console.info('Configured motion to loop infinitely:', selectedMotionGroup, selectedMotionIndex)
}
}
}
if (selectedMotionGroup !== null && selectedMotionIndex && live2dIdleAnimationEnabled.value) {
if (selectedMotionGroup != null && selectedMotionIndex != null && live2dIdleAnimationEnabled.value) {
setTimeout(() => {
console.info('Playing selected runtime motion:', selectedMotionGroup, selectedMotionIndex)
currentMotion.value = {
group: selectedMotionGroup,
index: Number.parseInt(selectedMotionIndex),
index: selectedMotionIndex,
}
}, 300)
}
@@ -281,15 +293,20 @@ async function loadModel() {
// 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
if (motionManager.groups.idle) {
motionManager.motionGroups[motionManager.groups.idle]?.forEach((motion) => {
motion._motionData.curves.forEach((curve: any) => {
// TODO: After emotion mapper, stage editor, eye related parameters should be take cared to be dynamical instead of hardcoding
if (curve.id === 'ParamEyeBallX' || curve.id === 'ParamEyeBallY') {
curve.id = `_${curve.id}`
}
const idleGroupName = motionManager.groups.idle ? 'idle' : (motionManager.groups.Idle ? 'Idle' : undefined)
if (idleGroupName) {
const groupTarget = motionManager.groups[idleGroupName]
if (groupTarget !== undefined) {
motionManager.idleMotionGroup = idleGroupName
motionManager.motionGroups[groupTarget]?.forEach((motion: any) => {
motion._motionData.curves.forEach((curve: any) => {
// TODO: After emotion mapper, stage editor, eye related parameters should be take cared to be dynamical instead of hardcoding
if (curve.id === 'ParamEyeBallX' || curve.id === 'ParamEyeBallY') {
curve.id = `_${curve.id}`
}
})
})
})
}
}
// This is hacky too
@@ -313,23 +330,25 @@ async function loadModel() {
return motionManagerUpdate.hookUpdate(model, now, hookedUpdate)
}
motionManager.on('motionStart', (group, index) => {
motionManager.on('motionStart', (group: string, index: number) => {
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')
const selectedMotionGroup = selectedRuntimeIdleMotion.value?.group
const selectedMotionIndex = selectedRuntimeIdleMotion.value?.index
if (selectedMotionGroup !== null && selectedMotionIndex && live2dIdleAnimationEnabled.value) {
if (selectedMotionGroup != null && selectedMotionIndex != null && 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),
if (selectedMotionGroup != null && selectedMotionIndex != null && live2dIdleAnimationEnabled.value) {
currentMotion.value = {
group: selectedMotionGroup,
index: selectedMotionIndex,
}
}
})
}
@@ -3,6 +3,7 @@ import type { Ref } from 'vue'
import type { BeatSyncController } from './beat-sync'
import { useLive2d } from '../../stores/live2d'
import { useLive2DIdleEyeFocus } from './animation'
type CubismModel = Cubism4InternalModel['coreModel']
@@ -55,6 +56,8 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
lastUpdateTime,
} = options
const live2dStore = useLive2d()
const prePlugins: MotionManagerPlugin[] = []
const postPlugins: MotionManagerPlugin[] = []
@@ -75,7 +78,7 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
function hookUpdate(model: CubismModel, now: number, hookedUpdate?: (model: CubismModel, now: number) => boolean) {
const timeDelta = lastUpdateTime.value ? now - lastUpdateTime.value : 0
const selectedMotionGroup = localStorage.getItem('selected-runtime-motion-group')
const selectedMotionGroup = live2dStore.selectedRuntimeIdleMotion?.group
const isIdleMotion = !motionManager.state.currentGroup
|| motionManager.state.currentGroup === motionManager.groups.idle
|| (!!selectedMotionGroup && motionManager.state.currentGroup === selectedMotionGroup)
+4 -1
View File
@@ -60,8 +60,9 @@ export const useLive2d = defineStore('live2d', () => {
x: `${position.value.x}%`,
y: `${position.value.y}%`,
}))
const [currentMotion, resetCurrentMotion] = createResettableRef<{ group: string, index?: number }>({ group: 'Idle', index: 0 })
const [currentMotion, resetCurrentMotion] = createResettableRef<{ group: string, index?: number }>({ group: 'Idle' })
const [availableMotions, resetAvailableMotions] = createResettableRef<{ motionName: string, motionIndex: number, fileName: string }[]>([])
const [selectedRuntimeIdleMotion, resetSelectedRuntimeIdleMotion] = createResettableRef<{ path: string, name: string, group: string, index?: number } | null>(null)
const [motionMap, resetMotionMap] = createResettableLocalStorage<Record<string, string>>('settings/live2d/motion-map', {})
const [scale, resetScale] = createResettableLocalStorage('settings/live2d/scale', 1)
@@ -72,6 +73,7 @@ export const useLive2d = defineStore('live2d', () => {
resetPosition()
resetCurrentMotion()
resetAvailableMotions()
resetSelectedRuntimeIdleMotion()
resetMotionMap()
resetScale()
resetModelParameters()
@@ -83,6 +85,7 @@ export const useLive2d = defineStore('live2d', () => {
positionInPercentageString,
currentMotion,
availableMotions,
selectedRuntimeIdleMotion,
motionMap,
scale,
modelParameters,