网站页面

This commit is contained in:
tianmo committed 2026-09-12 18:10:52 +08:00
1 parent 55f811ef4a
commit f0470410df
18 files changed
+25943

No files matched your search

+842
View File
@@ -0,0 +1,842 @@
/* ==========================================================================
SpineViewer 插件介绍网站脚本
- 主题切换、图片放大、滚动出现
- 查看器交互演示:官方 Raptor 示例(Spine 4.3.75-beta 导出)
仅支持 Spine 4.3:使用 4.3 官方运行时 + 插件同款 WebGL 光栅化器,
WebGL 不可用时自动回退到 Canvas(与插件查看器的回退行为相同)。
========================================================================== */
(function () {
'use strict';
var root = document.documentElement;
var viewerEl = document.getElementById('viewerDemo');
function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); }
/* ---------------- 主题切换 ---------------- */
var themeToggle = document.getElementById('themeToggle');
if (themeToggle) {
themeToggle.addEventListener('click', function () {
var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
try { localStorage.setItem('sv-site-theme', next); } catch (e) {}
refreshViewerColors();
});
}
/* ---------------- 滚动出现(带兜底,内容不会一直隐藏) ---------------- */
var revealEls = Array.prototype.slice.call(document.querySelectorAll('.reveal'));
var revealTick = 0;
function revealInViewport() {
var vh = window.innerHeight || document.documentElement.clientHeight;
for (var i = 0; i < revealEls.length; i++) {
var el = revealEls[i];
if (el.classList.contains('visible')) continue;
var rect = el.getBoundingClientRect();
if (rect.top < vh * 0.92 && rect.bottom > 0) el.classList.add('visible');
}
}
function revealAll() {
revealEls.forEach(function (el) { el.classList.add('visible'); });
}
function scheduleReveal() {
if (revealTick) return;
revealTick = window.requestAnimationFrame(function () {
revealTick = 0;
revealInViewport();
});
}
revealInViewport();
window.addEventListener('scroll', scheduleReveal, { passive: true });
window.addEventListener('resize', scheduleReveal);
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') revealInViewport();
});
// 兜底:后台标签页 / 未触发滚动时,稍后也把全部内容显示出来
setTimeout(revealAll, 2000);
/* ---------------- 图片放大 ---------------- */
var lightbox = document.getElementById('lightbox');
var lightboxImg = lightbox ? lightbox.querySelector('img') : null;
function closeLightbox() {
if (!lightbox || lightbox.hidden) return;
lightbox.hidden = true;
lightboxImg.removeAttribute('src');
document.body.style.overflow = '';
}
if (lightbox) {
Array.prototype.forEach.call(document.querySelectorAll('img.zoomable'), function (img) {
img.addEventListener('click', function () {
lightboxImg.src = img.currentSrc || img.src;
lightboxImg.alt = img.alt || '';
lightbox.hidden = false;
document.body.style.overflow = 'hidden';
});
});
lightbox.addEventListener('click', closeLightbox);
window.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeLightbox();
});
}
/* ====================================================================
查看器交互演示(官方 Raptor 示例 · Spine 4.3 运行时渲染)
==================================================================== */
if (!viewerEl) return;
var spine = window.spine;
var svLibs = window.__svLibs || {};
var FPS = 30;
var EPS = 1e-6;
var RUNTIME_DIR = '4.3'; // 演示只使用 4.3
var DEFAULT_ANIMATION = 'walk';
var DEMO_FILES = {
json: 'assets/demo/raptor/raptor-pro.json',
atlas: 'assets/demo/raptor/raptor.atlas',
texture: 'assets/demo/raptor/raptor.png'
};
var stageEl = document.getElementById('demoStage');
var canvas = document.getElementById('demoCanvas');
var ctx = canvas.getContext('2d');
var playBtn = document.getElementById('demoPlay');
var loopCheck = document.getElementById('demoLoop');
var speedRange = document.getElementById('demoSpeed');
var speedVal = document.getElementById('demoSpeedVal');
var bgCheck = document.getElementById('demoBg');
var reloadBtn = document.getElementById('demoReload');
var skinSelect = document.getElementById('demoSkin');
var animListEl = document.getElementById('demoAnimList');
var statusEl = document.getElementById('demoStatus');
var durationEl = document.getElementById('demoDuration');
var rulerEl = document.getElementById('demoRuler');
var timelineEl = document.getElementById('demoTimeline');
var keysEl = document.getElementById('demoKeys');
var readoutEl = document.getElementById('demoReadout');
var versionEl = document.getElementById('demoVersion');
var state = {
playing: true,
loop: true,
speed: 1,
background: false,
seeking: false,
animIndex: 0,
panX: 0,
panY: 0,
zoom: 1
};
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
state.playing = false;
}
var runtime = {
data: null,
skeleton: null,
animState: null,
atlas: null,
texture: null,
renderer: null,
canvasOnly: false,
ready: false,
loading: false
};
var colors = { bg: '#1e1e1e' };
var dpr = 1;
var fitScale = 1, fitCx = 0, fitCy = 0;
var statusTimer = 0;
var drawErrorReported = false;
var lastFrameTime = performance.now();
var needsDraw = true; // 暂停或没有交互时不再重绘
var frameHandle = 0; // rAF 句柄:空闲时停止循环,避免持续占用 CPU
// 需要重绘时唤醒渲染循环
function wake() {
needsDraw = true;
if (!frameHandle) frameHandle = requestAnimationFrame(tick);
}
function refreshViewerColors() {
var cs = getComputedStyle(viewerEl);
colors.bg = (cs.getPropertyValue('--v-bg') || '#1e1e1e').trim();
wake();
}
// 控制画布总像素,避免高分辨率屏上占用过多显存
function pickDpr(width, height) {
var value = Math.min(window.devicePixelRatio || 1, 2);
var maxPixels = 900000;
if (width > 0 && height > 0 && width * height * value * value > maxPixels) {
value = Math.max(1, Math.sqrt(maxPixels / (width * height)));
}
return value;
}
/* ---------------- 状态提示 ---------------- */
function setStatus(message, holdMs) {
statusEl.textContent = message;
statusEl.classList.add('show');
clearTimeout(statusTimer);
statusTimer = 0;
if (holdMs > 0 && isFinite(holdMs)) {
statusTimer = setTimeout(function () { statusEl.classList.remove('show'); }, holdMs);
}
}
/* ---------------- 时间与刻度(与查看器同算法) ---------------- */
function frameOf(seconds) { return Math.round((seconds || 0) * FPS); }
function formatSeconds(seconds) {
var value = Math.max(0, Number(seconds) || 0);
if (value < 10) return value.toFixed(2) + ' 秒';
if (value < 60) return value.toFixed(1) + ' 秒';
var minutes = Math.floor(value / 60);
return minutes + ':' + (value % 60).toFixed(1).padStart(4, '0');
}
function niceTickInterval(totalSeconds) {
var target = totalSeconds / 8;
var choices = [1 / 30, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
for (var i = 0; i < choices.length; i++) {
if (choices[i] >= target) return choices[i];
}
return Math.pow(10, Math.ceil(Math.log10(target)));
}
function currentAnimation() {
if (!runtime.data || !runtime.data.animations) return null;
return runtime.data.animations[state.animIndex] || null;
}
function animationDuration(anim) {
if (!anim) return 0;
var declared = Number(anim.duration);
if (Number.isFinite(declared) && declared > EPS) return declared;
var times = collectKeyframeTimes(anim);
return times.length ? Math.max.apply(null, times) : 0;
}
function isStatic() { return animationDuration(currentAnimation()) <= EPS; }
function snapTimeToFrame(seconds) {
var duration = animationDuration(currentAnimation());
var lastFrame = Math.max(0, frameOf(duration));
var frame = clamp(Math.round((Number(seconds) || 0) * FPS), 0, lastFrame);
return frame / FPS;
}
// 与查看器一致:收集各 timeline 的关键帧时间
function collectKeyframeTimes(anim) {
if (!anim || !anim.timelines) return [];
var times = new Set();
for (var i = 0; i < anim.timelines.length; i++) {
var tl = anim.timelines[i];
var frames = tl && tl.frames;
if (!frames || !frames.length) continue;
try {
if (Array.isArray(frames[0])) {
for (var f = 0; f < frames.length; f++) {
var frame = frames[f];
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
}
continue;
}
var frameCount = typeof tl.getFrameCount === 'function' ? tl.getFrameCount() : 0;
var entries = typeof tl.getFrameEntries === 'function'
? tl.getFrameEntries()
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
for (var k = 0; k < frameCount; k++) {
var value = frames[k * entries];
if (Number.isFinite(value)) times.add(value);
}
} catch (e) { /* 忽略异常的 timeline */ }
}
return Array.from(times).filter(function (time) { return time >= 0; }).sort(function (a, b) { return a - b; });
}
function renderTimeRuler() {
rulerEl.innerHTML = '';
var duration = animationDuration(currentAnimation());
if (duration <= EPS) {
var staticLabel = document.createElement('span');
staticLabel.className = 'tick-label first';
staticLabel.style.left = '0%';
staticLabel.textContent = '0 秒(静帧)';
rulerEl.appendChild(staticLabel);
return;
}
var major = niceTickInterval(duration);
var minor = major / 5;
var count = Math.floor(duration / minor);
// 窄屏下隔一个主刻度显示标签,避免文字互相叠压
var skipLabels = rulerEl.clientWidth > 0 && rulerEl.clientWidth < 430 ? 2 : 1;
for (var i = 0; i <= count; i++) {
var value = Math.min(i * minor, duration);
var percent = (value / duration) * 100;
var isMajor = i % 5 === 0 || Math.abs(value - duration) < 1e-8;
var tick = document.createElement('span');
tick.className = 'tick' + (isMajor ? ' major' : '');
tick.style.left = percent + '%';
rulerEl.appendChild(tick);
if (isMajor) {
var isEnd = Math.abs(value - duration) < 1e-8;
if (!isEnd && (i / 5) % skipLabels !== 0) continue;
var label = document.createElement('span');
label.className = 'tick-label' + (i === 0 ? ' first' : (isEnd ? ' last' : ''));
label.style.left = percent + '%';
label.textContent = value < 10 ? value.toFixed(1) + 's' : Math.round(value) + 's';
rulerEl.appendChild(label);
}
if (Math.abs(value - duration) < 1e-8) break;
}
if (Math.abs(duration / minor - Math.round(duration / minor)) > 1e-8) {
var endTick = document.createElement('span');
endTick.className = 'tick major';
endTick.style.left = '100%';
rulerEl.appendChild(endTick);
var endLabel = document.createElement('span');
endLabel.className = 'tick-label last';
endLabel.style.left = '100%';
endLabel.textContent = duration < 10 ? duration.toFixed(2) + 's' : duration.toFixed(1) + 's';
rulerEl.appendChild(endLabel);
}
}
function renderKeyframes() {
keysEl.innerHTML = '';
var anim = currentAnimation();
var duration = animationDuration(anim);
if (!anim) return;
if (duration <= EPS) {
var span = document.createElement('span');
span.style.left = '0%';
span.title = '静帧姿势 · 第 1 帧';
keysEl.appendChild(span);
return;
}
var sorted = collectKeyframeTimes(anim).filter(function (time) { return time <= duration; });
// 限制数量,避免过多标记
var maxMarkers = 200;
var step = Math.max(1, Math.ceil(sorted.length / maxMarkers));
for (var i = 0; i < sorted.length; i += step) {
var keyframeTime = sorted[i];
var mark = document.createElement('span');
mark.style.left = (keyframeTime / duration) * 100 + '%';
mark.title = '关键帧 · ' + formatSeconds(keyframeTime) + ' · 第 ' + frameOf(keyframeTime) + ' 帧';
keysEl.appendChild(mark);
}
}
// 仅支持 4.3:轨道查询使用 getTrack
function trackEntry() {
return runtime.animState ? (runtime.animState.getTrack(0) || null) : null;
}
function currentTrackTime() {
var entry = trackEntry();
if (!entry) return 0;
var raw = Number.isFinite(entry.trackTime) ? entry.trackTime : 0;
var duration = animationDuration(currentAnimation());
if (state.loop && duration > EPS) return raw % duration;
return Math.max(0, Math.min(raw, duration));
}
function updateReadout() {
var duration = animationDuration(currentAnimation());
if (duration <= EPS) {
readoutEl.textContent = '静帧 · 1 / 1 帧';
if (!state.seeking) timelineEl.value = '0';
return;
}
var time = clamp(currentTrackTime(), 0, duration);
readoutEl.textContent = formatSeconds(time) + ' / ' + formatSeconds(duration) +
' · ' + frameOf(time) + ' / ' + frameOf(duration) + ' 帧';
if (!state.seeking) timelineEl.value = String(clamp(time / duration, 0, 1));
}
function updatePlayButton() {
if (!runtime.ready || isStatic()) {
playBtn.textContent = runtime.ready ? '▮ 静帧' : '⏸ 暂停';
playBtn.disabled = true;
return;
}
playBtn.disabled = false;
playBtn.textContent = state.playing ? '⏸ 暂停' : '▶ 播放';
}
function updateTimelineUI() {
var duration = animationDuration(currentAnimation());
var staticAnim = duration <= EPS;
durationEl.textContent = staticAnim
? '静帧 · 1 帧'
: '时长 ' + formatSeconds(duration) + ' · ' + frameOf(duration) + ' 帧';
timelineEl.disabled = staticAnim;
timelineEl.value = '0';
updatePlayButton();
renderTimeRuler();
renderKeyframes();
updateReadout();
}
function highlightAnimationItem() {
Array.prototype.forEach.call(animListEl.children, function (item, i) {
item.classList.toggle('active', i === state.animIndex);
item.setAttribute('aria-selected', i === state.animIndex ? 'true' : 'false');
});
}
function selectAnimation(index) {
state.animIndex = index;
var anim = currentAnimation();
if (runtime.animState && anim) {
var entry = runtime.animState.setAnimation(0, anim.name, state.loop);
if (entry) entry.loop = state.loop;
}
highlightAnimationItem();
updateTimelineUI();
wake();
}
/* ---------------- 骨架姿态与世界变换(4.3 API) ---------------- */
function updateWorld() {
if (runtime.skeleton) runtime.skeleton.updateWorldTransform(spine.Physics.update);
}
function applyPose() {
if (!runtime.animState || !runtime.skeleton) return;
runtime.animState.apply(runtime.skeleton);
updateWorld();
}
function getBounds(sk) {
if (!sk || typeof sk.getBoundsRect !== 'function') return null;
var rect = sk.getBoundsRect();
if (!rect || !(rect.width > 0) || !(rect.height > 0)) return null;
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
function fitToCanvas() {
if (!runtime.skeleton) return;
var bundle = getBounds(runtime.skeleton);
var cw = canvas.clientWidth, ch = canvas.clientHeight;
if (!bundle || !cw || !ch) {
fitScale = 1; fitCx = 0; fitCy = 0;
return;
}
// 自动适配并居中;顶部控制栏与底部时间轴是浮动面板,按其实际高度留出空间
var toolbarEl = viewerEl.querySelector('.v-toolbar');
var timelineBar = viewerEl.querySelector('.v-timeline');
var padTop = (toolbarEl ? toolbarEl.offsetHeight : 46) + 24;
var padBottom = (timelineBar ? timelineBar.offsetHeight : 68) + 24;
var pad = 0.06;
var availW = cw * (1 - pad * 2);
var availH = Math.max(60, ch - padTop - padBottom);
var scale = Math.min(availW / bundle.width, availH / bundle.height);
fitScale = (isFinite(scale) && scale > 0) ? scale : 1;
fitCx = bundle.x + bundle.width / 2;
fitCy = bundle.y + bundle.height / 2 - ((padBottom - padTop) / 2) / fitScale;
}
/* ---------------- 渲染 ---------------- */
function buildRenderer(context, allowWebGL) {
if (!svLibs.clipping || typeof svLibs.clipping.createRenderer !== 'function') throw new Error('渲染模块未加载');
var renderer;
if (allowWebGL) {
renderer = svLibs.clipping.createRenderer(spine, context, RUNTIME_DIR);
} else {
// 临时停用 WebGL 光栅化附着,得到纯 Canvas 渲染器
var rasterizer = svLibs.rasterizer || {};
var originalAttach = rasterizer.attach;
rasterizer.attach = function (r) { return r; };
try { renderer = svLibs.clipping.createRenderer(spine, context, RUNTIME_DIR); }
finally { if (originalAttach) rasterizer.attach = originalAttach; }
}
// 示例为普通 Alpha 导出(atlas 未标注 pma),预乘保持关闭
renderer.premultipliedAlpha = false;
return renderer;
}
function drawSkeletonSafely() {
try {
runtime.renderer.draw(runtime.skeleton);
} catch (e) {
if (!runtime.canvasOnly) {
// WebGL 光栅化不可用(缺少 WebGL、纹理受限等)时回退到 Canvas 路径
runtime.canvasOnly = true;
console.warn('WebGL 光栅化不可用,已回退到 Canvas 渲染:', (e && e.message) || e);
try { if (runtime.renderer && runtime.renderer.dispose) runtime.renderer.dispose(); } catch (e2) { /* ignore */ }
runtime.renderer = buildRenderer(ctx, false);
runtime.renderer.draw(runtime.skeleton);
return;
}
if (!drawErrorReported) {
drawErrorReported = true;
console.error(e);
setStatus('渲染失败:' + ((e && e.message) || e), 0);
}
}
}
function draw() {
var cw = canvas.clientWidth, ch = canvas.clientHeight;
if (!cw || !ch) return;
var pixelWidth = Math.round(cw * dpr), pixelHeight = Math.round(ch * dpr);
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = colors.bg;
ctx.fillRect(0, 0, cw, ch);
if (!runtime.ready || !runtime.skeleton || !runtime.renderer) return;
// 与查看器相同:居中 + 缩放 + Y 翻转;panX/panY 为屏幕坐标偏移
var scale = fitScale * state.zoom;
ctx.save();
ctx.translate(cw / 2 + state.panX, ch / 2 + state.panY);
ctx.scale(scale, -scale);
ctx.translate(-fitCx, -fitCy);
if (state.background && svLibs.background) {
svLibs.background.drawCheckerboard(ctx, canvas.width, canvas.height);
}
drawSkeletonSafely();
ctx.restore();
}
/* ---------------- 主循环 ---------------- */
function tick(now) {
frameHandle = 0;
var dt = Math.min(0.05, (now - lastFrameTime) / 1000);
lastFrameTime = now;
var advancing = false;
if (runtime.ready) {
var duration = animationDuration(currentAnimation());
advancing = state.playing && !state.seeking && state.speed > 0 && duration > EPS;
runtime.animState.timeScale = advancing ? state.speed : 0;
runtime.animState.update(dt);
runtime.animState.apply(runtime.skeleton);
updateWorld();
if (advancing) needsDraw = true;
// 非循环播放到结尾时停止推进(与查看器行为一致)
var entry = trackEntry();
if (entry && !state.loop && duration > EPS && entry.trackTime >= duration - 1e-4 && state.playing) {
state.playing = false;
updatePlayButton();
needsDraw = true;
advancing = false;
}
}
if (needsDraw) {
draw();
updateReadout();
needsDraw = false;
}
// 播放中持续刷新;暂停或空闲时停在这里,等待 wake() 唤醒
if (advancing) frameHandle = requestAnimationFrame(tick);
}
/* ---------------- 控件事件 ---------------- */
function togglePlay() {
if (!runtime.ready || isStatic()) return;
var duration = animationDuration(currentAnimation());
var entry = trackEntry();
// 播放到结尾后再次播放:从头发起
if (!state.playing && entry && duration > EPS && entry.trackTime >= duration - 1e-4) {
var anim = currentAnimation();
runtime.animState.setAnimation(0, anim.name, state.loop);
var next = trackEntry();
if (next) next.loop = state.loop;
}
state.playing = !state.playing;
updatePlayButton();
wake();
}
playBtn.addEventListener('click', togglePlay);
loopCheck.addEventListener('change', function () {
state.loop = loopCheck.checked;
var entry = trackEntry();
if (entry) entry.loop = state.loop;
});
speedRange.addEventListener('input', function () {
state.speed = Number(speedRange.value);
speedVal.textContent = state.speed.toFixed(1) + 'x';
});
bgCheck.addEventListener('change', function () {
state.background = bgCheck.checked;
wake();
});
skinSelect.addEventListener('change', function () {
if (!runtime.skeleton) return;
runtime.skeleton.setSkinByName(skinSelect.value);
runtime.skeleton.setupPoseSlots();
setStatus('皮肤:' + skinSelect.value, 1600);
wake();
});
reloadBtn.addEventListener('click', function () {
state.panX = 0;
state.panY = 0;
state.zoom = 1;
if (runtime.ready) {
selectAnimation(state.animIndex);
fitToCanvas();
}
setStatus('已重新加载目录', 1800);
});
// 时间轴:拖动时暂停推进,并按帧对齐(与查看器一致)
timelineEl.addEventListener('pointerdown', function () { state.seeking = true; });
timelineEl.addEventListener('input', function () {
if (!runtime.ready || isStatic()) return;
state.seeking = true;
var duration = animationDuration(currentAnimation());
var time = snapTimeToFrame(Number(timelineEl.value) * duration);
var entry = trackEntry();
if (entry) entry.trackTime = time;
applyPose();
updateReadout();
wake();
});
timelineEl.addEventListener('change', function () { state.seeking = false; });
window.addEventListener('pointerup', function () { state.seeking = false; });
// 画布:左键拖拽平移,滚轮缩放,右键回到中心
var dragging = false, dragX = 0, dragY = 0, startPanX = 0, startPanY = 0;
stageEl.addEventListener('pointerdown', function (e) {
if (e.button !== 0) return;
dragging = true;
dragX = e.clientX;
dragY = e.clientY;
startPanX = state.panX;
startPanY = state.panY;
stageEl.classList.add('dragging');
if (stageEl.setPointerCapture) stageEl.setPointerCapture(e.pointerId);
});
stageEl.addEventListener('pointermove', function (e) {
if (!dragging) return;
state.panX = startPanX + (e.clientX - dragX);
state.panY = startPanY + (e.clientY - dragY);
wake();
});
function endDrag() {
dragging = false;
stageEl.classList.remove('dragging');
}
stageEl.addEventListener('pointerup', endDrag);
stageEl.addEventListener('pointercancel', endDrag);
stageEl.addEventListener('wheel', function (e) {
e.preventDefault();
state.zoom = clamp(state.zoom * Math.exp(-e.deltaY * 0.0012), 0.3, 6);
wake();
}, { passive: false });
stageEl.addEventListener('contextmenu', function (e) {
e.preventDefault();
state.panX = 0;
state.panY = 0;
state.zoom = 1;
wake();
setStatus('已回到画布中心', 1600);
});
// 空格:鼠标悬停或演示区聚焦时播放/暂停
var hovering = false;
viewerEl.addEventListener('pointerenter', function () { hovering = true; });
viewerEl.addEventListener('pointerleave', function () { hovering = false; });
window.addEventListener('keydown', function (e) {
if (e.code !== 'Space' && e.key !== ' ') return;
var active = document.activeElement;
if (active && viewerEl.contains(active) && active !== viewerEl && active.closest('button, select, input, summary, a')) return;
if (!hovering && (!active || !viewerEl.contains(active))) return;
e.preventDefault();
togglePlay();
});
/* ---------------- 示例数据加载 ---------------- */
function loadImage(src) {
return new Promise(function (resolve, reject) {
var image = new Image();
image.onload = function () { resolve(image); };
image.onerror = function () { reject(new Error('贴图加载失败')); };
image.src = src;
});
}
function fetchText(url) {
return fetch(url).then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text();
});
}
// 内嵌副本按需加载:file:// 或原始文件读取失败时才会用到
function ensureEmbeddedData() {
if (window.SPINE_DEMO_DATA) return Promise.resolve(window.SPINE_DEMO_DATA);
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'assets/demo/raptor/embedded.js';
script.onload = function () {
if (window.SPINE_DEMO_DATA) resolve(window.SPINE_DEMO_DATA);
else reject(new Error('缺少内嵌示例数据'));
};
script.onerror = function () { reject(new Error('缺少内嵌示例数据')); };
document.head.appendChild(script);
});
}
// http 环境读取同目录下的原始文件;file:// 下浏览器禁止 fetch 本地文本,改用内嵌副本
function loadDemoData() {
if (location.protocol === 'file:') {
return ensureEmbeddedData().then(function (embedded) {
return { data: embedded.data, atlas: embedded.atlas, texture: embedded.texture || DEMO_FILES.texture, source: 'embedded' };
});
}
return Promise.all([fetchText(DEMO_FILES.json), fetchText(DEMO_FILES.atlas)]).then(function (texts) {
return { data: JSON.parse(texts[0]), atlas: texts[1], texture: DEMO_FILES.texture, source: 'file' };
}).catch(function () {
return ensureEmbeddedData().then(function (embedded) {
return { data: embedded.data, atlas: embedded.atlas, texture: embedded.texture || DEMO_FILES.texture, source: 'embedded' };
});
});
}
function fillSkinSelect() {
skinSelect.innerHTML = '';
var skins = (runtime.data && runtime.data.skins) || [];
skins.forEach(function (skin) {
var option = document.createElement('option');
option.value = skin.name;
option.textContent = skin.name;
skinSelect.appendChild(option);
});
skinSelect.disabled = skins.length === 0;
}
function fillAnimationList() {
animListEl.innerHTML = '';
var animations = (runtime.data && runtime.data.animations) || [];
animations.forEach(function (anim, i) {
var item = document.createElement('button');
item.type = 'button';
item.className = 'v-anim-item';
item.textContent = anim.name;
item.title = anim.name;
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', 'false');
item.addEventListener('click', function () { selectAnimation(i); });
animListEl.appendChild(item);
});
}
async function initDemo() {
if (runtime.ready || runtime.loading) return;
runtime.loading = true;
setStatus('正在加载官方示例 Raptor…', 0);
try {
if (!spine || typeof spine.SkeletonJson !== 'function') throw new Error('Spine 运行时未加载');
var payload = await loadDemoData();
var image = await loadImage(payload.texture);
runtime.texture = new spine.CanvasTexture(image);
// 仅支持 4.3:TextureAtlas 构造器只接收文本,纹理通过 page.setTexture 绑定
runtime.atlas = new spine.TextureAtlas(payload.atlas);
for (var p = 0; p < runtime.atlas.pages.length; p++) {
runtime.atlas.pages[p].setTexture(runtime.texture);
}
var skeletonJson = new spine.SkeletonJson(new spine.AtlasAttachmentLoader(runtime.atlas));
runtime.data = skeletonJson.readSkeletonData(payload.data);
runtime.skeleton = new spine.Skeleton(runtime.data);
runtime.animState = new spine.AnimationState(new spine.AnimationStateData(runtime.data));
runtime.renderer = buildRenderer(ctx, true);
// 默认皮肤:优先使用数据声明的默认皮肤,没有则用列表第一个
var defaultSkin = runtime.data.defaultSkin || (runtime.data.skins && runtime.data.skins[0]) || null;
fillSkinSelect();
if (defaultSkin) {
runtime.skeleton.setSkin(defaultSkin);
skinSelect.value = defaultSkin.name;
}
runtime.skeleton.setupPoseSlots();
fillAnimationList();
// 默认播放 walk
var walkIndex = 0;
for (var i = 0; i < runtime.data.animations.length; i++) {
if (runtime.data.animations[i].name === DEFAULT_ANIMATION) { walkIndex = i; break; }
}
state.animIndex = walkIndex;
selectAnimation(walkIndex);
// 与查看器一致:先应用姿态并更新世界变换,再按包围盒自动适配
applyPose();
versionEl.textContent = 'Spine ' + (runtime.data.version || '');
runtime.ready = true;
dpr = pickDpr(canvas.clientWidth, canvas.clientHeight);
fitToCanvas();
updateTimelineUI();
wake();
setStatus('已加载官方示例 Raptor · ' + runtime.data.animations.length + ' 个动画 · ' +
(payload.source === 'embedded' ? '内嵌数据' : '本地文件'), 3200);
} catch (e) {
console.error(e);
setStatus('示例加载失败:' + ((e && e.message) || e), 0);
} finally {
runtime.loading = false;
}
}
/* ---------------- 尺寸与启动 ---------------- */
function resize() {
dpr = pickDpr(canvas.clientWidth, canvas.clientHeight);
fitToCanvas();
renderTimeRuler(); // 宽度变化时重新决定刻度标签密度
wake();
}
if ('ResizeObserver' in window) {
new ResizeObserver(resize).observe(stageEl);
} else {
window.addEventListener('resize', resize);
}
refreshViewerColors();
dpr = pickDpr(canvas.clientWidth, canvas.clientHeight);
updatePlayButton();
wake();
initDemo();
})();
+26
View File
@@ -0,0 +1,26 @@
Spine Runtimes License Agreement
Last updated April 5, 2025. Replaces all prior versions.
Copyright (c) 2013-2025, Esoteric Software LLC
Integration of the Spine Runtimes into software or otherwise creating
derivative works of the Spine Runtimes is permitted under the terms and
conditions of Section 2 of the Spine Editor License Agreement:
https://esotericsoftware.com/spine-editor-license
Otherwise, it is permitted to integrate the Spine Runtimes into software
or otherwise create derivative works of the Spine Runtimes (collectively,
"Products"), provided that each user of the Products must obtain their own
Spine Editor license and redistribution of the Products in any form must
include this license and copyright notice.
THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+12
View File
@@ -0,0 +1,12 @@
// 官方 Raptor 示例(Spine 4.3.75-beta 导出)的 JS 内嵌副本。
// 用途:以 file:// 直接打开页面时,浏览器不允许 fetch 本地文本,本地图片也会污染画布,
// 这里预先内嵌 JSON、Atlas 与贴图的 data URL,保证离线与 file:// 场景下同样可以渲染。
// 数据来源:Esoteric Software spine-runtimes 仓库 examples/raptor/export
// 固定提交:ba17cf88b198c6531c6c51d8d4e1e4b3c3ab47dd(Spine Runtimes License,见 LICENSE.txt)。
window.SPINE_DEMO_DATA = {
name: "Raptor",
version: "4.3.75-beta",
data: {"skeleton":{"hash":"OKE722Fi+rc","spine":"4.3.75-beta","x":-809.163,"y":-73.5388,"width":1287.646,"height":1039.443,"images":"./images/","audio":null},"bones":[{"name":"root"},{"name":"hip","parent":"root","rotation":8.95755,"x":-122.52013,"y":392.648,"color":"fbff00ff"},{"name":"torso1","parent":"hip","length":126.26,"rotation":-4.98,"x":30.04,"y":-0.4,"color":"eaff00ff"},{"name":"saddle","parent":"torso1","length":50.92,"rotation":91.8,"x":25.310295,"y":70.64713,"color":"ff7300ff"},{"name":"spineboy-hip","parent":"saddle","length":0.53,"rotation":90.02,"x":81.88,"y":2.69,"color":"e8ff00ff"},{"name":"spineboy-torso","parent":"spineboy-hip","length":122.45,"rotation":-75.86,"x":1.05,"y":-2.11,"color":"e8ff00ff"},{"name":"torso2","parent":"torso1","length":121.2,"rotation":39.85,"x":126.26,"y":-0.38,"color":"e9ff00ff"},{"name":"neck","parent":"torso2","length":70.6,"rotation":41.38,"x":121.2,"y":0.35,"color":"eaff00ff"},{"name":"head","parent":"neck","length":105.51,"rotation":9.83,"x":70.6,"y":0.04,"color":"eaff00ff"},{"name":"horn-back","parent":"head","length":73.78,"rotation":44.32,"x":104.76395,"y":-242.01355,"color":"e07800ff"},{"name":"spineboy-back-arm-target","parent":"horn-back","rotation":-133.55,"x":232.68,"y":245.85,"inherit":"noScale","color":"ff3f00ff"},{"name":"back-arm","parent":"spineboy-torso","length":67.21,"rotation":-120.9,"x":96.33,"y":-38.47,"color":"e07800ff"},{"name":"back-bracer","parent":"back-arm","length":43.69,"rotation":17.48,"x":67.22,"y":-0.32,"color":"e07800ff"},{"name":"back-arm1","parent":"torso2","length":109.56,"rotation":-124.72,"x":83.67854,"y":-83.23534,"color":"e07800ff"},{"name":"back-arm2","parent":"back-arm1","length":85.8,"rotation":123.56,"x":109.57,"y":-0.01,"color":"e07800ff"},{"name":"back-foot-target","parent":"root","x":33.44,"y":30.82,"color":"ff3f00ff","icon":"ik"},{"name":"back-leg-target","parent":"back-foot-target","x":-127.51,"y":75.99,"color":"ff3f00ff","icon":"ik"},{"name":"back-leg1","parent":"hip","length":226.28,"rotation":-54.76,"x":55.19,"y":-71.25,"color":"e07800ff"},{"name":"back-leg2","parent":"back-leg1","length":172.59,"rotation":-92.25,"x":226.32,"y":0.23,"color":"e07800ff"},{"name":"back-leg3","parent":"back-leg2","length":103.05,"rotation":82.82,"x":172.32,"y":2.21,"color":"e07800ff"},{"name":"back-foot1","parent":"back-leg3","length":84.51,"rotation":75.43,"x":102.38,"y":-0.03,"color":"e07800ff"},{"name":"back-foot2","parent":"back-foot1","length":102.31,"rotation":-6.14,"x":84.5,"y":-0.35,"inherit":"noRotationOrReflection","color":"e07800ff"},{"name":"back-hand","parent":"back-arm2","length":45.81,"rotation":-76.28,"x":85.8,"y":0.11,"color":"e07800ff"},{"name":"back-hand2","parent":"spineboy-back-arm-target","length":41.98,"rotation":27.77738,"x":-0.02857208,"y":0.05377197,"inherit":"noRotationOrReflection","color":"e07800ff"},{"name":"spineboy-back-foot-target","parent":"saddle","x":-30.44,"y":-100.08,"color":"ff3f00ff"},{"name":"back-thigh","parent":"spineboy-hip","length":71.16,"rotation":160.75,"x":-9.57,"y":2.32,"color":"e07800ff"},{"name":"back-knee","parent":"back-thigh","length":97.17,"rotation":-54.98,"x":71.16,"y":-0.28,"color":"e07800ff"},{"name":"neck2","parent":"spineboy-torso","length":32.05,"rotation":-45.23,"x":113.44,"y":-15.22,"color":"e8ff00ff"},{"name":"head2","parent":"neck2","length":167.1939,"rotation":11.66,"x":25.68,"y":-0.77,"inherit":"noScale","color":"e7ff00ff"},{"name":"bone","parent":"head2","length":39.91506,"rotation":-35.22991,"x":166.09326,"y":-79.27026,"color":"e7ff00ff"},{"name":"bone2","parent":"bone","length":47.4192,"rotation":51.7975,"x":39.9151,"color":"e7ff00ff"},{"name":"bone3","parent":"head2","length":45.40962,"rotation":-12.343472,"x":179.80286,"y":-20.90898,"color":"e7ff00ff"},{"name":"bone4","parent":"bone3","length":43.31327,"rotation":42.0083,"x":44.63544,"y":0.303009,"color":"e7ff00ff"},{"name":"bone5","parent":"bone4","length":44.87501,"rotation":48.80142,"x":43.31311,"color":"e7ff00ff"},{"name":"horn-front","parent":"head","length":87.48,"rotation":49.36,"x":87.97336,"y":-235.14723,"color":"15ff00ff"},{"name":"spineboy-front-arm-target","parent":"horn-front","rotation":-138.6,"x":294.58,"y":234.18,"color":"ff3f00ff"},{"name":"front-arm","parent":"spineboy-torso","length":74.52,"rotation":-118.17,"x":101.38,"y":9.79,"color":"14ff00ff"},{"name":"front-bracer","parent":"front-arm","length":39.85,"rotation":20.31,"x":74.52,"y":-0.42,"color":"14ff00ff"},{"name":"front-arm1","parent":"torso2","length":109.99,"rotation":224.54,"x":73.00035,"y":-72.46397,"color":"15ff00ff"},{"name":"front-arm2","parent":"front-arm1","length":86.33,"rotation":105.24,"x":109.99,"y":0.2,"color":"15ff00ff"},{"name":"front-foot-target","parent":"root","rotation":-6.956577,"x":-45.8,"y":-28.67,"color":"ff3f00ff","icon":"ik"},{"name":"front-leg-target","parent":"front-foot-target","x":-106.06,"y":115.58,"color":"ff3f00ff","icon":"ik"},{"name":"front-leg1","parent":"hip","length":251.75,"rotation":-51.51,Line truncated
atlas: "raptor.png\n\tsize: 1024, 512\n\tfilter: Linear, Linear\n\tscale: 0.5\nback-arm\n\tbounds: 206, 49, 46, 25\nback-bracer\n\tbounds: 498, 42, 39, 28\nback-hand\n\tbounds: 400, 55, 36, 34\nback-knee\n\tbounds: 498, 72, 49, 67\nback-thigh\n\tbounds: 254, 40, 39, 24\neyes-open\n\tbounds: 232, 328, 47, 45\nfront-arm\n\tbounds: 350, 63, 48, 26\nfront-bracer\n\tbounds: 163, 56, 41, 29\nfront-hand\n\tbounds: 956, 231, 41, 38\nfront-open-hand\n\tbounds: 770, 329, 43, 44\nfront-thigh\n\tbounds: 897, 240, 57, 29\ngun\n\tbounds: 915, 106, 107, 103\ngun-nohand\n\tbounds: 916, 271, 105, 102\nhead\n\tbounds: 626, 81, 136, 149\nlower-leg\n\tbounds: 551, 42, 73, 98\nmouth-grind\n\tbounds: 2, 64, 47, 30\nmouth-smile\n\tbounds: 841, 127, 47, 30\nneck\n\tbounds: 281, 352, 18, 21\nraptor-back-arm\n\tbounds: 80, 11, 82, 86\nraptor-body\n\tbounds: 2, 140, 632, 233\nraptor-front-arm\n\tbounds: 911, 2, 81, 102\nraptor-front-leg\n\tbounds: 524, 182, 191, 257\n\trotate: 90\nraptor-hindleg-back\n\tbounds: 745, 158, 169, 215\n\trotate: 180\nraptor-horn\n\tbounds: 2, 293, 182, 80\nraptor-horn-back\n\tbounds: 174, 123, 176, 77\nraptor-jaw\n\tbounds: 772, 35, 126, 138\n\trotate: 270\nraptor-jaw-tooth\n\tbounds: 686, 242, 37, 48\nraptor-mouth-inside\n\tbounds: 755, 39, 36, 41\nraptor-saddle-strap-back\n\tbounds: 626, 26, 54, 74\nraptor-saddle-strap-front\n\tbounds: 2, 96, 57, 95\nraptor-saddle-w-shadow\n\tbounds: 2, 88, 162, 171\n\trotate: 90\nraptor-tail-shadow\n\tbounds: 159, 58, 189, 63\nraptor-tongue\n\tbounds: 685, 33, 86, 64\nstirrup-back\n\tbounds: 783, 204, 44, 35\nstirrup-front\n\tbounds: 185, 323, 45, 50\nstirrup-strap\n\tbounds: 636, 217, 49, 46\ntorso\n\tbounds: 442, 48, 54, 91\nvisor\n\tbounds: 356, 91, 131, 84\n\trotate: 90\n",
texture: "data:image/png;base64,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 truncated
};
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
raptor.png
size: 1024, 512
filter: Linear, Linear
scale: 0.5
back-arm
bounds: 206, 49, 46, 25
back-bracer
bounds: 498, 42, 39, 28
back-hand
bounds: 400, 55, 36, 34
back-knee
bounds: 498, 72, 49, 67
back-thigh
bounds: 254, 40, 39, 24
eyes-open
bounds: 232, 328, 47, 45
front-arm
bounds: 350, 63, 48, 26
front-bracer
bounds: 163, 56, 41, 29
front-hand
bounds: 956, 231, 41, 38
front-open-hand
bounds: 770, 329, 43, 44
front-thigh
bounds: 897, 240, 57, 29
gun
bounds: 915, 106, 107, 103
gun-nohand
bounds: 916, 271, 105, 102
head
bounds: 626, 81, 136, 149
lower-leg
bounds: 551, 42, 73, 98
mouth-grind
bounds: 2, 64, 47, 30
mouth-smile
bounds: 841, 127, 47, 30
neck
bounds: 281, 352, 18, 21
raptor-back-arm
bounds: 80, 11, 82, 86
raptor-body
bounds: 2, 140, 632, 233
raptor-front-arm
bounds: 911, 2, 81, 102
raptor-front-leg
bounds: 524, 182, 191, 257
rotate: 90
raptor-hindleg-back
bounds: 745, 158, 169, 215
rotate: 180
raptor-horn
bounds: 2, 293, 182, 80
raptor-horn-back
bounds: 174, 123, 176, 77
raptor-jaw
bounds: 772, 35, 126, 138
rotate: 270
raptor-jaw-tooth
bounds: 686, 242, 37, 48
raptor-mouth-inside
bounds: 755, 39, 36, 41
raptor-saddle-strap-back
bounds: 626, 26, 54, 74
raptor-saddle-strap-front
bounds: 2, 96, 57, 95
raptor-saddle-w-shadow
bounds: 2, 88, 162, 171
rotate: 90
raptor-tail-shadow
bounds: 159, 58, 189, 63
raptor-tongue
bounds: 685, 33, 86, 64
stirrup-back
bounds: 783, 204, 44, 35
stirrup-front
bounds: 185, 323, 45, 50
stirrup-strap
bounds: 636, 217, 49, 46
torso
bounds: 442, 48, 54, 91
visor
bounds: 356, 91, 131, 84
rotate: 90
Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+25
View File
@@ -0,0 +1,25 @@
'use strict';
const patterns = new WeakMap();
// Called with the same world-to-canvas transform used to draw the skeleton.
function drawCheckerboard(ctx, width, height) {
let pattern = patterns.get(ctx);
if (!pattern) {
const tile = ctx.canvas.ownerDocument.createElement('canvas');
tile.width = tile.height = 100;
const paint = tile.getContext('2d');
paint.fillStyle = '#505052'; paint.fillRect(0, 0, 100, 100);
paint.fillStyle = '#5a5a5c'; paint.fillRect(0, 0, 50, 50); paint.fillRect(50, 50, 50, 50);
pattern = ctx.createPattern(tile, 'repeat'); patterns.set(ctx, pattern);
}
const m = ctx.getTransform().inverse();
const corners = [[0,0], [width,0], [width,height], [0,height]].map(([x,y]) => [m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f]);
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
const x = Math.min(...xs), y = Math.min(...ys);
if (!Number.isFinite(x + y)) return;
ctx.save();
try { ctx.imageSmoothingEnabled = false; ctx.fillStyle = pattern; ctx.fillRect(x, y, Math.max(...xs)-x, Math.max(...ys)-y); }
finally { ctx.restore(); }
}
module.exports = { drawCheckerboard };
File diff suppressed because it is too large. Load diff
+243
View File
@@ -0,0 +1,243 @@
'use strict';
// The official spine-canvas SkeletonRenderer does not process clipping
// attachments. Keep its triangle rasterizer, but feed it geometry produced by
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen'];
const unpremultipliedImages = new WeakMap();
function rendererConstructor(spine) {
return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer;
}
function imageFromAttachment(attachment) {
const region = attachment && attachment.region;
// 4.0 stores the texture on the atlas page, while the other bundled
// runtimes expose it directly on the attachment region.
const renderRegion = region && region.renderObject;
const texture = region && (region.texture
|| (region.page && region.page.texture)
|| (renderRegion && renderRegion.texture)
|| (renderRegion && renderRegion.page && renderRegion.page.texture));
return texture && typeof texture.getImage === 'function' ? texture.getImage() : null;
}
// Canvas 2D always applies source-alpha blending. A Spine texture exported
// with PMA already has RGB multiplied by alpha, so drawing it directly would
// multiply translucent edge pixels a second time. Convert it once to straight
// alpha and cache the result; Canvas can then blend it correctly.
function imageForCanvas(image, context, premultipliedAlpha) {
if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image;
const cached = unpremultipliedImages.get(image);
if (cached) return cached;
try {
const ownerDocument = context.canvas && context.canvas.ownerDocument;
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
if (!image.width || !image.height) return image;
let canvas;
if (doc) canvas = doc.createElement('canvas');
else {
try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); }
catch (e) { return image; }
}
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0 && alpha < 255) {
const factor = 255 / alpha;
data[i] = Math.min(255, Math.round(data[i] * factor));
data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor));
data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor));
}
}
ctx.putImageData(pixels, 0, 0);
unpremultipliedImages.set(image, canvas);
return canvas;
} catch (e) {
// If pixel access is unavailable, retain the original texture so preview
// remains usable instead of failing the whole frame.
return image;
}
}
function unpackVertices(vertices, count) {
const positions = new Array(count * 2);
const uvs = new Array(count * 2);
for (let i = 0, p = 0; i < count; i++, p += 2) {
const source = i * 8;
positions[p] = vertices[source];
positions[p + 1] = vertices[source + 1];
uvs[p] = vertices[source + 6];
uvs[p + 1] = vertices[source + 7];
}
return { positions, uvs };
}
function drawInterleaved(base, image, vertices, triangles, darkColor) {
for (let i = 0; i < triangles.length; i += 3) {
const a = triangles[i] * 8;
const b = triangles[i + 1] * 8;
const c = triangles[i + 2] * 8;
base.drawTriangle(image,
vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7],
vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7],
vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7],
[...vertices.slice(a+2,a+6), ...vertices.slice(b+2,b+6), ...vertices.slice(c+2,c+6)],
darkColor ? Array.from({length:3}, () => [darkColor.r,darkColor.g,darkColor.b,0]).flat() : undefined);
}
}
function createLegacyRenderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const clipper = new spine.SkeletonClipping();
const dark = new spine.Color(0, 0, 0, 0);
base.draw = function drawWithClipping(skeleton) {
const composite = context.globalCompositeOperation;
const drawOrder = skeleton.drawOrder;
const pma = !!base.premultipliedAlpha;
try {
for (let i = 0; i < drawOrder.length; i++) {
const slot = drawOrder[i];
if (slot.bone && slot.bone.active === false) {
clipper.clipEndWithSlot(slot);
continue;
}
const attachment = slot.getAttachment();
if (attachment instanceof spine.ClippingAttachment) {
clipper.clipStart(slot, attachment);
continue;
}
try {
if (!attachment) continue;
let vertices;
let triangles;
let vertexCount;
if (attachment instanceof spine.RegionAttachment) {
vertices = base.computeRegionVertices(slot, attachment, false);
triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES;
vertexCount = 4;
} else if (attachment instanceof spine.MeshAttachment) {
vertices = base.computeMeshVertices(slot, attachment, false);
triangles = attachment.triangles;
vertexCount = attachment.worldVerticesLength >> 1;
} else {
continue;
}
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = base.tempColor.a;
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
if (clipper.isClipping()) {
const unpacked = unpackVertices(vertices, vertexCount);
clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false);
drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles, slot.darkColor);
} else {
drawInterleaved(base, image, vertices, triangles, slot.darkColor);
}
} finally {
clipper.clipEndWithSlot(slot);
}
}
} finally {
clipper.clipEnd();
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = clipper;
return base;
}
// 3.4/3.5 predate clipping attachments and compute packed vertices on attachments.
function create34Renderer(spine, context) {
const base = new (rendererConstructor(spine))(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
base.draw = skeleton => {
const composite = context.globalCompositeOperation;
try {
for (const slot of skeleton.drawOrder) {
const attachment = slot.getAttachment();
if (!(attachment instanceof spine.RegionAttachment) && !(attachment instanceof spine.MeshAttachment)) continue;
const vertices = attachment.updateWorldVertices(slot, false);
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, base.premultipliedAlpha);
if (!image) continue;
context.globalAlpha = vertices[5];
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
}
} finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; }
};
return base;
}
function create43Renderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const core = new spine.SkeletonRendererCore();
base.draw = function drawWithCoreClipping(skeleton) {
const composite = context.globalCompositeOperation;
try {
const pma = !!base.premultipliedAlpha;
for (let command = core.render(skeleton, false); command; command = command.next) {
const sourceImage = command.texture && command.texture.getImage && command.texture.getImage();
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over';
const positions = command.positions;
const uvs = command.uvs;
const indices = command.indices;
for (let i = 0; i < command.numIndices; i += 3) {
const a = indices[i] * 2;
const b = indices[i + 1] * 2;
const c = indices[i + 2] * 2;
const unpack = packed => [((packed>>>16)&255)/255,((packed>>>8)&255)/255,(packed&255)/255,((packed>>>24)&255)/255];
const colors = [a,b,c].flatMap(index => unpack(command.colors[index/2]));
const darkColors = [a,b,c].flatMap(index => unpack(command.darkColors[index/2]));
base.drawTriangle(image,
positions[a], positions[a + 1], uvs[a], uvs[a + 1],
positions[b], positions[b + 1], uvs[b], uvs[b + 1],
positions[c], positions[c + 1], uvs[c], uvs[c + 1], colors, darkColors);
}
}
} finally {
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = core.clipping;
return base;
}
function createRenderer(spine, context, runtimeDir) {
const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3'
? create43Renderer(spine, context)
: createLegacyRenderer(spine, context);
// Explicitly disable debug drawing in every supported runtime.
renderer.debugRendering = false;
return require('./spine-webgl-rasterizer.js').attach(renderer, context);
}
module.exports = { createRenderer };
+177
View File
@@ -0,0 +1,177 @@
'use strict';
// Decode file bytes without the Canvas 2D premultiply/unpremultiply round trip.
// In low-alpha PMA pixels that round trip destroys RGB precision before blending.
const preparedTextures = new WeakMap();
async function prepareTexture(image, blob) {
if (typeof createImageBitmap !== 'function') return;
const options = { colorSpaceConversion: 'none' };
const [pma, straight] = await Promise.all([
createImageBitmap(blob, { ...options, premultiplyAlpha: 'none' }),
createImageBitmap(blob, { ...options, premultiplyAlpha: 'premultiply' })
]);
preparedTextures.set(image, { pma, straight });
}
// Rasterize adjacent mesh triangles together, with GPU edge ownership rules.
// Canvas clip()+drawImage() anti-aliases each triangle separately and exposes
// the background along shared edges, which looks like a wireframe overlay.
function createRasterizer(context) {
const document = context.canvas && context.canvas.ownerDocument;
if (!document || typeof context.getTransform !== 'function') return null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl', {
alpha: true, premultipliedAlpha: true, antialias: true,
preserveDrawingBuffer: true, depth: false, stencil: false
});
if (!gl) return null;
function shader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source); gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
return shader;
}
const vertex = shader(gl.VERTEX_SHADER, `
attribute vec2 position;
attribute vec2 uv;
attribute vec4 light;
attribute vec4 dark;
uniform vec2 resolution;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec2 p = position / resolution * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
textureUV = uv;
lightColor = light; darkColor = dark;
}`);
const fragment = shader(gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D image;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec4 color = texture2D(image, textureUV);
float alpha = color.a * lightColor.a;
// Textures are PMA in GPU memory. Light/dark colors arrive straight.
vec3 rgb = (color.rgb * lightColor.rgb + (color.a - color.rgb) * darkColor.rgb) * lightColor.a;
gl_FragColor = vec4(rgb, alpha);
}`);
const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
gl.deleteShader(vertex); gl.deleteShader(fragment);
const buffer = gl.createBuffer();
const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv');
const resolution = gl.getUniformLocation(program, 'resolution');
const light = gl.getAttribLocation(program, 'light'), dark = gl.getAttribLocation(program, 'dark');
const textures = new Map();
let coordinates = [], currentImage = null, currentAlpha = 1, currentMode = 'source-over', transform;
let disposed = false, lost = false;
let premultipliedAlpha = false;
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
function texture(image) {
const pma = image !== context.canvas && premultipliedAlpha;
let pair = textures.get(image);
if (!pair) { pair = {}; textures.set(image, pair); }
const key = pma ? 'pma' : 'straight';
if (pair[key] && image !== context.canvas) return pair[key];
const t = pair[key] || gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
// Premultiply before interpolation so translucent edges do not darken.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, !pma);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const prepared = preparedTextures.get(image);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, prepared ? prepared[key] : image);
pair[key] = t; return t;
}
function flush() {
if (!coordinates.length) return;
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
// Premultiplied RGB factors; keep source-over alpha independently.
const factors = currentMode === 'lighter' ? [gl.ONE, gl.ONE]
: currentMode === 'multiply' ? [gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA]
: currentMode === 'screen' ? [gl.ONE, gl.ONE_MINUS_SRC_COLOR]
: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA];
gl.blendFuncSeparate(factors[0], factors[1], gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 12);
coordinates.length = 0;
}
return {
begin(pma = false) {
if (disposed || lost || gl.isContextLost()) return false;
premultipliedAlpha = pma;
const target = context.canvas;
if (canvas.width !== target.width) canvas.width = target.width;
if (canvas.height !== target.height) canvas.height = target.height;
transform = context.getTransform(); coordinates.length = 0; currentImage = null;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 48, 0);
gl.enableVertexAttribArray(uv); gl.vertexAttribPointer(uv, 2, gl.FLOAT, false, 48, 8);
gl.enableVertexAttribArray(light); gl.vertexAttribPointer(light, 4, gl.FLOAT, false, 48, 16);
gl.enableVertexAttribArray(dark); gl.vertexAttribPointer(dark, 4, gl.FLOAT, false, 48, 32);
gl.uniform2f(resolution, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Seed the same framebuffer with the actual Canvas pixels (solid or
// checkerboard background). CSS backgrounds cannot participate in blending.
currentImage = target; currentAlpha = 1; currentMode = 'source-over';
const w = canvas.width, h = canvas.height;
for (const vertex of [[0,0,0,0],[w,0,1,0],[w,h,1,1],[0,0,0,0],[w,h,1,1],[0,h,0,1]])
coordinates.push(...vertex, 1,1,1,1, 0,0,0,0);
flush(); currentImage = null;
return true;
},
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, colors, darkColors) {
const alpha = context.globalAlpha;
const mode = context.globalCompositeOperation || 'source-over';
if (image !== currentImage || alpha !== currentAlpha || mode !== currentMode) { flush(); currentImage = image; currentAlpha = alpha; currentMode = mode; }
const t = transform;
const points = [[x0,y0,u0,v0],[x1,y1,u1,v1],[x2,y2,u2,v2]];
for (let i=0; i<3; i++) {
const [x,y,u,v] = points[i], j=i*4;
coordinates.push(t.a*x+t.c*y+t.e, t.b*x+t.d*y+t.f, u,v,
...(colors ? colors.slice(j,j+4) : [1,1,1,alpha]),
...(darkColors ? darkColors.slice(j,j+4) : [0,0,0,0]));
}
},
end() {
flush();
context.save();
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'copy'; context.drawImage(canvas, 0, 0); }
finally { context.restore(); }
},
dispose() {
if (disposed) return; disposed = true;
for (const pair of textures.values()) for (const value of Object.values(pair)) gl.deleteTexture(value);
textures.clear(); gl.deleteBuffer(buffer); gl.deleteProgram(program);
const extension = gl.getExtension('WEBGL_lose_context'); if (extension) extension.loseContext();
}
};
}
function attach(renderer, context) {
let rasterizer;
try { rasterizer = createRasterizer(context); } catch (e) { return renderer; }
if (!rasterizer) return renderer;
const draw = renderer.draw.bind(renderer), triangle = renderer.drawTriangle.bind(renderer);
renderer.draw = skeleton => {
if (!rasterizer.begin(!!renderer.premultipliedAlpha)) return draw(skeleton);
renderer.gpuDrawing = true;
renderer.drawTriangle = rasterizer.triangle;
try { draw(skeleton); rasterizer.end(); }
finally { renderer.drawTriangle = triangle; renderer.gpuDrawing = false; }
};
renderer.dispose = rasterizer.dispose;
renderer.rasterization = 'webgl';
return renderer;
}
module.exports = { createRasterizer, attach, prepareTexture };
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

+434
View File
@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpineViewer — Eagle 插件 · 离线预览 Spine 骨骼动画</title>
<meta name="description" content="SpineViewer 是一个完全离线的 Eagle 插件,在素材库中直接预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt),内置 3.4–4.3 全部官方运行时,支持动画与皮肤切换、关键帧时间轴、PMA 预乘与自动缩略图。">
<link rel="icon" href="assets/logo-spineviewer.png">
<link rel="stylesheet" href="style.css">
<noscript><style>.reveal { opacity: 1; transform: none; }</style></noscript>
<script>
// 提前应用主题,避免首屏闪白
try {
var saved = localStorage.getItem('sv-site-theme');
document.documentElement.dataset.theme = saved || 'dark';
} catch (e) {}
</script>
</head>
<body>
<header class="topbar">
<div class="wrap topbar-inner">
<a class="brand" href="#top">
<img src="assets/logo-spineviewer.png" alt="SpineViewer 图标" width="30" height="30">
<span class="brand-name">SpineViewer</span>
<span class="brand-ver">v1.1.0</span>
</a>
<nav class="nav" aria-label="页面导航">
<a href="#features">功能特性</a>
<a href="#ui">实际界面</a>
<a href="#versions">支持版本</a>
<a href="#install">安装使用</a>
<a href="#faq">常见问题</a>
</nav>
<div class="topbar-actions">
<button class="icon-btn" id="themeToggle" type="button" aria-label="切换深浅主题" title="切换深浅主题">
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2z"/></svg>
</button>
<a class="btn btn-primary btn-sm" href="https://community-cn.eagle.cool/plugins?search=SpineViewer" target="_blank" rel="noopener">下载插件</a>
</div>
</div>
</header>
<main id="top">
<!-- ======== Hero ======== -->
<section class="hero">
<div class="hero-glow" aria-hidden="true"></div>
<div class="wrap hero-grid">
<div class="hero-copy">
<span class="pill"><i class="dot" aria-hidden="true"></i>Eagle 插件 · 完全离线 · 无 CDN</span>
<h1>在 Eagle 里直接预览<br><span class="grad">Spine 骨骼动画</span></h1>
<p class="hero-sub">
SpineViewer 接管 <code>skel</code> / <code>skel.bytes</code> / <code>json</code> / <code>atlas</code> / <code>atlas.txt</code>,
自动在素材库中配对骨骼、图集与贴图,识别版本并加载本地运行时。
点开任意一个文件,就能播放动画、切换皮肤、导出缩略图。
</p>
<div class="hero-cta">
<a class="btn btn-primary" href="https://community-cn.eagle.cool/plugins?search=SpineViewer" target="_blank" rel="noopener">下载插件</a>
<a class="btn btn-ghost" href="#ui">体验界面</a>
<a class="link-arrow" href="#features">功能特性 →</a>
</div>
<ul class="stats">
<li><b>9</b><span>内置运行时版本<br>3.4 – 4.3</span></li>
<li><b>8</b><span>种界面语言<br>简繁英日韩俄西德</span></li>
<li><b>0</b><span>网络请求<br>运行时全部本地化</span></li>
<li><b>400px</b><span>缩略图自动生成<br>可手动更新</span></li>
</ul>
</div>
<div class="hero-visual">
<figure class="shot shot-hero reveal">
<img class="zoomable" src="assets/ui-window.png" alt="SpineViewer 查看器界面:正在播放 Raptor 的 walk 动画,上方为浮动控制栏与动画列表,下方为时间轴" width="1600" height="818">
<figcaption>查看器窗口 · Raptor walk(Spine 4.3.75-beta)</figcaption>
</figure>
<div class="badge-chip reveal">
<img src="assets/spine-badge.png" alt="Spine 官方 badge" width="22" height="22">
<span>缩略图右下角带 Spine 官方 badge,左下角标注版本号(如 <code>3.4.02</code>)</span>
</div>
</div>
</div>
<div class="wrap">
<ul class="file-chips" aria-label="接管与放行的文件类型">
<li class="chip chip-on">skel</li>
<li class="chip chip-on">skel.bytes</li>
<li class="chip chip-on">json</li>
<li class="chip chip-on">atlas</li>
<li class="chip chip-on">atlas.txt</li>
<li class="chip chip-off">png / jpg / webp · 仍由 Eagle 原生预览</li>
</ul>
</div>
</section>
<!-- ======== 功能特性 ======== -->
<section class="section" id="features">
<div class="wrap">
<div class="section-head reveal">
<span class="kicker">FEATURES</span>
<h2>功能特性</h2>
<p class="section-lead">下面每一项都对应插件的真实行为:跨 item 文件夹配对、旧版二进制解析、WebGL 渲染、缩略图补刷……不只是“能看动画”。</p>
</div>
<div class="features">
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 20h.01"/><path d="M5 12.6a10 10 0 0 1 14 0"/><path d="M8.5 16.1a5 5 0 0 1 7 0"/><path d="M2.5 2.5l19 19"/></svg></div>
<h3>完全离线运行</h3>
<p>官方运行时全部拷贝在插件本地 <code>lib/</code> 目录,无 CDN、无网络请求。断网、内网环境同样可用。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 15l6-6"/><path d="M10.5 6.6L12.1 5a4 4 0 0 1 5.7 5.7l-1.6 1.6"/><path d="M13.5 17.4L11.9 19a4 4 0 0 1-5.7-5.7l1.6-1.6"/></svg></div>
<h3>跨文件夹自动配对</h3>
<p>Eagle 会把每个文件拆成独立 item 文件夹。插件扫描同级目录,忽略 <code>-pro</code> / <code>-ess</code> / <code>-pma</code> 等后缀,自动关联骨骼、atlas 与贴图;点开贴图或 atlas 也能找到整套资源。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 3.5v11"/><circle cx="18" cy="6" r="2.6"/><circle cx="6" cy="17.5" r="2.6"/><path d="M18 8.6a9 9 0 0 1-9 8.9"/></svg></div>
<h3>9 个版本自动识别</h3>
<p>从骨骼数据读取版本号并匹配对应运行时:3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3,带 <code>-beta</code> 的完整版本号也会显示在水印中。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8.5 12.2l2.5 2.5 4.7-5"/></svg></div>
<h3>旧版二进制兼容</h3>
<p>3.4–3.7 的 <code>.skel</code> / <code>.skel.bytes</code> 由移植自官方 <code>SkeletonBinary</code> 的解析层处理,保留网格、权重、变形、约束、裁剪与绘制顺序,无需编译或联网。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M10 8.4l6 3.6-6 3.6z"/></svg></div>
<h3>动画与皮肤切换</h3>
<p>左上角浮动动画列表按需自适应,条目多时独立滚动,没有动画数据时自动隐藏;切换皮肤后按 setup pose 重建槽位附件,画面立即刷新。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 12.5h18"/><circle cx="7" cy="12.5" r="1.7"/><circle cx="12.5" cy="12.5" r="1.7"/><circle cx="18" cy="12.5" r="1.7"/><path d="M7 8.5v-2M12.5 7v-2M18 8.5v-2"/></svg></div>
<h3>完整时间轴</h3>
<p>拖动定位、显示动画时长与帧号、秒刻度,蓝色短线标出骨骼 / 插槽 / 约束的关键帧位置,悬停即可查看时间与帧号。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="2.5" y="5" width="19" height="14" rx="2.5"/><path d="M10.2 9.4l5 2.6-5 2.6z"/></svg></div>
<h3>精准回放控制</h3>
<p>空格播放 / 暂停、循环开关、0–3x 速度调节;零时长动画自动按单帧静态姿势预览,不会出现空白画面。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/><path d="M8 16l3.5-4.5L15 15l2-2.4 2.5 3.4"/><circle cx="8.6" cy="8.8" r="1.5"/></svg></div>
<h3>WebGL 渲染 · PMA 预乘</h3>
<p>WebGL 绘制网格三角形避免细线接缝,贴图直传 GPU,避免低透明度颜色被量化、光晕分层;预乘默认读取 atlas 的 <code>pma</code> 字段,手动切换后按素材记住。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="15" rx="2.5"/><circle cx="9" cy="10" r="1.8"/><path d="M21 15.5l-4.2-4.2L7 21"/></svg></div>
<h3>缩略图自动化</h3>
<p>导入即渲染首帧缩略图,带官方 badge 与版本号水印;资源暂时缺失时显示回退图,并在后台观察约两分钟,文件补齐稳定后自动生成、通知 Eagle 刷新。</p>
</article>
</div>
<ul class="chips reveal" aria-label="更多实现细节">
<li>复合后缀规范化 <code>skel.bytes</code> / <code>atlas.txt</code></li>
<li>50×50 方格背景(随缩放平移、参与混合)</li>
<li>普通 JSON / TXT 安全放行</li>
<li>8 种界面语言</li>
<li>自动适配并居中</li>
<li>左键平移 · 滚轮缩放 · 右键回中心</li>
<li>设置存用户目录,不写 <code>metadata.json</code></li>
<li>手动缩略图跟随当前背景开关</li>
</ul>
</div>
</section>
<!-- ======== 实际界面 ======== -->
<section class="section alt" id="ui">
<div class="wrap">
<div class="section-head reveal">
<span class="kicker">REAL UI</span>
<h2>实际界面</h2>
<p class="section-lead">截图取自下方的交互演示,与插件查看器的控件布局、文案完全一致。浮动控制栏、动画列表与时间轴全部悬浮在画布上方,不挤占预览区域。</p>
</div>
<div class="gallery">
<figure class="shot shot-main reveal">
<img class="zoomable" src="assets/ui-window.png" alt="查看器完整窗口截图" width="1600" height="818">
<figcaption>查看器窗口 · 点击可放大</figcaption>
</figure>
<div class="crop-grid">
<figure class="shot crop toolbar reveal">
<img class="zoomable" src="assets/ui-toolbar.png" alt="浮动控制栏截图" width="1600" height="72">
<figcaption>控制栏:皮肤、播放/暂停、循环、速度、背景、版本号、重载</figcaption>
</figure>
<figure class="shot crop animlist reveal">
<img class="zoomable" src="assets/ui-animlist.png" alt="动画列表截图" width="256" height="382">
<figcaption>动画列表:自适应宽度,条目多时独立滚动</figcaption>
</figure>
<figure class="shot crop timeline reveal">
<img class="zoomable" src="assets/ui-timeline.png" alt="时间轴截图" width="1600" height="90">
<figcaption>时间轴:时长与帧号、秒刻度、蓝色关键帧短线</figcaption>
</figure>
</div>
</div>
<div class="demo-block">
<div class="demo-head reveal">
<h3>交互演示</h3>
<p>复刻查看器的控件布局与文案,直接在页面上操作:切换动画、拖动时间轴、缩放画布试试。画面使用官方示例 <strong>Raptor</strong>(Spine 4.3.75-beta 导出,5 个动画),由 4.3 官方运行时 + 插件同款渲染模块在本地实时渲染,断网或直接双击打开同样可用。</p>
</div>
<div class="viewer reveal" id="viewerDemo" tabindex="0" aria-label="SpineViewer 查看器界面交互演示">
<div class="v-toolbar">
<label class="v-field">皮肤
<select id="demoSkin" aria-label="皮肤">
<option value="default">default</option>
<option value="blue">blue</option>
<option value="green">green</option>
<option value="red">red</option>
</select>
</label>
<button class="v-btn" id="demoPlay" type="button" title="播放/暂停 (空格)">⏸ 暂停</button>
<label class="v-check"><input type="checkbox" id="demoLoop" checked> 循环</label>
<label class="v-field">速度
<input type="range" id="demoSpeed" min="0" max="3" step="0.05" value="1" aria-label="播放速度">
<span id="demoSpeedVal">1.0x</span>
</label>
<label class="v-check" title="50×50 方格背景,随画布移动和缩放,并参与混合模式"><input type="checkbox" id="demoBg"> 背景</label>
<span class="v-spacer"></span>
<span class="v-version" id="demoVersion" title="Spine 版本">Spine --</span>
<button class="v-btn v-icon" id="demoReload" type="button" title="重新加载目录">↻</button>
</div>
<aside class="v-anim-panel" aria-label="动画列表">
<div class="v-anim-title">动画</div>
<div class="v-anim-list" id="demoAnimList" role="listbox" aria-label="动画"></div>
</aside>
<div class="v-stage" id="demoStage">
<canvas id="demoCanvas"></canvas>
<div class="v-status" id="demoStatus"></div>
</div>
<div class="v-timeline">
<div class="v-tl-labels">
<span class="v-tl-label" id="demoDuration">时长 --</span>
<span class="v-tl-legend" title="蓝色短线表示动画中至少一个骨骼、插槽或约束属性在该时刻有关键帧">蓝色短线:关键帧</span>
</div>
<div class="v-track">
<div class="v-ruler" id="demoRuler" aria-hidden="true"></div>
<input type="range" id="demoTimeline" min="0" max="1" step="0.001" value="0" aria-label="时间轴">
<div class="v-keys" id="demoKeys"></div>
</div>
<span class="v-readout" id="demoReadout">0.00 / 0.00 秒 · 0 / 0 帧</span>
</div>
</div>
<p class="demo-hint">空格 播放/暂停 · 左键拖拽平移 · 滚轮缩放 · 右键回到中心 · 拖动时间轴定位帧</p>
</div>
</div>
</section>
<!-- ======== 支持版本 ======== -->
<section class="section" id="versions">
<div class="wrap">
<div class="section-head reveal">
<span class="kicker">VERSIONS</span>
<h2>支持版本</h2>
<p class="section-lead">覆盖 Spine 3.4 – 4.3:新版二进制直接支持,旧版二进制由同版本运行时与解析层处理。</p>
</div>
<ul class="v-chips reveal" aria-label="支持的 Spine 版本">
<li>3.4</li><li>3.5</li><li>3.6</li><li>3.7</li><li class="sep" aria-hidden="true">·</li>
<li>3.8</li><li>4.0</li><li>4.1</li><li>4.2</li><li>4.3</li>
</ul>
<div class="v-table-wrap reveal">
<table class="v-table">
<thead>
<tr><th>版本</th><th>支持格式</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td><code>3.4</code> / <code>3.5</code></td>
<td>JSON · SKEL · SKEL.BYTES · ATLAS · ATLAS.TXT</td>
<td>使用官方 3.4.02 / 3.5.51 运行时与匹配的二进制分支,已验证多组官方示例的姿势对比。</td>
</tr>
<tr>
<td><code>3.6</code> / <code>3.7</code></td>
<td>同上 + 二进制解析兼容层</td>
<td>移植自官方同版本 <code>spine-libgdx/SkeletonBinary.java</code>;支持 3.7 的 IK 压缩/拉伸与音频事件字段,无需同时导入 JSON。</td>
</tr>
<tr>
<td><code>3.8</code> – <code>4.3</code></td>
<td>JSON · SKEL / SKEL.BYTES · ATLAS</td>
<td>二进制文件完整支持;4.3 等带 <code>-beta</code> 后缀的完整版本号也会显示在缩略图水印中。</td>
</tr>
</tbody>
</table>
</div>
<p class="v-note reveal">损坏、截断或版本不匹配的文件会直接报告解析错误,不会以错误姿势渲染。</p>
</div>
</section>
<!-- ======== 工作流程 ======== -->
<section class="section alt" id="how">
<div class="wrap">
<div class="section-head reveal">
<span class="kicker">WORKFLOW</span>
<h2>从导入到播放</h2>
</div>
<ol class="steps">
<li class="step reveal">
<span class="step-num">01</span>
<h3>导入整套文件</h3>
<p>把 <code>.skel</code> / <code>.json</code> + <code>.atlas</code> + <code>.png</code> 放在同一个文件夹导入 Eagle。</p>
</li>
<li class="step reveal">
<span class="step-num">02</span>
<h3>自动配对资源</h3>
<p>点击任一文件,插件从同级 item 文件夹定位骨骼、atlas 与贴图,配套文件不在同一目录也能找到。</p>
</li>
<li class="step reveal">
<span class="step-num">03</span>
<h3>识别版本</h3>
<p>读取骨骼数据中的版本号(JSON 读 <code>skeleton.spine</code>,SKEL 读二进制头部),加载匹配的本地运行时。</p>
</li>
<li class="step reveal">
<span class="step-num">04</span>
<h3>预览与缩略图</h3>
<p>实时播放动画;缩略图自动生成,资源稍后补齐时会在后台自动重渲并通知 Eagle 刷新列表。</p>
</li>
</ol>
</div>
</section>
<!-- ======== 安装使用 ======== -->
<section class="section" id="install">
<div class="wrap">
<div class="section-head reveal">
<span class="kicker">INSTALL</span>
<h2>安装使用</h2>
<p class="section-lead">在 Eagle 插件社区获取插件包,然后在 Eagle 插件面板(快捷键 <kbd>P</kbd>)安装即可。</p>
</div>
<a class="dl-cta reveal" href="https://community-cn.eagle.cool/plugins?search=SpineViewer" target="_blank" rel="noopener">
<span class="dl-cta-info">
<span class="dl-os">Eagle 插件社区</span>
<strong>在 Eagle 插件社区获取 SpineViewer</strong>
<span class="dl-meta">插件页提供下载与安装说明;本页面不提供任何文件下载。</span>
</span>
<span class="btn btn-primary">前往插件页 →</span>
</a>
<p class="dl-note reveal">原生二进制需要与目标平台匹配;如需自行构建,可在源码目录执行 <code>npm ci --omit=dev</code> 后从 Eagle 插件面板打包。</p>
<ol class="install-steps reveal">
<li>
<h3>安装插件</h3>
<p>双击 <code>.eagleplugin</code> 安装;或在 Eagle 插件面板中右键选择“打包/加载插件”。</p>
</li>
<li>
<h3>源码加载(可选)</h3>
<p>克隆源码后在插件目录执行 <code>npm ci --omit=dev</code>,安装可选的 <code>@napi-rs/canvas</code>,再从插件面板加载。</p>
</li>
<li>
<h3>导入素材</h3>
<p>把同一套 Spine 导出文件放进同一个文件夹导入 Eagle,点击任意一个文件即可预览。</p>
</li>
</ol>
<aside class="callout reveal">
<strong>关于缩略图依赖</strong>
<p><code>@napi-rs/canvas</code> 只用于高质量渲染缩略图。保留它时缩略图会渲染骨骼形象;移除后动画预览完全不受影响,缩略图会显示纯 JS 占位图。</p>
</aside>
</div>
</section>
<!-- ======== 常见问题 ======== -->
<section class="section alt" id="faq">
<div class="wrap">
<div class="section-head reveal">
<span class="kicker">FAQ</span>
<h2>常见问题</h2>
</div>
<div class="faq">
<details class="reveal">
<summary>插件会接管普通图片吗?</summary>
<p>不会。插件只接管 <code>skel</code>、<code>skel.bytes</code>、<code>json</code>、<code>atlas</code>、<code>atlas.txt</code>(以及为复合后缀注册的 <code>bytes</code> / <code>txt</code>)。普通图片仍由 Eagle 原生预览,普通 TXT 与 JSON 也会保持原样。</p>
</details>
<details class="reveal">
<summary>预乘(PMA)设置保存在哪里?</summary>
<p>保存在 Eagle 用户数据目录的 <code>SpineViewer/preview-settings/</code> 下,按素材库与素材 ID 区分。不会修改 Eagle 的 <code>metadata.json</code>、标签或注释,也不会随素材导出到其他电脑。默认值读取配套 atlas 里的 <code>pma</code> 字段。</p>
</details>
<details class="reveal">
<summary>为什么有些 JSON 打开后没有播放控件?</summary>
<p>只有当 <code>skeleton.spine</code> 是有效版本号时,插件才按 Spine 骨骼数据处理。普通 JSON、JSONC、空版本字段会立即放行,不写缩略图、不扫描资源库,查看器此时显示格式化的 JSON 文本。</p>
</details>
<details class="reveal">
<summary>3.6 / 3.7 的二进制解析可靠吗?</summary>
<p>解析层移植自官方同版本 <code>spine-libgdx/SkeletonBinary.java</code>,并已验证官方 3.6.32 / 3.7.90 的七套示例。损坏、截断或版本不匹配会报告解析错误;官方 Raptor 数据中一条 IK 阶梯曲线的已知差异也已在测试中单独记录。</p>
</details>
<details class="reveal">
<summary>缩略图是怎么生成的?</summary>
<p>使用 <code>@napi-rs/canvas</code> 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧,尺寸 400×400,右下角为 Spine 官方 badge,左下角为完整导出版本号。也可以在查看器里用当前帧一键生成;手动缩略图跟随当前背景开关。</p>
</details>
<details class="reveal">
<summary>资源不齐的文件会怎样?</summary>
<p>缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台会以逐步降频的方式观察约两分钟,链路补齐且文件稳定后自动生成缩略图并通知 Eagle 刷新。稍后补拖的文件会重新触发识别。</p>
</details>
</div>
<p class="faq-contact reveal">没有找到答案?欢迎把问题或建议发到 <a href="mailto:1181558906@qq.com">1181558906@qq.com</a>。</p>
</div>
</section>
</main>
<footer class="footer">
<div class="wrap footer-inner">
<div class="foot-brand">
<img src="assets/logo-spineviewer.png" alt="" width="34" height="34">
<div>
<strong>SpineViewer</strong>
<span>Eagle 插件 · v1.1.0</span>
</div>
</div>
</div>
</footer>
<div class="lightbox" id="lightbox" hidden>
<img alt="放大查看界面截图">
</div>
<!-- 官方 Spine 4.3 运行时、插件同款渲染模块与演示示例(全部本地打包,无网络请求) -->
<script>
// 插件模块是 CommonJS 文件,这里用最小的 module / require 垫片在浏览器中加载它们。
window.__svLibs = {};
window.module = { exports: {} };
window.require = function (id) {
if (String(id).indexOf('spine-webgl-rasterizer') !== -1) return window.__svLibs.rasterizer;
throw new Error('模块不可用:' + id);
};
</script>
<script src="assets/spine/spine-background.js"></script>
<script>window.__svLibs.background = window.module.exports; window.module = { exports: {} };</script>
<script src="assets/spine/spine-canvas-clipping.js"></script>
<script>window.__svLibs.clipping = window.module.exports; window.module = { exports: {} };</script>
<script src="assets/spine/spine-webgl-rasterizer.js"></script>
<script>window.__svLibs.rasterizer = window.module.exports; delete window.module;</script>
<script src="assets/spine/spine-canvas-4.3.js"></script>
<script src="app.js"></script>
</body>
</html>
+1190
View File
File diff suppressed because it is too large. Load diff