新增3.4版本支持
This commit is contained in:
1 parent
4c5033d2f9
commit
9635428f78
21 files changed
+5425
-51
No files matched your search
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const utils = require('../js/spine-utils');
|
||||
const manifest = require('../manifest.json');
|
||||
const registered = Object.keys(manifest.preview).flatMap(key => key.split(','));
|
||||
// Eagle uses path.extname for item.ext, then dispatches by that exact key.
|
||||
for (const name of ['hero.atlas.txt', 'hero.skel.bytes', 'HERO.ATLAS.TXT', 'HERO.SKEL.BYTES']) {
|
||||
assert(registered.includes(path.extname(name).slice(1).toLowerCase()));
|
||||
assert.equal(utils.stemOf(name).toLowerCase(), 'hero');
|
||||
}
|
||||
for (const name of ['notes.txt', 'payload.bytes', 'hero.atlas.txt.bak']) assert.equal(utils.extOf(name), null);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-extensions-test-'));
|
||||
try {
|
||||
// Sibling directories with identical filenames must not share stale indexes.
|
||||
for (const version of ['3.4', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3']) {
|
||||
const directory = path.join(root, version); fs.mkdirSync(directory);
|
||||
const skeleton = path.join(directory, 'hero.skel.bytes');
|
||||
const atlas = path.join(directory, 'hero.atlas.txt');
|
||||
// These tests cover routing/discovery; real binary decoding is tested by
|
||||
// legacy-binary.test.js and the all-version official sample smoke tests.
|
||||
fs.writeFileSync(skeleton, Buffer.from('\0' + version + '.1\0'));
|
||||
fs.writeFileSync(atlas, 'hero.png\nsize: 64,64\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nregion\n rotate: false\n xy: 0,0\n size: 64,64\n');
|
||||
fs.writeFileSync(path.join(directory, 'hero.png'), 'texture');
|
||||
for (const selected of [skeleton, atlas]) for (const strictStem of [false, true]) {
|
||||
const assets = utils.discoverAssets(selected, { strictStem });
|
||||
assert.equal(assets.skeletonPath, skeleton); assert.equal(assets.atlasPath, atlas);
|
||||
assert.equal(assets.isBinary, true); assert.equal(utils.chooseRuntimeDir(utils.detectVersion(skeleton), true), version);
|
||||
}
|
||||
}
|
||||
console.log('PASS: Eagle last-extension routing, case handling, ordinary-file exclusion and sibling cache isolation');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
@@ -4,6 +4,20 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const destination = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
|
||||
if (process.argv[3] === '3.4') {
|
||||
const revision = 'ef5013143178918ddfc7cabfbddeae7aec7cf84e';
|
||||
for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'raptor']) {
|
||||
const directory = path.join(destination, '3.4', name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const variants = name === 'spineboy' ? [name, name + '-mesh', name + '-hover'] : name === 'goblins' ? [name, name + '-mesh'] : [name];
|
||||
for (const file of [...variants.flatMap(v => [v + '.json', v + '.skel']), name + '.atlas', name + '.png']) {
|
||||
const url = `https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/examples/${name}/export/${file}`;
|
||||
execFileSync('curl', ['--http1.1', '-LfsS', '--retry', '2', '--max-time', '60', url, '-o', path.join(directory, file)]);
|
||||
}
|
||||
console.log('3.4 / ' + name);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
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']) {
|
||||
@@ -16,4 +30,3 @@ for (const [version, revision] of Object.entries(revisions)) {
|
||||
console.log(version + ' / ' + name);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-15
@@ -24,22 +24,38 @@ function runtime(version) {
|
||||
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 versions = process.argv[3] === '3.4' ? ['3.4'] : ['3.6', '3.7'];
|
||||
let pairs = 0;
|
||||
for (const version of versions) {
|
||||
const spine = runtime(version);
|
||||
// 3.4 JSON export truncates to two decimal places instead of rounding.
|
||||
const quantization = version === '3.4' ? 0.0102 : 0.0052;
|
||||
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'));
|
||||
for (const name of version === '3.4' ? ['spineboy', 'spineboy-mesh', 'spineboy-hover', 'goblins', 'goblins-mesh', 'vine', 'stretchyman', 'tank', 'raptor'] : names) {
|
||||
const directory = path.join(fixtures, version, version === '3.4' ? name.split('-')[0] : name);
|
||||
const file = name + (version === '3.4' ? '' : '-pro');
|
||||
const bytes = fs.readFileSync(path.join(directory, file + '.skel'));
|
||||
const json = JSON.parse(fs.readFileSync(path.join(directory, file + '.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);
|
||||
assert.equal(utils.detectVersion(path.join(directory, file + '.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 atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, (version === '3.4' ? name.split('-')[0] : name) + '.atlas'), 'utf8'), () => texture);
|
||||
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas);
|
||||
// 3.4 Goblins binary optimizes three constant segments to linear;
|
||||
// JSON keeps stepped. Their equal endpoints produce identical poses.
|
||||
if (version === '3.4' && name.startsWith('goblins')) {
|
||||
for (const [bone, type, index] of [['left lower leg', 'translate', 2], ['right lower leg', 'translate', 0], ['hip', 'rotate', 0]]) {
|
||||
const timeline = json.animations.walk.bones[bone][type];
|
||||
assert.equal(timeline[index].curve, 'stepped');
|
||||
assert.equal(decoded.animations.walk.bones[bone][type][index].curve, undefined);
|
||||
for (const field of type === 'rotate' ? ['angle'] : ['x', 'y']) assert.equal(timeline[index][field], timeline[index + 1][field]);
|
||||
delete timeline[index].curve;
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -60,7 +76,7 @@ for (const version of ['3.6', '3.7']) {
|
||||
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);
|
||||
near(data.bones[i][k], expected.bones[i][k], quantization, name + '/bone/' + k);
|
||||
}
|
||||
for (const skin of data.skins) {
|
||||
const reference = expected.findSkin(skin.name);
|
||||
@@ -69,20 +85,24 @@ for (const version of ['3.6', '3.7']) {
|
||||
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);
|
||||
if (a[field]) arrayNear(a[field], b[field], field === 'triangles' || field === 'bones' ? 0 : quantization, name + '/' + key + '/' + field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const animation of data.animations) {
|
||||
const refAnimation = expected.findAnimation(animation.name);
|
||||
near(animation.duration, refAnimation.duration, 0.00006, 'duration');
|
||||
near(animation.duration, refAnimation.duration, version === '3.4' ? 0.00011 : 0.00006, 'duration');
|
||||
assert.equal(animation.timelines.length, refAnimation.timelines.length);
|
||||
// Older JSON and binary exports order independent slot timelines differently.
|
||||
const timelineKey = t => [t.constructor.name, t.slotIndex, t.boneIndex, t.ikConstraintIndex, t.transformConstraintIndex, t.pathConstraintIndex, t.attachment && t.attachment.name].join('/');
|
||||
const references = new Map(refAnimation.timelines.map(t => [timelineKey(t), t]));
|
||||
for (let i = 0; i < animation.timelines.length; i++) {
|
||||
const a = animation.timelines[i], b = refAnimation.timelines[i];
|
||||
const a = animation.timelines[i], b = references.get(timelineKey(a));
|
||||
assert(b, timelineKey(a));
|
||||
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');
|
||||
arrayNear(a.frames, b.frames, quantization, name + '/' + animation.name + '/frames');
|
||||
if (a.curves) arrayNear(a.curves, b.curves, quantization, 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');
|
||||
}
|
||||
@@ -100,7 +120,11 @@ for (const version of ['3.6', '3.7']) {
|
||||
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);
|
||||
// 3.4 truncates weights/scales by up to 0.01. Bound accumulated
|
||||
// world-position error by 1% of export size; local data is checked above.
|
||||
const tolerance = version === '3.4' && field.startsWith('world')
|
||||
? Math.max(2.1, Math.max(json.skeleton.width, json.skeleton.height) * scale * 0.01) : 2.1;
|
||||
near(pair[0].bones[i][field], pair[1].bones[i][field], tolerance, name + '/' + animation.name + '/' + field);
|
||||
}
|
||||
const renderer = clipping.createRenderer(spine, { globalAlpha: 1 }, version);
|
||||
let triangles = 0;
|
||||
@@ -116,6 +140,7 @@ for (const version of ['3.6', '3.7']) {
|
||||
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);
|
||||
pairs++;
|
||||
}
|
||||
}
|
||||
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) assert.equal(utils.chooseRuntimeDir(version + '.1', true), version);
|
||||
@@ -174,4 +199,4 @@ for (const version of ['3.6', '3.7']) for (const nonessential of [false, true])
|
||||
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`);
|
||||
console.log(`PASS: ${pairs} official JSON/SKEL pairs, ${animations} animation/scale checks, ${poses} pose/render checks`);
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { createStore } = require('../js/spine-preview-settings');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-settings-test-'));
|
||||
try {
|
||||
const userData = path.join(root, 'settings');
|
||||
const source = path.join(root, 'A.library', 'images', 'ITEM.info', 'hero.skel.bytes');
|
||||
const store = createStore(userData, source);
|
||||
assert.equal(store.read(), undefined);
|
||||
assert.equal(fs.existsSync(userData), false, 'opening an item must not create metadata');
|
||||
store.write(true);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.file)), { premultipliedAlpha: true });
|
||||
assert.equal(createStore(userData, source).read(), true);
|
||||
// Asset rename preserves the Eagle item identity.
|
||||
assert.equal(createStore(userData, path.join(path.dirname(source), 'renamed.skel.bytes')).read(), true);
|
||||
store.write(false);
|
||||
assert.equal(createStore(userData, source).read(), false, 'false must not be treated as unset');
|
||||
assert.equal(createStore(userData, source.replace('ITEM.info', 'OTHER.info')).read(), undefined);
|
||||
assert.equal(createStore(userData, source.replace('A.library', 'B.library')).read(), undefined);
|
||||
assert.throws(() => store.write('false'), TypeError);
|
||||
assert.equal(fs.existsSync(path.dirname(source)), false, 'never modify Eagle metadata');
|
||||
console.log('PASS: no default writes, explicit true/false, reopening, rename and library isolation');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
const assert = require('assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { normalizeItem } = require('../js/eagle-compound');
|
||||
const generate = require('../thumbnail/spine');
|
||||
const { canvasLib } = require('../js/spine-render');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-thumbnail-'));
|
||||
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures/3.4/spineboy'));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for (const [name, ext, full] of [['lili.atlas', 'txt', 'atlas.txt'], ['lili.skel', 'bytes', 'skel.bytes']]) {
|
||||
const item = { name, ext, text: 'old text' };
|
||||
const source = path.join(root, name + '.' + ext);
|
||||
assert(normalizeItem(source, item));
|
||||
assert.equal(item.name, 'lili'); assert.equal(item.ext, full);
|
||||
assert.equal(path.basename(source), item.name + '.' + item.ext);
|
||||
assert.equal(item.text, undefined); assert.equal(normalizeItem(source, item), false);
|
||||
}
|
||||
for (const text of ['', 'ordinary text\nsecond line']) {
|
||||
const src = path.join(root, 'notes.txt'); fs.writeFileSync(src, text);
|
||||
const result = await generate({ src, dest: path.join(root, 'notes_thumbnail.png'), item: { name: 'notes', ext: 'txt' } });
|
||||
assert.equal(result.text, text); assert.equal(result.ext, 'txt'); assert.equal(result.name, 'notes');
|
||||
}
|
||||
for (const [source, target] of [['spineboy-mesh.skel', 'spineboy.skel.bytes'], ['spineboy.atlas', 'spineboy.atlas.txt'], ['spineboy.png', 'spineboy.png']])
|
||||
fs.copyFileSync(path.join(fixtures, source), path.join(root, target));
|
||||
for (const [name, ext] of [['spineboy.atlas', 'txt'], ['spineboy.skel', 'bytes']]) {
|
||||
const src = path.join(root, name + '.' + ext), before = fs.readFileSync(src);
|
||||
const dest = path.join(root, name + '_thumbnail.png');
|
||||
const result = await generate({ src, dest, item: { name, ext } });
|
||||
assert.equal(result.name, 'spineboy'); assert.equal(result.ext, name.split('.')[1] + '.' + ext);
|
||||
assert(before.equals(fs.readFileSync(path.join(root, result.name + '.' + result.ext))));
|
||||
const canonical = path.join(root, 'spineboy_thumbnail.png');
|
||||
assert(fs.existsSync(dest)); assert(fs.existsSync(canonical));
|
||||
assert.equal(fs.readFileSync(canonical).subarray(8,12).toString(), 'WEBP');
|
||||
const image = await canvasLib.loadImage(canonical), canvas = canvasLib.createCanvas(400,400);
|
||||
const ctx = canvas.getContext('2d'); ctx.drawImage(image,0,0);
|
||||
const pixels = ctx.getImageData(12,356,110,32).data;
|
||||
let white = 0; for (let i = 0; i < pixels.length; i += 4) if (pixels[i]>190 && pixels[i+1]>190 && pixels[i+2]>190) white++;
|
||||
assert(white > 50, 'bottom-left version watermark must be visible');
|
||||
}
|
||||
console.log('PASS: ordinary TXT remains visible, compound metadata round-trip, canonical WebP cache and version watermark');
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Spine mesh seam regression</title>
|
||||
<pre id="result">Running</pre>
|
||||
<script>var module = { exports: {} };</script>
|
||||
<script src="../js/spine-webgl-rasterizer.js"></script>
|
||||
<script>
|
||||
// Open locally in Chromium. A translucent quad must have neither cracks nor
|
||||
// double-blended pixels along its shared diagonal, including after scaling.
|
||||
try {
|
||||
for (const scale of [1, 1.25]) {
|
||||
const target = document.createElement('canvas');
|
||||
target.width = target.height = 96;
|
||||
const ctx = target.getContext('2d');
|
||||
ctx.translate(2, 3); ctx.scale(scale, scale);
|
||||
const image = document.createElement('canvas');
|
||||
image.width = image.height = 8;
|
||||
const paint = image.getContext('2d');
|
||||
paint.fillStyle = '#80c040'; paint.fillRect(0, 0, 8, 8);
|
||||
const rasterizer = module.exports.createRasterizer(ctx);
|
||||
if (!rasterizer || !rasterizer.begin()) throw new Error('WebGL unavailable');
|
||||
ctx.globalAlpha = 0.5;
|
||||
rasterizer.triangle(image, 8,8,0,0, 56,8,1,0, 56,56,1,1);
|
||||
rasterizer.triangle(image, 8,8,0,0, 56,56,1,1, 8,56,0,1);
|
||||
rasterizer.end();
|
||||
const pixels = ctx.getImageData(0,0,96,96).data;
|
||||
for (let y = 15; y < 50; y++) for (let x = 15; x < 50; x++) {
|
||||
const offset = (y * 96 + x) * 4;
|
||||
if (Math.abs(pixels[offset+3]-128)>1 || Math.abs(pixels[offset]-128)>2)
|
||||
throw new Error(`Seam/overlap at ${x},${y}, scale ${scale}: ${pixels.slice(offset,offset+4)}`);
|
||||
}
|
||||
rasterizer.dispose();
|
||||
}
|
||||
document.getElementById('result').textContent = 'PASS: shared edges, opacity, transformed geometry';
|
||||
} catch (error) { document.getElementById('result').textContent = 'FAIL: ' + error.stack; }
|
||||
</script>
|
||||
Reference in new issue
Block a user