增加对3.7 3.8的二进制读取
This commit is contained in:
1 parent
1b67933cac
commit
4c5033d2f9
10 files changed
+526
-52
No files matched your search
@@ -9,3 +9,4 @@ Thumbs.db
|
||||
# Temp test artifacts
|
||||
/tmp/
|
||||
*.png.d.ts
|
||||
tests/.fixtures/
|
||||
@@ -0,0 +1,62 @@
|
||||
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.6/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
|
||||
/******************************************************************************
|
||||
* Spine Runtimes Software License v2.5
|
||||
*
|
||||
* Copyright (c) 2013-2016, Esoteric Software
|
||||
* All rights reserved.
|
||||
*
|
||||
* You are granted a perpetual, non-exclusive, non-sublicensable, and
|
||||
* non-transferable license to use, install, execute, and perform the Spine
|
||||
* Runtimes software and derivative works solely for personal or internal
|
||||
* use. Without the written permission of Esoteric Software (see Section 2 of
|
||||
* the Spine Software License Agreement), you may not (a) modify, translate,
|
||||
* adapt, or develop new applications using the Spine Runtimes or otherwise
|
||||
* create derivative works or improvements of the Spine Runtimes or (b) remove,
|
||||
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
|
||||
* or other intellectual property or proprietary rights notices on or in the
|
||||
* Software, including any copy thereof. Redistributions in binary or source
|
||||
* form must include this license and terms.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
|
||||
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
|
||||
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
|
||||
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
|
||||
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*****************************************************************************/
|
||||
|
||||
|
||||
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.7/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
|
||||
/******************************************************************************
|
||||
* Spine Runtimes License Agreement
|
||||
* Last updated May 1, 2019. Replaces all prior versions.
|
||||
*
|
||||
* Copyright (c) 2013-2019, Esoteric Software LLC
|
||||
*
|
||||
* Integration of the Spine Runtimes into software or otherwise creating
|
||||
* derivative works of the Spine Runtimes is permitted under the terms and
|
||||
* conditions of Section 2 of the Spine Editor License Agreement:
|
||||
* http://esotericsoftware.com/spine-editor-license
|
||||
*
|
||||
* Otherwise, it is permitted to integrate the Spine Runtimes into software
|
||||
* or otherwise create derivative works of the Spine Runtimes (collectively,
|
||||
* "Products"), provided that each user of the Products must obtain their own
|
||||
* Spine Editor license and redistribution of the Products in any form must
|
||||
* include this license and copyright notice.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY EXPRESS
|
||||
* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
|
||||
* NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS
|
||||
* INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY
|
||||
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
||||
* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*****************************************************************************/
|
||||
|
||||
@@ -28,6 +28,7 @@ SpineViewer/
|
||||
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
|
||||
├─ js/
|
||||
│ ├─ spine-utils.js # 资源匹配、版本检测工具
|
||||
│ ├─ spine-legacy-binary.js # 3.6/3.7 二进制解析兼容层
|
||||
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用)
|
||||
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
|
||||
│ ├─ 3.6/ spine-canvas.js
|
||||
@@ -79,7 +80,20 @@ SpineViewer/
|
||||
## 关于新版/旧版二进制
|
||||
|
||||
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
|
||||
- **3.6 / 3.7** 的官方 JS 运行时 **不包含二进制解析器**,且其二进制格式与 3.8 不兼容。因此这两版的 `.skel` / `.skel.bytes` 无法由官方 JS 运行时直接加载。同批存在 **JSON** 时,插件会用 JSON 为 `atlas/json/skel` 三个条目生成相同的骨骼缩略图;没有 JSON 时则显示实际 atlas 贴图预览。
|
||||
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览、生成缩略图及后台补刷。
|
||||
- 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。
|
||||
- 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。
|
||||
|
||||
### 旧版二进制回归测试
|
||||
|
||||
```sh
|
||||
node tests/fetch-legacy-fixtures.js
|
||||
node tests/legacy-binary.test.js
|
||||
```
|
||||
|
||||
下载器从官方仓库固定提交获取两版 Spineboy、Goblins、Vine、Stretchyman、Tank、Coin、Raptor 的 JSON/SKEL/atlas。测试素材放在 `tests/.fixtures/`,不提交 Git,也不是插件运行依赖。测试不依赖原生 Canvas,比较附件、关键帧、不同皮肤与缩放下的动画姿势,并调用查看器渲染器检查几何数据。
|
||||
|
||||
官方 3.6 Raptor 的 JSON 与 SKEL 在一条 IK 曲线的阶梯插值位置上存在差异,测试明确记录该例外;解析器保留 SKEL 中的原始值。原始代码许可见 `LICENSES/Spine-Legacy-Binary-License.txt`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
// Adapted from Esoteric Software's 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.
|
||||
// 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\.[67]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.6 / 3.7');
|
||||
const version = skeleton.spine.slice(0, 3);
|
||||
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
|
||||
const v37 = version === '3.7';
|
||||
skeleton.width = r.float(); skeleton.height = r.float();
|
||||
const nonessential = r.bool();
|
||||
if (nonessential) { 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');
|
||||
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() };
|
||||
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: 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: r.varint(), bones: boneList(), target: boneName(), local: r.bool(), 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: 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();
|
||||
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) 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 (runtimeDir !== '3.6' && runtimeDir !== '3.7') return spine;
|
||||
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 };
|
||||
+2
-6
@@ -53,7 +53,7 @@ function loadRuntime(dir) {
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
|
||||
return sandbox.spine;
|
||||
return require('./spine-legacy-binary.js').install(sandbox.spine, dir);
|
||||
}
|
||||
|
||||
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
|
||||
@@ -157,13 +157,9 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
|
||||
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
|
||||
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
|
||||
|
||||
// 3.6/3.7 的二进制骨骼与 3.8 运行时二进制格式不兼容,
|
||||
// 且 3.6/3.7 的 JS 运行时本身不含二进制解析器,因此这类文件无法加载。
|
||||
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
|
||||
const name = path.basename(skeletonPath);
|
||||
throw new Error(version && parseFloat(version) < 3.8
|
||||
? 'Spine ' + version + ' 的二进制骨骼(' + name + ')无法由官方 JS 运行时解析,请改用 .json 格式导出。'
|
||||
: '骨骼数据为空:' + name);
|
||||
throw new Error('骨骼数据为空:' + name);
|
||||
}
|
||||
const skeleton = new spine.Skeleton(skeletonData);
|
||||
|
||||
|
||||
+1
-21
@@ -97,13 +97,12 @@ function detectVersion(filePath) {
|
||||
|
||||
/**
|
||||
* 根据检测到的版本与文件类型,选择本地运行时目录。
|
||||
* - 3.x 只有 3.8 的 JS 构建包含二进制解析器,因此二进制使用 3.8 运行时
|
||||
* - 3.6/3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
|
||||
*/
|
||||
function chooseRuntimeDir(version, isBinary) {
|
||||
if (!version) return null;
|
||||
const major = parseInt(version.split('.')[0], 10);
|
||||
if (major === 3) {
|
||||
if (isBinary) return '3.8';
|
||||
const minor = parseFloat(version);
|
||||
if (minor <= 3.6) return '3.6';
|
||||
if (minor <= 3.7 + 0.0001) return '3.7';
|
||||
@@ -412,7 +411,6 @@ function resolveRealSource(filePath) {
|
||||
function discoverAssets(filePath, options) {
|
||||
options = options || {};
|
||||
const strictStem = !!options.strictStem;
|
||||
const preferCompatibleJson = !!options.preferCompatibleJson;
|
||||
const requestedCrossBatch = !!options.allowCrossBatch;
|
||||
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
|
||||
filePath = resolveRealSource(filePath);
|
||||
@@ -518,24 +516,6 @@ function discoverAssets(filePath, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Spine 3.6/3.7 的官方 JS 运行时不能读取对应二进制格式。若同批导入中
|
||||
// 同时带有 JSON,则 atlas 和 skel 条目都用该 JSON 渲染真实缩略图。
|
||||
if (preferCompatibleJson && skeleton && isSkeletonFile(skeleton.name)) {
|
||||
let selectedVersion = null;
|
||||
try { selectedVersion = detectVersion(skeleton.fullPath); } catch (e) { /* ignore */ }
|
||||
const isOldBinary = /\.skel(?:\.bytes)?$/i.test(skeleton.name) && selectedVersion && parseFloat(selectedVersion) < 3.8;
|
||||
if (isOldBinary) {
|
||||
const stem = normalizeStem(skeleton.name);
|
||||
const jsonCands = realSkeletonFiles.filter((f) =>
|
||||
f.name.toLowerCase().endsWith('.json') &&
|
||||
normalizeStem(f.name) === stem &&
|
||||
familyOfSkel(f) === familyOfSkel(skeleton) &&
|
||||
(!strictStem || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= allowedImportDelta)
|
||||
);
|
||||
const jsonFallback = selectBest(jsonCands, skeleton.fullPath);
|
||||
if (jsonFallback) skeleton = jsonFallback;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
|
||||
let atlas = null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
// Download only test assets, pinned to the official 3.6 and 3.7 branch commits.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const destination = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
|
||||
const revisions = { '3.6': '654c20e5b0e523040b6366bbd1042510d2645134', '3.7': '9639bcc81722d7178fd9d1cdc1a3d55a4c91f989' };
|
||||
for (const [version, revision] of Object.entries(revisions)) {
|
||||
for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor']) {
|
||||
const directory = path.join(destination, version, name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
for (const file of [name + '-pro.json', name + '-pro.skel', name + '.atlas']) {
|
||||
const url = `https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/examples/${name}/export/${file}`;
|
||||
execFileSync('curl', ['-LfsS', '--retry', '2', '--max-time', '60', url, '-o', path.join(directory, file)]);
|
||||
}
|
||||
console.log(version + ' / ' + name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { decode, install } = require('../js/spine-legacy-binary');
|
||||
const utils = require('../js/spine-utils');
|
||||
const clipping = require('../js/spine-canvas-clipping');
|
||||
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
|
||||
const names = ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor'];
|
||||
let poses = 0;
|
||||
let animations = 0;
|
||||
function near(actual, expected, tolerance, label) {
|
||||
assert(Number.isFinite(actual) && Number.isFinite(expected) && Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: ${actual} != ${expected} (tolerance ${tolerance})`);
|
||||
}
|
||||
function arrayNear(actual, expected, tolerance, label) {
|
||||
assert.equal(actual.length, expected.length, label + ' length');
|
||||
for (let i = 0; i < actual.length; i++) near(actual[i], expected[i], tolerance, label + '[' + i + ']');
|
||||
}
|
||||
function runtime(version) {
|
||||
const context = { console };
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'lib', version, 'spine-canvas.js'), 'utf8'), context);
|
||||
return install(context.spine, version);
|
||||
}
|
||||
for (const version of ['3.6', '3.7']) {
|
||||
const spine = runtime(version);
|
||||
assert.equal(utils.chooseRuntimeDir(version + '.32', true), version);
|
||||
for (const name of names) {
|
||||
const directory = path.join(fixtures, version, name);
|
||||
const bytes = fs.readFileSync(path.join(directory, name + '-pro.skel'));
|
||||
const json = JSON.parse(fs.readFileSync(path.join(directory, name + '-pro.json'), 'utf8'));
|
||||
const decoded = decode(bytes, version);
|
||||
assert.equal(decoded.skeleton.hash, json.skeleton.hash);
|
||||
assert.equal(decoded.skeleton.spine, json.skeleton.spine);
|
||||
assert.equal(utils.detectVersion(path.join(directory, name + '-pro.skel')), json.skeleton.spine);
|
||||
// Region geometry uses a real official atlas; no native Canvas dependency
|
||||
// is needed to verify animation poses and renderer triangle output.
|
||||
const texture = { getImage: () => ({ width: 1024, height: 1024 }), setFilters() {}, setWraps() {} };
|
||||
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, name + '.atlas'), 'utf8'), () => texture);
|
||||
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas);
|
||||
// In the pinned 3.6 raptor exports, the same-hash JSON puts the stepped
|
||||
// IK curve on frame 0, while the SKEL stores it on frame 1. Assert the
|
||||
// exact discrepancy, then align only this JSON comparison oracle.
|
||||
if (version === '3.6' && name === 'raptor') {
|
||||
const binIK = decoded.animations['gun-holster'].ik['front-arm-goal'];
|
||||
const jsonIK = json.animations['gun-holster'].ik['front-arm-goal'];
|
||||
assert.equal(binIK[0].curve, undefined); assert.equal(binIK[1].curve, 'stepped');
|
||||
assert.equal(jsonIK[0].curve, 'stepped'); assert.equal(jsonIK[1].curve, undefined);
|
||||
delete jsonIK[0].curve; jsonIK[1].curve = 'stepped';
|
||||
}
|
||||
for (const scale of [1, 0.5]) {
|
||||
const binaryLoader = new spine.SkeletonBinary(attachmentLoader); binaryLoader.scale = scale;
|
||||
const jsonLoader = new spine.SkeletonJson(attachmentLoader); jsonLoader.scale = scale;
|
||||
const data = binaryLoader.readSkeletonData(bytes), expected = jsonLoader.readSkeletonData(json);
|
||||
assert.deepEqual(Array.from(data.bones, b => b.name), Array.from(expected.bones, b => b.name));
|
||||
assert.deepEqual(Array.from(data.slots, s => s.name), Array.from(expected.slots, s => s.name));
|
||||
assert.deepEqual(Array.from(data.skins, s => s.name), Array.from(expected.skins, s => s.name));
|
||||
assert.deepEqual(Array.from(data.animations, a => a.name), Array.from(expected.animations, a => a.name));
|
||||
for (let i = 0; i < data.bones.length; i++) {
|
||||
for (const k of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'length'])
|
||||
near(data.bones[i][k], expected.bones[i][k], 0.0052, name + '/bone/' + k);
|
||||
}
|
||||
for (const skin of data.skins) {
|
||||
const reference = expected.findSkin(skin.name);
|
||||
for (const slot of Object.keys(skin.attachments)) {
|
||||
for (const [key, a] of Object.entries(skin.attachments[slot])) {
|
||||
const b = reference.getAttachment(Number(slot), key);
|
||||
assert(b, key); assert.equal(a.constructor.name, b.constructor.name);
|
||||
for (const field of ['vertices', 'regionUVs', 'triangles', 'bones', 'lengths']) {
|
||||
if (a[field]) arrayNear(a[field], b[field], field === 'triangles' || field === 'bones' ? 0 : 0.0052, name + '/' + key + '/' + field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const animation of data.animations) {
|
||||
const refAnimation = expected.findAnimation(animation.name);
|
||||
near(animation.duration, refAnimation.duration, 0.00006, 'duration');
|
||||
assert.equal(animation.timelines.length, refAnimation.timelines.length);
|
||||
for (let i = 0; i < animation.timelines.length; i++) {
|
||||
const a = animation.timelines[i], b = refAnimation.timelines[i];
|
||||
assert.equal(a.constructor.name, b.constructor.name);
|
||||
arrayNear(a.frames, b.frames, 0.0052, name + '/' + animation.name + '/frames');
|
||||
if (a.curves) arrayNear(a.curves, b.curves, 0.0052, name + '/' + animation.name + '/curves');
|
||||
if (a.attachmentNames) assert.deepEqual(Array.from(a.attachmentNames), Array.from(b.attachmentNames));
|
||||
if (a.frameVertices) for (let f = 0; f < a.frameVertices.length; f++) arrayNear(a.frameVertices[f], b.frameVertices[f], 0.0104, 'deform');
|
||||
}
|
||||
animations++;
|
||||
// Avoid exactly half-duration: rounding JSON times can put samples
|
||||
// on opposite sides of an attachment keyframe (eg spineboy jump).
|
||||
for (const fraction of [0, 0.13, 0.473, 0.79, 1]) for (const skin of data.skins) {
|
||||
const pair = [data, expected].map(d => {
|
||||
const sk = new spine.Skeleton(d); sk.setSkinByName(skin.name); sk.setToSetupPose();
|
||||
const state = new spine.AnimationState(new spine.AnimationStateData(d));
|
||||
state.setAnimation(0, animation.name, false); state.update(d.findAnimation(animation.name).duration * fraction);
|
||||
state.apply(sk); sk.updateWorldTransform(); return sk;
|
||||
});
|
||||
assert.deepEqual(Array.from(pair[0].drawOrder, s => s.data.name), Array.from(pair[1].drawOrder, s => s.data.name));
|
||||
assert.deepEqual(Array.from(pair[0].slots, s => s.getAttachment()?.name), Array.from(pair[1].slots, s => s.getAttachment()?.name));
|
||||
for (let i = 0; i < pair[0].bones.length; i++) for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY']) {
|
||||
// Quantized JSON weights/curves compound along bone chains.
|
||||
near(pair[0].bones[i][field], pair[1].bones[i][field], 2.1, name + '/' + animation.name + '/' + field);
|
||||
}
|
||||
const renderer = clipping.createRenderer(spine, { globalAlpha: 1 }, version);
|
||||
let triangles = 0;
|
||||
renderer.drawTriangle = (_image, ...coordinates) => { assert(coordinates.every(Number.isFinite)); triangles++; };
|
||||
renderer.draw(pair[0]); assert(triangles > 0, name + ' visible geometry'); poses++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A damaged file must terminate with an error, never be silently accepted.
|
||||
for (const end of [0, 1, 12, Math.floor(bytes.length / 2), bytes.length - 1]) assert.throws(() => decode(bytes.subarray(0, end), version));
|
||||
assert.throws(() => decode(Buffer.concat([bytes, Buffer.from([0])]), version), /尾部/);
|
||||
assert.throws(() => decode(bytes, version === '3.6' ? '3.7' : '3.6'), /不一致/);
|
||||
const wrapped = Buffer.concat([Buffer.from([9, 8, 7]), bytes, Buffer.from([6])]);
|
||||
assert.equal(decode(wrapped.subarray(3, -1), version).skeleton.hash, json.skeleton.hash);
|
||||
console.log('PASS ' + version + ' / ' + name);
|
||||
}
|
||||
}
|
||||
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) assert.equal(utils.chooseRuntimeDir(version + '.1', true), version);
|
||||
|
||||
// Small hand-authored wire fixtures cover fields absent from the official
|
||||
// example set: point attachments, audio events, UTF-16 surrogate names, signed
|
||||
// event integers and the nonessential=false header. Values are intentionally
|
||||
// distinct so a field-order mistake cannot accidentally pass.
|
||||
function edgeFixture(version, nonessential) {
|
||||
const chunks = [];
|
||||
const byte = n => chunks.push(Buffer.from([n]));
|
||||
const int = n => { const b = Buffer.alloc(4); b.writeInt32BE(n); chunks.push(b); };
|
||||
const float = n => { const b = Buffer.alloc(4); b.writeFloatBE(n); chunks.push(b); };
|
||||
const variable = n => { n >>>= 0; while (n >= 128) { byte((n & 127) | 128); n >>>= 7; } byte(n); };
|
||||
const string = s => {
|
||||
if (s === null) return variable(0);
|
||||
const out = [];
|
||||
for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); if (c < 128) out.push(c); else if (c < 2048) out.push(192 | c >> 6, 128 | c & 63); else out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63); }
|
||||
variable(out.length + 1); chunks.push(Buffer.from(out));
|
||||
};
|
||||
const v37 = version === '3.7';
|
||||
string('edge'); string(version + '.90'); float(100); float(200); byte(nonessential ? 1 : 0);
|
||||
if (nonessential) { float(24); string('图像/'); if (v37) string('声音/'); }
|
||||
variable(1); string('根😀'); // root bone
|
||||
[0, 3, 4, 1, 1, 0, 0, 10].forEach(float); variable(0); if (nonessential) int(-1);
|
||||
variable(1); string('slot'); variable(0); int(-1); int(-1); string('point'); variable(0);
|
||||
variable(1); string('ik'); variable(0); variable(1); variable(0); variable(0); float(0.25); byte(255);
|
||||
if (v37) { byte(1); byte(0); byte(1); }
|
||||
variable(0); variable(0); // transform / path constraints
|
||||
variable(1); variable(0); variable(1); string('point'); string(null); byte(5);
|
||||
float(35); float(7); float(9); if (nonessential) int(-1);
|
||||
variable(0); // additional skins
|
||||
variable(1); string('事件🔔'); variable(245); float(1.25); string('默认');
|
||||
if (v37) { string('声音.wav'); float(0.75); float(-0.5); }
|
||||
variable(1); string('anim'); variable(0); variable(0); // slot / bone timelines
|
||||
variable(1); variable(0); variable(1); float(0.25); float(0.6); byte(255); if (v37) { byte(0); byte(1); }
|
||||
variable(0); variable(0); variable(0); variable(0); // transform / path / deform / draw order
|
||||
variable(1); float(0.5); variable(0); variable(641); float(2.5); byte(0);
|
||||
if (v37) { float(0.4); float(0.2); }
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
for (const version of ['3.6', '3.7']) for (const nonessential of [false, true]) {
|
||||
const bytes = edgeFixture(version, nonessential), d = decode(bytes, version);
|
||||
assert.equal(d.bones[0].name, '根😀'); assert.equal(d.events['事件🔔'].int, -123);
|
||||
assert.equal(d.animations.anim.events[0].int, -321); assert.equal(d.animations.anim.events[0].string, '默认');
|
||||
assert.equal(d.skins.default.slot.point.rotation, 35);
|
||||
assert.equal(d.skeleton.fps, nonessential ? 24 : undefined);
|
||||
if (version === '3.7') {
|
||||
assert.equal(d.ik[0].compress, true); assert.equal(d.ik[0].uniform, true);
|
||||
assert.equal(d.animations.anim.ik.ik[0].stretch, true);
|
||||
assert.equal(d.events['事件🔔'].audio, '声音.wav'); near(d.animations.anim.events[0].volume, 0.4, 1e-6, 'audio volume');
|
||||
}
|
||||
const spine = runtime(version);
|
||||
const loader = new spine.SkeletonBinary({ newPointAttachment: (_skin, name) => new spine.PointAttachment(name) });
|
||||
const data = loader.readSkeletonData(bytes);
|
||||
assert.equal(data.bones[0].name, '根😀'); assert.equal(data.events[0].intValue, -123);
|
||||
assert.equal(data.animations[0].duration, 0.5);
|
||||
}
|
||||
console.log(`PASS: 14 official JSON/SKEL pairs, ${animations} animation/scale checks, ${poses} pose/render checks`);
|
||||
+2
-17
@@ -22,7 +22,7 @@ const renderJobs = new Map();
|
||||
const refreshJobs = new Map();
|
||||
|
||||
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
|
||||
const VERSION = 'v10';
|
||||
const VERSION = 'v11';
|
||||
|
||||
function coordinationKey(src) {
|
||||
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
|
||||
@@ -65,7 +65,6 @@ async function runDiscovery(src, retries, delay) {
|
||||
// 分批拖入的跨批次补齐统一交给后台任务处理。
|
||||
assets = utils.discoverAssets(src, {
|
||||
strictStem: true,
|
||||
preferCompatibleJson: true,
|
||||
allowCrossBatch: false,
|
||||
});
|
||||
const signature = assetSignature(assets);
|
||||
@@ -180,17 +179,6 @@ module.exports = async ({ src, dest, item }) => {
|
||||
let version = utils.detectVersion(assets.skeletonPath);
|
||||
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
|
||||
// 3.6/3.7 二进制没有对应解析器。若有同批 JSON,资源发现阶段已自动切换;
|
||||
// 走到这里说明没有 JSON,直接显示 atlas 实际贴图,避免无意义地重复解析。
|
||||
if (assets.isBinary && version && parseFloat(version) < 3.8) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
log('旧版二进制无 JSON,已使用 atlas 贴图缩略图: ' + src);
|
||||
await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||||
item.width = 400; item.height = 400;
|
||||
return resolve(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (!runtimeDir) {
|
||||
if (await writeAtlasFallback(assets, dest)) {
|
||||
await refreshRelatedThumbnails(assets, dest, item && item.id);
|
||||
@@ -356,7 +344,6 @@ function scheduleBackgroundReconcile(src) {
|
||||
await sleep(delay);
|
||||
const assets = utils.discoverAssets(src, {
|
||||
strictStem: true,
|
||||
preferCompatibleJson: true,
|
||||
allowCrossBatch: true,
|
||||
});
|
||||
const signature = assetSignature(assets);
|
||||
@@ -370,9 +357,7 @@ function scheduleBackgroundReconcile(src) {
|
||||
const version = utils.detectVersion(assets.skeletonPath);
|
||||
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
|
||||
let wrote = false;
|
||||
if (assets.isBinary && version && parseFloat(version) < 3.8) {
|
||||
wrote = await writeAtlasFallback(assets, tempPng);
|
||||
} else if (runtimeDir) {
|
||||
if (runtimeDir) {
|
||||
try {
|
||||
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
|
||||
fs.writeFileSync(tempPng, png);
|
||||
|
||||
+5
-7
@@ -272,7 +272,10 @@
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = '../lib/' + dir + '/spine-canvas.js';
|
||||
s.onload = () => resolve();
|
||||
s.onload = () => {
|
||||
try { require('../js/spine-legacy-binary.js').install(spine, dir); resolve(); }
|
||||
catch (e) { reject(e); }
|
||||
};
|
||||
s.onerror = () => reject(new Error('无法加载 Spine 运行时:lib/' + dir + '/spine-canvas.js'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
@@ -859,13 +862,8 @@
|
||||
skeletonData2 = loader.readSkeletonData(skelData);
|
||||
}
|
||||
|
||||
// 3.6/3.7 的二进制骨骼无法由官方 JS 运行时解析
|
||||
if (!skeletonData2 || !skeletonData2.bones || skeletonData2.bones.length === 0) {
|
||||
const isOldBin = assets.isBinary && version && parseFloat(version) < 3.8;
|
||||
showError(isOldBin ? '版本二进制暂不支持' : '骨骼数据为空',
|
||||
isOldBin
|
||||
? 'Spine ' + version + ' 的二进制骨骼(.skel / .skel.bytes)无法由官方 JS 运行时解析。\n\n建议在 Spine 中改用 JSON 导出(`.json`)后再导入预览。'
|
||||
: '未能读取到有效的骨骼数据。');
|
||||
showError('骨骼数据为空', '未能读取到有效的骨骼数据。');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user