加入3.5 修复水印

This commit is contained in:
tianmo committed 2026-09-10 18:51:29 +08:00
1 parent ef01cdd6ee
commit e82a62a9ad
19 files changed
+5894 -38

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
Source revision: 2cd9467d2736b7f3a0b81382f117c73823bfb44b (official 3.5.51 tag)
Runtime: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/spine-ts/build/spine-canvas.js
Binary parser source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/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.
*****************************************************************************/
+18 -3
View File
@@ -10,7 +10,7 @@
Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再检查完整文件名。导入复合后缀时,插件通过缩略图回调将 `lili.atlas.txt` 规范为 `name: "lili"`、`ext: "atlas.txt"`,`skel.bytes` 同理,磁盘文件名保持不变;同时生成匹配新名称的 WebP 缓存。这样可避开 Eagle 对 `txt` 强制显示文本卡片的逻辑。普通 TXT 保留文本内容、正常显示;普通 BYTES 显示文件说明,两者都不扫描资源库。修改注册后需要重新加载插件或重启 Eagle。旧条目重新生成缩略图后,如果列表仍保留旧名称,需要重新打开素材库。 Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再检查完整文件名。导入复合后缀时,插件通过缩略图回调将 `lili.atlas.txt` 规范为 `name: "lili"`、`ext: "atlas.txt"`,`skel.bytes` 同理,磁盘文件名保持不变;同时生成匹配新名称的 WebP 缓存。这样可避开 Eagle 对 `txt` 强制显示文本卡片的逻辑。普通 TXT 保留文本内容、正常显示;普通 BYTES 显示文件说明,两者都不扫描资源库。修改注册后需要重新加载插件或重启 Eagle。旧条目重新生成缩略图后,如果列表仍保留旧名称,需要重新打开素材库。
支持的 Spine 版本:**3.4 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。 支持的 Spine 版本:**3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。
> 关键特性:无需网络、不走 CDN。所有 Spine 官方运行时代码都已拷贝到插件本地 `lib/` 目录,不含 git 子模块。 > 关键特性:无需网络、不走 CDN。所有 Spine 官方运行时代码都已拷贝到插件本地 `lib/` 目录,不含 git 子模块。
@@ -30,10 +30,11 @@ SpineViewer/
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配) │ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
├─ js/ ├─ js/
│ ├─ spine-utils.js # 资源匹配、版本检测工具 │ ├─ spine-utils.js # 资源匹配、版本检测工具
│ ├─ spine-legacy-binary.js # 3.6/3.7 二进制解析兼容层 │ ├─ spine-legacy-binary.js # 3.4–3.7 二进制解析兼容层
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用) │ └─ spine-render.js # 纯 Node 渲染模块(缩略图用)
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地) ├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
│ ├─ 3.4/ spine-canvas.js │ ├─ 3.4/ spine-canvas.js
│ ├─ 3.5/ spine-canvas.js
│ ├─ 3.6/ spine-canvas.js │ ├─ 3.6/ spine-canvas.js
│ ├─ 3.7/ spine-canvas.js │ ├─ 3.7/ spine-canvas.js
│ ├─ 3.8/ spine-canvas.js │ ├─ 3.8/ spine-canvas.js
@@ -76,7 +77,9 @@ SpineViewer/
## 使用说明 ## 使用说明
预览与手动缩略图使用 WebGL 绘制网格三角形,避免 Canvas 逐三角形裁切造成的细线接缝,覆盖全部八个运行时版本。WebGL 不可用时回退到 Canvas。 预览与手动缩略图使用 WebGL 绘制网格三角形,避免 Canvas 逐三角形裁切造成的细线接缝,覆盖全部九个运行时版本。WebGL 不可用时回退到 Canvas。
“背景”开关默认开启,绘制每格 50×50 世界单位的灰色方格,随骨骼视图一起移动和缩放。背景像素与骨骼在同一个帧缓冲中合成,支持正常、相加、相乘和滤色;关闭方格后使用实色背景。手动缩略图跟随当前背景开关。4.3 等带 `-beta` 后缀的完整版本号也会显示在水印中。
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。 Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
@@ -87,6 +90,8 @@ Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置
## 关于新版/旧版二进制 ## 关于新版/旧版二进制
- **3.5**:独立使用官方 3.5.51 运行时及对应二进制分支,支持 JSON、SKEL、SKEL.BYTES、ATLAS、ATLAS.TXT、预乘、动画、皮肤和缩略图。该版本没有裁剪附件。已对七组匹配的官方 JSON/SKEL 做姿势对比;Tank/Raptor 的不同导出批次分别做解析和渲染检查,详见 `lib/3.5/README.md`。
- **3.4**:使用官方 3.4.02 运行时与匹配的二进制解析分支,支持 JSON、SKEL、SKEL.BYTES、ATLAS、ATLAS.TXT,包含网格、变形、多皮肤、约束、时间轴、预乘及缩略图。3.4 本身没有裁剪附件。已验证九组官方 JSON/SKEL 示例;本地路径长度修正见 `lib/3.4/README.md`。 - **3.4**:使用官方 3.4.02 运行时与匹配的二进制解析分支,支持 JSON、SKEL、SKEL.BYTES、ATLAS、ATLAS.TXT,包含网格、变形、多皮肤、约束、时间轴、预乘及缩略图。3.4 本身没有裁剪附件。已验证九组官方 JSON/SKEL 示例;本地路径长度修正见 `lib/3.4/README.md`。
3.4 测试资源与回归命令(下载包括配套 PNG,可直接导入 Eagle): 3.4 测试资源与回归命令(下载包括配套 PNG,可直接导入 Eagle):
@@ -105,6 +110,16 @@ node tests/legacy-binary.test.js tests/.fixtures 3.4
### 旧版二进制回归测试 ### 旧版二进制回归测试
九个版本的完整普通 Alpha 示例可下载到本地(包括配套 Atlas 和 PNG,记录固定提交与文件校验值):
```sh
node tests/fetch-official-examples.js
node tests/official-examples.test.js
node tests/legacy-binary.test.js tests/.fixtures 3.5
```
位置:`tests/.fixtures/`,按版本和示例分目录。每个示例可单独拖入 Eagle;不要把不同版本的同名文件混在同一个测试目录。3.5 官方 tag 的部分示例保留 beta 版本号,下载器不修改原始数据。
```sh ```sh
node tests/fetch-legacy-fixtures.js node tests/fetch-legacy-fixtures.js
node tests/legacy-binary.test.js node tests/legacy-binary.test.js
+25
View File
@@ -0,0 +1,25 @@
'use strict';
const patterns = new WeakMap();
// Called with the same world-to-canvas transform used to draw the skeleton.
function drawCheckerboard(ctx, width, height) {
let pattern = patterns.get(ctx);
if (!pattern) {
const tile = ctx.canvas.ownerDocument.createElement('canvas');
tile.width = tile.height = 100;
const paint = tile.getContext('2d');
paint.fillStyle = '#505052'; paint.fillRect(0, 0, 100, 100);
paint.fillStyle = '#5a5a5c'; paint.fillRect(0, 0, 50, 50); paint.fillRect(50, 50, 50, 50);
pattern = ctx.createPattern(tile, 'repeat'); patterns.set(ctx, pattern);
}
const m = ctx.getTransform().inverse();
const corners = [[0,0], [width,0], [width,height], [0,height]].map(([x,y]) => [m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f]);
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
const x = Math.min(...xs), y = Math.min(...ys);
if (!Number.isFinite(x + y)) return;
ctx.save();
try { ctx.imageSmoothingEnabled = false; ctx.fillStyle = pattern; ctx.fillRect(x, y, Math.max(...xs)-x, Math.max(...ys)-y); }
finally { ctx.restore(); }
}
module.exports = { drawCheckerboard };
+2 -1
View File
@@ -12,7 +12,8 @@ function dataUrl() {
} }
function drawVersion(ctx, width, height, version) { function drawVersion(ctx, width, height, version) {
if (!/^\d+\.\d+(?:\.\d+)?$/.test(String(version || ''))) return; version = String(version || '');
if (version.length > 40 || !/^\d+\.\d+(?:\.\d+)?(?:[-+][a-z0-9][a-z0-9.-]*)?$/i.test(version)) return;
const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.055)); const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.055));
const margin = Math.max(10, Math.round(Math.min(width, height) * 0.03)); const margin = Math.max(10, Math.round(Math.min(width, height) * 0.03));
ctx.save(); ctx.save();
+12 -3
View File
@@ -5,6 +5,7 @@
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules. // SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen'];
const unpremultipliedImages = new WeakMap(); const unpremultipliedImages = new WeakMap();
function rendererConstructor(spine) { function rendererConstructor(spine) {
@@ -96,6 +97,7 @@ function createLegacyRenderer(spine, context) {
const dark = new spine.Color(0, 0, 0, 0); const dark = new spine.Color(0, 0, 0, 0);
base.draw = function drawWithClipping(skeleton) { base.draw = function drawWithClipping(skeleton) {
const composite = context.globalCompositeOperation;
const drawOrder = skeleton.drawOrder; const drawOrder = skeleton.drawOrder;
const pma = !!base.premultipliedAlpha; const pma = !!base.premultipliedAlpha;
try { try {
@@ -131,6 +133,7 @@ function createLegacyRenderer(spine, context) {
const image = imageForCanvas(imageFromAttachment(attachment), context, pma); const image = imageForCanvas(imageFromAttachment(attachment), context, pma);
if (!image) continue; if (!image) continue;
context.globalAlpha = base.tempColor.a; context.globalAlpha = base.tempColor.a;
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
if (clipper.isClipping()) { if (clipper.isClipping()) {
const unpacked = unpackVertices(vertices, vertexCount); const unpacked = unpackVertices(vertices, vertexCount);
clipper.clipTriangles(unpacked.positions, unpacked.positions.length, clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
@@ -146,18 +149,20 @@ function createLegacyRenderer(spine, context) {
} finally { } finally {
clipper.clipEnd(); clipper.clipEnd();
context.globalAlpha = 1; context.globalAlpha = 1;
context.globalCompositeOperation = composite;
} }
}; };
base.clipper = clipper; base.clipper = clipper;
return base; return base;
} }
// 3.4 predates clipping attachments and computes packed vertices on attachments. // 3.4/3.5 predate clipping attachments and compute packed vertices on attachments.
function create34Renderer(spine, context) { function create34Renderer(spine, context) {
const base = new (rendererConstructor(spine))(context); const base = new (rendererConstructor(spine))(context);
base.triangleRendering = true; base.triangleRendering = true;
base.premultipliedAlpha = false; base.premultipliedAlpha = false;
base.draw = skeleton => { base.draw = skeleton => {
const composite = context.globalCompositeOperation;
try { try {
for (const slot of skeleton.drawOrder) { for (const slot of skeleton.drawOrder) {
const attachment = slot.getAttachment(); const attachment = slot.getAttachment();
@@ -166,9 +171,10 @@ function create34Renderer(spine, context) {
const image = imageForCanvas(imageFromAttachment(attachment), context, base.premultipliedAlpha); const image = imageForCanvas(imageFromAttachment(attachment), context, base.premultipliedAlpha);
if (!image) continue; if (!image) continue;
context.globalAlpha = vertices[5]; context.globalAlpha = vertices[5];
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES); drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
} }
} finally { context.globalAlpha = 1; } } finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; }
}; };
return base; return base;
} }
@@ -182,6 +188,7 @@ function create43Renderer(spine, context) {
const core = new spine.SkeletonRendererCore(); const core = new spine.SkeletonRendererCore();
base.draw = function drawWithCoreClipping(skeleton) { base.draw = function drawWithCoreClipping(skeleton) {
const composite = context.globalCompositeOperation;
try { try {
const pma = !!base.premultipliedAlpha; const pma = !!base.premultipliedAlpha;
for (let command = core.render(skeleton, pma); command; command = command.next) { for (let command = core.render(skeleton, pma); command; command = command.next) {
@@ -189,6 +196,7 @@ function create43Renderer(spine, context) {
const image = imageForCanvas(sourceImage, context, pma); const image = imageForCanvas(sourceImage, context, pma);
if (!image) continue; if (!image) continue;
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255; context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over';
const positions = command.positions; const positions = command.positions;
const uvs = command.uvs; const uvs = command.uvs;
const indices = command.indices; const indices = command.indices;
@@ -204,6 +212,7 @@ function create43Renderer(spine, context) {
} }
} finally { } finally {
context.globalAlpha = 1; context.globalAlpha = 1;
context.globalCompositeOperation = composite;
} }
}; };
base.clipper = core.clipping; base.clipper = core.clipping;
@@ -211,7 +220,7 @@ function create43Renderer(spine, context) {
} }
function createRenderer(spine, context, runtimeDir) { function createRenderer(spine, context, runtimeDir) {
const renderer = runtimeDir === '3.4' ? create34Renderer(spine, context) : runtimeDir === '4.3' const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3'
? create43Renderer(spine, context) ? create43Renderer(spine, context)
: createLegacyRenderer(spine, context); : createLegacyRenderer(spine, context);
// Explicitly disable debug drawing in every supported runtime. // Explicitly disable debug drawing in every supported runtime.
+9 -7
View File
@@ -1,9 +1,10 @@
'use strict'; 'use strict';
// Adapted from Esoteric Software's 3.4/3.6/3.7 spine-libgdx SkeletonBinary.java. // Adapted from Esoteric Software's 3.4/3.5/3.6/3.7 spine-libgdx SkeletonBinary.java.
// Copyright (c) 2013-2019, Esoteric Software. See // Copyright (c) 2013-2019, Esoteric Software. See
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices. // LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
// 3.4 source and license: LICENSES/Spine-3.4-License.txt. // 3.4 source and license: LICENSES/Spine-3.4-License.txt.
// 3.5 source and license: LICENSES/Spine-3.5-License.txt.
// Decode the versioned wire format to an in-memory SkeletonJson object. The // Decode the versioned wire format to an in-memory SkeletonJson object. The
// matching runtime builds attachments/timelines and applies loader.scale once. // matching runtime builds attachments/timelines and applies loader.scale once.
@@ -61,11 +62,12 @@ class Input {
function decode(bytes, runtimeDir) { function decode(bytes, runtimeDir) {
const r = new Input(bytes); const r = new Input(bytes);
const skeleton = { hash: r.string(), spine: r.string() }; const skeleton = { hash: r.string(), spine: r.string() };
if (!/^3\.[467]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.6 / 3.7'); if (!/^3\.[4567]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.5 / 3.6 / 3.7');
const version = skeleton.spine.slice(0, 3); const version = skeleton.spine.slice(0, 3);
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致'); if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
const v37 = version === '3.7'; const v37 = version === '3.7';
const v34 = version === '3.4'; const v34 = version === '3.4';
const before36 = v34 || version === '3.5';
skeleton.width = r.float(); skeleton.height = r.float(); skeleton.width = r.float(); skeleton.height = r.float();
const nonessential = r.bool(); const nonessential = r.bool();
if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); } if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
@@ -83,7 +85,7 @@ function decode(bytes, runtimeDir) {
} }
for (let i = 0, n = r.count(); i < n; i++) { for (let i = 0, n = r.count(); i < n; i++) {
const s = { name: r.string(), bone: boneName(), color: r.color() }; const s = { name: r.string(), bone: boneName(), color: r.color() };
if (!v34) { const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2); } if (!before36) { 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); 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++) { for (let i = 0, n = r.count(); i < n; i++) {
@@ -92,7 +94,7 @@ function decode(bytes, runtimeDir) {
} }
for (let i = 0, n = r.count(); i < n; i++) { for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() }; const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
if (!v34) { c.local = r.bool(); c.relative = r.bool(); } if (!before36) { c.local = r.bool(); c.relative = r.bool(); }
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c); 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++) { for (let i = 0, n = r.count(); i < n; i++) {
@@ -112,7 +114,7 @@ function decode(bytes, runtimeDir) {
function attachment(key) { function attachment(key) {
const a = { name: r.string() || key }; const a = { name: r.string() || key };
const type = r.byte(); const type = r.byte();
if (v34 && type > 4) r.fail('3.4 不支持此附件类型:' + type); if (before36 && type > 4) r.fail(version + ' 不支持此附件类型:' + type);
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type]; a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
if (!a.type) r.fail('未知附件类型:' + type); if (!a.type) r.fail('未知附件类型:' + type);
if (type === 0) { if (type === 0) {
@@ -172,7 +174,7 @@ function decode(bytes, runtimeDir) {
const type = r.byte(); const type = r.byte();
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false); 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 === 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 if (type === 2 && !before36) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else r.fail('未知插槽时间线'); else r.fail('未知插槽时间线');
} }
} }
@@ -233,7 +235,7 @@ function decode(bytes, runtimeDir) {
} }
function install(spine, runtimeDir) { function install(spine, runtimeDir) {
if (!['3.4', '3.6', '3.7'].includes(runtimeDir)) return spine; if (!['3.4', '3.5', '3.6', '3.7'].includes(runtimeDir)) return spine;
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader; if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
spine.SkeletonBinary = class SkeletonBinary { spine.SkeletonBinary = class SkeletonBinary {
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; } constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
+1 -1
View File
@@ -191,7 +191,7 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
ctx.translate(-cx, -cy); ctx.translate(-cx, -cy);
let renderer = null; let renderer = null;
if (runtimeDir === '3.4') renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); if (runtimeDir === '3.4' || runtimeDir === '3.5') renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir);
else if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx); else if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx); else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
if (renderer) { if (renderer) {
+2 -1
View File
@@ -97,7 +97,7 @@ function detectVersion(filePath) {
/** /**
* 根据检测到的版本与文件类型,选择本地运行时目录。 * 根据检测到的版本与文件类型,选择本地运行时目录。
* - 3.6/3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时 * - 3.4–3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
*/ */
function chooseRuntimeDir(version, isBinary) { function chooseRuntimeDir(version, isBinary) {
if (!version) return null; if (!version) return null;
@@ -105,6 +105,7 @@ function chooseRuntimeDir(version, isBinary) {
if (major === 3) { if (major === 3) {
const minor = parseFloat(version); const minor = parseFloat(version);
if (minor === 3.4) return '3.4'; if (minor === 3.4) return '3.4';
if (minor === 3.5) return '3.5';
if (minor <= 3.6) return '3.6'; if (minor <= 3.6) return '3.6';
if (minor <= 3.7 + 0.0001) return '3.7'; if (minor <= 3.7 + 0.0001) return '3.7';
return '3.8'; return '3.8';
+18 -5
View File
@@ -45,12 +45,12 @@ function createRasterizer(context) {
const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv'); const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv');
const resolution = gl.getUniformLocation(program, 'resolution'), opacity = gl.getUniformLocation(program, 'opacity'); const resolution = gl.getUniformLocation(program, 'resolution'), opacity = gl.getUniformLocation(program, 'opacity');
const textures = new Map(); const textures = new Map();
let coordinates = [], currentImage = null, currentAlpha = 1, transform; let coordinates = [], currentImage = null, currentAlpha = 1, currentMode = 'source-over', transform;
let disposed = false, lost = false; let disposed = false, lost = false;
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; }); canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
function texture(image) { function texture(image) {
if (textures.has(image)) return textures.get(image); if (textures.has(image) && image !== context.canvas) return textures.get(image);
const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); const t = textures.get(image) || gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
// Premultiply before interpolation so translucent edges do not darken. // Premultiply before interpolation so translucent edges do not darken.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
@@ -65,6 +65,12 @@ function createRasterizer(context) {
if (!coordinates.length) return; if (!coordinates.length) return;
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage)); gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
gl.uniform1f(opacity, currentAlpha); gl.uniform1f(opacity, currentAlpha);
// Premultiplied RGB factors; keep source-over alpha independently.
const factors = currentMode === 'lighter' ? [gl.ONE, gl.ONE]
: currentMode === 'multiply' ? [gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA]
: currentMode === 'screen' ? [gl.ONE, gl.ONE_MINUS_SRC_COLOR]
: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA];
gl.blendFuncSeparate(factors[0], factors[1], gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 4); gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 4);
coordinates.length = 0; coordinates.length = 0;
@@ -84,11 +90,18 @@ function createRasterizer(context) {
gl.uniform2f(resolution, canvas.width, canvas.height); gl.uniform2f(resolution, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0); gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Seed the same framebuffer with the actual Canvas pixels (solid or
// checkerboard background). CSS backgrounds cannot participate in blending.
currentImage = target; currentAlpha = 1; currentMode = 'source-over';
const w = canvas.width, h = canvas.height;
coordinates.push(0,0,0,0, w,0,1,0, w,h,1,1, 0,0,0,0, w,h,1,1, 0,h,0,1);
flush(); currentImage = null;
return true; return true;
}, },
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2) { triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2) {
const alpha = context.globalAlpha; const alpha = context.globalAlpha;
if (image !== currentImage || alpha !== currentAlpha) { flush(); currentImage = image; currentAlpha = alpha; } const mode = context.globalCompositeOperation || 'source-over';
if (image !== currentImage || alpha !== currentAlpha || mode !== currentMode) { flush(); currentImage = image; currentAlpha = alpha; currentMode = mode; }
const t = transform; const t = transform;
coordinates.push( coordinates.push(
t.a * x0 + t.c * y0 + t.e, t.b * x0 + t.d * y0 + t.f, u0, v0, t.a * x0 + t.c * y0 + t.e, t.b * x0 + t.d * y0 + t.f, u0, v0,
@@ -98,7 +111,7 @@ function createRasterizer(context) {
end() { end() {
flush(); flush();
context.save(); context.save();
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.drawImage(canvas, 0, 0); } try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'copy'; context.drawImage(canvas, 0, 0); }
finally { context.restore(); } finally { context.restore(); }
}, },
dispose() { dispose() {
+11
View File
@@ -0,0 +1,11 @@
# Spine 3.5
Official `3.5.51` tag, commit `2cd9467d2736b7f3a0b81382f117c73823bfb44b`, `spine-ts/build/spine-canvas.js`. The branch named `3.5` contains later 3.6 beta data; this bundle deliberately uses the tag.
Local correction: the path-length loop uses `lengths[i]` instead of `lengths[i++]`, avoiding a double increment and NaN values.
The binary compatibility layer reads 3.5 transform modes and constraint order, but does not read 3.6 dark-slot color or local/relative transform fields. Rendering uses the older attachment vertex interface. The version predates clipping attachments.
The official tag includes matching 3.5.03-beta JSON/SKEL pairs for seven variants. Tank and Raptor have 3.5.49 JSON but 3.5.03-beta SKEL; these are tested independently, not compared as identical exports. No version strings or data are rewritten.
License: `LICENSES/Spine-3.5-License.txt`.
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
'use strict';
const fs=require('fs'),path=require('path'),os=require('os');
const root=path.resolve(__dirname,'..'),fixtures=path.join(__dirname,'.fixtures');
const script=text=>'<script>'+text.replace(/<\/script/gi,'<\\/script')+'</script>';
const versions=['3.4','3.5','3.6','3.7','3.8','4.0','4.1','4.2','4.3'];
const frames=versions.map(version=>{
const dir=path.join(fixtures,version,'spineboy');
const source=path.join(dir,['3.4','3.5'].includes(version)?'spineboy-mesh.skel':'spineboy-pro.skel');
let html=script(`var module={exports:{}};const modules={};function require(n){return modules[n]}const version=${JSON.stringify(version)};`);
for(const name of ['spine-webgl-rasterizer','spine-canvas-clipping','spine-background','spine-legacy-binary'])
html+=script('module={exports:{}};\n'+fs.readFileSync(path.join(root,'js',name+'.js'),'utf8')+`\nmodules['./${name}.js']=module.exports;`);
html+=script(fs.readFileSync(path.join(root,'lib',version,'spine-canvas.js'),'utf8'));
html+=script(`(async()=>{try{
const image=new Image();image.src='data:image/png;base64,${fs.readFileSync(path.join(dir,'spineboy.png')).toString('base64')}';await image.decode();
const text=${JSON.stringify(fs.readFileSync(path.join(dir,'spineboy.atlas'),'utf8'))};
const texture=new (spine.CanvasTexture||spine.canvas.CanvasTexture)(image);let atlas;
if(spine.TextureAtlas.length<=1){atlas=new spine.TextureAtlas(text);for(const page of atlas.pages){if(page.setTexture)page.setTexture(texture);else page.texture=texture}}
else atlas=new spine.TextureAtlas(text,()=>texture);
modules['./spine-legacy-binary.js'].install(spine,version);
const bytes=Uint8Array.from(atob('${fs.readFileSync(source).toString('base64')}'),c=>c.charCodeAt(0));
const data=new spine.SkeletonBinary(new spine.AtlasAttachmentLoader(atlas)).readSkeletonData(bytes),skeleton=new spine.Skeleton(data);
if(spine.Physics)skeleton.updateWorldTransform(spine.Physics.update);else skeleton.updateWorldTransform();
const offset=new spine.Vector2(),size=new spine.Vector2();skeleton.getBounds(offset,size,[]);
const canvas=document.createElement('canvas');canvas.width=canvas.height=400;document.body.appendChild(canvas);
const ctx=canvas.getContext('2d'),renderer=modules['./spine-canvas-clipping.js'].createRenderer(spine,ctx,version);
if(renderer.rasterization!=='webgl')throw Error('WebGL not active');
let normal;
for(const mode of [0,2]){
ctx.setTransform(1,0,0,1,0,0);ctx.fillStyle='#1e1e1e';ctx.fillRect(0,0,400,400);
const scale=320/Math.max(size.x,size.y);ctx.translate(200,200);ctx.scale(scale,-scale);ctx.translate(-offset.x-size.x/2,-offset.y-size.y/2);
modules['./spine-background.js'].drawCheckerboard(ctx,400,400);
for(const slot of skeleton.slots)slot.data.blendMode=mode;
renderer.draw(skeleton);const pixels=ctx.getImageData(0,0,400,400).data;
if(mode===0)normal=pixels;else{let changed=0;for(let i=0;i<pixels.length;i+=4)if(Math.abs(pixels[i]-normal[i])>3)changed++;if(changed<100)throw Error('Slot multiply did not change image');}
}
renderer.premultipliedAlpha=true;renderer.draw(skeleton);renderer.dispose();
parent.postMessage({version,result:'PASS'},'*');
}catch(error){parent.postMessage({version,error:error.stack},'*')}})();`);
return html;
});
const html='<!doctype html><meta charset="utf-8"><style>iframe{width:420px;height:420px;border:0}body{background:#222;color:#eee}</style><pre id="result">RUNNING</pre>'+script(`let results=[];window.addEventListener('message',e=>{if(e.data.version){results.push(e.data);document.getElementById('result').textContent=JSON.stringify(results)}});for(const html of ${JSON.stringify(frames)}){const frame=document.createElement('iframe');frame.srcdoc=html;document.body.appendChild(frame)}`);
const output=path.resolve(process.argv[2]||path.join(os.tmpdir(),'spineviewer-browser-preview-test.html'));
fs.writeFileSync(output,html);console.log(output);
+1 -1
View File
@@ -15,7 +15,7 @@ for (const name of ['notes.txt', 'payload.bytes', 'hero.atlas.txt.bak']) assert.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-extensions-test-')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-extensions-test-'));
try { try {
// Sibling directories with identical filenames must not share stale indexes. // 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']) { for (const version of ['3.4', '3.5', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3']) {
const directory = path.join(root, version); fs.mkdirSync(directory); const directory = path.join(root, version); fs.mkdirSync(directory);
const skeleton = path.join(directory, 'hero.skel.bytes'); const skeleton = path.join(directory, 'hero.skel.bytes');
const atlas = path.join(directory, 'hero.atlas.txt'); const atlas = path.join(directory, 'hero.atlas.txt');
+5 -4
View File
@@ -4,17 +4,18 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const destination = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures')); const destination = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
if (process.argv[3] === '3.4') { if (['3.4', '3.5'].includes(process.argv[3])) {
const revision = 'ef5013143178918ddfc7cabfbddeae7aec7cf84e'; const version = process.argv[3];
const revision = version === '3.4' ? 'ef5013143178918ddfc7cabfbddeae7aec7cf84e' : '2cd9467d2736b7f3a0b81382f117c73823bfb44b';
for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'raptor']) { for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'raptor']) {
const directory = path.join(destination, '3.4', name); const directory = path.join(destination, version, name);
fs.mkdirSync(directory, { recursive: true }); fs.mkdirSync(directory, { recursive: true });
const variants = name === 'spineboy' ? [name, name + '-mesh', name + '-hover'] : name === 'goblins' ? [name, name + '-mesh'] : [name]; 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']) { 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}`; 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)]); execFileSync('curl', ['--http1.1', '-LfsS', '--retry', '2', '--max-time', '60', url, '-o', path.join(directory, file)]);
} }
console.log('3.4 / ' + name); console.log(version + ' / ' + name);
} }
process.exit(0); process.exit(0);
} }
+76
View File
@@ -0,0 +1,76 @@
'use strict';
// Version-pinned, complete official export sets for manual Eagle testing.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');
const root = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
const revisions = {
'3.4': 'ef5013143178918ddfc7cabfbddeae7aec7cf84e',
'3.5': '2cd9467d2736b7f3a0b81382f117c73823bfb44b',
'3.6': '654c20e5b0e523040b6366bbd1042510d2645134',
'3.7': '9639bcc81722d7178fd9d1cdc1a3d55a4c91f989',
'3.8': '8b4844bd4b193ba9e54487ed397a777993cbad56',
'4.0': '425ce416bb218b28caeec47b317aa57cd7140375',
'4.1': '77a5db0ec6d16331f5efbaa7662bba9355bd3424',
'4.2': 'e7dc1435fa4a0083ab431f1b28e083c14a1f5c68',
'4.3': 'ba17cf88b198c6531c6c51d8d4e1e4b3c3ab47dd'
};
const names = ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor'];
const curl = (url, file) => new Promise((resolve, reject) => execFile('curl', ['--http1.1', '-LfsS', '--retry', '2', '--max-time', '60', url, '-o', file], error => error ? reject(error) : resolve()));
function blobHash(file) {
const bytes = fs.readFileSync(file);
return crypto.createHash('sha1').update('blob ' + bytes.length + '\0').update(bytes).digest('hex');
}
async function pool(jobs) {
let cursor = 0;
await Promise.all(Array.from({ length: 6 }, async () => { while (cursor < jobs.length) await jobs[cursor++](); }));
}
(async () => {
fs.mkdirSync(root, { recursive: true });
const inventory = [];
for (const [version, revision] of Object.entries(revisions)) {
const folder = path.join(root, version); fs.mkdirSync(folder, { recursive: true });
let previous = [];
try { previous = JSON.parse(fs.readFileSync(path.join(folder, '.sources.json'), 'utf8')).files; } catch (_) {}
const treeFile = path.join(folder, '.tree.json');
await curl(`https://api.github.com/repos/EsotericSoftware/spine-runtimes/git/trees/${revision}?recursive=1`, treeFile);
const tree = JSON.parse(fs.readFileSync(treeFile, 'utf8'));
if (tree.truncated || !tree.tree || tree.sha !== revision) throw new Error('Incomplete source tree: ' + version);
const license = tree.tree.find(f => f.path === 'LICENSE');
if (license) {
const dest = path.join(folder, 'LICENSE.txt');
if (!fs.existsSync(dest) || blobHash(dest) !== license.sha) await curl(`https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/LICENSE`, dest);
if (blobHash(dest) !== license.sha) throw new Error('License checksum mismatch: ' + version);
}
const files = tree.tree.filter(f => f.type === 'blob' && names.some(n => f.path.startsWith('examples/' + n + '/export/')) && /\.(json|skel|atlas|png)$/.test(f.path) && !/-pma/.test(f.path));
for (const old of previous.filter(old => !files.some(f => f.path === old.path))) {
const parts = old.path.split('/'), dest = path.join(folder, parts[1], parts.slice(3).join('/'));
if (fs.existsSync(dest) && blobHash(dest) === old.sha) {
fs.unlinkSync(dest);
if (!fs.readdirSync(path.dirname(dest)).length) fs.rmdirSync(path.dirname(dest));
}
}
await pool(files.map(f => async () => {
const parts = f.path.split('/'), dest = path.join(folder, parts[1], parts.slice(3).join('/'));
fs.mkdirSync(path.dirname(dest), { recursive: true });
if (fs.existsSync(dest) && blobHash(dest) === f.sha) return;
const temp = dest + '.download';
await curl(`https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/${f.path}`, temp);
if (blobHash(temp) !== f.sha) throw new Error('Checksum mismatch: ' + f.path);
fs.renameSync(temp, dest);
}));
for (const f of files.filter(f => f.path.endsWith('.json'))) {
const parts = f.path.split('/');
const data = JSON.parse(fs.readFileSync(path.join(folder, parts[1], parts.slice(3).join('/')), 'utf8'));
if (!data.skeleton || !data.skeleton.spine.startsWith(version + '.')) throw new Error('Wrong export version: ' + f.path);
}
const entry = { version, revision, files: files.map(f => ({ path: f.path, sha: f.sha, size: f.size })) };
fs.writeFileSync(path.join(folder, '.sources.json'), JSON.stringify(entry, null, 2));
inventory.push(entry);
console.log(version + ': ' + files.length + ' official files verified');
}
const rows = inventory.map(e => '| ' + e.version + ' | ' + new Set(e.files.map(f => f.path.split('/')[1])).size + ' | ' + e.files.length + ' | `' + e.revision + '` |').join('\n');
fs.writeFileSync(path.join(root, 'README.md'), '# Spine 官方测试资源\n\n按版本和示例分目录,骨骼、Atlas 和贴图均来自官方仓库,所有文件已校验 Git blob SHA-1。这里下载的是普通 Alpha 导出,未混入同名 PMA 图集。\n\n| 版本系列 | 示例组 | 文件数 | 固定提交 |\n|---|---:|---:|---|\n' + rows + '\n\n将单个示例文件夹导入 Eagle。JSON 与 SKEL 可分别测试;测试复合后缀时请复制文件后将 `.atlas` 改为 `.atlas.txt`、`.skel` 改为 `.skel.bytes`。保留原始版本作为对照。每个版本的 `.sources.json` 记录来源文件及校验值。\n\n官方仓库:https://github.com/EsotericSoftware/spine-runtimes\n');
console.log('Saved to ' + root);
})().catch(error => { console.error(error); process.exitCode = 1; });
+14 -10
View File
@@ -24,30 +24,34 @@ function runtime(version) {
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'lib', version, 'spine-canvas.js'), 'utf8'), context); vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'lib', version, 'spine-canvas.js'), 'utf8'), context);
return install(context.spine, version); return install(context.spine, version);
} }
const versions = process.argv[3] === '3.4' ? ['3.4'] : ['3.6', '3.7']; const versions = ['3.4', '3.5'].includes(process.argv[3]) ? [process.argv[3]] : ['3.6', '3.7'];
let pairs = 0; let pairs = 0;
for (const version of versions) { for (const version of versions) {
const spine = runtime(version); const spine = runtime(version);
const oldNames = version === '3.4' || version === '3.5';
// 3.4 JSON export truncates to two decimal places instead of rounding. // 3.4 JSON export truncates to two decimal places instead of rounding.
const quantization = version === '3.4' ? 0.0102 : 0.0052; const quantization = oldNames ? 0.0102 : 0.0052;
assert.equal(utils.chooseRuntimeDir(version + '.32', true), version); assert.equal(utils.chooseRuntimeDir(version + '.32', true), version);
for (const name of version === '3.4' ? ['spineboy', 'spineboy-mesh', 'spineboy-hover', 'goblins', 'goblins-mesh', 'vine', 'stretchyman', 'tank', 'raptor'] : names) { const oldExamples = ['spineboy', 'spineboy-mesh', 'spineboy-hover', 'goblins', 'goblins-mesh', 'vine', 'stretchyman', 'tank', 'raptor'];
const directory = path.join(fixtures, version, version === '3.4' ? name.split('-')[0] : name); // The 3.5.51 tag keeps beta SKELs but newer 3.5.49 JSON for Tank/Raptor.
const file = name + (version === '3.4' ? '' : '-pro'); // Those independent exports are smoke-tested separately, not used as parity oracles.
for (const name of oldNames ? oldExamples.filter(n => version !== '3.5' || !['tank', 'raptor'].includes(n)) : names) {
const directory = path.join(fixtures, version, oldNames ? name.split('-')[0] : name);
const file = name + (oldNames ? '' : '-pro');
const bytes = fs.readFileSync(path.join(directory, file + '.skel')); const bytes = fs.readFileSync(path.join(directory, file + '.skel'));
const json = JSON.parse(fs.readFileSync(path.join(directory, file + '.json'), 'utf8')); const json = JSON.parse(fs.readFileSync(path.join(directory, file + '.json'), 'utf8'));
const decoded = decode(bytes, version); const decoded = decode(bytes, version);
assert.equal(decoded.skeleton.hash, json.skeleton.hash); assert.equal(decoded.skeleton.hash, json.skeleton.hash);
assert.equal(decoded.skeleton.spine, json.skeleton.spine); assert.equal(decoded.skeleton.spine, json.skeleton.spine);
assert.equal(utils.detectVersion(path.join(directory, file + '.skel')), json.skeleton.spine); assert.equal(utils.detectVersion(path.join(directory, file + '.skel')), json.skeleton.spine.match(/^\d+\.\d+\.\d+/)[0]);
// Region geometry uses a real official atlas; no native Canvas dependency // Region geometry uses a real official atlas; no native Canvas dependency
// is needed to verify animation poses and renderer triangle output. // is needed to verify animation poses and renderer triangle output.
const texture = { getImage: () => ({ width: 1024, height: 1024 }), setFilters() {}, setWraps() {} }; const texture = { getImage: () => ({ width: 1024, height: 1024 }), setFilters() {}, setWraps() {} };
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, (version === '3.4' ? name.split('-')[0] : name) + '.atlas'), 'utf8'), () => texture); const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, (oldNames ? name.split('-')[0] : name) + '.atlas'), 'utf8'), () => texture);
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas); const attachmentLoader = new spine.AtlasAttachmentLoader(atlas);
// 3.4 Goblins binary optimizes three constant segments to linear; // 3.4 Goblins binary optimizes three constant segments to linear;
// JSON keeps stepped. Their equal endpoints produce identical poses. // JSON keeps stepped. Their equal endpoints produce identical poses.
if (version === '3.4' && name.startsWith('goblins')) { if (oldNames && name.startsWith('goblins')) {
for (const [bone, type, index] of [['left lower leg', 'translate', 2], ['right lower leg', 'translate', 0], ['hip', 'rotate', 0]]) { 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]; const timeline = json.animations.walk.bones[bone][type];
assert.equal(timeline[index].curve, 'stepped'); assert.equal(timeline[index].curve, 'stepped');
@@ -92,7 +96,7 @@ for (const version of versions) {
} }
for (const animation of data.animations) { for (const animation of data.animations) {
const refAnimation = expected.findAnimation(animation.name); const refAnimation = expected.findAnimation(animation.name);
near(animation.duration, refAnimation.duration, version === '3.4' ? 0.00011 : 0.00006, 'duration'); near(animation.duration, refAnimation.duration, oldNames ? 0.00011 : 0.00006, 'duration');
assert.equal(animation.timelines.length, refAnimation.timelines.length); assert.equal(animation.timelines.length, refAnimation.timelines.length);
// Older JSON and binary exports order independent slot timelines differently. // 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 timelineKey = t => [t.constructor.name, t.slotIndex, t.boneIndex, t.ikConstraintIndex, t.transformConstraintIndex, t.pathConstraintIndex, t.attachment && t.attachment.name].join('/');
@@ -122,7 +126,7 @@ for (const version of versions) {
// Quantized JSON weights/curves compound along bone chains. // Quantized JSON weights/curves compound along bone chains.
// 3.4 truncates weights/scales by up to 0.01. Bound accumulated // 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. // world-position error by 1% of export size; local data is checked above.
const tolerance = version === '3.4' && field.startsWith('world') const tolerance = oldNames && field.startsWith('world')
? Math.max(2.1, Math.max(json.skeleton.width, json.skeleton.height) * scale * 0.01) : 2.1; ? 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); near(pair[0].bones[i][field], pair[1].bones[i][field], tolerance, name + '/' + animation.name + '/' + field);
} }
+34
View File
@@ -0,0 +1,34 @@
'use strict';
const fs=require('fs'),path=require('path'),assert=require('assert/strict');
const render=require('../js/spine-render'),utils=require('../js/spine-utils'),clipping=require('../js/spine-canvas-clipping');
const root=path.resolve(process.argv[2]||path.join(__dirname,'.fixtures'));
(async()=>{
let files=0,poses=0;
for(const version of ['3.4','3.5','3.6','3.7','3.8','4.0','4.1','4.2','4.3']){
const spine=render.loadRuntime(version);
for(const name of fs.readdirSync(path.join(root,version)).filter(n=>!n.startsWith('.')&&fs.statSync(path.join(root,version,n)).isDirectory())){
const dir=path.join(root,version,name),atlasPath=path.join(dir,name+'.atlas');
if(!fs.existsSync(atlasPath))continue;
const text=fs.readFileSync(atlasPath,'utf8');
for(const page of utils.parseAtlasPages(text))assert(fs.existsSync(path.join(dir,page)),version+'/'+name+' missing '+page);
const atlas=await render.buildTextureAtlas(spine,text,dir);
for(const file of fs.readdirSync(dir).filter(n=>/\.(json|skel)$/.test(n))){
const source=path.join(dir,file),detected=utils.detectVersion(source);
assert.equal(utils.chooseRuntimeDir(detected,file.endsWith('.skel')),version,source+' version');
const data=render.readSkeletonData(spine,source,atlas,file.endsWith('.skel'));
assert(data.bones.length>0,source);
const renderer=clipping.createRenderer(spine,{globalAlpha:1,globalCompositeOperation:'source-over'},version);
let triangles=0;renderer.drawTriangle=(_image,...coords)=>{assert(coords.every(Number.isFinite),source+' geometry');triangles++};
for(const animation of data.animations){
const skeleton=new spine.Skeleton(data),state=new spine.AnimationState(new spine.AnimationStateData(data));
state.setAnimation(0,animation.name,true);state.update(animation.duration*0.37);state.apply(skeleton);
if(spine.Physics)skeleton.updateWorldTransform(spine.Physics.update);else skeleton.updateWorldTransform();
renderer.draw(skeleton);poses++;
}
assert(triangles>0||data.animations.length===0,source+' empty geometry');files++;
}
}
console.log('PASS '+version+' official exports');
}
console.log('PASS: '+files+' skeleton files, '+poses+' animation/render checks; all atlas pages present');
})().catch(error=>{console.error(error);process.exitCode=1});
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><meta charset="utf-8"><title>Spine preview compositing tests</title>
<pre id="result">RUNNING</pre>
<script>var module={exports:{}},__dirname='';function require(n){return n==='path'?{join:()=>''}:{}}</script>
<script src="../js/spine-webgl-rasterizer.js"></script>
<script>const rasterizer=module.exports;module={exports:{}};</script>
<script src="../js/spine-background.js"></script>
<script>const background=module.exports;module={exports:{}};</script>
<script src="../js/spine-badge.js"></script>
<script>
try {
const badge=module.exports;
function check(ok,message){if(!ok)throw Error(message)}
function canvas(w,h){const c=document.createElement('canvas');c.width=w;c.height=h;return c.getContext('2d')}
function pixel(c,x,y){return c.getImageData(x,y,1,1).data}
const grid=canvas(220,220);grid.translate(10,20);grid.scale(2,2);background.drawCheckerboard(grid,220,220);
check(pixel(grid,109,70)[0]===90 && pixel(grid,110,70)[0]===80,'50-unit cells must scale to 100px and follow translation');
const source=canvas(8,8);source.fillStyle='rgb(128,64,192)';source.fillRect(0,0,8,8);
const destination=[80,120,160],color=[128,64,192];
for(const mode of ['source-over','lighter','multiply','screen']) for(const alpha of [1,0.5]){
const target=canvas(64,64);target.fillStyle='rgb(80,120,160)';target.fillRect(0,0,64,64);
const raster=rasterizer.createRasterizer(target);check(raster&&raster.begin(),'WebGL unavailable');
target.globalAlpha=alpha;target.globalCompositeOperation=mode;
raster.triangle(source.canvas,8,8,0,0,56,8,1,0,56,56,1,1);
raster.triangle(source.canvas,8,8,0,0,56,56,1,1,8,56,0,1);raster.end();
const actual=pixel(target,32,32);
for(let channel=0;channel<3;channel++){
const s=color[channel]*alpha,d=destination[channel];
const expected=mode==='lighter'?Math.min(255,s+d):mode==='multiply'?d*(s/255+1-alpha):mode==='screen'?s+d*(1-s/255):s+d*(1-alpha);
check(Math.abs(actual[channel]-expected)<2,mode+' '+alpha+' channel '+channel+': '+actual[channel]+' vs '+expected);
}
check(actual[3]===255,'background alpha must remain opaque');
target.globalAlpha=1;target.globalCompositeOperation='source-over';target.fillStyle='#ff0000';target.fillRect(0,0,64,64);
raster.begin();raster.end();check(pixel(target,32,32)[0]===255&&pixel(target,32,32)[1]===0,'background texture must refresh each frame');
raster.dispose();
}
const stamp=canvas(400,400);let text='';const fillText=stamp.fillText.bind(stamp);stamp.fillText=(s,...args)=>{text=s;fillText(s,...args)};
badge.drawVersion(stamp,400,400,'4.3.75-beta');check(text==='4.3.75-beta','beta version watermark');
let white=0;for(const value of stamp.getImageData(12,354,170,34).data)if(value>200)white++;
check(white>100,'watermark pixels');
document.getElementById('result').textContent='PASS: moving/scaling 50-unit checkerboard, four blend modes at two opacities, background refresh, 4.3 beta watermark';
}catch(error){document.getElementById('result').textContent='FAIL: '+error.stack}
</script>
+9 -2
View File
@@ -139,6 +139,9 @@
<label title="用于以 Premultiplied Alpha(PMA)方式导出的贴图"> <label title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck" disabled> 预乘 <input type="checkbox" id="pmaCheck" disabled> 预乘
</label> </label>
<label title="50×50 方格背景,随画布移动和缩放,并参与混合模式">
<input type="checkbox" id="backgroundCheck" checked> 背景
</label>
<div class="spacer"></div> <div class="spacer"></div>
<button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button> <button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" title="Spine 版本"></label> <label id="verLabel" style="color: var(--text-dim);" title="Spine 版本"></label>
@@ -390,6 +393,7 @@
} }
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.clearRect(0, 0, cw, ch); ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#1e1e1e'; ctx.fillRect(0, 0, cw, ch);
if (!skeleton || !renderer) return; if (!skeleton || !renderer) return;
ctx.save(); ctx.save();
// 居中 + 缩放 + Y 翻转,使 Spine 坐标正确映射到 Canvas。 // 居中 + 缩放 + Y 翻转,使 Spine 坐标正确映射到 Canvas。
@@ -397,6 +401,7 @@
ctx.translate(cw / 2 + panX, ch / 2 + panY); ctx.translate(cw / 2 + panX, ch / 2 + panY);
ctx.scale(fitScale, -fitScale); ctx.scale(fitScale, -fitScale);
ctx.translate(-fitCx, -fitCy); ctx.translate(-fitCx, -fitCy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(ctx, canvas.width, canvas.height);
renderer.draw(skeleton); renderer.draw(skeleton);
ctx.restore(); ctx.restore();
} }
@@ -466,6 +471,7 @@
octx.translate(size / 2, size / 2); octx.translate(size / 2, size / 2);
octx.scale(scale, -scale); octx.scale(scale, -scale);
octx.translate(-cx, -cy); octx.translate(-cx, -cy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
let offRenderer = null; let offRenderer = null;
try { try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir); offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
@@ -477,7 +483,7 @@
} }
} }
await drawSpineBadge(octx, size, size); await drawSpineBadge(octx, size, size);
require('../js/spine-badge.js').drawVersion(octx, size, size, activeVersion); require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。 // Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
let out = off.toDataURL('image/webp', 0.9); let out = off.toDataURL('image/webp', 0.9);
@@ -860,7 +866,7 @@
activeVersion = version; activeVersion = version;
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary); const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) { if (!runtimeDir) {
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.4 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3'); showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
return; return;
} }
activeRuntimeDir = runtimeDir; activeRuntimeDir = runtimeDir;
@@ -983,6 +989,7 @@
speed = parseFloat(e.target.value); speed = parseFloat(e.target.value);
document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x'; document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x';
}); });
document.getElementById('backgroundCheck').addEventListener('change', () => draw());
document.getElementById('pmaCheck').addEventListener('change', (e) => { document.getElementById('pmaCheck').addEventListener('change', (e) => {
premultipliedAlpha = e.target.checked; premultipliedAlpha = e.target.checked;
if (renderer) renderer.premultipliedAlpha = premultipliedAlpha; if (renderer) renderer.premultipliedAlpha = premultipliedAlpha;