mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-09 15:58:27 +00:00
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
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:
parent
92178b9598
commit
911ebb283b
20 changed files with 320 additions and 186 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: Активный вариант
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ defineExpose({
|
|||
:canvas="canvas"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:resolution="renderScale"
|
||||
:paused="paused"
|
||||
:premultiplied-alpha="premultipliedAlpha"
|
||||
:default-mix-duration="defaultMixDuration"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue