843 lines
29 KiB
JavaScript
843 lines
29 KiB
JavaScript
/* ==========================================================================
|
||
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();
|
||
})();
|