From 52bdbc9f3c44a78c64a5ba043dd2547ffd19aeec Mon Sep 17 00:00:00 2001 From: tianmo Date: Fri, 18 Sep 2026 17:50:54 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=A2=84=E8=A7=88setup=20pos?= =?UTF-8?q?e=E3=80=81=E4=BF=AE=E5=A4=8D=E6=92=AD=E6=94=BE=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=97=B6=E7=9A=84=E7=94=BB=E5=B8=83=E7=BC=A9=E6=94=BE?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _locales/de_DE.json | 1 + _locales/en.json | 1 + _locales/es_ES.json | 1 + _locales/ja_JP.json | 1 + _locales/ko_KR.json | 1 + _locales/ru_RU.json | 1 + _locales/zh_CN.json | 1 + _locales/zh_TW.json | 1 + .../UNIVERSAL_SpineViewer/_locales/de_DE.json | 1 + .../UNIVERSAL_SpineViewer/_locales/en.json | 1 + .../UNIVERSAL_SpineViewer/_locales/es_ES.json | 1 + .../UNIVERSAL_SpineViewer/_locales/ja_JP.json | 1 + .../UNIVERSAL_SpineViewer/_locales/ko_KR.json | 1 + .../UNIVERSAL_SpineViewer/_locales/ru_RU.json | 1 + .../UNIVERSAL_SpineViewer/_locales/zh_CN.json | 1 + .../UNIVERSAL_SpineViewer/_locales/zh_TW.json | 1 + .../UNIVERSAL_SpineViewer/viewer/spine.html | 73 ++++++++++++++----- tests/setup-pose-zoom.test.js | 44 +++++++++++ viewer/spine.html | 73 ++++++++++++++----- 19 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 tests/setup-pose-zoom.test.js diff --git a/_locales/de_DE.json b/_locales/de_DE.json index a249ad9..6668042 100644 --- a/_locales/de_DE.json +++ b/_locales/de_DE.json @@ -39,6 +39,7 @@ "reloadTitle": "Ordner neu laden", "animationList": "Animationsliste", "animations": "Animationen", + "setupPose": "Setup Pose", "errorTitle": "Vorschau nicht möglich", "durationPlaceholder": "Dauer --", "keyframesLegend": "Blaue Marken: Keyframes", diff --git a/_locales/en.json b/_locales/en.json index 823f1d4..2b57ea1 100644 --- a/_locales/en.json +++ b/_locales/en.json @@ -41,6 +41,7 @@ "reloadTitle": "Reload folder", "animationList": "Animation list", "animations": "Animations", + "setupPose": "Setup Pose", "errorTitle": "Cannot preview", "durationPlaceholder": "Duration --", "keyframesLegend": "Blue ticks: keyframes", diff --git a/_locales/es_ES.json b/_locales/es_ES.json index 37d57a5..ef1444f 100644 --- a/_locales/es_ES.json +++ b/_locales/es_ES.json @@ -39,6 +39,7 @@ "reloadTitle": "Recargar carpeta", "animationList": "Lista de animaciones", "animations": "Animaciones", + "setupPose": "Setup Pose", "errorTitle": "No se puede previsualizar", "durationPlaceholder": "Duración --", "keyframesLegend": "Marcas azules: fotogramas clave", diff --git a/_locales/ja_JP.json b/_locales/ja_JP.json index e9d11e1..1990da3 100644 --- a/_locales/ja_JP.json +++ b/_locales/ja_JP.json @@ -39,6 +39,7 @@ "reloadTitle": "フォルダーを再読み込み", "animationList": "アニメーション一覧", "animations": "アニメーション", + "setupPose": "Setup Pose", "errorTitle": "プレビューできません", "durationPlaceholder": "長さ --", "keyframesLegend": "青い線:キーフレーム", diff --git a/_locales/ko_KR.json b/_locales/ko_KR.json index 0c6ea1d..1b5ef3c 100644 --- a/_locales/ko_KR.json +++ b/_locales/ko_KR.json @@ -39,6 +39,7 @@ "reloadTitle": "폴더 다시 불러오기", "animationList": "애니메이션 목록", "animations": "애니메이션", + "setupPose": "Setup Pose", "errorTitle": "미리볼 수 없음", "durationPlaceholder": "길이 --", "keyframesLegend": "파란 선: 키프레임", diff --git a/_locales/ru_RU.json b/_locales/ru_RU.json index f0ceafc..7060822 100644 --- a/_locales/ru_RU.json +++ b/_locales/ru_RU.json @@ -39,6 +39,7 @@ "reloadTitle": "Перезагрузить папку", "animationList": "Список анимаций", "animations": "Анимации", + "setupPose": "Setup Pose", "errorTitle": "Просмотр невозможен", "durationPlaceholder": "Длительность --", "keyframesLegend": "Синие метки: ключевые кадры", diff --git a/_locales/zh_CN.json b/_locales/zh_CN.json index 469b367..fac532b 100644 --- a/_locales/zh_CN.json +++ b/_locales/zh_CN.json @@ -41,6 +41,7 @@ "reloadTitle": "重新加载目录", "animationList": "动画列表", "animations": "动画", + "setupPose": "Setup Pose", "errorTitle": "无法预览", "durationPlaceholder": "时长 --", "keyframesLegend": "蓝色短线:关键帧", diff --git a/_locales/zh_TW.json b/_locales/zh_TW.json index 7155422..20392a9 100644 --- a/_locales/zh_TW.json +++ b/_locales/zh_TW.json @@ -41,6 +41,7 @@ "reloadTitle": "重新載入目錄", "animationList": "動畫清單", "animations": "動畫", + "setupPose": "Setup Pose", "errorTitle": "無法預覽", "durationPlaceholder": "時長 --", "keyframesLegend": "藍色短線:關鍵影格", diff --git a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json index a249ad9..6668042 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json +++ b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json @@ -39,6 +39,7 @@ "reloadTitle": "Ordner neu laden", "animationList": "Animationsliste", "animations": "Animationen", + "setupPose": "Setup Pose", "errorTitle": "Vorschau nicht möglich", "durationPlaceholder": "Dauer --", "keyframesLegend": "Blaue Marken: Keyframes", diff --git a/release/UNIVERSAL_SpineViewer/_locales/en.json b/release/UNIVERSAL_SpineViewer/_locales/en.json index 823f1d4..2b57ea1 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/en.json +++ b/release/UNIVERSAL_SpineViewer/_locales/en.json @@ -41,6 +41,7 @@ "reloadTitle": "Reload folder", "animationList": "Animation list", "animations": "Animations", + "setupPose": "Setup Pose", "errorTitle": "Cannot preview", "durationPlaceholder": "Duration --", "keyframesLegend": "Blue ticks: keyframes", diff --git a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json index 37d57a5..ef1444f 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json +++ b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json @@ -39,6 +39,7 @@ "reloadTitle": "Recargar carpeta", "animationList": "Lista de animaciones", "animations": "Animaciones", + "setupPose": "Setup Pose", "errorTitle": "No se puede previsualizar", "durationPlaceholder": "Duración --", "keyframesLegend": "Marcas azules: fotogramas clave", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json index e9d11e1..1990da3 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json @@ -39,6 +39,7 @@ "reloadTitle": "フォルダーを再読み込み", "animationList": "アニメーション一覧", "animations": "アニメーション", + "setupPose": "Setup Pose", "errorTitle": "プレビューできません", "durationPlaceholder": "長さ --", "keyframesLegend": "青い線:キーフレーム", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json index 0c6ea1d..1b5ef3c 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json @@ -39,6 +39,7 @@ "reloadTitle": "폴더 다시 불러오기", "animationList": "애니메이션 목록", "animations": "애니메이션", + "setupPose": "Setup Pose", "errorTitle": "미리볼 수 없음", "durationPlaceholder": "길이 --", "keyframesLegend": "파란 선: 키프레임", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json index f0ceafc..7060822 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json @@ -39,6 +39,7 @@ "reloadTitle": "Перезагрузить папку", "animationList": "Список анимаций", "animations": "Анимации", + "setupPose": "Setup Pose", "errorTitle": "Просмотр невозможен", "durationPlaceholder": "Длительность --", "keyframesLegend": "Синие метки: ключевые кадры", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json index 469b367..fac532b 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json @@ -41,6 +41,7 @@ "reloadTitle": "重新加载目录", "animationList": "动画列表", "animations": "动画", + "setupPose": "Setup Pose", "errorTitle": "无法预览", "durationPlaceholder": "时长 --", "keyframesLegend": "蓝色短线:关键帧", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json index 7155422..20392a9 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json @@ -41,6 +41,7 @@ "reloadTitle": "重新載入目錄", "animationList": "動畫清單", "animations": "動畫", + "setupPose": "Setup Pose", "errorTitle": "無法預覽", "durationPlaceholder": "時長 --", "keyframesLegend": "藍色短線:關鍵影格", diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.html b/release/UNIVERSAL_SpineViewer/viewer/spine.html index 383b758..934033a 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -250,8 +250,9 @@ let renderer = null; let activeRuntimeDir = null; let activeVersion = null; - let currentAnim = 0, currentSkin = null; - let playing = true, loop = true, speed = 1; + const SETUP_POSE_INDEX = -1; + let currentAnim = SETUP_POSE_INDEX, currentSkin = null; + let playing = false, loop = true, speed = 1; let premultipliedAlpha = false; let previewSettings = null; let timeKeeper = null, lastTime = 0, running = false; @@ -265,6 +266,7 @@ // 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心) let panX = 0, panY = 0, zoom = 1; let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0; + let stableFitBounds = null; let dragging = false; // 是否正在拖拽 let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0; @@ -460,8 +462,17 @@ // baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。 // fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。 let fitScale = 1, fitCx = 0, fitCy = 0; - function fitToCanvas() { + function captureStableFitBounds() { const bounds = getBounds(skeleton); + stableFitBounds = bounds && bounds.width > 0 && bounds.height > 0 + ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } + : null; + } + + function fitToCanvas() { + // 自动适配始终使用加载完成时的 Setup Pose 包围盒。动画播放会改变当前帧 + // 的包围盒,但不应改变用户滚轮缩放所依赖的基准比例。 + const bounds = stableFitBounds; const cw = canvas.clientWidth, ch = canvas.clientHeight; if (!bounds || bounds.width <= 0 || bounds.height <= 0) { baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return; @@ -981,16 +992,25 @@ function fillAnimations() { animationList.innerHTML = ''; const animations = skeletonData.animations || []; - animationPanel.style.display = animations.length ? 'flex' : 'none'; + animationPanel.style.display = 'flex'; + const setupItem = document.createElement('button'); + setupItem.type = 'button'; + setupItem.className = 'animation-item active'; + setupItem.dataset.index = String(SETUP_POSE_INDEX); + setupItem.textContent = t('viewer.setupPose'); + setupItem.title = t('viewer.setupPose'); + setupItem.setAttribute('role', 'option'); + setupItem.setAttribute('aria-selected', 'true'); + animationList.appendChild(setupItem); animations.forEach((a, i) => { const item = document.createElement('button'); item.type = 'button'; - item.className = 'animation-item' + (i === 0 ? ' active' : ''); + item.className = 'animation-item'; item.dataset.index = String(i); item.textContent = a.name; item.title = a.name; item.setAttribute('role', 'option'); - item.setAttribute('aria-selected', i === 0 ? 'true' : 'false'); + item.setAttribute('aria-selected', 'false'); animationList.appendChild(item); }); } @@ -1026,6 +1046,7 @@ const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim); if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex); state.setAnimation(0, name, loop); + playing = true; updateTimelineDuration(anim); state.update(0); state.apply(skeleton); @@ -1033,6 +1054,23 @@ draw(); } + function showSetupPose() { + if (!state || !skeleton) return; + selectAnimationItem(SETUP_POSE_INDEX); + playing = false; + if (typeof state.clearTracks === 'function') state.clearTracks(); + else if (typeof state.clearTrack === 'function') state.clearTrack(0); + if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose(); + else { + if (typeof skeleton.setBonesToSetupPose === 'function') skeleton.setBonesToSetupPose(); + if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose(); + } + updateWorld(skeleton); + updateTimelineDuration(null, true); + setStatus(t('viewer.setupPose')); + draw(); + } + // 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组) function findAnimByName(name) { const arr = (skeletonData && skeletonData.animations) || []; @@ -1043,9 +1081,9 @@ } // 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记 - function updateTimelineDuration(anim) { + function updateTimelineDuration(anim, forceStatic = false) { duration = animationDuration(anim); - isStaticAnimation = !!anim && duration <= STATIC_EPSILON; + isStaticAnimation = forceStatic || (!!anim && duration <= STATIC_EPSILON); timeline.max = String(isStaticAnimation ? 1 : Math.max(duration, 0.001)); timeline.step = String(isStaticAnimation ? 1 : Math.min(1 / FPS, Math.max(duration / 1000, 0.001))); timeline.value = '0'; @@ -1287,18 +1325,9 @@ fillAnimations(); fillSkins(currentSkin); - // 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。 - if (skeletonData.animations.length > 0) { - const defaultAnim = skeletonData.animations[0]; - state.setAnimation(0, defaultAnim.name, loop); - const idx = skeletonData.animations.indexOf(defaultAnim); - if (idx >= 0) selectAnimationItem(idx); - updateTimelineDuration(defaultAnim); - } else { - updateTimelineDuration(null); - } - - updateWorld(skeleton); + // 默认显示 Setup Pose 静帧,由用户从列表中主动选择动画。 + showSetupPose(); + captureStableFitBounds(); fitToCanvas(); draw(); @@ -1330,6 +1359,10 @@ const item = e.target.closest('.animation-item'); if (!item) return; const idx = parseInt(item.dataset.index, 10); + if (idx === SETUP_POSE_INDEX) { + showSetupPose(); + return; + } const anims = skeletonData.animations; if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx); }); diff --git a/tests/setup-pose-zoom.test.js b/tests/setup-pose-zoom.test.js new file mode 100644 index 0000000..b62b913 --- /dev/null +++ b/tests/setup-pose-zoom.test.js @@ -0,0 +1,44 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const roots = ['.', 'release/UNIVERSAL_SpineViewer']; + +function functionBody(source, name, nextName) { + const start = source.indexOf(`\tfunction ${name}(`); + const end = source.indexOf(`\n\tfunction ${nextName}(`, start + 1); + assert.ok(start >= 0 && end > start, `missing function ${name}`); + return source.slice(start, end); +} + +for (const root of roots) { + const viewerPath = path.join(root, 'viewer/spine.html'); + const html = fs.readFileSync(viewerPath, 'utf8'); + const script = html.match(/