141 lines
6.5 KiB
JavaScript
141 lines
6.5 KiB
JavaScript
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载后挂到 window。
|
|
(function () {
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SKELETON_PATTERN = /\.(skel|skel\.bytes|json)$/i;
|
|
const ATLAS_PATTERN = /\.(atlas|atlas\.txt)$/i;
|
|
const TEXTURE_PATTERN = /\.(png|jpe?g|webp)$/i;
|
|
const THUMBNAIL_PATTERN = /_thumbnail\.(png|webp)$/i;
|
|
|
|
/**
|
|
* 分析一个 Eagle item 目录。
|
|
*
|
|
* Eagle 会把导入的每个文件拆成独立的 <id>.info 目录,所以一套 Spine 资产的骨架、
|
|
* atlas、贴图通常分散在多个目录里,必须跨目录配对。配对逻辑不重写,直接复用主插件的
|
|
* discoverAssets(后缀表、同名筛选、路径校验都在里面)。
|
|
*/
|
|
function analyzeItemDir(itemDir, dirName, utils, pairCache, searchRoots, refreshIndex) {
|
|
let files;
|
|
try {
|
|
files = fs.readdirSync(itemDir);
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
// metadata.json 是 Eagle 的 item 元数据,不是骨骼文件,必须排除。
|
|
const skeletons = files.filter((name) => SKELETON_PATTERN.test(name) && name.toLowerCase() !== 'metadata.json');
|
|
const atlases = files.filter((name) => ATLAS_PATTERN.test(name));
|
|
const textures = files.filter((name) => TEXTURE_PATTERN.test(name) && !THUMBNAIL_PATTERN.test(name));
|
|
// .skel / .skel.bytes 的后缀够特定,直接信任;.json 必须确认内容真的是 Spine 骨骼,
|
|
// 否则任何普通 JSON(甚至改了后缀的其它数据)都会被误当成骨骼文件。
|
|
const usableSkeletons = skeletons.filter((name) => {
|
|
if (!/\.json$/i.test(name)) return true;
|
|
try {
|
|
// classifySkeletonJson 的肯定标识是 'spine',普通 JSON 是 'other',仍在写入是 'pending'。
|
|
return utils.classifySkeletonJson(path.join(itemDir, name)) === 'spine';
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
});
|
|
// 一个目录里既没有可用骨架也没有 atlas,说明它不是 Spine 资产的入口。
|
|
if (!usableSkeletons.length && !atlases.length) return null;
|
|
|
|
const entryName = usableSkeletons[0] || atlases[0];
|
|
// Eagle 删除素材只是把 item 的 metadata.json 标记成 isDeleted,目录和文件都留在
|
|
// 磁盘上。这种目录仍然会出现在「新增目录」里,必须在配对前挡掉,否则会给回收站
|
|
// 里的素材渲染一次缩略图(白做,还可能算成失败)。
|
|
if (utils.isItemDeleted(path.join(itemDir, entryName))) return null;
|
|
let assets = null;
|
|
// 一套资产的多个入口(atlas / skel / json)配对结果完全一样,缓存下来避免重复扫描。
|
|
// 缓存键必须带上 Eagle 文件夹:同名素材可能分属不同文件夹(同一示例的不同版本就是这样),
|
|
// 只用 stem 会把文件夹 A 的配对结果复用给文件夹 B。
|
|
let folders = [];
|
|
try {
|
|
folders = utils.foldersOfItem(path.join(itemDir, entryName));
|
|
} catch (error) { /* 读不到就按未分类处理 */ }
|
|
const folderKey = folders.slice().sort().join(',') || 'root';
|
|
const cacheKey = utils.normalizeStem(entryName) + '|' + folderKey;
|
|
if (pairCache && pairCache.has(cacheKey)) {
|
|
assets = pairCache.get(cacheKey);
|
|
} else {
|
|
try {
|
|
// searchRoots 限定在本次新增的目录里。不传的话 Utils 会把搜索范围扩到整个库,
|
|
// 几百上千个素材时一次配对就要一秒以上——Eagle 的「文件夹」只是逻辑分组,
|
|
// 磁盘上所有素材都平铺在 images/ 下,所以「扫同级」等于「扫全库」。
|
|
assets = utils.discoverAssets(path.join(itemDir, entryName), {
|
|
strictStem: true,
|
|
searchRoots,
|
|
// 文件索引有 1.5 秒缓存,刚导入的目录可能还没进索引,
|
|
// 所以每批的第一个目录强制刷新,其余复用。
|
|
refreshIndex,
|
|
});
|
|
} catch (error) {
|
|
assets = null;
|
|
}
|
|
if (pairCache) pairCache.set(cacheKey, assets);
|
|
}
|
|
|
|
// 贴图是否齐全要看 atlas 里声明了哪些页面,再看这些页面能不能在库里找到。
|
|
let pages = 0;
|
|
let missingTextures = 0;
|
|
if (assets && assets.atlasPath) {
|
|
try {
|
|
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
|
|
const pageNames = utils.parseAtlasPages(atlasText);
|
|
pages = pageNames.length;
|
|
missingTextures = pageNames.filter((name) => !assets.texturePaths[name]).length;
|
|
} catch (error) { /* atlas 读不了就当作不完整 */ }
|
|
}
|
|
|
|
// 骨架路径必须是骨骼文件后缀,防止配对结果指到贴图或其它文件上。
|
|
const skeletonPath = (assets && assets.skeletonPath) || null;
|
|
const skeletonOk = !!skeletonPath && /\.(skel|skel\.bytes|json)$/i.test(skeletonPath);
|
|
return {
|
|
dirName,
|
|
// 日志里要显示 Eagle 界面上的文件名和文件夹路径,而不是 MU9RVSxxxx.info 这种 id。
|
|
entryName,
|
|
folders,
|
|
skeletonPath,
|
|
atlasPath: (assets && assets.atlasPath) || null,
|
|
texturePaths: (assets && assets.texturePaths) || {},
|
|
// 必须按「实际要读的那个骨架文件」判断是否二进制:配对的骨架可能是同名的 .skel,
|
|
// 而当前目录里放的是 .json,用目录里的文件名判断就会拿 JSON 解析器去读二进制。
|
|
isBinary: /\.(skel|skel\.bytes)$/i.test(skeletonPath || entryName),
|
|
hasSkeletonInDir: usableSkeletons.length > 0,
|
|
hasAtlasInDir: atlases.length > 0,
|
|
hasTextureInDir: textures.length > 0,
|
|
pages,
|
|
missingTextures,
|
|
complete: !!(skeletonOk && assets.atlasPath && pages > 0 && missingTextures === 0),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 扫描指定的 item 目录,返回其中的 Spine 资产(其他素材会被过滤掉)。
|
|
* 每处理完一个目录就让出一次主线程:discoverAssets 是同步扫全库的重活,
|
|
* 连续跑几十个会把界面彻底卡住,日志写了也刷不出来。
|
|
*/
|
|
async function scanSpineItems(imagesDir, dirNames, utils, onProgress) {
|
|
const found = [];
|
|
// 本次新增的目录就是配对范围:配套的骨架 / atlas / 贴图在同一次导入里,
|
|
// 因此不需要、也不应该去翻整个素材库。
|
|
const searchRoots = dirNames.map((dirName) => path.resolve(imagesDir, dirName));
|
|
const pairCache = new Map();
|
|
for (let index = 0; index < dirNames.length; index++) {
|
|
const dirName = dirNames[index];
|
|
const info = analyzeItemDir(path.join(imagesDir, dirName), dirName, utils, pairCache, searchRoots, index === 0);
|
|
if (info) found.push(info);
|
|
if (onProgress) onProgress(index + 1, dirNames.length);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
return found;
|
|
}
|
|
|
|
const api = { analyzeItemDir, scanSpineItems, SKELETON_PATTERN, ATLAS_PATTERN, TEXTURE_PATTERN };
|
|
if (typeof window !== 'undefined') window.SpineThumbSpineAssets = api;
|
|
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
|
|
})();
|