Files
SpineViewer/html/app.js
T
2026-09-12 18:10:52 +08:00

843 lines
29 KiB
JavaScript
Raw Blame History

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