加入3.5 修复水印
This commit is contained in:
1 parent
ef01cdd6ee
commit
e82a62a9ad
19 files changed
+5894
-38
No files matched your search
@@ -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.
|
||||||
|
*****************************************************************************/
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user