diff --git a/docs/ai/context/ui-components.md b/docs/ai/context/ui-components.md index 1d725839b..29401a936 100644 --- a/docs/ai/context/ui-components.md +++ b/docs/ai/context/ui-components.md @@ -489,6 +489,7 @@ All Field components wrap a base input with `label`, `description`, and consiste | `description` | `string?` | — | Helper text | | `formatValue` | `(value: number) => string?` | — | Value formatter | | `as` | `'label' \| 'div'` | `'label'` | Wrapper element | +| `defaultValue` | `number?` | — | When set, shows a reset button next to the label that restores this value. Use with `as="div"`. | **v-model**: `modelValue: number` diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index e6bb99113..7a87cd105 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -119,7 +119,7 @@ model-select: support-status-header: We support both 2D and 3D models support-status: > Click {select-button} to select or import models. - Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. @@ -465,7 +465,7 @@ pages: all_deleted: All local data deleted. desktop_reset: Desktop data reset. models: - description: Live2D, VRM, etc. + description: Live2D, VRM, Spine, etc. title: Models sections: section: @@ -1722,6 +1722,8 @@ spine: idle-animation: Idle Animation mix-duration: Mix Duration (seconds) speed: Animation Speed + appearance: + title: Appearance variant: title: Variant current-variant: Active Variant diff --git a/packages/i18n/src/locales/es/settings.yaml b/packages/i18n/src/locales/es/settings.yaml index 0652717ca..b4b4b7020 100644 --- a/packages/i18n/src/locales/es/settings.yaml +++ b/packages/i18n/src/locales/es/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: Soportamos tanto modelos 2D como 3D support-status: > - Click {select-button} to select or import models. Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Click {select-button} to select or import models. Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: Todos los datos locales eliminados. desktop_reset: Datos de escritorio restablecidos. models: - description: Live2D, VRM, etc. + description: Live2D, VRM, Spine, etc. title: Modelos sections: section: @@ -1650,6 +1650,8 @@ spine: idle-animation: Animación inactiva mix-duration: Mezclar duración (segundos) speed: Velocidad de animación + appearance: + title: Apariencia variant: title: Variante current-variant: Variante activa diff --git a/packages/i18n/src/locales/fr/settings.yaml b/packages/i18n/src/locales/fr/settings.yaml index 160e98645..c8a2481e2 100644 --- a/packages/i18n/src/locales/fr/settings.yaml +++ b/packages/i18n/src/locales/fr/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: We support both 2D and 3D models support-status: > - Click {select-button} to select or import models. Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Click {select-button} to select or import models. Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: Toutes les données locales ont été supprimées. desktop_reset: Données du bureau réinitialisées. models: - description: Live2D, VRM, etc. + description: Live2D, VRM, Spine, etc. title: Modèles sections: section: @@ -1650,6 +1650,8 @@ spine: idle-animation: Idle Animation mix-duration: Mix Duration (seconds) speed: Animation Speed + appearance: + title: Apparence variant: title: Variant current-variant: Active Variant diff --git a/packages/i18n/src/locales/ja/settings.yaml b/packages/i18n/src/locales/ja/settings.yaml index 677d46982..589361be9 100644 --- a/packages/i18n/src/locales/ja/settings.yaml +++ b/packages/i18n/src/locales/ja/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: 私たちは2Dと3Dのモデル両方をサポートしています support-status: > - Click {select-button} to select or import models. Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Click {select-button} to select or import models. Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: すべてのローカルデータを削除しました。 desktop_reset: デスクトップのデータをリセットしました。 models: - description: Live2D、VRMなど。 + description: Live2D、VRM、Spineなど。 title: モデル sections: section: @@ -1650,6 +1650,8 @@ spine: idle-animation: Idle Animation mix-duration: Mix Duration (seconds) speed: Animation Speed + appearance: + title: 外観 variant: title: Variant current-variant: Active Variant diff --git a/packages/i18n/src/locales/ko/settings.yaml b/packages/i18n/src/locales/ko/settings.yaml index d08801173..be7d87235 100644 --- a/packages/i18n/src/locales/ko/settings.yaml +++ b/packages/i18n/src/locales/ko/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: We support both 2D and 3D models support-status: > - Click {select-button} to select or import models. Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Click {select-button} to select or import models. Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: 로컬 데이터가 초기화되었습니다. desktop_reset: 데스크탑 데이터가 초기화되었습니다. models: - description: Live2D, VRM, 기타 등등. + description: Live2D, VRM, Spine, 기타 등등. title: 모델 sections: section: @@ -1650,6 +1650,8 @@ spine: idle-animation: Idle Animation mix-duration: Mix Duration (seconds) speed: Animation Speed + appearance: + title: 외형 variant: title: Variant current-variant: Active Variant diff --git a/packages/i18n/src/locales/ru/settings.yaml b/packages/i18n/src/locales/ru/settings.yaml index 6c6433d5e..6fef28a57 100644 --- a/packages/i18n/src/locales/ru/settings.yaml +++ b/packages/i18n/src/locales/ru/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: Мы поддерживаем 2D и 3D модели support-status: > - Нажмите {select-button}, чтобы выбрать или импортировать модели. В настоящее время поддерживаются {zip} (для моделей Live2D) и {vrm} (для моделей VRM). + Нажмите {select-button}, чтобы выбрать или импортировать модели. В настоящее время поддерживаются {zip} (для моделей Live2D и Spine) и {vrm} (для моделей VRM). model-type-example: > Neuro-sama использует 2D модель, основанную на фреймворк, разработанной компанией Live2D Inc, В то время как Grok Ani (в компании Grok) использует 3D-модели, основанные на открытых форматах VRM / MMD. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: Все локальные данные удалены. desktop_reset: Данные рабочего стола сброшены. models: - description: Live2D, VRM и др. + description: Live2D, VRM, Spine и др. title: Модели sections: section: @@ -1650,6 +1650,8 @@ spine: idle-animation: Анимация бездействия mix-duration: Длительность смешивания (в секундах) speed: Скорость анимации + appearance: + title: Внешний вид variant: title: Вариация current-variant: Активный вариант diff --git a/packages/i18n/src/locales/vi/settings.yaml b/packages/i18n/src/locales/vi/settings.yaml index 6971b9cb5..40bc77a1f 100644 --- a/packages/i18n/src/locales/vi/settings.yaml +++ b/packages/i18n/src/locales/vi/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: We support both 2D and 3D models support-status: > - Click {select-button} to select or import models. Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Click {select-button} to select or import models. Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: Tất cả dữ liệu cục bộ đã bị xóa. desktop_reset: Đã đặt lại dữ liệu ứng dụng máy tính. models: - description: Live2D, VRM, v.v. + description: Live2D, VRM, Spine, v.v. title: Mô hình sections: section: @@ -1650,6 +1650,8 @@ spine: idle-animation: Idle Animation mix-duration: Mix Duration (seconds) speed: Animation Speed + appearance: + title: Diện mạo variant: title: Variant current-variant: Active Variant diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 82c8f1f6e..01ca738e2 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: We support both 2D and 3D models support-status: > - Click {select-button} to select or import models. Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Click {select-button} to select or import models. Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: 已删除所有本地数据。 desktop_reset: 桌面数据重置。 models: - description: 切换角色的 Live2D,VRM 模型 + description: 切换角色的 Live2D、VRM、Spine 模型 title: 角色模型 sections: section: @@ -1653,6 +1653,8 @@ spine: idle-animation: Idle Animation mix-duration: Mix Duration (seconds) speed: Animation Speed + appearance: + title: 外观 variant: title: Variant current-variant: Active Variant diff --git a/packages/i18n/src/locales/zh-Hant/settings.yaml b/packages/i18n/src/locales/zh-Hant/settings.yaml index 06cbb6408..e4ab87345 100644 --- a/packages/i18n/src/locales/zh-Hant/settings.yaml +++ b/packages/i18n/src/locales/zh-Hant/settings.yaml @@ -109,7 +109,7 @@ model-select: panel-callout: support-status-header: We support both 2D and 3D models support-status: > - Click {select-button} to select or import models. Currently, {zip} (for Live2D models) and {vrm} (for VRM models) are supported. + Click {select-button} to select or import models. Currently, {zip} (for Live2D and Spine models) and {vrm} (for VRM models) are supported. model-type-example: > Neuro-sama uses 2D model driven by framework developed by Live2D Inc, while Grok Ani (in Grok Companion) uses 3D model driven by VRM / MMD open formats. select-model: @@ -446,7 +446,7 @@ pages: all_deleted: 所有本機資料已刪除 desktop_reset: 桌面資料已重設 models: - description: 切換角色的 Live2D、VRM 模型 + description: 切換角色的 Live2D、VRM、Spine 模型 title: 角色模型 sections: section: @@ -1650,6 +1650,8 @@ spine: idle-animation: Idle Animation mix-duration: Mix Duration (seconds) speed: Animation Speed + appearance: + title: 外觀 variant: title: Variant current-variant: Active Variant diff --git a/packages/stage-ui-spine/src/components/scenes/Spine.vue b/packages/stage-ui-spine/src/components/scenes/Spine.vue index 21952029f..b7173876b 100644 --- a/packages/stage-ui-spine/src/components/scenes/Spine.vue +++ b/packages/stage-ui-spine/src/components/scenes/Spine.vue @@ -66,6 +66,7 @@ defineExpose({ :canvas="canvas" :width="width" :height="height" + :resolution="renderScale" :paused="paused" :premultiplied-alpha="premultipliedAlpha" :default-mix-duration="defaultMixDuration" diff --git a/packages/stage-ui-spine/src/components/scenes/spine/Model.vue b/packages/stage-ui-spine/src/components/scenes/spine/Model.vue index 209644f4b..f51a9dbe3 100644 --- a/packages/stage-ui-spine/src/components/scenes/spine/Model.vue +++ b/packages/stage-ui-spine/src/components/scenes/spine/Model.vue @@ -7,10 +7,10 @@ import type { SpineModelVariant } from '../../../utils/spine-zip-loader' import { Mutex } from 'es-toolkit' import { storeToRefs } from 'pinia' -import { onMounted, onUnmounted, ref, toRef, watch } from 'vue' +import { nextTick, onMounted, onUnmounted, ref, toRef, watch } from 'vue' import { useSpineAnimationManager } from '../../../composables/spine' -import { EMOTION_SpineAnimationName_value, SpineAnimationName } from '../../../constants/emotions' +import { EMOTION_SpineAnimationName_value, SPINE_IDLE_TRACK, SpineAnimationName } from '../../../constants/emotions' import { useSpine } from '../../../stores/spine' import { loadSpineRuntime } from '../../../utils/spine-runtime' import { detectSpineVersionFromBinary, detectSpineVersionFromJson } from '../../../utils/spine-version' @@ -22,6 +22,7 @@ const props = withDefaults(defineProps<{ canvas?: HTMLCanvasElement width: number height: number + resolution?: number paused?: boolean premultipliedAlpha?: boolean defaultMixDuration?: number @@ -29,6 +30,7 @@ const props = withDefaults(defineProps<{ maxFps?: number }>(), { paused: false, + resolution: 1, premultipliedAlpha: true, defaultMixDuration: 0.2, idleAnimationEnabled: true, @@ -54,6 +56,7 @@ const { availableVariants, currentVariant, animationSpeed, + oneShotAnimation, } = storeToRefs(spineStore) let isUnmounted = false @@ -68,6 +71,23 @@ let skeleton: Skeleton | undefined let animationState: AnimationState | undefined let loadedVariants: SpineModelVariant[] = [] +// Intrinsic model bounds at scale 1 with the root at the origin, captured on +// load and used to auto-fit the skeleton to the canvas. Undefined until a model +// is loaded, or when the setup pose has no renderable bounds. +let modelIntrinsicBounds: { x: number, y: number, width: number, height: number } | undefined + +// Last time the skeleton was drawn, used to honour `maxFps`. The skeleton +// still advances every frame in `update`; only the GPU draw is throttled. +let lastRenderTime = 0 + +// Mutable defaults handed to the animation manager. The manager reads these +// fields on every call, so mutating them in place (see the prop watches +// below) propagates live setting changes without rebuilding the manager. +const animationDefaults = { + mixDuration: props.defaultMixDuration, + idleAnimationEnabled: props.idleAnimationEnabled, +} + const canvas = toRef(() => props.canvas) const modelSrc = toRef(() => props.modelSrc) const paused = toRef(() => props.paused) @@ -87,6 +107,8 @@ function disposeSpine() { animationManager = undefined skeleton = undefined animationState = undefined + modelIntrinsicBounds = undefined + spineStore.isModelLoaded = false } async function loadModel() { @@ -201,16 +223,12 @@ async function loadModel() { skeleton = new spine.Skeleton(skeletonData) skeleton.setToSetupPose() - applyTransformFromStore() const stateData = new spine.AnimationStateData(skeletonData) stateData.defaultMix = props.defaultMixDuration animationState = new spine.AnimationState(stateData) - animationManager = useSpineAnimationManager(animationState, skeleton, { - mixDuration: props.defaultMixDuration, - idleAnimationEnabled: props.idleAnimationEnabled, - }) + animationManager = useSpineAnimationManager(animationState, skeleton, animationDefaults) // Inventory animations and skins, populate the store. const animations = skeletonData.animations.map(animation => ({ name: animation.name, duration: animation.duration })) @@ -222,9 +240,31 @@ async function loadModel() { // Apply the user's saved skin (if any). applySkin(currentSkin.value) + // Capture the model's intrinsic bounds (scale 1, root at origin) + // so applyTransformFromStore can auto-fit it to the canvas. Done + // after the skin is applied because skin selection changes which + // attachments are visible, and therefore the model's extent. + skeleton.scaleX = 1 + skeleton.scaleY = 1 + skeleton.x = 0 + skeleton.y = 0 + if (spine.Physics) + skeleton.updateWorldTransform(spine.Physics.update) + else + (skeleton as any).updateWorldTransform() + const boundsOffset = new spine.Vector2() + const boundsSize = new spine.Vector2() + skeleton.getBounds(boundsOffset, boundsSize, []) + modelIntrinsicBounds = boundsSize.x > 0 && boundsSize.y > 0 + ? { x: boundsOffset.x, y: boundsOffset.y, width: boundsSize.x, height: boundsSize.y } + : undefined + + applyTransformFromStore() + // Apply the user's saved idle animation. applyCurrentAnimation() + spineStore.isModelLoaded = true emits('modelLoaded') resolve() } @@ -251,6 +291,15 @@ async function loadModel() { render: (sc) => { if (!skeleton) return + // Cap the draw rate when maxFps > 0. Animation timing stays correct + // because `update` keeps advancing every frame; we only skip the GPU + // draw to honour the configured ceiling. + if (props.maxFps > 0) { + const now = performance.now() + if (now - lastRenderTime < 1000 / props.maxFps) + return + lastRenderTime = now + } const renderer = sc.renderer renderer.resize(spine.ResizeMode.Expand) sc.gl.clearColor(0, 0, 0, 0) @@ -368,21 +417,46 @@ function applyTransformFromStore() { if (!skeleton || !canvas.value) return - // Centre the skeleton roughly at the bottom-middle of the canvas, then - // apply user offsets/scale on top. This mirrors the Live2D anchor. + // The SpineCanvas camera sits at world origin (0,0), so screen centre maps + // to world (0,0) and the visible region is [-w/2, w/2] x [-h/2, h/2] (y up). const w = canvas.value.width const h = canvas.value.height - skeleton.x = w / 2 + position.value.x - skeleton.y = h * 0.05 + position.value.y - skeleton.scaleX = scale.value - skeleton.scaleY = scale.value + + // Base scale auto-fits the model's intrinsic bounds into the canvas so tall + // or oversized rigs are fully visible by default. The user's `scale` setting + // multiplies on top, so 1 means "fit". Without bounds we fall back to raw + // user scale and a centred root. + let baseScale = 1 + if (modelIntrinsicBounds) { + const margin = 0.9 + const fitScale = Math.min(w / modelIntrinsicBounds.width, h / modelIntrinsicBounds.height) * margin + if (Number.isFinite(fitScale) && fitScale > 0) + baseScale = fitScale + } + const finalScale = baseScale * scale.value + skeleton.scaleX = finalScale + skeleton.scaleY = finalScale + + if (modelIntrinsicBounds) { + // Centre the bounding box at world origin, then apply the user's pixel + // offsets. Bounds scale linearly with skeleton scale because the root sits + // at the origin without rotation. + const centreX = modelIntrinsicBounds.x + modelIntrinsicBounds.width / 2 + const centreY = modelIntrinsicBounds.y + modelIntrinsicBounds.height / 2 + skeleton.x = -finalScale * centreX + position.value.x + skeleton.y = -finalScale * centreY + position.value.y + } + else { + skeleton.x = position.value.x + skeleton.y = position.value.y + } } function applyCurrentAnimation() { if (!animationManager) return const desired = currentAnimation.value?.name ?? SpineAnimationName.Idle - animationManager.setIdle(desired) + animationManager.setIdle(desired, currentAnimation.value?.loop ?? true) } function applySkin(skinName: string) { @@ -417,13 +491,17 @@ function applySkin(skinName: string) { * Returns: * - The resolved animation name when one was found, otherwise `undefined`. */ -function setEmotion(emotion: Emotion, _intensity: number = 1): string | undefined { +function setEmotion(emotion: Emotion, intensity: number = 1): string | undefined { if (!animationManager) return undefined const animationName = EMOTION_SpineAnimationName_value[emotion] if (!animationName) return undefined - const entry = animationManager.playEmotion(animationName) + // Intensity scales the emotion track's blend weight so a stronger emotion + // overrides more of the idle pose. Clamp to [0, 1]; alpha outside that range + // is undefined behaviour in Spine's track mixing. + const alpha = Math.min(1, Math.max(0, intensity)) + const entry = animationManager.playEmotion(animationName, { alpha }) return entry?.animation?.name } @@ -433,7 +511,11 @@ watch(canvas, async (next, prev) => { await loadModel() }) -watch([() => props.width, () => props.height, position, scale], () => { +watch([() => props.width, () => props.height, () => props.resolution, position, scale], async () => { + // The sibling Canvas component resizes the backing store in its own watcher + // when width/height/resolution change. Wait a tick so `canvas.width/height` + // reflect the new size before we recompute the skeleton's centre. + await nextTick() applyTransformFromStore() }, { deep: true }) @@ -441,6 +523,11 @@ watch(currentAnimation, () => { applyCurrentAnimation() }, { deep: true }) +watch(oneShotAnimation, (req) => { + if (req) + animationManager?.playEmotion(req.name, { loop: req.loop }) +}) + watch(currentSkin, (skinName) => { applySkin(skinName) }) @@ -450,16 +537,18 @@ watch(currentVariant, async () => { await loadModel() }) -watch(() => props.idleAnimationEnabled, () => { +watch(() => props.idleAnimationEnabled, (enabled) => { + animationDefaults.idleAnimationEnabled = enabled if (!animationManager || !skeleton || !animationState) return - if (props.idleAnimationEnabled) + if (enabled) applyCurrentAnimation() else - animationState.setEmptyAnimation(0, props.defaultMixDuration) + animationState.setEmptyAnimation(SPINE_IDLE_TRACK, props.defaultMixDuration) }) watch(() => props.defaultMixDuration, (mix) => { + animationDefaults.mixDuration = mix if (animationState) animationState.data.defaultMix = mix }) diff --git a/packages/stage-ui-spine/src/composables/spine/animation-manager.ts b/packages/stage-ui-spine/src/composables/spine/animation-manager.ts index 3148add20..0aebd3fae 100644 --- a/packages/stage-ui-spine/src/composables/spine/animation-manager.ts +++ b/packages/stage-ui-spine/src/composables/spine/animation-manager.ts @@ -3,10 +3,10 @@ import type { AnimationState, Skeleton, TrackEntry } from '@esotericsoftware/spi import { SPINE_EMOTION_TRACK, SPINE_IDLE_TRACK } from '../../constants/emotions' export interface SpineAnimationManager { - /** Set the looping idle animation on track 0. */ - setIdle: (name: string) => TrackEntry | null + /** Set the looping idle animation on track 0. `loop` defaults to true. */ + setIdle: (name: string, loop?: boolean) => TrackEntry | null /** Play a one-shot emotion animation on track 1. */ - playEmotion: (name: string, options?: { loop?: boolean, mixDuration?: number }) => TrackEntry | null + playEmotion: (name: string, options?: { loop?: boolean, mixDuration?: number, alpha?: number }) => TrackEntry | null /** Stop the emotion track and re-empty back to the idle state. */ clearEmotion: (mixDuration?: number) => void /** Resolve the closest matching animation name. Case-insensitive substring match. */ @@ -27,6 +27,9 @@ export interface SpineAnimationManager { * Expects: * - `animationState` and `skeleton` are already initialized for a model * that the caller mounted via `loadSpineZip()` or a URL source. + * - `defaults` is read on every call, not captured once. Callers that own + * reactive settings (mix duration, idle toggle) should pass a stable + * object and mutate its fields in place so changes take effect live. * * Returns: * - A handle that mutates the underlying `AnimationState` directly. @@ -68,7 +71,7 @@ export function useSpineAnimationManager( return undefined } - function setIdle(name: string): TrackEntry | null { + function setIdle(name: string, loop: boolean = true): TrackEntry | null { if (!defaults.idleAnimationEnabled) { animationState.setEmptyAnimation(SPINE_IDLE_TRACK, defaults.mixDuration) return null @@ -78,16 +81,20 @@ export function useSpineAnimationManager( if (!resolved) return null - return animationState.setAnimation(SPINE_IDLE_TRACK, resolved, true) + return animationState.setAnimation(SPINE_IDLE_TRACK, resolved, loop) } - function playEmotion(name: string, options?: { loop?: boolean, mixDuration?: number }): TrackEntry | null { + function playEmotion(name: string, options?: { loop?: boolean, mixDuration?: number, alpha?: number }): TrackEntry | null { const resolved = resolveAnimation(name) if (!resolved) return null const entry = animationState.setAnimation(SPINE_EMOTION_TRACK, resolved, options?.loop ?? false) entry.mixDuration = options?.mixDuration ?? defaults.mixDuration + // Track alpha scales how strongly the emotion overrides the idle pose + // underneath. Callers pass emotion intensity here; default is full weight. + if (options?.alpha != null) + entry.alpha = options.alpha // Auto-clear after the one-shot animation completes; the listener fires // on `complete` for non-looping tracks, restoring the idle state. if (!entry.loop) { diff --git a/packages/stage-ui-spine/src/stores/spine.ts b/packages/stage-ui-spine/src/stores/spine.ts index 74e6eb1b0..be510f59d 100644 --- a/packages/stage-ui-spine/src/stores/spine.ts +++ b/packages/stage-ui-spine/src/stores/spine.ts @@ -40,6 +40,16 @@ export const defaultSpineAnimation: SpineCurrentAnimation = { loop: true, } +/** Transient request to play a one-shot animation on the emotion track. */ +export interface SpineOneShotAnimation { + /** Animation name; resolved against the loaded skeleton by the scene. */ + name: string + /** Whether the one-shot should loop instead of reverting to idle. */ + loop: boolean + /** Bumped on every request so repeat calls with the same name re-trigger. */ + nonce: number +} + export const useSpine = defineStore('spine', () => { const { post, data } = useBroadcastChannel({ name: 'airi-stores-stage-ui-spine', @@ -94,20 +104,38 @@ export const useSpine = defineStore('spine', () => { /** Active variant name. Empty string means use the default (first) variant. */ const currentVariant = useLocalStorageManualReset('settings/spine/current-variant', '') - /** Premultiplied alpha — most modern Spine atlases ship as PMA. */ - const premultipliedAlpha = useLocalStorageManualReset('settings/spine/premultiplied-alpha', true) - - /** Default mix-in/out duration (s) between track animations. */ - const defaultMixDuration = useLocalStorageManualReset('settings/spine/default-mix', 0.2) - - /** Auto-play idle animation on load. */ - const idleAnimationEnabled = useLocalStorageManualReset('settings/spine/idle-enabled', true) - /** Animation playback speed multiplier (1.0 = normal). */ const animationSpeed = useLocalStorageManualReset('settings/spine/animation-speed', 1) - /** Maximum FPS for the WebGL render loop (0 = uncapped). */ - const maxFps = useLocalStorageManualReset('settings/spine/max-fps', 0) + // NOTICE: + // Premultiplied-alpha, default-mix, idle-enabled, and max-fps live in + // `useSettingsSpine` (packages/stage-ui/src/stores/settings/spine.ts) and + // are bound to the same `settings/spine/*` keys. They used to be declared + // here too, which created two refs per key and two competing sources of + // truth. Stage, the preview, and the settings panel all read the + // `useSettingsSpine` copies, so the duplicates were removed from this store. + // Removal condition: keep these settings owned by a single store. + + /** + * Whether a Spine skeleton is currently mounted in a live scene. + * + * Runtime-only and never persisted: `availableAnimations` survives reloads + * via localStorage, so it cannot tell tools whether a model is actually on + * screen. The scene sets this on mount and clears it on dispose/unmount. + */ + const isModelLoaded = ref(false) + + /** + * Transient one-shot animation request. The scene watches this and layers + * the animation on the emotion track over the persistent idle loop. Not + * persisted — it is a fire-and-forget trigger rather than durable state. + */ + const oneShotAnimation = ref() + + /** Queue a one-shot animation; bumps the nonce so repeat calls re-trigger. */ + function playOneShotAnimation(name: string, loop = false) { + oneShotAnimation.value = { name, loop, nonce: (oneShotAnimation.value?.nonce ?? 0) + 1 } + } const { position, scale, reset: resetViewControl } = useSpineViewControl() @@ -119,11 +147,7 @@ export const useSpine = defineStore('spine', () => { currentSkin.reset() availableVariants.reset() currentVariant.reset() - premultipliedAlpha.reset() - defaultMixDuration.reset() - idleAnimationEnabled.reset() animationSpeed.reset() - maxFps.reset() shouldUpdateView() } @@ -136,11 +160,11 @@ export const useSpine = defineStore('spine', () => { currentSkin, availableVariants, currentVariant, - premultipliedAlpha, - defaultMixDuration, - idleAnimationEnabled, animationSpeed, - maxFps, + + isModelLoaded, + oneShotAnimation, + playOneShotAnimation, onShouldUpdateView, shouldUpdateView, diff --git a/packages/stage-ui-spine/src/stores/view-control.ts b/packages/stage-ui-spine/src/stores/view-control.ts index 59e305a36..8ad71427f 100644 --- a/packages/stage-ui-spine/src/stores/view-control.ts +++ b/packages/stage-ui-spine/src/stores/view-control.ts @@ -13,13 +13,16 @@ const position = useLocalStorage<{ x: number, y: number }>('settings/spine/posit /** uniform model scaling. `1` means no scaling. */ const scale = useLocalStorage('settings/spine/scale', 1) -const formatPercentD1 = (val: number) => `${val.toFixed(1)}%` +const formatPixels = (val: number) => `${val.toFixed(0)}px` const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%` +// Position is stored and applied in canvas pixels (see applyTransformFromStore +// in Model.vue: `skeleton.x = w / 2 + position.x`), so the x/y controls use a +// pixel range and formatter matching the settings panel sliders. export const controlConfig: Record = { - x: { min: -500, max: 500, step: 0.1, default: 0, format: formatPercentD1 }, - y: { min: -500, max: 500, step: 0.1, default: 0, format: formatPercentD1 }, - scale: { min: 0.01, max: 3, step: 0.01, default: 1, format: formatToPercent }, + x: { min: -3000, max: 3000, step: 1, default: 0, format: formatPixels }, + y: { min: -3000, max: 3000, step: 1, default: 0, format: formatPixels }, + scale: { min: 0.1, max: 3, step: 0.01, default: 1, format: formatToPercent }, } export function useSpineViewControl() { diff --git a/packages/stage-ui-spine/src/tools/animation-tools.ts b/packages/stage-ui-spine/src/tools/animation-tools.ts index 7eb502eed..37aa5ce61 100644 --- a/packages/stage-ui-spine/src/tools/animation-tools.ts +++ b/packages/stage-ui-spine/src/tools/animation-tools.ts @@ -15,7 +15,7 @@ function serialize(result: SpineToolResult): string { function ensureModelLoaded(): SpineToolResult | null { const store = useSpine() - if (store.availableAnimations.length === 0) + if (!store.isModelLoaded) return { success: false, error: 'No Spine model is currently loaded.' } return null } @@ -45,18 +45,13 @@ export const tools = [ const store = useSpine() if (oneShot) { - // The active model component watches a `nonce` field on - // currentAnimation to re-trigger the same animation, but for - // one-shot we let the Stage forward the call to setEmotion via - // the Spine instance ref. The store-level signal here updates - // the persisted idle when oneShot is false. + // One-shot plays on the dedicated emotion track over the persistent + // idle loop and reverts when it completes. The scene watches + // `oneShotAnimation` and resolves the closest matching name. + store.playOneShotAnimation(name, loop ?? false) return serialize({ success: true, - data: { - queued: name, - mode: 'one-shot', - note: 'Forwarded to scene; the scene resolves the closest matching animation name.', - }, + data: { queued: name, mode: 'one-shot', loop: loop ?? false }, }) } diff --git a/packages/stage-ui-spine/src/utils/spine-preview.ts b/packages/stage-ui-spine/src/utils/spine-preview.ts index 9a1efadf8..a536357c4 100644 --- a/packages/stage-ui-spine/src/utils/spine-preview.ts +++ b/packages/stage-ui-spine/src/utils/spine-preview.ts @@ -73,9 +73,10 @@ export async function loadSpineModelPreview(file: File): Promise { const am = canvasApp.assetManager @@ -89,6 +90,13 @@ export async function loadSpineModelPreview(file: File): Promise 0) { + skeleton.setSkinByName(skeletonData.skins[0].name) + skeleton.setSlotsToSetupPose() + } ;(canvasApp as unknown as { __previewSkeleton: import('@esotericsoftware/spine-webgl').Skeleton }).__previewSkeleton = skeleton }, update: (canvasApp: import('@esotericsoftware/spine-webgl').SpineCanvas, _delta: number) => { @@ -106,13 +114,42 @@ export async function loadSpineModelPreview(file: File): Promise 0 && size.y > 0) { + const padding = 1.1 + camera.position.x = offset.x + size.x / 2 + camera.position.y = offset.y + size.y / 2 + camera.zoom = Math.max(size.x / camera.viewportWidth, size.y / camera.viewportHeight) * padding + camera.update() + } + canvasApp.gl.clearColor(0, 0, 0, 0) canvasApp.gl.clear(canvasApp.gl.COLOR_BUFFER_BIT) renderer.begin() renderer.drawSkeleton(skeleton, true) renderer.end() + // Wait for a valid bounding box before capturing. A degenerate box + // (empty setup pose, attachments not yet resolved) would produce a + // blank or mis-framed thumbnail, so retry on the next frame instead. + if (size.x <= 0 || size.y <= 0) + return + try { const dataUrl = canvas!.toDataURL('image/png') finish(dataUrl) diff --git a/packages/stage-ui-spine/src/utils/spine-version.ts b/packages/stage-ui-spine/src/utils/spine-version.ts index 0bf263b7c..9c96bfc28 100644 --- a/packages/stage-ui-spine/src/utils/spine-version.ts +++ b/packages/stage-ui-spine/src/utils/spine-version.ts @@ -20,16 +20,22 @@ export type SpineVersion = '4.0' | '4.1' | '4.2' * The binary format header is: * - int32 hashLow * - int32 hashHigh - * - varint-length-prefixed string: version (e.g. "4.2.18") + * - length-prefixed string: version (e.g. "4.2.18"). Spine encodes the + * length as a varint of `(utf8ByteLength + 1)`, where 0 means null and 1 + * means the empty string. */ export function detectSpineVersionFromBinary(data: Uint8Array): SpineVersion | undefined { try { // Skip 8 bytes of hash (two int32s) let offset = 8 - // Read varint-encoded string length - const { value: strLen, bytesRead } = readVarint(data, offset) + // Read varint-encoded string length. Spine stores (byteLength + 1): + // 0 → null, 1 → "". Subtract 1 to get the real UTF-8 byte count. + const { value: rawLength, bytesRead } = readVarint(data, offset) offset += bytesRead - if (strLen <= 0 || offset + strLen > data.byteLength) + if (rawLength <= 1) + return undefined + const strLen = rawLength - 1 + if (offset + strLen > data.byteLength) return undefined const versionStr = new TextDecoder().decode(data.slice(offset, offset + strLen)) diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/spine.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/spine.vue index cf02e5edd..0a719f0fd 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/spine.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/spine.vue @@ -103,36 +103,9 @@ function handleSkinSelect(skinName: string | number | undefined) { size="sm" :expand="true" > - - - - - - - - - + + +
+
+ + +
+
- - - - - - -
- -
- -
- -
- + +
@@ -249,15 +191,6 @@ function handleSkinSelect(skinName: string | number | undefined) {
- - - +
diff --git a/packages/ui/src/components/form/field/field-range.vue b/packages/ui/src/components/form/field/field-range.vue index b9e1bd6aa..a901134b5 100644 --- a/packages/ui/src/components/form/field/field-range.vue +++ b/packages/ui/src/components/form/field/field-range.vue @@ -9,11 +9,22 @@ const props = withDefaults(defineProps<{ description?: string formatValue?: (value: number) => string as?: 'label' | 'div' + /** + * When provided, renders a reset control next to the label that restores the + * value to this default. Prefer `as="div"` when using this, since the reset + * button should not live inside a `