Files
SpineViewer/viewer/spine.html
T
2026-09-08 18:42:08 +08:00

798 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>