优化显示
This commit is contained in:
1 parent
2ede939a62
commit
6f7230458d
2 files changed
+128
-36
No files matched your search
@@ -56,13 +56,13 @@ SpineViewer/
|
||||
- 从 atlas 中解析它引用的贴图页面,再到库中定位实际贴图文件。
|
||||
2. **版本检测**:读取骨骼数据里的版本号(`.json` 读 `skeleton.spine`,`.skel` 读二进制头部),自动选择匹配的本地运行时。
|
||||
3. **预览**:查看器通过 Node `fs` 直接读取分离在各 item 文件夹中的骨骼/atlas/贴图文件(无需网络、无 CORS),用 `spine-canvas` 渲染器在 `<canvas>` 上实时播放动画,并支持:
|
||||
- 动画列表切换
|
||||
- 画布左上方自适应的浮动动画列表,动画较多时独立滚动,点击条目立即切换;没有动画数据时自动隐藏
|
||||
- 皮肤切换
|
||||
- 播放 / 暂停(空格)
|
||||
- 循环开关
|
||||
- 播放速度调节
|
||||
- 预乘 Alpha(PMA)默认读取配套 `.atlas` / `.atlas.txt` 的 `pma:true/false`;用户手动切换后保存为该素材的显式覆盖值,重新打开时继续沿用
|
||||
- 时间轴拖动、动画时长、秒刻度与关键帧标记
|
||||
- 浮动时间轴支持拖动、动画时长、秒刻度与关键帧标记;顶部控制栏同样采用浮动布局
|
||||
- 零时长动画按单帧静态姿势预览
|
||||
- 自动适配并居中
|
||||
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
|
||||
|
||||
+126
-34
@@ -32,11 +32,18 @@
|
||||
background: var(--bg); color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
#app { display: flex; flex-direction: column; height: 100%; }
|
||||
#app {
|
||||
position: relative; height: 100%;
|
||||
--toolbar-height: 46px; --timeline-height: 68px;
|
||||
}
|
||||
#toolbar {
|
||||
position: absolute; top: 12px; left: 12px; right: 12px;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 8px 12px; background: var(--panel);
|
||||
border-bottom: 1px solid var(--border); z-index: 10;
|
||||
padding: 8px 12px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 5px 18px rgba(0, 0, 0, .25);
|
||||
backdrop-filter: blur(8px); z-index: 10;
|
||||
}
|
||||
#toolbar .spacer { flex: 1; }
|
||||
#toolbar label { color: var(--text-dim); display: flex; align-items: center; gap: 4px; }
|
||||
@@ -51,16 +58,66 @@
|
||||
}
|
||||
button:hover { background: var(--border); }
|
||||
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
#stage { position: relative; flex: 1; overflow: hidden; }
|
||||
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
|
||||
#animationPanel {
|
||||
position: absolute;
|
||||
top: calc(22px + var(--toolbar-height)); left: 12px; z-index: 9;
|
||||
width: max-content; min-width: 128px; max-width: min(260px, 34vw);
|
||||
max-height: calc(100% - var(--toolbar-height) - var(--timeline-height) - 56px);
|
||||
display: none; flex-direction: column; overflow: hidden;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
#animationPanelTitle {
|
||||
padding: 8px 10px; color: var(--text-dim); font-size: 12px;
|
||||
border-bottom: 1px solid var(--border); flex: none;
|
||||
}
|
||||
#animationList {
|
||||
width: max-content; min-width: 100%; max-width: 100%; min-height: 0;
|
||||
overflow: auto; padding: 5px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--text-dim) 58%, transparent) transparent;
|
||||
}
|
||||
#animationList::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
#animationList::-webkit-scrollbar-track { background: transparent; }
|
||||
#animationList::-webkit-scrollbar-thumb {
|
||||
min-height: 28px; border: 2px solid transparent; border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text-dim) 58%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
#animationList::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--text-dim) 82%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
#animationList::-webkit-scrollbar-corner { background: transparent; }
|
||||
.animation-item {
|
||||
display: block; width: 100%; min-width: 116px; max-width: 248px;
|
||||
padding: 6px 8px; margin: 0 0 2px;
|
||||
border: 0; border-radius: 4px; background: transparent;
|
||||
color: var(--text); text-align: left; overflow: hidden;
|
||||
text-overflow: ellipsis; cursor: pointer;
|
||||
}
|
||||
.animation-item:hover { background: var(--panel-2); }
|
||||
.animation-item.active {
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||
color: var(--accent); box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
#viewport { position: relative; width: 100%; min-width: 0; flex: 1; overflow: hidden; }
|
||||
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status {
|
||||
position: absolute; bottom: 10px; left: 12px; color: var(--text-dim);
|
||||
position: absolute; bottom: calc(22px + var(--timeline-height)); left: 12px; color: var(--text-dim);
|
||||
font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||||
}
|
||||
#timelineBar {
|
||||
position: absolute; left: 12px; right: 12px; bottom: 12px; z-index: 10;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 12px; background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 -4px 18px rgba(0, 0, 0, .22);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
#timelineLabels {
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
@@ -124,9 +181,6 @@
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="toolbar">
|
||||
<label>动画
|
||||
<select id="animSelect" disabled></select>
|
||||
</label>
|
||||
<label>皮肤
|
||||
<select id="skinSelect" disabled></select>
|
||||
</label>
|
||||
@@ -148,13 +202,19 @@
|
||||
<button id="reloadBtn" title="重新加载目录">↻</button>
|
||||
</div>
|
||||
<div id="stage">
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="status"></div>
|
||||
<div id="loading"><div class="spinner"></div></div>
|
||||
<div id="overlay" class="hidden">
|
||||
<div class="box">
|
||||
<div class="title" id="errorTitle">无法预览</div>
|
||||
<div class="msg" id="errorMsg"></div>
|
||||
<aside id="animationPanel" aria-label="动画列表">
|
||||
<div id="animationPanelTitle">动画</div>
|
||||
<div id="animationList" role="listbox" aria-label="动画"></div>
|
||||
</aside>
|
||||
<div id="viewport">
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="status"></div>
|
||||
<div id="loading"><div class="spinner"></div></div>
|
||||
<div id="overlay" class="hidden">
|
||||
<div class="box">
|
||||
<div class="title" id="errorTitle">无法预览</div>
|
||||
<div class="msg" id="errorMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,12 +289,27 @@
|
||||
const overlay = document.getElementById('overlay');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const statusEl = document.getElementById('status');
|
||||
const animationPanel = document.getElementById('animationPanel');
|
||||
const animationList = document.getElementById('animationList');
|
||||
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 = '蓝色短线表示动画关键帧;悬停短线可查看时间和帧号';
|
||||
const appEl = document.getElementById('app');
|
||||
const toolbarEl = document.getElementById('toolbar');
|
||||
const timelineBarEl = document.getElementById('timelineBar');
|
||||
function updateFloatingInsets() {
|
||||
appEl.style.setProperty('--toolbar-height', toolbarEl.offsetHeight + 'px');
|
||||
appEl.style.setProperty('--timeline-height', timelineBarEl.offsetHeight + 'px');
|
||||
}
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const floatingResizeObserver = new ResizeObserver(updateFloatingInsets);
|
||||
floatingResizeObserver.observe(toolbarEl);
|
||||
floatingResizeObserver.observe(timelineBarEl);
|
||||
}
|
||||
requestAnimationFrame(updateFloatingInsets);
|
||||
|
||||
// ---------- 全局状态 ----------
|
||||
let skeleton = null, state = null, stateData = null, skeletonData = null;
|
||||
@@ -677,21 +752,34 @@
|
||||
}
|
||||
|
||||
function animName(name) {
|
||||
const sel = document.getElementById('animSelect');
|
||||
return sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
|
||||
return name || '';
|
||||
}
|
||||
|
||||
// ---------- 填充 UI ----------
|
||||
function fillAnimations() {
|
||||
const sel = document.getElementById('animSelect');
|
||||
sel.innerHTML = '';
|
||||
(skeletonData.animations || []).forEach((a, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = i; opt.textContent = a.name;
|
||||
sel.appendChild(opt);
|
||||
animationList.innerHTML = '';
|
||||
const animations = skeletonData.animations || [];
|
||||
animationPanel.style.display = animations.length ? 'flex' : 'none';
|
||||
animations.forEach((a, i) => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'animation-item' + (i === 0 ? ' active' : '');
|
||||
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');
|
||||
animationList.appendChild(item);
|
||||
});
|
||||
sel.disabled = skeletonData.animations.length <= 1;
|
||||
if (skeletonData.animations.length > 0) sel.value = '0';
|
||||
}
|
||||
|
||||
function selectAnimationItem(index) {
|
||||
currentAnim = index;
|
||||
for (const item of animationList.querySelectorAll('.animation-item')) {
|
||||
const active = Number(item.dataset.index) === index;
|
||||
item.classList.toggle('active', active);
|
||||
item.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function fillSkins(selectedSkin) {
|
||||
@@ -709,9 +797,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setAnimationByName(name) {
|
||||
function setAnimationByName(name, index) {
|
||||
if (!state) return;
|
||||
const anim = findAnimByName(name);
|
||||
if (!anim) return;
|
||||
const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim);
|
||||
if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex);
|
||||
state.setAnimation(0, name, loop);
|
||||
updateTimelineDuration(anim);
|
||||
state.update(0);
|
||||
@@ -833,6 +924,7 @@
|
||||
// Ordinary text remains readable and never starts Spine discovery.
|
||||
document.getElementById('toolbar').style.display = 'none';
|
||||
document.getElementById('timelineBar').style.display = 'none';
|
||||
document.getElementById('animationPanel').style.display = 'none';
|
||||
let message = '此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。';
|
||||
if (/\.txt$/i.test(filePath)) {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
@@ -951,10 +1043,8 @@
|
||||
if (skeletonData.animations.length > 0) {
|
||||
const defaultAnim = skeletonData.animations[0];
|
||||
state.setAnimation(0, defaultAnim.name, loop);
|
||||
// 同步下拉框选中项
|
||||
const sel = document.getElementById('animSelect');
|
||||
const idx = skeletonData.animations.indexOf(defaultAnim);
|
||||
if (idx >= 0) sel.value = String(idx);
|
||||
if (idx >= 0) selectAnimationItem(idx);
|
||||
updateTimelineDuration(defaultAnim);
|
||||
} else {
|
||||
updateTimelineDuration(null);
|
||||
@@ -988,10 +1078,12 @@
|
||||
if (isStaticAnimation) return;
|
||||
playing = !playing; updatePlayButton();
|
||||
});
|
||||
document.getElementById('animSelect').addEventListener('change', (e) => {
|
||||
const idx = parseInt(e.target.value, 10);
|
||||
animationList.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.animation-item');
|
||||
if (!item) return;
|
||||
const idx = parseInt(item.dataset.index, 10);
|
||||
const anims = skeletonData.animations;
|
||||
if (anims && anims[idx]) setAnimationByName(anims[idx].name);
|
||||
if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx);
|
||||
});
|
||||
document.getElementById('skinSelect').addEventListener('change', (e) => {
|
||||
const idx = parseInt(e.target.value, 10);
|
||||
|
||||
Reference in new issue
Block a user