'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 };