diff --git a/README.md b/README.md
index 388764c..9bc89ef 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,10 @@ SpineViewer/
- 播放 / 暂停(空格)
- 循环开关
- 播放速度调节
+ - 时间轴拖动、动画时长、秒刻度与关键帧标记
+ - 零时长动画按单帧静态姿势预览
- 自动适配并居中
+ - 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧生成 PNG。若该依赖不可用,会自动回退为一条纯 JS 生成的占位 PNG。
---
diff --git a/viewer/spine.html b/viewer/spine.html
index a2d9700..946067f 100644
--- a/viewer/spine.html
+++ b/viewer/spine.html
@@ -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 @@
-
帧
+
+ 时长 --
+ 蓝色短线:关键帧
+
-
0 / 0
+
0.00 / 0.00 秒 · 0 / 0 帧
@@ -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 默认缩略图 _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;