feat(stage-ui): shuffle idle motions for Live2D model (#820)
Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user