插件优化
This commit is contained in:
1 parent
91815a355b
commit
7263a841cb
2 files changed
+298
-104
No files matched your search
@@ -53,7 +53,10 @@ SpineViewer/
|
||||
- 播放 / 暂停(空格)
|
||||
- 循环开关
|
||||
- 播放速度调节
|
||||
- 时间轴拖动、动画时长、秒刻度与关键帧标记
|
||||
- 零时长动画按单帧静态姿势预览
|
||||
- 自动适配并居中
|
||||
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
|
||||
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧生成 PNG。若该依赖不可用,会自动回退为一条纯 JS 生成的占位 PNG。
|
||||
|
||||
---
|
||||
|
||||
+295
-104
@@ -58,22 +58,48 @@
|
||||
font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||||
}
|
||||
#timelineBar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 12px; background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
#timelineLabels {
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
align-self: stretch; min-width: 136px; padding: 8px 0 1px;
|
||||
}
|
||||
#timelineBar .tl-label { color: var(--text-dim); font-size: 12px; white-space: nowrap; }
|
||||
#timelineBar .tl-wrap { position: relative; flex: 1; padding: 8px 0; }
|
||||
#keyframeLegend {
|
||||
display: flex; align-items: center; gap: 6px; color: var(--text-dim);
|
||||
font-size: 11px; white-space: nowrap; cursor: help;
|
||||
}
|
||||
#keyframeLegend::before {
|
||||
content: ''; width: 10px; height: 7px; border-radius: 1px;
|
||||
background: var(--accent); opacity: .7;
|
||||
}
|
||||
#timelineBar .tl-wrap { position: relative; flex: 1; padding-top: 20px; }
|
||||
#timelineBar input[type="range"] {
|
||||
display: block; width: 100%; margin: 0; accent-color: var(--accent);
|
||||
}
|
||||
#timeRuler { position: absolute; top: 0; left: 7px; right: 7px; height: 19px; }
|
||||
#timeRuler .tick {
|
||||
position: absolute; bottom: 0; width: 1px; height: 5px;
|
||||
background: var(--border); transform: translateX(-.5px);
|
||||
}
|
||||
#timeRuler .tick.major { height: 9px; background: var(--text-dim); }
|
||||
#timeRuler .tick-label {
|
||||
position: absolute; bottom: 8px; color: var(--text-dim); font-size: 10px;
|
||||
font-variant-numeric: tabular-nums; transform: translateX(-50%); white-space: nowrap;
|
||||
}
|
||||
#timeRuler .tick-label.first { transform: none; }
|
||||
#timeRuler .tick-label.last { transform: translateX(-100%); }
|
||||
#keyframes { position: relative; height: 8px; margin-top: 2px; }
|
||||
#keyframes span {
|
||||
position: absolute; top: 0; width: 2px; height: 8px;
|
||||
background: var(--accent); opacity: .55; transform: translateX(-1px);
|
||||
position: absolute; top: 0; width: 3px; height: 8px;
|
||||
background: var(--accent); opacity: .62; transform: translateX(-1.5px);
|
||||
cursor: help; transition: opacity .12s, transform .12s;
|
||||
}
|
||||
#keyframes span:hover { opacity: 1; transform: translateX(-1.5px) scaleY(1.35); }
|
||||
#timeLabel {
|
||||
color: var(--text-dim); font-size: 12px; min-width: 110px;
|
||||
color: var(--text-dim); font-size: 12px; min-width: 178px;
|
||||
text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||
}
|
||||
#overlay {
|
||||
@@ -127,12 +153,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="timelineBar">
|
||||
<span class="tl-label">帧</span>
|
||||
<div id="timelineLabels">
|
||||
<span class="tl-label" id="durationLabel">时长 --</span>
|
||||
<span id="keyframeLegend" title="蓝色短线表示动画中至少一个骨骼、插槽或约束属性在该时刻有关键帧">蓝色短线:关键帧</span>
|
||||
</div>
|
||||
<div class="tl-wrap">
|
||||
<div id="timeRuler"></div>
|
||||
<input type="range" id="timeline" min="0" max="1" step="0.001" value="0" disabled>
|
||||
<div id="keyframes"></div>
|
||||
</div>
|
||||
<span id="timeLabel">0 / 0</span>
|
||||
<span id="timeLabel">0.00 / 0.00 秒 · 0 / 0 帧</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +196,10 @@
|
||||
const statusEl = document.getElementById('status');
|
||||
const timeline = document.getElementById('timeline');
|
||||
const timeLabel = document.getElementById('timeLabel');
|
||||
const durationLabel = document.getElementById('durationLabel');
|
||||
const timeRuler = document.getElementById('timeRuler');
|
||||
const keyframesEl = document.getElementById('keyframes');
|
||||
keyframesEl.title = '蓝色短线表示动画关键帧;悬停短线可查看时间和帧号';
|
||||
|
||||
// ---------- 全局状态 ----------
|
||||
let skeleton = null, state = null, stateData = null, skeletonData = null;
|
||||
@@ -176,8 +209,11 @@
|
||||
let timeKeeper = null, lastTime = 0, running = false;
|
||||
let seeking = false; // 用户正在拖动时间轴
|
||||
let duration = 0; // 当前动画时长
|
||||
let isStaticAnimation = false;
|
||||
let wasPlayingBeforeSeek = false; // 记录拖动前是否在播放
|
||||
const FPS = 30;
|
||||
const STATIC_EPSILON = 1e-6;
|
||||
let statusHoldUntil = 0;
|
||||
|
||||
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
|
||||
let panX = 0, panY = 0, zoom = 1;
|
||||
@@ -192,7 +228,10 @@
|
||||
document.getElementById('errorMsg').textContent = msg;
|
||||
}
|
||||
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
function setStatus(msg, holdMs = 0) {
|
||||
statusEl.textContent = msg;
|
||||
statusHoldUntil = holdMs === Infinity ? Infinity : (holdMs > 0 ? performance.now() + holdMs : 0);
|
||||
}
|
||||
|
||||
// ---------- 动态加载本地 Spine 运行时 ----------
|
||||
function loadRuntime(dir) {
|
||||
@@ -332,71 +371,97 @@
|
||||
}
|
||||
|
||||
// ---------- 生成当前帧缩略图(400×400,自适应骨骼包围盒并居中)----------
|
||||
function generateThumbnail() {
|
||||
function eagleItemIdFromPath(sourcePath) {
|
||||
const match = path.basename(path.dirname(sourcePath)).match(/^(.+)\.info$/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
|
||||
const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: itemId, thumbnailPath })
|
||||
});
|
||||
let result = null;
|
||||
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
|
||||
if (!response.ok || (result && result.status && result.status !== 'success')) {
|
||||
const reason = result && (result.message || result.error);
|
||||
throw new Error(reason || ('Eagle API 返回 HTTP ' + response.status));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function generateThumbnail() {
|
||||
if (!skeleton || !renderer) { setStatus('尚未加载骨骼,无法生成缩略图'); return; }
|
||||
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
|
||||
playing = false;
|
||||
updatePlayButton();
|
||||
if (state) state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
// 离屏 canvas 用于导出 PNG
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
const cx = bounds.x + bounds.width / 2;
|
||||
const cy = bounds.y + bounds.height / 2;
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-cx, -cy);
|
||||
// 用独立的 renderer 绑定离屏 ctx
|
||||
let offRenderer = renderer;
|
||||
try {
|
||||
if (spine.canvas && spine.canvas.SkeletonRenderer) offRenderer = new spine.canvas.SkeletonRenderer(octx);
|
||||
else if (spine.SkeletonRenderer) offRenderer = new spine.SkeletonRenderer(octx);
|
||||
} catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ }
|
||||
if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true;
|
||||
offRenderer.draw(skeleton);
|
||||
octx.restore();
|
||||
}
|
||||
|
||||
// 导出 PNG 并保存到文件旁(覆盖 Eagle 默认缩略图 <stem>_thumbnail.png)
|
||||
// 用 WebP 编码以显著减小体积(Eagle 的缩略图本身也是 WebP 格式,尽管扩展名为 .png)
|
||||
let out = null;
|
||||
const thumbBtn = document.getElementById('thumbBtn');
|
||||
thumbBtn.disabled = true;
|
||||
setStatus('正在生成并更新 Eagle 缩略图…', Infinity);
|
||||
try {
|
||||
out = off.toDataURL('image/webp');
|
||||
} catch (e) {
|
||||
out = off.toDataURL('image/png'); // 回退 PNG
|
||||
}
|
||||
const b64 = out.split(',')[1];
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
|
||||
playing = false;
|
||||
updatePlayButton();
|
||||
if (state) state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
|
||||
// 覆盖默认缩略图:用文件基名(stem)+ _thumbnail.png,即 Eagle 管理缩略图的文件名
|
||||
const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, '');
|
||||
let outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
|
||||
try {
|
||||
fs.writeFileSync(outPath, buf);
|
||||
setStatus('已更新缩略图: ' + path.basename(outPath) + ' (' + (buf.length / 1024).toFixed(1) + 'KB)');
|
||||
log('生成缩略图: ' + outPath + ' (' + buf.length + 'B)');
|
||||
} catch (e) {
|
||||
// 若无法写入(无目录写权限),回退为下载
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
a.href = out; a.download = stem + '_thumbnail.png';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setStatus('已通过下载保存缩略图');
|
||||
} catch (e2) {
|
||||
setStatus('生成缩略图失败: ' + ((e && e.message) || String(e)));
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
const cx = bounds.x + bounds.width / 2;
|
||||
const cy = bounds.y + bounds.height / 2;
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-cx, -cy);
|
||||
let offRenderer = renderer;
|
||||
try {
|
||||
if (spine.canvas && spine.canvas.SkeletonRenderer) offRenderer = new spine.canvas.SkeletonRenderer(octx);
|
||||
else if (spine.SkeletonRenderer) offRenderer = new spine.SkeletonRenderer(octx);
|
||||
} catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ }
|
||||
if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true;
|
||||
offRenderer.draw(skeleton);
|
||||
octx.restore();
|
||||
}
|
||||
|
||||
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
|
||||
let out = off.toDataURL('image/webp', 0.9);
|
||||
const isWebp = out.startsWith('data:image/webp');
|
||||
if (!isWebp) out = off.toDataURL('image/png');
|
||||
const buf = Buffer.from(out.split(',')[1], 'base64');
|
||||
const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, '');
|
||||
const itemId = urlParams.get('id') || eagleItemIdFromPath(filePath);
|
||||
const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
|
||||
const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png'));
|
||||
try {
|
||||
if (!itemId) throw new Error('无法从素材路径识别 Eagle 项目 ID');
|
||||
fs.writeFileSync(tempPath, buf);
|
||||
await setEagleCustomThumbnail(itemId, tempPath);
|
||||
setStatus('Eagle 缩略图已更新 · ' + (isWebp ? 'WebP' : 'PNG') + ' ' + (buf.length / 1024).toFixed(1) + 'KB', 3500);
|
||||
log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)');
|
||||
} catch (e) {
|
||||
// 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。
|
||||
try {
|
||||
fs.writeFileSync(outPath, buf);
|
||||
setStatus('缩略图已写入;当前 Eagle 版本未能自动刷新,请按 Cmd/Ctrl+R', 6000);
|
||||
log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e)));
|
||||
} catch (e2) {
|
||||
setStatus('生成缩略图失败: ' + ((e2 && e2.message) || String(e2)), 6000);
|
||||
}
|
||||
} finally {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('生成缩略图失败: ' + ((e && e.message) || String(e)), 6000);
|
||||
log('生成当前帧缩略图失败: ' + ((e && e.stack) || e));
|
||||
} finally {
|
||||
thumbBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,17 +479,17 @@
|
||||
}
|
||||
updateWorld(skeleton);
|
||||
draw();
|
||||
const time = state.getCurrent(0);
|
||||
if (time) {
|
||||
setStatus(animName(time.animation ? time.animation.name : '') + ' · ' + time.time.toFixed(2) + 's');
|
||||
// 同步时间轴(若用户未在拖动),用取模避免循环时滑块越界
|
||||
const entry = state.getCurrent(0);
|
||||
if (entry) {
|
||||
const currentTime = getTrackAnimationTime(entry);
|
||||
if (now >= statusHoldUntil) {
|
||||
setStatus(animName(entry.animation ? entry.animation.name : '') + ' · ' + (isStaticAnimation ? '静帧' : formatSeconds(currentTime)));
|
||||
}
|
||||
// 同步时间轴(若用户未在拖动)。兼容 3.x 的 time 与 4.x 的 trackTime/getAnimationTime。
|
||||
if (!seeking) {
|
||||
const t = (loop && duration > 0) ? (time.time % duration) : time.time;
|
||||
// 归一化到 [0, duration],避免越界
|
||||
const norm = (duration > 0) ? Math.max(0, Math.min(t, duration)) : 0;
|
||||
if (timeline.max !== String(Math.max(duration, 0.001))) timeline.max = String(Math.max(duration, 0.001));
|
||||
const norm = isStaticAnimation ? 0 : Math.max(0, Math.min(currentTime, duration));
|
||||
timeline.value = String(norm);
|
||||
timeLabel.textContent = frameOf(norm) + ' / ' + frameOf(duration) + ' 帧';
|
||||
updateTimeReadout(norm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,6 +500,123 @@
|
||||
return Math.round((seconds || 0) * FPS);
|
||||
}
|
||||
|
||||
function formatSeconds(seconds) {
|
||||
const value = Math.max(0, Number(seconds) || 0);
|
||||
if (value < 10) return value.toFixed(2) + ' 秒';
|
||||
if (value < 60) return value.toFixed(1) + ' 秒';
|
||||
const minutes = Math.floor(value / 60);
|
||||
return minutes + ':' + (value % 60).toFixed(1).padStart(4, '0');
|
||||
}
|
||||
|
||||
function getTrackAnimationTime(entry) {
|
||||
if (!entry) return 0;
|
||||
try {
|
||||
if (typeof entry.getAnimationTime === 'function') {
|
||||
const value = entry.getAnimationTime();
|
||||
if (Number.isFinite(value)) return Math.max(0, Math.min(value, duration));
|
||||
}
|
||||
} catch (e) { /* fall through to version-specific fields */ }
|
||||
const raw = Number.isFinite(entry.trackTime) ? entry.trackTime : (Number.isFinite(entry.time) ? entry.time : 0);
|
||||
if (loop && duration > STATIC_EPSILON) return raw % duration;
|
||||
return Math.max(0, Math.min(raw, duration));
|
||||
}
|
||||
|
||||
function animationDuration(anim) {
|
||||
const declared = anim && Number(anim.duration);
|
||||
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
|
||||
const times = collectKeyframeTimes(anim);
|
||||
return times.length ? Math.max.apply(null, times) : 0;
|
||||
}
|
||||
|
||||
function collectKeyframeTimes(anim) {
|
||||
if (!anim || !anim.timelines) return [];
|
||||
const times = new Set();
|
||||
for (const tl of anim.timelines) {
|
||||
const frames = tl && tl.frames;
|
||||
if (!frames || !frames.length) continue;
|
||||
try {
|
||||
if (Array.isArray(frames[0])) {
|
||||
for (const frame of frames) {
|
||||
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const frameCount = typeof tl.getFrameCount === 'function' ? tl.getFrameCount() : 0;
|
||||
const entries = typeof tl.getFrameEntries === 'function'
|
||||
? tl.getFrameEntries()
|
||||
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
|
||||
if (frameCount > 0) {
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const value = frames[i * entries];
|
||||
if (Number.isFinite(value)) times.add(value);
|
||||
}
|
||||
} else {
|
||||
// 极旧运行时中没有帧数接口的 timeline,其 frames 通常只保存时间。
|
||||
for (const value of frames) if (Number.isFinite(value) && value >= 0) times.add(value);
|
||||
}
|
||||
} catch (e) { /* ignore malformed timeline */ }
|
||||
}
|
||||
return [...times].filter((time) => time >= 0).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function updateTimeReadout(time) {
|
||||
if (isStaticAnimation) {
|
||||
timeLabel.textContent = '静帧 · 1 / 1 帧';
|
||||
return;
|
||||
}
|
||||
const current = Math.max(0, Math.min(Number(time) || 0, duration));
|
||||
timeLabel.textContent = formatSeconds(current) + ' / ' + formatSeconds(duration) + ' · ' + frameOf(current) + ' / ' + frameOf(duration) + ' 帧';
|
||||
}
|
||||
|
||||
function niceTickInterval(totalSeconds) {
|
||||
const target = totalSeconds / 8;
|
||||
const choices = [1 / 30, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||
for (const choice of choices) if (choice >= target) return choice;
|
||||
return Math.pow(10, Math.ceil(Math.log10(target)));
|
||||
}
|
||||
|
||||
function renderTimeRuler() {
|
||||
timeRuler.innerHTML = '';
|
||||
if (isStaticAnimation || duration <= STATIC_EPSILON) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tick-label first';
|
||||
label.style.left = '0%';
|
||||
label.textContent = '0 秒(静帧)';
|
||||
timeRuler.appendChild(label);
|
||||
return;
|
||||
}
|
||||
const major = niceTickInterval(duration);
|
||||
const minor = major / 5;
|
||||
const count = Math.floor(duration / minor);
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const value = Math.min(i * minor, duration);
|
||||
if (value > duration + STATIC_EPSILON) break;
|
||||
const percent = (value / duration) * 100;
|
||||
const isMajor = i % 5 === 0 || Math.abs(value - duration) < STATIC_EPSILON;
|
||||
const tick = document.createElement('span');
|
||||
tick.className = 'tick' + (isMajor ? ' major' : '');
|
||||
tick.style.left = percent + '%';
|
||||
timeRuler.appendChild(tick);
|
||||
if (isMajor) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tick-label' + (i === 0 ? ' first' : (Math.abs(value - duration) < STATIC_EPSILON ? ' last' : ''));
|
||||
label.style.left = percent + '%';
|
||||
label.textContent = value < 10 ? value.toFixed(1) + 's' : Math.round(value) + 's';
|
||||
timeRuler.appendChild(label);
|
||||
}
|
||||
if (Math.abs(value - duration) < STATIC_EPSILON) break;
|
||||
}
|
||||
// 时长不是整刻度时,确保终点始终有明确标签。
|
||||
if (Math.abs((duration / minor) - Math.round(duration / minor)) > STATIC_EPSILON) {
|
||||
const tick = document.createElement('span');
|
||||
tick.className = 'tick major'; tick.style.left = '100%'; timeRuler.appendChild(tick);
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tick-label last'; label.style.left = '100%';
|
||||
label.textContent = duration < 10 ? duration.toFixed(2) + 's' : duration.toFixed(1) + 's';
|
||||
timeRuler.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
function animName(name) {
|
||||
const sel = document.getElementById('animSelect');
|
||||
return sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
|
||||
@@ -469,6 +651,10 @@
|
||||
const anim = findAnimByName(name);
|
||||
state.setAnimation(0, name, loop);
|
||||
updateTimelineDuration(anim);
|
||||
state.update(0);
|
||||
state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
draw();
|
||||
}
|
||||
|
||||
// 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组)
|
||||
@@ -482,33 +668,32 @@
|
||||
|
||||
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
|
||||
function updateTimelineDuration(anim) {
|
||||
duration = (anim && anim.duration) ? anim.duration : 0;
|
||||
timeline.max = String(Math.max(duration, 0.001));
|
||||
duration = animationDuration(anim);
|
||||
isStaticAnimation = !!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';
|
||||
timeline.disabled = duration <= 0;
|
||||
timeLabel.textContent = '0 / ' + frameOf(duration) + ' 帧';
|
||||
timeline.disabled = !anim || isStaticAnimation;
|
||||
durationLabel.textContent = isStaticAnimation ? '静帧 · 1 帧' : ('时长 ' + formatSeconds(duration) + ' · ' + frameOf(duration) + ' 帧');
|
||||
updateTimeReadout(0);
|
||||
renderTimeRuler();
|
||||
renderKeyframeMarkers(anim);
|
||||
updatePlayButton();
|
||||
}
|
||||
|
||||
// 渲染关键帧标记(收集各 timeline 的关键时间点,用竖线标出)
|
||||
function renderKeyframeMarkers(anim) {
|
||||
keyframesEl.innerHTML = '';
|
||||
if (!anim || !anim.timelines || duration <= 0) return;
|
||||
const times = new Set();
|
||||
for (const tl of anim.timelines) {
|
||||
if (!tl || !tl.frames) continue;
|
||||
// frames 数组在 3.x/4.x 结构不同:3.x 是 [time, value, time, value...],4.x 是 [[time,value],...] 或带 keyframes
|
||||
try {
|
||||
if (Array.isArray(tl.frames[0])) {
|
||||
for (const f of tl.frames) { if (Array.isArray(f) && typeof f[0] === 'number') times.add(f[0]); }
|
||||
} else {
|
||||
for (let i = 0; i < tl.frames.length; i += 2) {
|
||||
if (typeof tl.frames[i] === 'number') times.add(tl.frames[i]);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
if (!anim) return;
|
||||
if (isStaticAnimation) {
|
||||
const span = document.createElement('span');
|
||||
span.style.left = '0%';
|
||||
span.title = '静帧姿势 · 第 1 帧';
|
||||
span.setAttribute('aria-label', span.title);
|
||||
keyframesEl.appendChild(span);
|
||||
return;
|
||||
}
|
||||
const sorted = [...times].filter((t) => t >= 0 && t <= duration).sort((a, b) => a - b);
|
||||
const sorted = collectKeyframeTimes(anim).filter((time) => time <= duration);
|
||||
// 限制数量,避免过多标记
|
||||
const maxMarkers = 200;
|
||||
const step = Math.max(1, Math.ceil(sorted.length / maxMarkers));
|
||||
@@ -516,6 +701,8 @@
|
||||
const t = sorted[i];
|
||||
const span = document.createElement('span');
|
||||
span.style.left = (duration > 0 ? (t / duration) * 100 : 0) + '%';
|
||||
span.title = '关键帧 · ' + formatSeconds(t) + ' · 第 ' + frameOf(t) + ' 帧';
|
||||
span.setAttribute('aria-label', span.title);
|
||||
keyframesEl.appendChild(span);
|
||||
}
|
||||
}
|
||||
@@ -535,7 +722,7 @@
|
||||
state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
draw();
|
||||
timeLabel.textContent = frameOf(time) + ' / ' + frameOf(duration) + ' 帧';
|
||||
updateTimeReadout(time);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -655,12 +842,9 @@
|
||||
fillAnimations();
|
||||
fillSkins();
|
||||
|
||||
// 默认播放第一个“有时长”的动画(避免选中 duration=0 的空动画导致时间轴失效)
|
||||
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
|
||||
if (skeletonData.animations.length > 0) {
|
||||
let defaultAnim = skeletonData.animations[0];
|
||||
for (const anim of skeletonData.animations) {
|
||||
if (anim.duration > 0) { defaultAnim = anim; break; }
|
||||
}
|
||||
const defaultAnim = skeletonData.animations[0];
|
||||
state.setAnimation(0, defaultAnim.name, loop);
|
||||
// 同步下拉框选中项
|
||||
const sel = document.getElementById('animSelect');
|
||||
@@ -685,11 +869,18 @@
|
||||
// ---------- UI 事件绑定 ----------
|
||||
function updatePlayButton() {
|
||||
const btn = document.getElementById('playBtn');
|
||||
btn.disabled = isStaticAnimation;
|
||||
if (isStaticAnimation) {
|
||||
btn.textContent = '▮ 静帧';
|
||||
btn.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
btn.textContent = playing ? '⏸ 暂停' : '▶ 播放';
|
||||
btn.classList.toggle('active', !playing);
|
||||
}
|
||||
|
||||
document.getElementById('playBtn').addEventListener('click', () => {
|
||||
if (isStaticAnimation) return;
|
||||
playing = !playing; updatePlayButton();
|
||||
});
|
||||
document.getElementById('animSelect').addEventListener('change', (e) => {
|
||||
@@ -774,7 +965,7 @@
|
||||
}, { passive: false });
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') { e.preventDefault(); playing = !playing; updatePlayButton(); }
|
||||
if (e.code === 'Space' && !isStaticAnimation) { e.preventDefault(); playing = !playing; updatePlayButton(); }
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
if (!running) return;
|
||||
|
||||
Reference in new issue
Block a user