249 lines
13 KiB
JavaScript
249 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
// Adapted from Esoteric Software's 3.4/3.6/3.7 spine-libgdx SkeletonBinary.java.
|
|
// Copyright (c) 2013-2019, Esoteric Software. See
|
|
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
|
|
// 3.4 source and license: LICENSES/Spine-3.4-License.txt.
|
|
// Decode the versioned wire format to an in-memory SkeletonJson object. The
|
|
// matching runtime builds attachments/timelines and applies loader.scale once.
|
|
|
|
const dict = () => Object.create(null);
|
|
class Input {
|
|
constructor(bytes) {
|
|
if (!ArrayBuffer.isView(bytes)) throw new Error('骨骼二进制数据必须为字节数组');
|
|
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
this.offset = 0;
|
|
}
|
|
fail(message) { throw new Error('Spine 二进制解析失败(字节 ' + this.offset + '):' + message); }
|
|
need(n) { if (n < 0 || n > this.view.byteLength - this.offset) this.fail('文件截断或长度无效'); }
|
|
byte() { this.need(1); return this.view.getUint8(this.offset++); }
|
|
bool() { const b = this.byte(); if (b > 1) this.fail('无效布尔值'); return b === 1; }
|
|
float() { this.need(4); const v = this.view.getFloat32(this.offset); this.offset += 4; if (!Number.isFinite(v)) this.fail('无效浮点数'); return v; }
|
|
int() { this.need(4); const v = this.view.getInt32(this.offset); this.offset += 4; return v; }
|
|
varint(positive = true) {
|
|
let value = 0;
|
|
for (let shift = 0; shift <= 28; shift += 7) {
|
|
const b = this.byte();
|
|
if (shift === 28 && (b & 240)) this.fail('无效变长整数');
|
|
value |= (b & 127) << shift;
|
|
if (!(b & 128)) return positive ? value : ((value >>> 1) ^ -(value & 1));
|
|
}
|
|
this.fail('无效变长整数');
|
|
}
|
|
count() { const n = this.varint(); this.need(n); return n; }
|
|
string() {
|
|
const size = this.varint();
|
|
if (size === 0) return null;
|
|
this.need(size - 1);
|
|
const end = this.offset + size - 1;
|
|
let result = '';
|
|
// Old Spine exports use Java's 1/2/3-byte UTF-8 encoding of UTF-16
|
|
// code units (including surrogate pairs), rather than string references.
|
|
while (this.offset < end) {
|
|
const b = this.byte();
|
|
if (b < 128) result += String.fromCharCode(b);
|
|
else {
|
|
const extra = (b & 224) === 192 ? 1 : (b & 240) === 224 ? 2 : -1;
|
|
if (extra < 0 || this.offset + extra > end) this.fail('无效字符串');
|
|
let code = b & (extra === 1 ? 31 : 15);
|
|
for (let i = 0; i < extra; i++) { const c = this.byte(); if ((c & 192) !== 128) this.fail('无效 UTF-8'); code = (code << 6) | (c & 63); }
|
|
result += String.fromCharCode(code);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
color() { return (this.int() >>> 0).toString(16).padStart(8, '0'); }
|
|
floats(n) { this.need(n * 4); return Array.from({ length: n }, () => this.float()); }
|
|
shorts() { const n = this.count(); this.need(n * 2); return Array.from({ length: n }, () => { const v = this.view.getUint16(this.offset); this.offset += 2; return v; }); }
|
|
ref(list) { const i = this.varint(); if (i < 0 || i >= list.length) this.fail('索引越界:' + i); return list[i]; }
|
|
}
|
|
|
|
function decode(bytes, runtimeDir) {
|
|
const r = new Input(bytes);
|
|
const skeleton = { hash: r.string(), spine: r.string() };
|
|
if (!/^3\.[467]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.6 / 3.7');
|
|
const version = skeleton.spine.slice(0, 3);
|
|
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
|
|
const v37 = version === '3.7';
|
|
const v34 = version === '3.4';
|
|
skeleton.width = r.float(); skeleton.height = r.float();
|
|
const nonessential = r.bool();
|
|
if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
|
|
const root = { skeleton, bones: [], slots: [], ik: [], transform: [], path: [], skins: dict(), events: dict(), animations: dict() };
|
|
const boneName = () => r.ref(root.bones).name;
|
|
const slotName = () => r.ref(root.slots).name;
|
|
const fields = (obj, names) => { for (const name of names.split(' ')) obj[name] = r.float(); return obj; };
|
|
const boneList = () => Array.from({ length: r.count() }, boneName);
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const b = { name: r.string() }; if (i) b.parent = boneName();
|
|
fields(b, 'rotation x y scaleX scaleY shearX shearY length');
|
|
if (v34) { b.inheritRotation = r.bool(); b.inheritScale = r.bool(); }
|
|
else b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
|
|
if (nonessential) b.color = r.color(); root.bones.push(b);
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const s = { name: r.string(), bone: boneName(), color: r.color() };
|
|
if (!v34) { const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2); }
|
|
s.attachment = r.string(); s.blend = r.ref(['normal', 'additive', 'multiply', 'screen']); root.slots.push(s);
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
|
|
if (v37) { c.compress = r.bool(); c.stretch = r.bool(); c.uniform = r.bool(); } root.ik.push(c);
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
|
|
if (!v34) { c.local = r.bool(); c.relative = r.bool(); }
|
|
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c);
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: slotName(),
|
|
positionMode: r.ref(['fixed', 'percent']), spacingMode: r.ref(['length', 'fixed', 'percent']), rotateMode: r.ref(['tangent', 'chain', 'chainScale']) };
|
|
fields(c, 'rotation position spacing rotateMix translateMix'); root.path.push(c);
|
|
}
|
|
function vertices(n) {
|
|
if (!r.bool()) return r.floats(n * 2);
|
|
const out = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const count = r.count(); if (!count) r.fail('加权顶点没有骨骼'); out.push(count);
|
|
for (let j = 0; j < count; j++) { const index = r.varint(); if (index < 0 || index >= root.bones.length) r.fail('顶点骨骼索引越界'); out.push(index, r.float(), r.float(), r.float()); }
|
|
}
|
|
return out;
|
|
}
|
|
function attachment(key) {
|
|
const a = { name: r.string() || key };
|
|
const type = r.byte();
|
|
if (v34 && type > 4) r.fail('3.4 不支持此附件类型:' + type);
|
|
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
|
|
if (!a.type) r.fail('未知附件类型:' + type);
|
|
if (type === 0) {
|
|
a.path = r.string() || a.name; fields(a, 'rotation x y scaleX scaleY width height'); a.color = r.color();
|
|
} else if (type === 2) {
|
|
a.path = r.string() || a.name; a.color = r.color(); const count = r.count();
|
|
a.uvs = r.floats(count * 2); a.triangles = r.shorts();
|
|
if (a.triangles.some(i => i >= count)) r.fail('网格三角形索引越界');
|
|
a.vertices = vertices(count); a.hull = r.varint();
|
|
if (nonessential) { a.edges = r.shorts(); fields(a, 'width height'); }
|
|
} else if (type === 3) {
|
|
a.path = r.string() || a.name; a.color = r.color(); const skin = r.string(); if (skin !== null) a.skin = skin;
|
|
a.parent = r.string(); a.deform = r.bool(); if (nonessential) fields(a, 'width height');
|
|
} else if (type === 5) {
|
|
fields(a, 'rotation x y'); if (nonessential) a.color = r.color();
|
|
} else {
|
|
if (type === 4) { a.closed = r.bool(); a.constantSpeed = r.bool(); }
|
|
if (type === 6) a.end = slotName();
|
|
a.vertexCount = r.count(); a.vertices = vertices(a.vertexCount);
|
|
if (type === 4) a.lengths = r.floats(Math.floor(a.vertexCount / 3));
|
|
if (nonessential) a.color = r.color();
|
|
}
|
|
return a;
|
|
}
|
|
const skinNames = [];
|
|
function skin(name, isDefault = false) {
|
|
const count = r.count(); if (!count && isDefault) return;
|
|
const data = root.skins[name] = dict(); skinNames.push(name);
|
|
for (let i = 0; i < count; i++) {
|
|
const attachments = data[slotName()] = dict();
|
|
for (let j = 0, n = r.count(); j < n; j++) { const key = r.string(); attachments[key] = attachment(key); }
|
|
}
|
|
}
|
|
skin('default', true);
|
|
for (let i = 0, n = r.count(); i < n; i++) skin(r.string());
|
|
const eventNames = [];
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const name = r.string(); eventNames.push(name);
|
|
const e = root.events[name] = { int: r.varint(false), float: r.float(), string: r.string() };
|
|
if (v37) { e.audio = r.string(); if (e.audio !== null) fields(e, 'volume balance'); }
|
|
}
|
|
function curve(frame) {
|
|
const type = r.byte();
|
|
if (type === 1) frame.curve = 'stepped';
|
|
else if (type === 2) frame.curve = r.floats(4);
|
|
else if (type !== 0) r.fail('未知曲线类型:' + type);
|
|
}
|
|
function frames(read, curved = true) {
|
|
const count = r.count(); if (!count) r.fail('空时间线');
|
|
return Array.from({ length: count }, (_, i) => { const frame = { time: r.float() }; read(frame); if (curved && i < count - 1) curve(frame); return frame; });
|
|
}
|
|
function animation() {
|
|
const a = { slots: dict(), bones: dict(), ik: dict(), transform: dict(), paths: dict(), deform: dict() };
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const timelines = a.slots[slotName()] = dict();
|
|
for (let j = 0, m = r.count(); j < m; j++) {
|
|
const type = r.byte();
|
|
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false);
|
|
else if (type === 1) timelines.color = frames(f => { f.color = r.color(); });
|
|
else if (type === 2 && !v34) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
|
|
else r.fail('未知插槽时间线');
|
|
}
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const timelines = a.bones[boneName()] = dict();
|
|
for (let j = 0, m = r.count(); j < m; j++) {
|
|
const type = r.byte(), name = ['rotate', 'translate', 'scale', 'shear'][type];
|
|
if (!name) r.fail('未知骨骼时间线');
|
|
timelines[name] = frames(f => fields(f, type === 0 ? 'angle' : 'x y'));
|
|
}
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const name = r.ref(root.ik).name;
|
|
a.ik[name] = frames(f => { f.mix = r.float(); f.bendPositive = r.byte() === 1; if (v37) { f.compress = r.bool(); f.stretch = r.bool(); } });
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const name = r.ref(root.transform).name; a.transform[name] = frames(f => fields(f, 'rotateMix translateMix scaleMix shearMix'));
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const timelines = a.paths[r.ref(root.path).name] = dict();
|
|
for (let j = 0, m = r.count(); j < m; j++) {
|
|
const type = r.byte(), name = ['position', 'spacing', 'mix'][type]; if (!name) r.fail('未知路径时间线');
|
|
timelines[name] = frames(f => fields(f, type === 2 ? 'rotateMix translateMix' : name));
|
|
}
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const name = r.ref(skinNames), skinDeform = a.deform[name] = dict();
|
|
for (let j = 0, m = r.count(); j < m; j++) {
|
|
const slot = slotName(), slotDeform = skinDeform[slot] = dict();
|
|
for (let k = 0, q = r.count(); k < q; k++) {
|
|
const key = r.string();
|
|
if (!root.skins[name][slot] || !root.skins[name][slot][key]) r.fail('变形附件不存在');
|
|
slotDeform[key] = frames(f => { const length = r.count(); if (length) { f.offset = r.varint(); if (f.offset < 0 || f.offset > bytes.byteLength) r.fail('变形偏移越界'); f.vertices = r.floats(length); } });
|
|
}
|
|
}
|
|
}
|
|
const drawCount = r.count();
|
|
if (drawCount) a.drawOrder = Array.from({ length: drawCount }, () => {
|
|
const f = { time: r.float(), offsets: [] }; let previous = -1;
|
|
for (let i = 0, n = r.count(); i < n; i++) {
|
|
const index = r.varint(); const offset = r.varint();
|
|
if (index <= previous || index >= root.slots.length || index + offset < 0 || index + offset >= root.slots.length) r.fail('绘制顺序索引越界');
|
|
previous = index; f.offsets.push({ slot: root.slots[index].name, offset });
|
|
}
|
|
return f;
|
|
});
|
|
const eventCount = r.count();
|
|
if (eventCount) a.events = Array.from({ length: eventCount }, () => {
|
|
const e = { time: r.float(), name: r.ref(eventNames), int: r.varint(false), float: r.float() };
|
|
const data = root.events[e.name]; e.string = r.bool() ? r.string() : data.string;
|
|
if (v37 && data.audio !== null) fields(e, 'volume balance'); return e;
|
|
});
|
|
return a;
|
|
}
|
|
for (let i = 0, n = r.count(); i < n; i++) { const name = r.string(); root.animations[name] = animation(); }
|
|
if (r.offset !== r.view.byteLength) r.fail('文件含未识别的尾部数据');
|
|
return root;
|
|
}
|
|
|
|
function install(spine, runtimeDir) {
|
|
if (!['3.4', '3.6', '3.7'].includes(runtimeDir)) return spine;
|
|
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
|
|
spine.SkeletonBinary = class SkeletonBinary {
|
|
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
|
|
readSkeletonData(bytes) {
|
|
const loader = new spine.SkeletonJson(this.attachmentLoader); loader.scale = this.scale;
|
|
return loader.readSkeletonData(decode(bytes, runtimeDir));
|
|
}
|
|
};
|
|
return spine;
|
|
}
|
|
|
|
module.exports = { install, decode };
|