423 lines
16 KiB
HTML
423 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Spine 4.2 独立播放器 (V30)</title>
|
|
<script src="spine-webgl-4.2.js"></script>
|
|
<style>
|
|
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background: #222; font-family: "Microsoft YaHei", sans-serif; overflow: hidden; color:#eee;}
|
|
|
|
#canvas-container { position: absolute; top:0; left:0; width: 100%; height: 100%; z-index: 0; background: radial-gradient(circle, #444 0%, #222 100%); }
|
|
canvas { display: block; width: 100%; height: 100%; }
|
|
|
|
#drop-zone { position: fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.9); z-index:99; display:flex; flex-direction:column; justify-content:center; align-items:center; transition: opacity 0.5s;}
|
|
|
|
#ui { position: absolute; top: 20px; left: 20px; background: rgba(0,0,0,0.7); padding: 20px; border-radius: 8px; display: none; min-width: 220px; z-index: 10; border: 1px solid #555; box-shadow: 0 4px 10px rgba(0,0,0,0.5); }
|
|
#controls { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 15px 30px; border-radius: 30px; color: white; display: none; gap: 30px; z-index: 10; align-items: center; border: 1px solid #555; box-shadow: 0 4px 15px rgba(0,0,0,0.5); }
|
|
|
|
#log { position: fixed; top: 10px; right: 10px; color: #00ff9d; font-family: monospace; font-size: 12px; pointer-events: none; background:rgba(0,0,0,0.8); padding:10px; border-radius:4px; max-width: 400px; max-height: 50%; overflow-y: auto; text-align: left; z-index: 100;}
|
|
|
|
.control-group { display: flex; flex-direction: column; align-items: center; gap: 5px; }
|
|
input[type=range] { width: 150px; cursor: pointer; }
|
|
select { background: #444; color: white; border: 1px solid #666; padding: 8px; border-radius: 4px; width: 100%; margin-top: 5px; outline: none; }
|
|
select:hover { background: #555; }
|
|
|
|
button { transition: transform 0.1s; cursor: pointer; }
|
|
button:active { transform: scale(0.95); }
|
|
|
|
.highlight { color: #00e5ff; font-weight: bold; }
|
|
.highlight-bin { color: #ff0055; font-weight: bold; }
|
|
|
|
.divider { height: 1px; background: #555; margin: 15px 0; }
|
|
.toggle-row { display: flex; justify-content: space-between; align-items: center; margin-top: 5px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="drop-zone">
|
|
<div style="font-size: 60px; margin-bottom: 20px;">🔥</div>
|
|
<h2>Spine 4.2 独立播放器</h2>
|
|
<p>支持格式:<span class="highlight">.json</span> / <span class="highlight-bin">.skel</span> (4.2+)</p>
|
|
<p>请选择包含 <b>骨架文件 + .atlas + .png</b> 的文件夹</p>
|
|
|
|
<input type="file" id="file-input" multiple style="display:none">
|
|
<input type="file" id="folder-input" webkitdirectory directory style="display:none">
|
|
|
|
<div style="margin-top:40px; display:flex; gap:20px; flex-direction:column; align-items:center;">
|
|
<button onclick="document.getElementById('folder-input').click()" style="padding:15px 40px; font-size:20px; background: linear-gradient(45deg, #ff9900, #ff0055); border:none; color:white; border-radius:8px; font-weight:bold; box-shadow: 0 4px 15px rgba(0,0,0,0.5);">
|
|
📂 选择文件夹 (自动识别)
|
|
</button>
|
|
<button onclick="document.getElementById('file-input').click()" style="padding:10px 20px; font-size:14px; background:transparent; border:1px solid #666; color:#aaa; border-radius:8px;">
|
|
或者点击选择文件...
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="canvas-container"><canvas id="canvas"></canvas></div>
|
|
|
|
<div id="ui">
|
|
<div style="font-weight:bold; color:#ff9900; margin-bottom:15px; font-size: 16px;">SPINE 4.2 PRO</div>
|
|
|
|
<div style="margin-bottom:15px;">
|
|
<label style="font-size:12px; color:#aaa;">动画 (Animation)</label>
|
|
<select id="anim-select"></select>
|
|
</div>
|
|
|
|
<div style="margin-bottom:15px;">
|
|
<label style="font-size:12px; color:#aaa;">皮肤 (Skin)</label>
|
|
<select id="skin-select"></select>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div class="toggle-row">
|
|
<label style="font-size:14px;">PMA (预乘Alpha)</label>
|
|
<input type="checkbox" id="chk-pma" checked>
|
|
</div>
|
|
|
|
<button onclick="resetPhysics()" style="width:100%; margin-top:15px; padding:10px; background:#880000; color:#fff; border:none; border-radius:4px; font-weight:bold;">💥 重置物理 (Reset Physics)</button>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<button onclick="location.reload()" style="width:100%; padding:8px; background:#444; color:#fff; border:1px solid #666; border-radius:4px;">🔄 打开新文件</button>
|
|
</div>
|
|
|
|
<div id="controls">
|
|
<div class="control-group">
|
|
<label style="font-size:12px; color:#aaa;">水平位置 (X)</label>
|
|
<input type="range" id="posX" min="-2000" max="2000" value="0" step="10">
|
|
</div>
|
|
<div class="control-group">
|
|
<label style="font-size:12px; color:#aaa;">垂直位置 (Y)</label>
|
|
<input type="range" id="posY" min="-2000" max="2000" value="0" step="10">
|
|
</div>
|
|
<div class="control-group" style="margin-left: 20px;">
|
|
<button onclick="resetView()" style="padding: 8px 20px; background:#444; color:white; border:1px solid #666; border-radius:4px;">重置视图</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="log">系统就绪 (V30 独立版)...</div>
|
|
|
|
<script>
|
|
// =============================================================
|
|
// 工具函数 & 变量
|
|
// =============================================================
|
|
const ui = {
|
|
log: (msg, type) => {
|
|
console.log(msg);
|
|
const box = document.getElementById('log');
|
|
const color = type==='err'?'#ff5555':(type==='ok'?'#55ff55':'#00ff9d');
|
|
box.innerHTML += `<div style="color:${color}; margin-bottom:4px;">> ${msg}</div>`;
|
|
box.scrollTop = box.scrollHeight;
|
|
},
|
|
updateSliders: () => {
|
|
document.getElementById('posX').value = cam.x;
|
|
document.getElementById('posY').value = cam.y;
|
|
}
|
|
};
|
|
|
|
const Mat4 = {
|
|
create: () => new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]),
|
|
ortho: (out, l, r, b, t, n, f) => {
|
|
const lr = 1/(l-r), bt = 1/(b-t), nf = 1/(n-f);
|
|
out[0]=-2*lr; out[5]=-2*bt; out[10]=2*nf; out[12]=(l+r)*lr; out[13]=(t+b)*bt; out[14]=(f+n)*nf; out[15]=1;
|
|
}
|
|
};
|
|
|
|
let canvas, gl, shader, batcher, renderer;
|
|
let skeleton, state, mvp = Mat4.create();
|
|
let lastTime = Date.now();
|
|
let cam = {x:0, y:0, zoom:1.0};
|
|
let globalTexture = null;
|
|
const Physics = { None: 0, Update: 1, Reset: 2 };
|
|
|
|
// =============================================================
|
|
// 初始化 & 输入绑定
|
|
// =============================================================
|
|
async function init() {
|
|
try {
|
|
canvas = document.getElementById('canvas');
|
|
gl = canvas.getContext('webgl', { alpha: false });
|
|
if(!gl) throw "WebGL 不可用";
|
|
|
|
shader = spine.Shader.newTwoColoredTextured(gl);
|
|
batcher = new spine.PolygonBatcher(gl, true);
|
|
renderer = new spine.SkeletonRenderer(gl);
|
|
renderer.twoColor = true;
|
|
renderer.premultipliedAlpha = true;
|
|
|
|
bindInput();
|
|
requestAnimationFrame(loop);
|
|
|
|
// 绑定输入文件事件
|
|
document.getElementById('file-input').onchange = e => handleFiles(e.target.files);
|
|
document.getElementById('folder-input').onchange = e => handleFiles(e.target.files);
|
|
|
|
// 拖拽支持
|
|
const dz = document.getElementById('drop-zone');
|
|
document.body.addEventListener('dragover', e => e.preventDefault());
|
|
document.body.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
handleFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
} catch(e) { ui.log(e, 'err'); }
|
|
}
|
|
|
|
function bindInput() {
|
|
// 鼠标/滚轮控制
|
|
canvas.onwheel = e => {
|
|
cam.zoom *= (e.deltaY>0 ? 0.9 : 1.1);
|
|
// 限制缩放范围
|
|
if(cam.zoom < 0.1) cam.zoom = 0.1;
|
|
if(cam.zoom > 10) cam.zoom = 10;
|
|
};
|
|
|
|
let drag=false, lx, ly, isRight=false;
|
|
|
|
canvas.oncontextmenu = e => e.preventDefault();
|
|
canvas.onmousedown = e => {
|
|
drag=true;
|
|
isRight = (e.button === 2);
|
|
lx=e.clientX; ly=e.clientY;
|
|
};
|
|
window.onmouseup = () => drag=false;
|
|
|
|
window.onmousemove = e => {
|
|
if(drag) {
|
|
let dx = e.clientX - lx;
|
|
let dy = e.clientY - ly;
|
|
|
|
if(isRight) {
|
|
// 右键缩放
|
|
let newZoom = cam.zoom * (1 + dy * 0.005);
|
|
if(newZoom > 0.1 && newZoom < 10) cam.zoom = newZoom;
|
|
} else {
|
|
// 左键拖拽 + 物理惯性
|
|
cam.x -= dx / cam.zoom;
|
|
cam.y += dy / cam.zoom;
|
|
|
|
// 物理惯性:反向作用于骨架
|
|
if (skeleton) {
|
|
skeleton.x += dx / cam.zoom;
|
|
skeleton.y -= dy / cam.zoom;
|
|
}
|
|
|
|
ui.updateSliders();
|
|
}
|
|
lx = e.clientX; ly = e.clientY;
|
|
}
|
|
};
|
|
|
|
// UI 滑块控制
|
|
const sx = document.getElementById('posX');
|
|
const sy = document.getElementById('posY');
|
|
sx.oninput = () => cam.x = parseFloat(sx.value);
|
|
sy.oninput = () => cam.y = parseFloat(sy.value);
|
|
}
|
|
|
|
function resetView() {
|
|
cam.x = 0; cam.y = 0; cam.zoom = 1.0;
|
|
ui.updateSliders();
|
|
}
|
|
|
|
function resetPhysics() {
|
|
if(skeleton && spine.Physics) {
|
|
skeleton.updateWorldTransform(spine.Physics.reset);
|
|
ui.log("物理已重置", 'ok');
|
|
}
|
|
}
|
|
|
|
// =============================================================
|
|
// 文件处理 & 核心加载
|
|
// =============================================================
|
|
async function handleFiles(files) {
|
|
if(files.length === 0) return;
|
|
|
|
let map = {};
|
|
for(let f of files) {
|
|
let n = f.name.toLowerCase();
|
|
if(n.includes('.atlas')) map.atlas = f;
|
|
else if(n.includes('.png') || n.includes('.jpg') || n.includes('.webp')) map.png = f;
|
|
else if(n.includes('.skel') || n.includes('.json')) map.main = f;
|
|
}
|
|
|
|
if(!map.atlas || !map.png || !map.main) {
|
|
ui.log("文件缺失: 需要 .json/.skel + .atlas + .png", 'err');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const imgBlob = URL.createObjectURL(map.png);
|
|
const img = new Image();
|
|
img.onload = () => processSpine(map, img);
|
|
img.src = imgBlob;
|
|
} catch(e) { ui.log(e, 'err'); }
|
|
}
|
|
|
|
async function processSpine(map, img) {
|
|
try {
|
|
ui.log("正在加载...", 'ok');
|
|
const atlasText = await readFile(map.atlas, 'text');
|
|
|
|
globalTexture = new spine.GLTexture(gl, img, false);
|
|
globalTexture.setFilters(gl.LINEAR, gl.LINEAR);
|
|
globalTexture.setWraps(gl.CLAMP_TO_EDGE, gl.CLAMP_TO_EDGE);
|
|
|
|
const atlas = new spine.TextureAtlas(atlasText, path => globalTexture);
|
|
// 强制修正 Atlas 尺寸
|
|
for(let page of atlas.pages) { page.width = img.width; page.height = img.height; }
|
|
|
|
const loader = new spine.AtlasAttachmentLoader(atlas);
|
|
let skelData;
|
|
|
|
if(map.main.name.includes('.skel')) {
|
|
ui.log("解析二进制文件...");
|
|
const buffer = await readFile(map.main, 'array');
|
|
skelData = new spine.SkeletonBinary(loader).readSkeletonData(new Uint8Array(buffer));
|
|
} else {
|
|
ui.log("解析 JSON 文件...");
|
|
const text = await readFile(map.main, 'text');
|
|
skelData = new spine.SkeletonJson(loader).readSkeletonData(JSON.parse(text));
|
|
}
|
|
|
|
ui.log(`版本: ${skelData.version}`, 'ok');
|
|
|
|
skeleton = new spine.Skeleton(skelData);
|
|
|
|
// 自动注入纹理
|
|
let fixCount = 0;
|
|
for(let skin of skeleton.data.skins) {
|
|
let entries = skin.getAttachments ? skin.getAttachments() : skin.attachments;
|
|
for(let entry of entries) {
|
|
let att = entry ? entry.attachment : null;
|
|
if(att && att.region) {
|
|
att.region.texture = globalTexture;
|
|
att.region.renderObject = att.region;
|
|
fixCount++;
|
|
}
|
|
}
|
|
}
|
|
ui.log(`纹理修复: ${fixCount} 个附件`, 'ok');
|
|
|
|
skeleton.setToSetupPose();
|
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.reset);
|
|
|
|
state = new spine.AnimationState(new spine.AnimationStateData(skelData));
|
|
|
|
setupUI(skelData); // 初始化 UI 选择器
|
|
|
|
// 预热两帧
|
|
state.update(0); state.apply(skeleton); skeleton.updateWorldTransform(spine.Physics.update);
|
|
state.update(0); state.apply(skeleton); skeleton.updateWorldTransform(spine.Physics.update);
|
|
|
|
// 自动对焦
|
|
const offset = new spine.Vector2(), size = new spine.Vector2();
|
|
skeleton.getBounds(offset, size, []);
|
|
if(size.x > 0) {
|
|
cam.x = offset.x + size.x/2;
|
|
cam.y = offset.y + size.y/2;
|
|
cam.zoom = Math.min(canvas.width/size.x, canvas.height/size.y) * 0.8;
|
|
} else {
|
|
cam.x = 0; cam.y = -200; // 默认位置
|
|
}
|
|
ui.updateSliders();
|
|
|
|
// 切换界面
|
|
document.getElementById('drop-zone').style.opacity = 0;
|
|
setTimeout(() => document.getElementById('drop-zone').style.display = 'none', 500);
|
|
document.getElementById('ui').style.display = 'block';
|
|
document.getElementById('controls').style.display = 'flex';
|
|
|
|
} catch(e) { ui.log(e.message, 'err'); }
|
|
}
|
|
|
|
function setupUI(data) {
|
|
const animSelect = document.getElementById('anim-select');
|
|
const skinSelect = document.getElementById('skin-select');
|
|
animSelect.innerHTML = "";
|
|
skinSelect.innerHTML = "";
|
|
|
|
data.animations.forEach(a => {
|
|
const opt = document.createElement('option');
|
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
|
});
|
|
|
|
if(data.animations.length > 0) {
|
|
state.setAnimation(0, data.animations[0].name, true);
|
|
}
|
|
|
|
data.skins.forEach(s => {
|
|
const opt = document.createElement('option');
|
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
|
});
|
|
if(data.defaultSkin) skinSelect.value = data.defaultSkin.name;
|
|
|
|
animSelect.onchange = () => state.setAnimation(0, animSelect.value, true);
|
|
skinSelect.onchange = () => {
|
|
skeleton.setSkinByName(skinSelect.value);
|
|
skeleton.setSlotsToSetupPose();
|
|
skeleton.setToSetupPose(); // 重置姿势以应用皮肤
|
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.reset); // 皮肤切换可能需要重置物理
|
|
};
|
|
}
|
|
|
|
function readFile(f, type) {
|
|
return new Promise(r => {
|
|
const fr = new FileReader();
|
|
fr.onload = () => r(fr.result);
|
|
if(type==='text') fr.readAsText(f); else fr.readAsArrayBuffer(f);
|
|
});
|
|
}
|
|
|
|
// =============================================================
|
|
// 渲染循环
|
|
// =============================================================
|
|
function loop() {
|
|
requestAnimationFrame(loop);
|
|
|
|
if(canvas.width !== window.innerWidth) { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
gl.clearColor(0.2, 0.2, 0.2, 1);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
if(!skeleton || !state) return;
|
|
|
|
try {
|
|
const now = Date.now();
|
|
const dt = (now - lastTime) / 1000;
|
|
lastTime = now;
|
|
|
|
state.update(dt);
|
|
state.apply(skeleton);
|
|
|
|
if(skeleton.update) skeleton.update(dt);
|
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.update);
|
|
else skeleton.updateWorldTransform(1);
|
|
|
|
if(!Number.isFinite(cam.zoom)) cam.zoom=0.1;
|
|
const w = canvas.width, h = canvas.height;
|
|
Mat4.ortho(mvp, cam.x - w/cam.zoom/2, cam.x + w/cam.zoom/2, cam.y - h/cam.zoom/2, cam.y + h/cam.zoom/2, -2000, 2000);
|
|
|
|
shader.bind();
|
|
shader.setUniform4x4f(spine.Shader.MVP_MATRIX, mvp);
|
|
shader.setUniformi(spine.Shader.SAMPLER, 0);
|
|
|
|
const pma = document.getElementById('chk-pma').checked;
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(pma ? gl.ONE : gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
|
|
batcher.begin(shader);
|
|
renderer.draw(batcher, skeleton);
|
|
batcher.end();
|
|
shader.unbind();
|
|
|
|
} catch(e) { }
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|