178 lines
11 KiB
JavaScript
178 lines
11 KiB
JavaScript
'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`);
|