diff --git a/README.md b/README.md index 7f1afe2..2878ab9 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ SpineViewer/ - 播放 / 暂停(空格) - 循环开关 - 播放速度调节 - - 预乘 Alpha(PMA)贴图渲染开关:仅手动切换时保存当前素材的设置,重新打开自动恢复;默认导入和预览不写入设置 + - 预乘 Alpha(PMA)默认读取配套 `.atlas` / `.atlas.txt` 的 `pma:true/false`;用户手动切换后保存为该素材的显式覆盖值,重新打开时继续沿用 - 时间轴拖动、动画时长、秒刻度与关键帧标记 - 零时长动画按单帧静态姿势预览 - 自动适配并居中 @@ -81,6 +81,8 @@ SpineViewer/ “背景”开关默认开启,绘制每格 50×50 世界单位的灰色方格,随骨骼视图一起移动和缩放。背景像素与骨骼在同一个帧缓冲中合成,支持正常、相加、相乘和滤色;关闭方格后使用实色背景。手动缩略图跟随当前背景开关。4.3 等带 `-beta` 后缀的完整版本号也会显示在水印中。 +加载骨骼时会先使用骨骼数据声明的默认皮肤;没有默认皮肤时使用皮肤列表中的第一个,并立即按 setup pose 重建槽位附件。手动切换皮肤也会执行同样的附件刷新,避免只有下拉框变化而画面为空。 + Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。 - 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。 diff --git a/js/spine-canvas-clipping.js b/js/spine-canvas-clipping.js index 2d8a7b6..ae70202 100644 --- a/js/spine-canvas-clipping.js +++ b/js/spine-canvas-clipping.js @@ -35,8 +35,13 @@ function imageForCanvas(image, context, premultipliedAlpha) { try { const ownerDocument = context.canvas && context.canvas.ownerDocument; const doc = ownerDocument || (typeof document !== 'undefined' ? document : null); - if (!doc || !image.width || !image.height) return image; - const canvas = doc.createElement('canvas'); + if (!image.width || !image.height) return image; + let canvas; + if (doc) canvas = doc.createElement('canvas'); + else { + try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); } + catch (e) { return image; } + } canvas.width = image.width; canvas.height = image.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); diff --git a/js/spine-render.js b/js/spine-render.js index 0936ff7..8721c80 100644 --- a/js/spine-render.js +++ b/js/spine-render.js @@ -152,6 +152,7 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath); const dir = path.dirname(skeletonPath); const atlasText = fs.readFileSync(atlasPath, 'utf8'); + const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true; const spine = loadRuntime(runtimeDir); const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths); @@ -162,6 +163,7 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, throw new Error('骨骼数据为空:' + name); } const skeleton = new spine.Skeleton(skeletonData); + utils.applySkin(skeleton, utils.preferredSkin(skeletonData)); // 计算所有动画的包围盒,取最合适的姿态用于缩略图。 // 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。 @@ -191,13 +193,17 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, ctx.translate(-cx, -cy); let renderer = null; - 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.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx); + try { renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); } + catch (e) { + if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx); + else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx); + } if (renderer) { // 使用三角渲染,正确处理 mesh 附件(如角色身体的网格) if ('triangleRendering' in renderer) renderer.triangleRendering = true; + renderer.premultipliedAlpha = premultipliedAlpha; renderer.draw(skeleton); + if (renderer.dispose) renderer.dispose(); } ctx.restore(); } diff --git a/js/spine-utils.js b/js/spine-utils.js index b187384..8332942 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -644,6 +644,37 @@ function parseAtlasPages(atlasText) { return pages.filter((p) => !/[=,:]/.test(p)); } +/** + * 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入 + * `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。 + * Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突 + * 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。 + */ +function parseAtlasPma(atlasText) { + if (typeof atlasText !== 'string') return undefined; + const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim)); + return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined; +} + +/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */ +function preferredSkin(skeletonData) { + if (!skeletonData) return null; + if (skeletonData.defaultSkin) return skeletonData.defaultSkin; + const skins = skeletonData.skins || []; + return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null; +} + +/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */ +function applySkin(skeleton, skin) { + if (!skeleton || !skin) return false; + if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin); + else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name); + else return false; + if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose(); + else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose(); + return true; +} + module.exports = { SKELETON_EXTS, ATLAS_EXTS, @@ -663,6 +694,9 @@ module.exports = { looksLikeSkeletonJson, discoverAssets, parseAtlasPages, + parseAtlasPma, + preferredSkin, + applySkin, isEagleItemDir, getSearchRoots, collectSpineFiles, diff --git a/tests/atlas-pma-skin.test.js b/tests/atlas-pma-skin.test.js new file mode 100644 index 0000000..999e918 --- /dev/null +++ b/tests/atlas-pma-skin.test.js @@ -0,0 +1,31 @@ +'use strict'; +const assert = require('node:assert/strict'); +const utils = require('../js/spine-utils'); +const render = require('../js/spine-render'); + +assert.equal(utils.parseAtlasPma('page.png\nsize:1,1\npma:true\nregion\n'), true); +assert.equal(utils.parseAtlasPma('page.png\r\n pma : false \r\nregion\r\n'), false); +assert.equal(utils.parseAtlasPma('page.png\nsize:1,1\nregion\n'), undefined); +assert.equal(utils.parseAtlasPma('page.png\npma:trueish\n'), undefined); + +const first = { name: 'first' }, namedDefault = { name: 'default' }, explicit = { name: 'custom' }; +assert.equal(utils.preferredSkin({ defaultSkin: explicit, skins: [first, namedDefault] }), explicit); +assert.equal(utils.preferredSkin({ skins: [first, namedDefault] }), namedDefault); +assert.equal(utils.preferredSkin({ skins: [first] }), first); +assert.equal(utils.preferredSkin({ skins: [] }), null); + +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); + const skin = new spine.Skin('visible'); + const data = new spine.SkeletonData(); + data.bones.push(new spine.BoneData(0, 'root', null)); + data.slots.push(new spine.SlotData(0, 'slot', data.bones[0])); + data.skins.push(skin); + if ('defaultSkin' in data) data.defaultSkin = skin; + const skeleton = new spine.Skeleton(data); + assert.equal(utils.preferredSkin(data), skin, version + ' preferred skin'); + assert(utils.applySkin(skeleton, skin), version + ' apply skin'); + assert.equal(skeleton.skin, skin, version + ' active skin'); +} + +console.log('PASS: Atlas PMA detection and default/first skin selection across Spine 3.4–4.3'); diff --git a/viewer/spine.html b/viewer/spine.html index d4d5832..9097c4d 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -690,7 +690,7 @@ if (skeletonData.animations.length > 0) sel.value = '0'; } - function fillSkins() { + function fillSkins(selectedSkin) { const sel = document.getElementById('skinSelect'); sel.innerHTML = ''; (skeletonData.skins || []).forEach((s, i) => { @@ -699,6 +699,10 @@ sel.appendChild(opt); }); sel.disabled = (skeletonData.skins || []).length <= 1; + if (selectedSkin) { + const index = (skeletonData.skins || []).indexOf(selectedSkin); + if (index >= 0) sel.value = String(index); + } } function setAnimationByName(name) { @@ -783,8 +787,13 @@ function applySkinByName(name) { if (!skeleton) return; - if (skeleton.setSkinByName) skeleton.setSkinByName(name); - else if (skeleton.setSkin && skeleton.data.findSkin) skeleton.setSkin(skeleton.data.findSkin(name)); + const skin = skeleton.data && skeleton.data.findSkin ? skeleton.data.findSkin(name) + : (skeletonData.skins || []).find((item) => item.name === name); + if (!utils.applySkin(skeleton, skin)) return; + currentSkin = skin; + if (state) state.apply(skeleton); + updateWorld(skeleton); + draw(); } // ---------- 非 Spine 图片回退:直接显示图片 ---------- @@ -852,13 +861,12 @@ } return; } + let savedPma; try { const settings = require('../js/spine-preview-settings.js'); const userData = await settings.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null); previewSettings = settings.createStore(userData, filePath, urlParams.get('id')); - const saved = previewSettings.read(); - if (saved !== undefined) premultipliedAlpha = saved; - document.getElementById('pmaCheck').checked = premultipliedAlpha; + savedPma = previewSettings.read(); } catch (e) { log('读取预乘设置失败: ' + e.message); } // 检测版本并选择运行时 @@ -891,6 +899,10 @@ let atlas; try { const atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); + const atlasPma = utils.parseAtlasPma(atlasText); + // 用户明确切换过的设置用于下次预览;没有用户设置时,以 Atlas 为准。 + premultipliedAlpha = savedPma !== undefined ? savedPma : (atlasPma !== undefined ? atlasPma : false); + document.getElementById('pmaCheck').checked = premultipliedAlpha; atlas = await buildBrowserAtlas(spine, atlasText, assets); } catch (e) { showError('Atlas 加载失败', (e && e.message) || String(e)); @@ -918,6 +930,8 @@ // 创建骨架与动画状态 skeleton = new spine.Skeleton(skeletonData); + currentSkin = utils.preferredSkin(skeletonData); + utils.applySkin(skeleton, currentSkin); stateData = new spine.AnimationStateData(skeletonData); stateData.defaultMix = 0.2; state = new spine.AnimationState(stateData); @@ -927,7 +941,7 @@ document.getElementById('pmaCheck').disabled = false; fillAnimations(); - fillSkins(); + fillSkins(currentSkin); // 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。 if (skeletonData.animations.length > 0) {