798 lines
29 KiB
HTML
798 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Spine 预览</title>
|
||
<style>
|
||
:root {
|
||
--bg: #1e1e1e;
|
||
--panel: #252526;
|
||
--panel-2: #2d2d30;
|
||
--text: #e6e6e6;
|
||
--text-dim: #9d9d9d;
|
||
--accent: #4fc1ff;
|
||
--border: #3c3c3c;
|
||
--error: #f14c4c;
|
||
}
|
||
[data-theme="light"] {
|
||
--bg: #f3f4f6;
|
||
--panel: #ffffff;
|
||
--panel-2: #f0f0f0;
|
||
--text: #1f1f1f;
|
||
--text-dim: #6b6b6b;
|
||
--accent: #0078d4;
|
||
--border: #ddd;
|
||
--error: #d13438;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: var(--bg); color: var(--text);
|
||
font-size: 13px;
|
||
}
|
||
#app { display: flex; flex-direction: column; height: 100%; }
|
||
#toolbar {
|
||
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;
|
||
}
|
||
#toolbar .spacer { flex: 1; }
|
||
#toolbar label { color: var(--text-dim); display: flex; align-items: center; gap: 4px; }
|
||
select, input[type="range"] {
|
||
background: var(--panel-2); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: 4px; padding: 4px 6px;
|
||
}
|
||
button {
|
||
background: var(--panel-2); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: 4px; padding: 5px 10px;
|
||
cursor: pointer; white-space: nowrap;
|
||
}
|
||
button:hover { background: var(--border); }
|
||
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
#stage { position: relative; 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);
|
||
font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||
}
|
||
#timelineBar {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 12px; background: var(--panel);
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
#timelineBar .tl-label { color: var(--text-dim); font-size: 12px; white-space: nowrap; }
|
||
#timelineBar .tl-wrap { position: relative; flex: 1; padding: 8px 0; }
|
||
#timelineBar input[type="range"] {
|
||
display: block; width: 100%; margin: 0; accent-color: var(--accent);
|
||
}
|
||
#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);
|
||
}
|
||
#timeLabel {
|
||
color: var(--text-dim); font-size: 12px; min-width: 110px;
|
||
text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap;
|
||
}
|
||
#overlay {
|
||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||
background: var(--bg); z-index: 5; text-align: center;
|
||
}
|
||
#overlay .box { max-width: 520px; padding: 20px; }
|
||
#overlay .title { font-size: 16px; margin-bottom: 8px; color: var(--error); }
|
||
#overlay .msg { color: var(--text-dim); white-space: pre-wrap; word-break: break-all; }
|
||
#overlay.hidden { display: none; }
|
||
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 4; background: var(--bg); }
|
||
#loading.hidden { display: none; }
|
||
.spinner {
|
||
width: 34px; height: 34px; border: 3px solid var(--border);
|
||
border-top-color: var(--accent); border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
#hideOthers { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div id="toolbar">
|
||
<label>动画
|
||
<select id="animSelect" disabled></select>
|
||
</label>
|
||
<label>皮肤
|
||
<select id="skinSelect" disabled></select>
|
||
</label>
|
||
<button id="playBtn" title="播放/暂停 (空格)">⏸ 暂停</button>
|
||
<label><input type="checkbox" id="loopCheck" checked> 循环</label>
|
||
<label>速度
|
||
<input type="range" id="speedRange" min="0" max="3" step="0.05" value="1">
|
||
<span id="speedVal">1.0x</span>
|
||
</label>
|
||
<div class="spacer"></div>
|
||
<button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
|
||
<label id="verLabel" style="color: var(--text-dim);" title="Spine 版本"></label>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="timelineBar">
|
||
<span class="tl-label">帧</span>
|
||
<div class="tl-wrap">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// 使用 Node 集成提供的模块
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const utils = require('../js/spine-utils.js');
|
||
|
||
// 简易日志(写入插件目录 thumbnails.log,便于诊断)
|
||
function log(msg) {
|
||
try {
|
||
const logPath = path.join(__dirname, '..', 'thumbnails.log');
|
||
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// ---------- 读取 URL 参数 ----------
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const filePath = urlParams.get('path');
|
||
const width = urlParams.get('width');
|
||
const height = urlParams.get('height');
|
||
const theme = urlParams.get('theme');
|
||
const lang = urlParams.get('lang');
|
||
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||
|
||
const canvas = document.getElementById('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const overlay = document.getElementById('overlay');
|
||
const loadingEl = document.getElementById('loading');
|
||
const statusEl = document.getElementById('status');
|
||
const timeline = document.getElementById('timeline');
|
||
const timeLabel = document.getElementById('timeLabel');
|
||
const keyframesEl = document.getElementById('keyframes');
|
||
|
||
// ---------- 全局状态 ----------
|
||
let skeleton = null, state = null, stateData = null, skeletonData = null;
|
||
let renderer = null;
|
||
let currentAnim = 0, currentSkin = null;
|
||
let playing = true, loop = true, speed = 1;
|
||
let timeKeeper = null, lastTime = 0, running = false;
|
||
let seeking = false; // 用户正在拖动时间轴
|
||
let duration = 0; // 当前动画时长
|
||
let wasPlayingBeforeSeek = false; // 记录拖动前是否在播放
|
||
const FPS = 30;
|
||
|
||
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
|
||
let panX = 0, panY = 0, zoom = 1;
|
||
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
|
||
let dragging = false; // 是否正在拖拽
|
||
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
|
||
|
||
function showError(title, msg) {
|
||
loadingEl.classList.add('hidden');
|
||
overlay.classList.remove('hidden');
|
||
document.getElementById('errorTitle').textContent = title;
|
||
document.getElementById('errorMsg').textContent = msg;
|
||
}
|
||
|
||
function setStatus(msg) { statusEl.textContent = msg; }
|
||
|
||
// ---------- 动态加载本地 Spine 运行时 ----------
|
||
function loadRuntime(dir) {
|
||
return new Promise((resolve, reject) => {
|
||
const s = document.createElement('script');
|
||
s.src = '../lib/' + dir + '/spine-canvas.js';
|
||
s.onload = () => resolve();
|
||
s.onerror = () => reject(new Error('无法加载 Spine 运行时:lib/' + dir + '/spine-canvas.js'));
|
||
document.head.appendChild(s);
|
||
});
|
||
}
|
||
|
||
// 1x1 灰色占位贴图 data URI
|
||
const PLACEHOLDER_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
||
|
||
// ---------- 在浏览器中构造 TextureAtlas(跨 Eagle item 文件夹加载贴图)----------
|
||
// assets.texturePaths: { atlas 引用的贴图名 -> 绝对路径 }
|
||
function buildBrowserAtlas(spine, atlasText, assets) {
|
||
return new Promise((resolve, reject) => {
|
||
(async () => {
|
||
// 1) 预先读取所有页面贴图,转成 data URI 并用 Image 加载
|
||
const pageNames = [];
|
||
try { pageNames.push(...utils.parseAtlasPages(atlasText)); } catch (e) { /* ignore */ }
|
||
const imgMap = {};
|
||
await Promise.all(pageNames.map((name) => {
|
||
return new Promise((res) => {
|
||
const p = (assets.texturePaths && assets.texturePaths[name]);
|
||
if (!p || !fs.existsSync(p)) { res(); return; }
|
||
const ext = path.extname(p).toLowerCase();
|
||
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
|
||
try {
|
||
const data = fs.readFileSync(p);
|
||
const img = new Image();
|
||
img.onload = () => { imgMap[name] = img; res(); };
|
||
img.onerror = () => res();
|
||
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
|
||
} catch (e) { res(); }
|
||
});
|
||
}));
|
||
|
||
// 2) 生成 Textures
|
||
const makeTexture = (name) => {
|
||
let img = imgMap[name];
|
||
if (!img) {
|
||
const ph = new Image(); ph.src = PLACEHOLDER_IMG;
|
||
img = ph;
|
||
}
|
||
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
|
||
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
|
||
return new spine.Texture(img);
|
||
};
|
||
|
||
// 3) 构建 Atlas(3.x 用构造器回调,4.x 用 setTexture)
|
||
let atlas;
|
||
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
|
||
if (is4x) {
|
||
atlas = new spine.TextureAtlas(atlasText);
|
||
for (const page of atlas.pages) {
|
||
const tex = makeTexture(page.name);
|
||
if (page.setTexture) page.setTexture(tex);
|
||
else page.texture = tex;
|
||
}
|
||
} else {
|
||
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
|
||
}
|
||
resolve(atlas);
|
||
})().catch(reject);
|
||
});
|
||
}
|
||
|
||
// ---------- 计算骨骼包围盒(跨版本兼容)----------
|
||
function getBounds(sk) {
|
||
try {
|
||
if (typeof sk.getBoundsRect === 'function') {
|
||
const r = sk.getBoundsRect();
|
||
if (r && (r.width !== undefined)) return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||
}
|
||
if (typeof sk.getBounds === 'function') {
|
||
const offset = new spine.Vector2();
|
||
const size = new spine.Vector2();
|
||
sk.getBounds(offset, size, []);
|
||
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
return null;
|
||
}
|
||
|
||
// ---------- 适配并居中绘制一帧 ----------
|
||
// Spine 世界坐标 Y 轴向上,Canvas 2D Y 轴向下,绘制时需翻转 Y。
|
||
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
|
||
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
|
||
let fitScale = 1, fitCx = 0, fitCy = 0;
|
||
function fitToCanvas() {
|
||
const bounds = getBounds(skeleton);
|
||
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;
|
||
}
|
||
const pad = 0.1;
|
||
const sx = (cw * (1 - pad * 2)) / bounds.width;
|
||
const sy = (ch * (1 - pad * 2)) / bounds.height;
|
||
baseFitScale = Math.min(sx, sy);
|
||
if (!isFinite(baseFitScale) || baseFitScale <= 0) baseFitScale = 1;
|
||
baseFitCx = bounds.x + bounds.width / 2;
|
||
baseFitCy = bounds.y + bounds.height / 2;
|
||
// 应用用户缩放;骨骼中心保持为默认中心(平移仅作用于屏幕坐标)
|
||
fitScale = baseFitScale * zoom;
|
||
fitCx = baseFitCx;
|
||
fitCy = baseFitCy;
|
||
}
|
||
|
||
function draw() {
|
||
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||
if (canvas.width !== cw * devicePixelRatio || canvas.height !== ch * devicePixelRatio) {
|
||
canvas.width = cw * devicePixelRatio;
|
||
canvas.height = ch * devicePixelRatio;
|
||
}
|
||
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||
ctx.clearRect(0, 0, cw, ch);
|
||
if (!skeleton || !renderer) return;
|
||
ctx.save();
|
||
// 居中 + 缩放 + Y 翻转,使 Spine 坐标正确映射到 Canvas。
|
||
// panX/panY 作为屏幕坐标偏移叠加,支持上下左右拖动画布。
|
||
ctx.translate(cw / 2 + panX, ch / 2 + panY);
|
||
ctx.scale(fitScale, -fitScale);
|
||
ctx.translate(-fitCx, -fitCy);
|
||
renderer.draw(skeleton);
|
||
ctx.restore();
|
||
}
|
||
|
||
// ---------- 更新动画状态 ----------
|
||
function updateWorld(sk) {
|
||
if (sk.updateWorldTransform) {
|
||
if (spine.Physics && spine.Physics.update !== undefined) sk.updateWorldTransform(spine.Physics.update);
|
||
else sk.updateWorldTransform();
|
||
}
|
||
}
|
||
|
||
// ---------- 生成当前帧缩略图(400×400,自适应骨骼包围盒并居中)----------
|
||
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;
|
||
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');
|
||
|
||
// 覆盖默认缩略图:用文件基名(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)));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 渲染主循环 ----------
|
||
function renderLoop(now) {
|
||
if (!running) return;
|
||
requestAnimationFrame(renderLoop);
|
||
const dt = (now - lastTime) / 1000;
|
||
lastTime = now;
|
||
if (skeleton && state) {
|
||
if (playing) {
|
||
// Spine 运行时自身处理循环;持续 update 即可。
|
||
state.update(dt * speed);
|
||
state.apply(skeleton);
|
||
}
|
||
updateWorld(skeleton);
|
||
draw();
|
||
const time = state.getCurrent(0);
|
||
if (time) {
|
||
setStatus(animName(time.animation ? time.animation.name : '') + ' · ' + time.time.toFixed(2) + 's');
|
||
// 同步时间轴(若用户未在拖动),用取模避免循环时滑块越界
|
||
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));
|
||
timeline.value = String(norm);
|
||
timeLabel.textContent = frameOf(norm) + ' / ' + frameOf(duration) + ' 帧';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 帧号换算(默认 30fps)
|
||
function frameOf(seconds) {
|
||
return Math.round((seconds || 0) * FPS);
|
||
}
|
||
|
||
function animName(name) {
|
||
const sel = document.getElementById('animSelect');
|
||
return sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
|
||
}
|
||
|
||
// ---------- 填充 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);
|
||
});
|
||
sel.disabled = skeletonData.animations.length <= 1;
|
||
if (skeletonData.animations.length > 0) sel.value = '0';
|
||
}
|
||
|
||
function fillSkins() {
|
||
const sel = document.getElementById('skinSelect');
|
||
sel.innerHTML = '';
|
||
(skeletonData.skins || []).forEach((s, i) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = i; opt.textContent = s.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
sel.disabled = (skeletonData.skins || []).length <= 1;
|
||
}
|
||
|
||
function setAnimationByName(name) {
|
||
if (!state) return;
|
||
const anim = findAnimByName(name);
|
||
state.setAnimation(0, name, loop);
|
||
updateTimelineDuration(anim);
|
||
}
|
||
|
||
// 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组)
|
||
function findAnimByName(name) {
|
||
const arr = (skeletonData && skeletonData.animations) || [];
|
||
for (let i = 0; i < arr.length; i++) {
|
||
if (arr[i] && arr[i].name === name) return arr[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
|
||
function updateTimelineDuration(anim) {
|
||
duration = (anim && anim.duration) ? anim.duration : 0;
|
||
timeline.max = String(Math.max(duration, 0.001));
|
||
timeline.value = '0';
|
||
timeline.disabled = duration <= 0;
|
||
timeLabel.textContent = '0 / ' + frameOf(duration) + ' 帧';
|
||
renderKeyframeMarkers(anim);
|
||
}
|
||
|
||
// 渲染关键帧标记(收集各 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 */ }
|
||
}
|
||
const sorted = [...times].filter((t) => t >= 0 && t <= duration).sort((a, b) => a - b);
|
||
// 限制数量,避免过多标记
|
||
const maxMarkers = 200;
|
||
const step = Math.max(1, Math.ceil(sorted.length / maxMarkers));
|
||
for (let i = 0; i < sorted.length; i += step) {
|
||
const t = sorted[i];
|
||
const span = document.createElement('span');
|
||
span.style.left = (duration > 0 ? (t / duration) * 100 : 0) + '%';
|
||
keyframesEl.appendChild(span);
|
||
}
|
||
}
|
||
|
||
// 跳到指定时间点:暂停播放,重置动画到目标时间
|
||
function seekTo(time) {
|
||
if (!state || !skeleton) return;
|
||
const track = state.getCurrent(0);
|
||
const anim = track ? track.animation : null;
|
||
playing = false;
|
||
updatePlayButton();
|
||
try {
|
||
if (anim) {
|
||
state.setAnimation(0, anim.name, loop);
|
||
}
|
||
state.update(Math.max(0, time));
|
||
state.apply(skeleton);
|
||
updateWorld(skeleton);
|
||
draw();
|
||
timeLabel.textContent = frameOf(time) + ' / ' + frameOf(duration) + ' 帧';
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function applySkinByName(name) {
|
||
if (!skeleton) return;
|
||
if (skeleton.setSkinByName) skeleton.setSkinByName(name);
|
||
else if (skeleton.setSkin && skeleton.data.findSkin) skeleton.setSkin(skeleton.data.findSkin(name));
|
||
}
|
||
|
||
// ---------- 非 Spine 图片回退:直接显示图片 ----------
|
||
function showImageFallback(file) {
|
||
try {
|
||
const ext = path.extname(file).toLowerCase();
|
||
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
|
||
const data = fs.readFileSync(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
loadingEl.classList.add('hidden');
|
||
running = false;
|
||
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||
canvas.width = cw * devicePixelRatio; canvas.height = ch * devicePixelRatio;
|
||
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||
ctx.clearRect(0, 0, cw, ch);
|
||
const scale = Math.min(cw / img.width, ch / img.height);
|
||
const w = img.width * scale, h = img.height * scale;
|
||
ctx.drawImage(img, (cw - w) / 2, (ch - h) / 2, w, h);
|
||
setStatus('图片预览(未检测到配套 Spine 资源)');
|
||
};
|
||
img.onerror = () => showError('图片加载失败', file);
|
||
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
|
||
} catch (e) {
|
||
showError('图片加载失败', (e && e.message) || String(e));
|
||
}
|
||
}
|
||
|
||
// ---------- 加载资源并与运行时交互 ----------
|
||
async function loadAssets() {
|
||
const assets = utils.discoverAssets(filePath);
|
||
|
||
if (!assets.skeletonPath || !assets.atlasPath) {
|
||
if (!assets.skeletonPath) {
|
||
showError('未找到骨骼文件', '未能定位到与所选文件配套的 Spine 骨骼文件(.skel / .skel.bytes / .json)。\n\n请将同一套 Spine 导出文件导入 Eagle。');
|
||
} else {
|
||
showError('未找到 Atlas', '未能定位到配套的 .atlas / .atlas.txt 文件。');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 检测版本并选择运行时
|
||
const version = utils.detectVersion(assets.skeletonPath);
|
||
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||
if (!runtimeDir) {
|
||
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
|
||
return;
|
||
}
|
||
document.getElementById('verLabel').textContent = 'Spine ' + (version || runtimeDir);
|
||
|
||
try {
|
||
await loadRuntime(runtimeDir);
|
||
} catch (e) {
|
||
showError('运行时加载失败', e.message);
|
||
return;
|
||
}
|
||
|
||
// 读取骨骼数据(直接用 Node fs 读取,兼容跨文件夹)
|
||
let skelData;
|
||
if (assets.isBinary) {
|
||
skelData = new Uint8Array(fs.readFileSync(assets.skeletonPath));
|
||
} else {
|
||
skelData = fs.readFileSync(assets.skeletonPath, 'utf8');
|
||
}
|
||
|
||
// 读取 atlas 文本并构造 TextureAtlas(跨 item 文件夹加载贴图)
|
||
let atlas;
|
||
try {
|
||
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
|
||
atlas = await buildBrowserAtlas(spine, atlasText, assets);
|
||
} catch (e) {
|
||
showError('Atlas 加载失败', (e && e.message) || String(e));
|
||
return;
|
||
}
|
||
|
||
// 创建骨骼加载器
|
||
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
|
||
const loader = assets.isBinary ? new spine.SkeletonBinary(atlasLoader) : new spine.SkeletonJson(atlasLoader);
|
||
loader.scale = 1;
|
||
|
||
let skeletonData2;
|
||
if (assets.isBinary) {
|
||
skeletonData2 = loader.readSkeletonData(skelData);
|
||
} else {
|
||
skeletonData2 = loader.readSkeletonData(skelData);
|
||
}
|
||
|
||
// 3.6/3.7 的二进制骨骼无法由官方 JS 运行时解析
|
||
if (!skeletonData2 || !skeletonData2.bones || skeletonData2.bones.length === 0) {
|
||
const isOldBin = assets.isBinary && version && parseFloat(version) < 3.8;
|
||
showError(isOldBin ? '版本二进制暂不支持' : '骨骼数据为空',
|
||
isOldBin
|
||
? 'Spine ' + version + ' 的二进制骨骼(.skel / .skel.bytes)无法由官方 JS 运行时解析。\n\n建议在 Spine 中改用 JSON 导出(`.json`)后再导入预览。'
|
||
: '未能读取到有效的骨骼数据。');
|
||
return;
|
||
}
|
||
|
||
skeletonData = skeletonData2;
|
||
|
||
// 创建骨架与动画状态
|
||
skeleton = new spine.Skeleton(skeletonData);
|
||
stateData = new spine.AnimationStateData(skeletonData);
|
||
stateData.defaultMix = 0.2;
|
||
state = new spine.AnimationState(stateData);
|
||
|
||
renderer = (spine.canvas && spine.canvas.SkeletonRenderer) ? new spine.canvas.SkeletonRenderer(ctx) : new spine.SkeletonRenderer(ctx);
|
||
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
|
||
if (renderer && 'triangleRendering' in renderer) renderer.triangleRendering = true;
|
||
|
||
fillAnimations();
|
||
fillSkins();
|
||
|
||
// 默认播放第一个“有时长”的动画(避免选中 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; }
|
||
}
|
||
state.setAnimation(0, defaultAnim.name, loop);
|
||
// 同步下拉框选中项
|
||
const sel = document.getElementById('animSelect');
|
||
const idx = skeletonData.animations.indexOf(defaultAnim);
|
||
if (idx >= 0) sel.value = String(idx);
|
||
updateTimelineDuration(defaultAnim);
|
||
} else {
|
||
updateTimelineDuration(null);
|
||
}
|
||
|
||
updateWorld(skeleton);
|
||
fitToCanvas();
|
||
draw();
|
||
|
||
loadingEl.classList.add('hidden');
|
||
running = true;
|
||
lastTime = performance.now();
|
||
requestAnimationFrame(renderLoop);
|
||
updatePlayButton();
|
||
}
|
||
|
||
// ---------- UI 事件绑定 ----------
|
||
function updatePlayButton() {
|
||
const btn = document.getElementById('playBtn');
|
||
btn.textContent = playing ? '⏸ 暂停' : '▶ 播放';
|
||
btn.classList.toggle('active', !playing);
|
||
}
|
||
|
||
document.getElementById('playBtn').addEventListener('click', () => {
|
||
playing = !playing; updatePlayButton();
|
||
});
|
||
document.getElementById('animSelect').addEventListener('change', (e) => {
|
||
const idx = parseInt(e.target.value, 10);
|
||
const anims = skeletonData.animations;
|
||
if (anims && anims[idx]) setAnimationByName(anims[idx].name);
|
||
});
|
||
document.getElementById('skinSelect').addEventListener('change', (e) => {
|
||
const idx = parseInt(e.target.value, 10);
|
||
const skins = skeletonData.skins;
|
||
if (skins && skins[idx]) applySkinByName(skins[idx].name);
|
||
});
|
||
document.getElementById('loopCheck').addEventListener('change', (e) => {
|
||
loop = e.target.checked;
|
||
const cur = state.getCurrent(0);
|
||
if (cur) cur.loop = loop;
|
||
});
|
||
document.getElementById('speedRange').addEventListener('input', (e) => {
|
||
speed = parseFloat(e.target.value);
|
||
document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x';
|
||
});
|
||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||
window.location.reload();
|
||
});
|
||
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
|
||
// 时间轴:拖动时实时跳帧
|
||
timeline.addEventListener('input', (e) => {
|
||
const t = parseFloat(e.target.value);
|
||
if (state) seekTo(t);
|
||
});
|
||
// 时间轴:按下时暂停动画,拖动结束(松开)恢复播放(若之前是播放状态)
|
||
timeline.addEventListener('pointerdown', () => {
|
||
seeking = true;
|
||
wasPlayingBeforeSeek = playing;
|
||
playing = false;
|
||
updatePlayButton();
|
||
});
|
||
const endTimelineSeek = () => {
|
||
if (!seeking) return;
|
||
seeking = false;
|
||
if (wasPlayingBeforeSeek) {
|
||
playing = true;
|
||
updatePlayButton();
|
||
}
|
||
};
|
||
timeline.addEventListener('pointerup', endTimelineSeek);
|
||
// 即便指针移出滑块松开,也能恢复
|
||
window.addEventListener('pointerup', endTimelineSeek);
|
||
|
||
// ---------- 画布交互:左键拖拽平移,右键回中心,滚轮缩放 ----------
|
||
canvas.addEventListener('pointerdown', (e) => {
|
||
if (e.button === 0) { // 左键
|
||
dragging = true;
|
||
dragStartX = e.clientX; dragStartY = e.clientY;
|
||
dragStartPanX = panX; dragStartPanY = panY;
|
||
canvas.setPointerCapture(e.pointerId);
|
||
} else if (e.button === 2) { // 右键:回到画布中心
|
||
panX = 0; panY = 0; zoom = 1;
|
||
fitToCanvas();
|
||
draw();
|
||
setStatus('已回到画布中心');
|
||
}
|
||
});
|
||
canvas.addEventListener('pointermove', (e) => {
|
||
if (!dragging) return;
|
||
// 直接按屏幕像素增量平移(clientX/Y 差值即像素位移,无需换算)
|
||
panX = dragStartPanX + (e.clientX - dragStartX);
|
||
panY = dragStartPanY + (e.clientY - dragStartY);
|
||
draw();
|
||
});
|
||
canvas.addEventListener('pointerup', () => { dragging = false; });
|
||
canvas.addEventListener('pointercancel', () => { dragging = false; });
|
||
// 右键菜单拦截
|
||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||
// 滚轮缩放(以画布中心为锚点,更快更跟手)
|
||
canvas.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // 每格缩放 15%
|
||
zoom = Math.min(50, Math.max(0.02, zoom * factor));
|
||
fitToCanvas();
|
||
draw();
|
||
}, { passive: false });
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.code === 'Space') { e.preventDefault(); playing = !playing; updatePlayButton(); }
|
||
});
|
||
window.addEventListener('resize', () => {
|
||
if (!running) return;
|
||
fitToCanvas();
|
||
draw();
|
||
});
|
||
|
||
// ---------- 启动 ----------
|
||
if (!filePath) {
|
||
showError('缺少文件', '未传入文件路径参数。');
|
||
} else if (!fs.existsSync(filePath)) {
|
||
showError('文件不存在', filePath);
|
||
} else {
|
||
loadAssets().catch((err) => {
|
||
showError('加载失败', (err && err.message) ? err.message : String(err));
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|