网站页面
No files matched your search
@@ -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();
|
||||
})();
|
||||
@@ -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.
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 339 KiB |
@@ -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>
|
||||