1107 lines
43 KiB
HTML
1107 lines
43 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: 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; }
|
||
#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: 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: 178px;
|
||
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>
|
||
<label title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
|
||
<input type="checkbox" id="pmaCheck" disabled> 预乘
|
||
</label>
|
||
<label title="50×50 方格背景,随画布移动和缩放,并参与混合模式">
|
||
<input type="checkbox" id="backgroundCheck" checked> 背景
|
||
</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">
|
||
<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.00 / 0.00 秒 · 0 / 0 帧</span>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// 使用 Node 集成提供的模块
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const utils = require('../js/spine-utils.js');
|
||
const spineBadge = require('../js/spine-badge.js');
|
||
const clippingRenderer = require('../js/spine-canvas-clipping.js');
|
||
let spineBadgeImagePromise = null;
|
||
|
||
function loadSpineBadgeImage() {
|
||
if (spineBadgeImagePromise) return spineBadgeImagePromise;
|
||
spineBadgeImagePromise = new Promise((resolve, reject) => {
|
||
try {
|
||
const image = new Image();
|
||
image.onload = () => resolve(image);
|
||
image.onerror = () => reject(new Error('Spine badge 加载失败'));
|
||
image.src = spineBadge.dataUrl();
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
});
|
||
return spineBadgeImagePromise;
|
||
}
|
||
|
||
async function drawSpineBadge(targetCtx, width, height) {
|
||
try {
|
||
const badge = await loadSpineBadgeImage();
|
||
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
|
||
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
||
targetCtx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
|
||
} catch (e) {
|
||
log('绘制 Spine badge 失败: ' + ((e && e.message) || String(e)));
|
||
}
|
||
}
|
||
|
||
// 简易日志(写入插件目录 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 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;
|
||
let renderer = null;
|
||
let activeRuntimeDir = null;
|
||
let activeVersion = null;
|
||
let currentAnim = 0, currentSkin = null;
|
||
let playing = true, loop = true, speed = 1;
|
||
let premultipliedAlpha = false;
|
||
let previewSettings = null;
|
||
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;
|
||
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, holdMs = 0) {
|
||
statusEl.textContent = msg;
|
||
statusHoldUntil = holdMs === Infinity ? Infinity : (holdMs > 0 ? performance.now() + holdMs : 0);
|
||
}
|
||
|
||
// ---------- 动态加载本地 Spine 运行时 ----------
|
||
function loadRuntime(dir) {
|
||
return new Promise((resolve, reject) => {
|
||
const s = document.createElement('script');
|
||
s.src = '../lib/' + dir + '/spine-canvas.js';
|
||
s.onload = () => {
|
||
try { require('../js/spine-legacy-binary.js').install(spine, dir); resolve(); }
|
||
catch (e) { reject(e); }
|
||
};
|
||
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 = async () => {
|
||
try { await require('../js/spine-webgl-rasterizer.js').prepareTexture(img, new Blob([data], { type: mime })); }
|
||
catch (e) { log('无损贴图解码不可用,使用浏览器贴图: ' + e.message); }
|
||
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);
|
||
ctx.fillStyle = '#1e1e1e'; ctx.fillRect(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);
|
||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(ctx, canvas.width, canvas.height);
|
||
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 eagleItemIdFromPath(sourcePath) {
|
||
const match = path.basename(path.dirname(sourcePath)).match(/^(.+)\.info$/i);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
|
||
if (/\.(atlas\.txt|skel\.bytes)$/i.test(filePath)) {
|
||
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
|
||
const info = await response.json();
|
||
if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath))
|
||
throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库');
|
||
if (info.data.ext === 'txt') throw new Error('此条目仍按 TXT 登记,请在 Eagle 重新生成缩略图并重新打开素材库');
|
||
}
|
||
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; }
|
||
const thumbBtn = document.getElementById('thumbBtn');
|
||
thumbBtn.disabled = true;
|
||
setStatus('正在生成并更新 Eagle 缩略图…', Infinity);
|
||
try {
|
||
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
|
||
playing = false;
|
||
updatePlayButton();
|
||
if (state) state.apply(skeleton);
|
||
updateWorld(skeleton);
|
||
|
||
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);
|
||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
|
||
let offRenderer = null;
|
||
try {
|
||
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
|
||
offRenderer.premultipliedAlpha = premultipliedAlpha;
|
||
offRenderer.draw(skeleton);
|
||
} finally {
|
||
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
|
||
octx.restore();
|
||
}
|
||
}
|
||
await drawSpineBadge(octx, size, size);
|
||
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
|
||
|
||
// 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('缩略图已写入;自动刷新失败:' + ((e && e.message) || String(e)), 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;
|
||
}
|
||
}
|
||
|
||
// ---------- 渲染主循环 ----------
|
||
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 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 norm = isStaticAnimation ? 0 : Math.max(0, Math.min(currentTime, duration));
|
||
timeline.value = String(norm);
|
||
updateTimeReadout(norm);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 帧号换算(默认 30fps)
|
||
function frameOf(seconds) {
|
||
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 : '';
|
||
}
|
||
|
||
// ---------- 填充 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(selectedSkin) {
|
||
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;
|
||
if (selectedSkin) {
|
||
const index = (skeletonData.skins || []).indexOf(selectedSkin);
|
||
if (index >= 0) sel.value = String(index);
|
||
}
|
||
}
|
||
|
||
function setAnimationByName(name) {
|
||
if (!state) return;
|
||
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 数组)
|
||
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 = 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 = !anim || isStaticAnimation;
|
||
durationLabel.textContent = isStaticAnimation ? '静帧 · 1 帧' : ('时长 ' + formatSeconds(duration) + ' · ' + frameOf(duration) + ' 帧');
|
||
updateTimeReadout(0);
|
||
renderTimeRuler();
|
||
renderKeyframeMarkers(anim);
|
||
updatePlayButton();
|
||
}
|
||
|
||
// 渲染关键帧标记(收集各 timeline 的关键时间点,用竖线标出)
|
||
function renderKeyframeMarkers(anim) {
|
||
keyframesEl.innerHTML = '';
|
||
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 = collectKeyframeTimes(anim).filter((time) => time <= duration);
|
||
// 限制数量,避免过多标记
|
||
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) + '%';
|
||
span.title = '关键帧 · ' + formatSeconds(t) + ' · 第 ' + frameOf(t) + ' 帧';
|
||
span.setAttribute('aria-label', span.title);
|
||
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();
|
||
updateTimeReadout(time);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function applySkinByName(name) {
|
||
if (!skeleton) return;
|
||
const skin = skeleton.data && skeleton.data.findSkin ? skeleton.data.findSkin(name)
|
||
: (skeletonData.skins || []).find((item) => item.name === name);
|
||
if (!utils.applySkin(skeleton, skin)) return;
|
||
currentSkin = skin;
|
||
if (state) state.apply(skeleton);
|
||
updateWorld(skeleton);
|
||
draw();
|
||
}
|
||
|
||
// ---------- 非 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() {
|
||
if (!utils.extOf(path.basename(filePath))) {
|
||
// Registering txt/bytes is required by Eagle's extension dispatch.
|
||
// Ordinary text remains readable and never starts Spine discovery.
|
||
document.getElementById('toolbar').style.display = 'none';
|
||
document.getElementById('timelineBar').style.display = 'none';
|
||
let message = '此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。';
|
||
if (/\.txt$/i.test(filePath)) {
|
||
const fd = fs.openSync(filePath, 'r');
|
||
try {
|
||
const buffer = Buffer.alloc(256 * 1024);
|
||
const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
||
message = buffer.subarray(0, count).toString('utf8');
|
||
if (fs.fstatSync(fd).size > count) message += '\n\n(仅显示前 256 KB)';
|
||
} finally { fs.closeSync(fd); }
|
||
}
|
||
showError(path.basename(filePath), message);
|
||
const box = overlay.querySelector('.box');
|
||
box.style.cssText = 'max-width:none;width:100%;height:100%;overflow:auto;text-align:left';
|
||
document.getElementById('errorTitle').style.color = 'var(--text)';
|
||
return;
|
||
}
|
||
// Eagle 只能按 .json 扩展名把文件交给格式扩展。查看器必须再次检查内容,
|
||
// 防止普通 JSON 被拿去匹配库中其他 Spine 骨骼资源。
|
||
if (utils.extOf(path.basename(filePath)) === '.json' && utils.classifySkeletonJson(filePath) !== 'spine') {
|
||
showError('普通 JSON 文件', '该文件不包含 Spine 导出的 skeleton.spine 版本字段,因此不会作为 Spine 骨骼数据加载。');
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
let savedPma;
|
||
try {
|
||
const settings = require('../js/spine-preview-settings.js');
|
||
const userData = await settings.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
|
||
previewSettings = settings.createStore(userData, filePath, urlParams.get('id'));
|
||
savedPma = previewSettings.read();
|
||
} catch (e) { log('读取预乘设置失败: ' + e.message); }
|
||
|
||
// 检测版本并选择运行时
|
||
const version = utils.detectVersion(assets.skeletonPath);
|
||
activeVersion = version;
|
||
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||
if (!runtimeDir) {
|
||
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
|
||
return;
|
||
}
|
||
activeRuntimeDir = runtimeDir;
|
||
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');
|
||
const atlasPma = utils.parseAtlasPma(atlasText);
|
||
// 用户明确切换过的设置用于下次预览;没有用户设置时,以 Atlas 为准。
|
||
premultipliedAlpha = savedPma !== undefined ? savedPma : (atlasPma !== undefined ? atlasPma : false);
|
||
document.getElementById('pmaCheck').checked = premultipliedAlpha;
|
||
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);
|
||
}
|
||
|
||
if (!skeletonData2 || !skeletonData2.bones || skeletonData2.bones.length === 0) {
|
||
showError('骨骼数据为空', '未能读取到有效的骨骼数据。');
|
||
return;
|
||
}
|
||
|
||
skeletonData = skeletonData2;
|
||
|
||
// 创建骨架与动画状态
|
||
skeleton = new spine.Skeleton(skeletonData);
|
||
currentSkin = utils.preferredSkin(skeletonData);
|
||
utils.applySkin(skeleton, currentSkin);
|
||
stateData = new spine.AnimationStateData(skeletonData);
|
||
stateData.defaultMix = 0.2;
|
||
state = new spine.AnimationState(stateData);
|
||
|
||
renderer = clippingRenderer.createRenderer(spine, ctx, runtimeDir);
|
||
renderer.premultipliedAlpha = premultipliedAlpha;
|
||
document.getElementById('pmaCheck').disabled = false;
|
||
|
||
fillAnimations();
|
||
fillSkins(currentSkin);
|
||
|
||
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
|
||
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);
|
||
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.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) => {
|
||
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('backgroundCheck').addEventListener('change', () => draw());
|
||
document.getElementById('pmaCheck').addEventListener('change', (e) => {
|
||
premultipliedAlpha = e.target.checked;
|
||
if (renderer) renderer.premultipliedAlpha = premultipliedAlpha;
|
||
draw();
|
||
try {
|
||
if (!previewSettings) throw new Error('素材设置尚未就绪');
|
||
previewSettings.write(premultipliedAlpha);
|
||
setStatus((premultipliedAlpha ? '预乘已开启' : '预乘已关闭') + ' · 已记住此素材的设置', 1800);
|
||
} catch (err) {
|
||
setStatus('预乘已切换,但设置保存失败:' + err.message, 5000);
|
||
}
|
||
});
|
||
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' && !isStaticAnimation) { 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>
|