fix(stage-tamagotchi): integration bugs and improve Spine UI, preview (#1994)
Some checks are pending
CI / Unit Test (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
Cloudflare Pages (Admin UI) / Deploy - ui-admin (push) Waiting to run
Cloudflare Pages (Auth UI) / Deploy - ui-server-auth (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run

Co-authored-by: nyueki <nyuek.i@proton.me>
This commit is contained in:
nyueki 2026-06-19 12:46:10 -04:00 committed by GitHub
parent 92178b9598
commit 911ebb283b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 320 additions and 186 deletions

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: Активный вариант

View file

@ -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

View file

@ -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: 切换角色的 Live2DVRM 模型
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

View file

@ -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

View file

@ -66,6 +66,7 @@ defineExpose({
:canvas="canvas"
:width="width"
:height="height"
:resolution="renderScale"
:paused="paused"
:premultiplied-alpha="premultipliedAlpha"
:default-mix-duration="defaultMixDuration"

View file

@ -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
})

View file

@ -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) {

View file

@ -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<BroadcastChannelEvents, BroadcastChannelEvents>({
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<string>('settings/spine/current-variant', '')
/** Premultiplied alpha — most modern Spine atlases ship as PMA. */
const premultipliedAlpha = useLocalStorageManualReset<boolean>('settings/spine/premultiplied-alpha', true)
/** Default mix-in/out duration (s) between track animations. */
const defaultMixDuration = useLocalStorageManualReset<number>('settings/spine/default-mix', 0.2)
/** Auto-play idle animation on load. */
const idleAnimationEnabled = useLocalStorageManualReset<boolean>('settings/spine/idle-enabled', true)
/** Animation playback speed multiplier (1.0 = normal). */
const animationSpeed = useLocalStorageManualReset<number>('settings/spine/animation-speed', 1)
/** Maximum FPS for the WebGL render loop (0 = uncapped). */
const maxFps = useLocalStorageManualReset<number>('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<SpineOneShotAnimation>()
/** 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,

View file

@ -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<SupportedControl, ControlConfig> = {
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() {

View file

@ -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 },
})
}

View file

@ -73,9 +73,10 @@ export async function loadSpineModelPreview(file: File): Promise<string | undefi
else
am.loadJson(skeletonAssetPath)
// loadTextureAtlas loads every page image referenced by the atlas
// through the patched downloader, so the texture pages do not need
// to be requested again individually.
am.loadTextureAtlas(atlasAssetPath)
for (const texPath of layout.texturePaths)
am.loadTexture(texPath)
},
initialize: (canvasApp: import('@esotericsoftware/spine-webgl').SpineCanvas) => {
const am = canvasApp.assetManager
@ -89,6 +90,13 @@ export async function loadSpineModelPreview(file: File): Promise<string | undefi
const skeleton = new spine.Skeleton(skeletonData)
skeleton.setToSetupPose()
// Some rigs ship without a default skin; their slot attachments
// only resolve once a skin is active, so setup pose renders empty.
// Fall back to the first skin so the preview isn't blank.
if (!skeletonData.defaultSkin && skeletonData.skins.length > 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<string | undefi
return
const renderer = canvasApp.renderer
renderer.resize(spine.ResizeMode.Fit)
// Expand keeps world units == canvas pixels at zoom 1, giving a
// predictable basis for the bounds-fit math below.
renderer.resize(spine.ResizeMode.Expand)
// Frame the camera to the skeleton's world-space bounding box.
// Most rigs anchor the root at the feet, so the model occupies
// roughly y: 0..height around x: 0. The renderer's default camera
// sits at the origin and would crop everything above the ankles —
// this is the actual cause of the cropped preview. getBounds gives
// the AABB of the currently posed attachments; we centre on it and
// zoom so the whole box fits with a small margin.
const offset = new spine.Vector2()
const size = new spine.Vector2()
skeleton.getBounds(offset, size, [])
const camera = renderer.camera
if (size.x > 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)

View file

@ -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))

View file

@ -103,36 +103,9 @@ function handleSkinSelect(skinName: string | number | undefined) {
size="sm"
:expand="true"
>
<FieldRange v-model="scale" as="div" :min="0.1" :max="3" :step="0.01" :label="t('settings.spine.scale-and-position.scale')">
<template #label>
<div flex items-center>
<div>{{ t('settings.spine.scale-and-position.scale') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="position.x" as="div" :min="-3000" :max="3000" :step="1" :label="t('settings.spine.scale-and-position.x')">
<template #label>
<div flex items-center>
<div>{{ t('settings.spine.scale-and-position.x') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="position.y" as="div" :min="-3000" :max="3000" :step="1" :label="t('settings.spine.scale-and-position.y')">
<template #label>
<div flex items-center>
<div>{{ t('settings.spine.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="scale" as="div" :min="0.1" :max="3" :step="0.01" :default-value="1" :label="t('settings.spine.scale-and-position.scale')" />
<FieldRange v-model="position.x" as="div" :min="-3000" :max="3000" :step="1" :default-value="0" :label="t('settings.spine.scale-and-position.x')" />
<FieldRange v-model="position.y" as="div" :min="-3000" :max="3000" :step="1" :default-value="0" :label="t('settings.spine.scale-and-position.y')" />
</Section>
<Section
@ -154,6 +127,32 @@ function handleSkinSelect(skinName: string | number | undefined) {
</Button>
</Section>
<Section
:title="t('settings.spine.appearance.title')"
icon="i-solar:hanger-2-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<FieldCombobox
v-if="hasMultipleVariants"
:model-value="currentVariant"
:options="variantOptions"
:label="t('settings.spine.variant.title')"
@update:model-value="handleVariantSelect"
/>
<FieldCombobox
:model-value="currentSkin"
:options="skinOptions"
:label="t('settings.spine.skin.title')"
@update:model-value="handleSkinSelect"
/>
</Section>
<Section
:title="t('settings.spine.animation.title')"
icon="i-solar:play-bold-duotone"
@ -163,7 +162,7 @@ function handleSkinSelect(skinName: string | number | undefined) {
'backdrop-blur-lg',
]"
size="sm"
:expand="true"
:expand="false"
>
<FieldCombobox
:model-value="currentAnimation.name"
@ -171,65 +170,8 @@ function handleSkinSelect(skinName: string | number | undefined) {
:label="t('settings.spine.animation.idle-animation')"
@update:model-value="handleAnimationSelect"
/>
<FieldRange v-model="spineDefaultMixDuration" as="div" :min="0" :max="2" :step="0.05" :label="t('settings.spine.animation.mix-duration')">
<template #label>
<div flex items-center>
<div>{{ t('settings.spine.animation.mix-duration') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => spineDefaultMixDuration = 0.2">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="animationSpeed" as="div" :min="0.1" :max="3" :step="0.05" :label="t('settings.spine.animation.speed')">
<template #label>
<div flex items-center>
<div>{{ t('settings.spine.animation.speed') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => animationSpeed = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
<Section
v-if="hasMultipleVariants"
:title="t('settings.spine.variant.title')"
icon="i-solar:layers-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="true"
>
<FieldCombobox
:model-value="currentVariant"
:options="variantOptions"
:label="t('settings.spine.variant.current-variant')"
@update:model-value="handleVariantSelect"
/>
</Section>
<Section
:title="t('settings.spine.skin.title')"
icon="i-solar:brush-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="true"
>
<FieldCombobox
:model-value="currentSkin"
:options="skinOptions"
:label="t('settings.spine.skin.current-skin')"
@update:model-value="handleSkinSelect"
/>
<FieldRange v-model="spineDefaultMixDuration" as="div" :min="0" :max="2" :step="0.05" :default-value="0.2" :label="t('settings.spine.animation.mix-duration')" />
<FieldRange v-model="animationSpeed" as="div" :min="0.1" :max="3" :step="0.05" :default-value="1" :label="t('settings.spine.animation.speed')" />
</Section>
<Section
@ -241,7 +183,7 @@ function handleSkinSelect(skinName: string | number | undefined) {
'backdrop-blur-lg',
]"
size="sm"
:expand="true"
:expand="false"
>
<div :class="['flex', 'items-center', 'justify-between', 'gap-2']">
<div :class="['text-sm', 'font-medium']">
@ -249,15 +191,6 @@ function handleSkinSelect(skinName: string | number | undefined) {
</div>
<SelectTab v-model="spineMaxFps" :options="fpsOptions" size="sm" :class="['shrink-0']" />
</div>
<FieldRange v-model="spineRenderScale" as="div" :min="0.5" :max="3" :step="0.1" :label="t('settings.spine.rendering.render-scale')">
<template #label>
<div flex items-center>
<div>{{ t('settings.spine.rendering.render-scale') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => spineRenderScale = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="spineRenderScale" as="div" :min="0.5" :max="3" :step="0.1" :default-value="1" :label="t('settings.spine.rendering.render-scale')" />
</Section>
</template>

View file

@ -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 `<label>` element.
*/
defaultValue?: number
}>(), {
as: 'label',
})
const modelValue = defineModel<number>({ required: true })
function resetToDefault() {
if (props.defaultValue !== undefined)
modelValue.value = props.defaultValue
}
</script>
<template>
@ -24,6 +35,15 @@ const modelValue = defineModel<number>({ required: true })
<slot name="label">
{{ label }}
</slot>
<button
v-if="defaultValue !== undefined"
type="button"
title="Reset to default"
:class="['px-2', 'text-xs', 'outline-none']"
@click.prevent="resetToDefault"
>
<div :class="['i-solar:forward-linear', 'transform-scale-x--100', 'text-neutral-500', 'dark:text-neutral-400']" />
</button>
</div>
<div :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
<slot name="description">