默认预览setup pose、修复播放动画时的画布缩放问题
This commit is contained in:
1 parent
a4e86b37fc
commit
52bdbc9f3c
19 files changed
+166
-40
No files matched your search
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "Ordner neu laden",
|
"reloadTitle": "Ordner neu laden",
|
||||||
"animationList": "Animationsliste",
|
"animationList": "Animationsliste",
|
||||||
"animations": "Animationen",
|
"animations": "Animationen",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "Vorschau nicht möglich",
|
"errorTitle": "Vorschau nicht möglich",
|
||||||
"durationPlaceholder": "Dauer --",
|
"durationPlaceholder": "Dauer --",
|
||||||
"keyframesLegend": "Blaue Marken: Keyframes",
|
"keyframesLegend": "Blaue Marken: Keyframes",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"reloadTitle": "Reload folder",
|
"reloadTitle": "Reload folder",
|
||||||
"animationList": "Animation list",
|
"animationList": "Animation list",
|
||||||
"animations": "Animations",
|
"animations": "Animations",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "Cannot preview",
|
"errorTitle": "Cannot preview",
|
||||||
"durationPlaceholder": "Duration --",
|
"durationPlaceholder": "Duration --",
|
||||||
"keyframesLegend": "Blue ticks: keyframes",
|
"keyframesLegend": "Blue ticks: keyframes",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "Recargar carpeta",
|
"reloadTitle": "Recargar carpeta",
|
||||||
"animationList": "Lista de animaciones",
|
"animationList": "Lista de animaciones",
|
||||||
"animations": "Animaciones",
|
"animations": "Animaciones",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "No se puede previsualizar",
|
"errorTitle": "No se puede previsualizar",
|
||||||
"durationPlaceholder": "Duración --",
|
"durationPlaceholder": "Duración --",
|
||||||
"keyframesLegend": "Marcas azules: fotogramas clave",
|
"keyframesLegend": "Marcas azules: fotogramas clave",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "フォルダーを再読み込み",
|
"reloadTitle": "フォルダーを再読み込み",
|
||||||
"animationList": "アニメーション一覧",
|
"animationList": "アニメーション一覧",
|
||||||
"animations": "アニメーション",
|
"animations": "アニメーション",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "プレビューできません",
|
"errorTitle": "プレビューできません",
|
||||||
"durationPlaceholder": "長さ --",
|
"durationPlaceholder": "長さ --",
|
||||||
"keyframesLegend": "青い線:キーフレーム",
|
"keyframesLegend": "青い線:キーフレーム",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "폴더 다시 불러오기",
|
"reloadTitle": "폴더 다시 불러오기",
|
||||||
"animationList": "애니메이션 목록",
|
"animationList": "애니메이션 목록",
|
||||||
"animations": "애니메이션",
|
"animations": "애니메이션",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "미리볼 수 없음",
|
"errorTitle": "미리볼 수 없음",
|
||||||
"durationPlaceholder": "길이 --",
|
"durationPlaceholder": "길이 --",
|
||||||
"keyframesLegend": "파란 선: 키프레임",
|
"keyframesLegend": "파란 선: 키프레임",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "Перезагрузить папку",
|
"reloadTitle": "Перезагрузить папку",
|
||||||
"animationList": "Список анимаций",
|
"animationList": "Список анимаций",
|
||||||
"animations": "Анимации",
|
"animations": "Анимации",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "Просмотр невозможен",
|
"errorTitle": "Просмотр невозможен",
|
||||||
"durationPlaceholder": "Длительность --",
|
"durationPlaceholder": "Длительность --",
|
||||||
"keyframesLegend": "Синие метки: ключевые кадры",
|
"keyframesLegend": "Синие метки: ключевые кадры",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"reloadTitle": "重新加载目录",
|
"reloadTitle": "重新加载目录",
|
||||||
"animationList": "动画列表",
|
"animationList": "动画列表",
|
||||||
"animations": "动画",
|
"animations": "动画",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "无法预览",
|
"errorTitle": "无法预览",
|
||||||
"durationPlaceholder": "时长 --",
|
"durationPlaceholder": "时长 --",
|
||||||
"keyframesLegend": "蓝色短线:关键帧",
|
"keyframesLegend": "蓝色短线:关键帧",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"reloadTitle": "重新載入目錄",
|
"reloadTitle": "重新載入目錄",
|
||||||
"animationList": "動畫清單",
|
"animationList": "動畫清單",
|
||||||
"animations": "動畫",
|
"animations": "動畫",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "無法預覽",
|
"errorTitle": "無法預覽",
|
||||||
"durationPlaceholder": "時長 --",
|
"durationPlaceholder": "時長 --",
|
||||||
"keyframesLegend": "藍色短線:關鍵影格",
|
"keyframesLegend": "藍色短線:關鍵影格",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "Ordner neu laden",
|
"reloadTitle": "Ordner neu laden",
|
||||||
"animationList": "Animationsliste",
|
"animationList": "Animationsliste",
|
||||||
"animations": "Animationen",
|
"animations": "Animationen",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "Vorschau nicht möglich",
|
"errorTitle": "Vorschau nicht möglich",
|
||||||
"durationPlaceholder": "Dauer --",
|
"durationPlaceholder": "Dauer --",
|
||||||
"keyframesLegend": "Blaue Marken: Keyframes",
|
"keyframesLegend": "Blaue Marken: Keyframes",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"reloadTitle": "Reload folder",
|
"reloadTitle": "Reload folder",
|
||||||
"animationList": "Animation list",
|
"animationList": "Animation list",
|
||||||
"animations": "Animations",
|
"animations": "Animations",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "Cannot preview",
|
"errorTitle": "Cannot preview",
|
||||||
"durationPlaceholder": "Duration --",
|
"durationPlaceholder": "Duration --",
|
||||||
"keyframesLegend": "Blue ticks: keyframes",
|
"keyframesLegend": "Blue ticks: keyframes",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "Recargar carpeta",
|
"reloadTitle": "Recargar carpeta",
|
||||||
"animationList": "Lista de animaciones",
|
"animationList": "Lista de animaciones",
|
||||||
"animations": "Animaciones",
|
"animations": "Animaciones",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "No se puede previsualizar",
|
"errorTitle": "No se puede previsualizar",
|
||||||
"durationPlaceholder": "Duración --",
|
"durationPlaceholder": "Duración --",
|
||||||
"keyframesLegend": "Marcas azules: fotogramas clave",
|
"keyframesLegend": "Marcas azules: fotogramas clave",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "フォルダーを再読み込み",
|
"reloadTitle": "フォルダーを再読み込み",
|
||||||
"animationList": "アニメーション一覧",
|
"animationList": "アニメーション一覧",
|
||||||
"animations": "アニメーション",
|
"animations": "アニメーション",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "プレビューできません",
|
"errorTitle": "プレビューできません",
|
||||||
"durationPlaceholder": "長さ --",
|
"durationPlaceholder": "長さ --",
|
||||||
"keyframesLegend": "青い線:キーフレーム",
|
"keyframesLegend": "青い線:キーフレーム",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "폴더 다시 불러오기",
|
"reloadTitle": "폴더 다시 불러오기",
|
||||||
"animationList": "애니메이션 목록",
|
"animationList": "애니메이션 목록",
|
||||||
"animations": "애니메이션",
|
"animations": "애니메이션",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "미리볼 수 없음",
|
"errorTitle": "미리볼 수 없음",
|
||||||
"durationPlaceholder": "길이 --",
|
"durationPlaceholder": "길이 --",
|
||||||
"keyframesLegend": "파란 선: 키프레임",
|
"keyframesLegend": "파란 선: 키프레임",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"reloadTitle": "Перезагрузить папку",
|
"reloadTitle": "Перезагрузить папку",
|
||||||
"animationList": "Список анимаций",
|
"animationList": "Список анимаций",
|
||||||
"animations": "Анимации",
|
"animations": "Анимации",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "Просмотр невозможен",
|
"errorTitle": "Просмотр невозможен",
|
||||||
"durationPlaceholder": "Длительность --",
|
"durationPlaceholder": "Длительность --",
|
||||||
"keyframesLegend": "Синие метки: ключевые кадры",
|
"keyframesLegend": "Синие метки: ключевые кадры",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"reloadTitle": "重新加载目录",
|
"reloadTitle": "重新加载目录",
|
||||||
"animationList": "动画列表",
|
"animationList": "动画列表",
|
||||||
"animations": "动画",
|
"animations": "动画",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "无法预览",
|
"errorTitle": "无法预览",
|
||||||
"durationPlaceholder": "时长 --",
|
"durationPlaceholder": "时长 --",
|
||||||
"keyframesLegend": "蓝色短线:关键帧",
|
"keyframesLegend": "蓝色短线:关键帧",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"reloadTitle": "重新載入目錄",
|
"reloadTitle": "重新載入目錄",
|
||||||
"animationList": "動畫清單",
|
"animationList": "動畫清單",
|
||||||
"animations": "動畫",
|
"animations": "動畫",
|
||||||
|
"setupPose": "Setup Pose",
|
||||||
"errorTitle": "無法預覽",
|
"errorTitle": "無法預覽",
|
||||||
"durationPlaceholder": "時長 --",
|
"durationPlaceholder": "時長 --",
|
||||||
"keyframesLegend": "藍色短線:關鍵影格",
|
"keyframesLegend": "藍色短線:關鍵影格",
|
||||||
|
|||||||
@@ -250,8 +250,9 @@
|
|||||||
let renderer = null;
|
let renderer = null;
|
||||||
let activeRuntimeDir = null;
|
let activeRuntimeDir = null;
|
||||||
let activeVersion = null;
|
let activeVersion = null;
|
||||||
let currentAnim = 0, currentSkin = null;
|
const SETUP_POSE_INDEX = -1;
|
||||||
let playing = true, loop = true, speed = 1;
|
let currentAnim = SETUP_POSE_INDEX, currentSkin = null;
|
||||||
|
let playing = false, loop = true, speed = 1;
|
||||||
let premultipliedAlpha = false;
|
let premultipliedAlpha = false;
|
||||||
let previewSettings = null;
|
let previewSettings = null;
|
||||||
let timeKeeper = null, lastTime = 0, running = false;
|
let timeKeeper = null, lastTime = 0, running = false;
|
||||||
@@ -265,6 +266,7 @@
|
|||||||
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
|
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
|
||||||
let panX = 0, panY = 0, zoom = 1;
|
let panX = 0, panY = 0, zoom = 1;
|
||||||
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
|
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
|
||||||
|
let stableFitBounds = null;
|
||||||
let dragging = false; // 是否正在拖拽
|
let dragging = false; // 是否正在拖拽
|
||||||
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
|
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
|
||||||
|
|
||||||
@@ -460,8 +462,17 @@
|
|||||||
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
|
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
|
||||||
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
|
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
|
||||||
let fitScale = 1, fitCx = 0, fitCy = 0;
|
let fitScale = 1, fitCx = 0, fitCy = 0;
|
||||||
function fitToCanvas() {
|
function captureStableFitBounds() {
|
||||||
const bounds = getBounds(skeleton);
|
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;
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||||
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return;
|
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return;
|
||||||
@@ -981,16 +992,25 @@
|
|||||||
function fillAnimations() {
|
function fillAnimations() {
|
||||||
animationList.innerHTML = '';
|
animationList.innerHTML = '';
|
||||||
const animations = skeletonData.animations || [];
|
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) => {
|
animations.forEach((a, i) => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.type = 'button';
|
item.type = 'button';
|
||||||
item.className = 'animation-item' + (i === 0 ? ' active' : '');
|
item.className = 'animation-item';
|
||||||
item.dataset.index = String(i);
|
item.dataset.index = String(i);
|
||||||
item.textContent = a.name;
|
item.textContent = a.name;
|
||||||
item.title = a.name;
|
item.title = a.name;
|
||||||
item.setAttribute('role', 'option');
|
item.setAttribute('role', 'option');
|
||||||
item.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
|
item.setAttribute('aria-selected', 'false');
|
||||||
animationList.appendChild(item);
|
animationList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1046,7 @@
|
|||||||
const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim);
|
const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim);
|
||||||
if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex);
|
if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex);
|
||||||
state.setAnimation(0, name, loop);
|
state.setAnimation(0, name, loop);
|
||||||
|
playing = true;
|
||||||
updateTimelineDuration(anim);
|
updateTimelineDuration(anim);
|
||||||
state.update(0);
|
state.update(0);
|
||||||
state.apply(skeleton);
|
state.apply(skeleton);
|
||||||
@@ -1033,6 +1054,23 @@
|
|||||||
draw();
|
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 数组)
|
// 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组)
|
||||||
function findAnimByName(name) {
|
function findAnimByName(name) {
|
||||||
const arr = (skeletonData && skeletonData.animations) || [];
|
const arr = (skeletonData && skeletonData.animations) || [];
|
||||||
@@ -1043,9 +1081,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
|
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
|
||||||
function updateTimelineDuration(anim) {
|
function updateTimelineDuration(anim, forceStatic = false) {
|
||||||
duration = animationDuration(anim);
|
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.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.step = String(isStaticAnimation ? 1 : Math.min(1 / FPS, Math.max(duration / 1000, 0.001)));
|
||||||
timeline.value = '0';
|
timeline.value = '0';
|
||||||
@@ -1287,18 +1325,9 @@
|
|||||||
fillAnimations();
|
fillAnimations();
|
||||||
fillSkins(currentSkin);
|
fillSkins(currentSkin);
|
||||||
|
|
||||||
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
|
// 默认显示 Setup Pose 静帧,由用户从列表中主动选择动画。
|
||||||
if (skeletonData.animations.length > 0) {
|
showSetupPose();
|
||||||
const defaultAnim = skeletonData.animations[0];
|
captureStableFitBounds();
|
||||||
state.setAnimation(0, defaultAnim.name, loop);
|
|
||||||
const idx = skeletonData.animations.indexOf(defaultAnim);
|
|
||||||
if (idx >= 0) selectAnimationItem(idx);
|
|
||||||
updateTimelineDuration(defaultAnim);
|
|
||||||
} else {
|
|
||||||
updateTimelineDuration(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWorld(skeleton);
|
|
||||||
fitToCanvas();
|
fitToCanvas();
|
||||||
draw();
|
draw();
|
||||||
|
|
||||||
@@ -1330,6 +1359,10 @@
|
|||||||
const item = e.target.closest('.animation-item');
|
const item = e.target.closest('.animation-item');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const idx = parseInt(item.dataset.index, 10);
|
const idx = parseInt(item.dataset.index, 10);
|
||||||
|
if (idx === SETUP_POSE_INDEX) {
|
||||||
|
showSetupPose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const anims = skeletonData.animations;
|
const anims = skeletonData.animations;
|
||||||
if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx);
|
if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(/<script>([\s\S]*?)<\/script>/)[1];
|
||||||
|
new vm.Script(script);
|
||||||
|
|
||||||
|
assert.match(script, /const SETUP_POSE_INDEX = -1;/);
|
||||||
|
assert.match(script, /let currentAnim = SETUP_POSE_INDEX[\s\S]*let playing = false/);
|
||||||
|
assert.match(script, /setupItem\.dataset\.index = String\(SETUP_POSE_INDEX\)/);
|
||||||
|
assert.match(script, /showSetupPose\(\);\s*captureStableFitBounds\(\);\s*fitToCanvas\(\);/);
|
||||||
|
assert.match(script, /state\.clearTracks[\s\S]*skeleton\.setToSetupPose/);
|
||||||
|
assert.match(script, /if \(idx === SETUP_POSE_INDEX\)[\s\S]*showSetupPose\(\)/);
|
||||||
|
|
||||||
|
const fit = functionBody(script, 'fitToCanvas', 'draw');
|
||||||
|
assert.match(fit, /const bounds = stableFitBounds;/);
|
||||||
|
assert.doesNotMatch(fit, /getBounds\(/, 'wheel/resize fitting must not sample an animated frame');
|
||||||
|
|
||||||
|
const capture = functionBody(script, 'captureStableFitBounds', 'fitToCanvas');
|
||||||
|
assert.match(capture, /getBounds\(skeleton\)/);
|
||||||
|
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'));
|
||||||
|
for (const language of manifest.languages) {
|
||||||
|
const locale = JSON.parse(fs.readFileSync(path.join(root, '_locales', language + '.json'), 'utf8'));
|
||||||
|
assert.equal(locale.viewer.setupPose, 'Setup Pose', `${root}/${language} must keep the Spine term untranslated`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('PASS: Setup Pose is the default still frame and zoom uses stable bounds');
|
||||||
+53
-20
@@ -250,8 +250,9 @@
|
|||||||
let renderer = null;
|
let renderer = null;
|
||||||
let activeRuntimeDir = null;
|
let activeRuntimeDir = null;
|
||||||
let activeVersion = null;
|
let activeVersion = null;
|
||||||
let currentAnim = 0, currentSkin = null;
|
const SETUP_POSE_INDEX = -1;
|
||||||
let playing = true, loop = true, speed = 1;
|
let currentAnim = SETUP_POSE_INDEX, currentSkin = null;
|
||||||
|
let playing = false, loop = true, speed = 1;
|
||||||
let premultipliedAlpha = false;
|
let premultipliedAlpha = false;
|
||||||
let previewSettings = null;
|
let previewSettings = null;
|
||||||
let timeKeeper = null, lastTime = 0, running = false;
|
let timeKeeper = null, lastTime = 0, running = false;
|
||||||
@@ -265,6 +266,7 @@
|
|||||||
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
|
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
|
||||||
let panX = 0, panY = 0, zoom = 1;
|
let panX = 0, panY = 0, zoom = 1;
|
||||||
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
|
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
|
||||||
|
let stableFitBounds = null;
|
||||||
let dragging = false; // 是否正在拖拽
|
let dragging = false; // 是否正在拖拽
|
||||||
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
|
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
|
||||||
|
|
||||||
@@ -460,8 +462,17 @@
|
|||||||
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
|
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
|
||||||
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
|
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
|
||||||
let fitScale = 1, fitCx = 0, fitCy = 0;
|
let fitScale = 1, fitCx = 0, fitCy = 0;
|
||||||
function fitToCanvas() {
|
function captureStableFitBounds() {
|
||||||
const bounds = getBounds(skeleton);
|
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;
|
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||||
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return;
|
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return;
|
||||||
@@ -981,16 +992,25 @@
|
|||||||
function fillAnimations() {
|
function fillAnimations() {
|
||||||
animationList.innerHTML = '';
|
animationList.innerHTML = '';
|
||||||
const animations = skeletonData.animations || [];
|
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) => {
|
animations.forEach((a, i) => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.type = 'button';
|
item.type = 'button';
|
||||||
item.className = 'animation-item' + (i === 0 ? ' active' : '');
|
item.className = 'animation-item';
|
||||||
item.dataset.index = String(i);
|
item.dataset.index = String(i);
|
||||||
item.textContent = a.name;
|
item.textContent = a.name;
|
||||||
item.title = a.name;
|
item.title = a.name;
|
||||||
item.setAttribute('role', 'option');
|
item.setAttribute('role', 'option');
|
||||||
item.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
|
item.setAttribute('aria-selected', 'false');
|
||||||
animationList.appendChild(item);
|
animationList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1046,7 @@
|
|||||||
const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim);
|
const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim);
|
||||||
if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex);
|
if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex);
|
||||||
state.setAnimation(0, name, loop);
|
state.setAnimation(0, name, loop);
|
||||||
|
playing = true;
|
||||||
updateTimelineDuration(anim);
|
updateTimelineDuration(anim);
|
||||||
state.update(0);
|
state.update(0);
|
||||||
state.apply(skeleton);
|
state.apply(skeleton);
|
||||||
@@ -1033,6 +1054,23 @@
|
|||||||
draw();
|
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 数组)
|
// 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组)
|
||||||
function findAnimByName(name) {
|
function findAnimByName(name) {
|
||||||
const arr = (skeletonData && skeletonData.animations) || [];
|
const arr = (skeletonData && skeletonData.animations) || [];
|
||||||
@@ -1043,9 +1081,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
|
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
|
||||||
function updateTimelineDuration(anim) {
|
function updateTimelineDuration(anim, forceStatic = false) {
|
||||||
duration = animationDuration(anim);
|
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.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.step = String(isStaticAnimation ? 1 : Math.min(1 / FPS, Math.max(duration / 1000, 0.001)));
|
||||||
timeline.value = '0';
|
timeline.value = '0';
|
||||||
@@ -1287,18 +1325,9 @@
|
|||||||
fillAnimations();
|
fillAnimations();
|
||||||
fillSkins(currentSkin);
|
fillSkins(currentSkin);
|
||||||
|
|
||||||
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
|
// 默认显示 Setup Pose 静帧,由用户从列表中主动选择动画。
|
||||||
if (skeletonData.animations.length > 0) {
|
showSetupPose();
|
||||||
const defaultAnim = skeletonData.animations[0];
|
captureStableFitBounds();
|
||||||
state.setAnimation(0, defaultAnim.name, loop);
|
|
||||||
const idx = skeletonData.animations.indexOf(defaultAnim);
|
|
||||||
if (idx >= 0) selectAnimationItem(idx);
|
|
||||||
updateTimelineDuration(defaultAnim);
|
|
||||||
} else {
|
|
||||||
updateTimelineDuration(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWorld(skeleton);
|
|
||||||
fitToCanvas();
|
fitToCanvas();
|
||||||
draw();
|
draw();
|
||||||
|
|
||||||
@@ -1330,6 +1359,10 @@
|
|||||||
const item = e.target.closest('.animation-item');
|
const item = e.target.closest('.animation-item');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const idx = parseInt(item.dataset.index, 10);
|
const idx = parseInt(item.dataset.index, 10);
|
||||||
|
if (idx === SETUP_POSE_INDEX) {
|
||||||
|
showSetupPose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const anims = skeletonData.animations;
|
const anims = skeletonData.animations;
|
||||||
if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx);
|
if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user