@bscotch/sprite-source
Version:
Art pipeline scripting module for GameMaker sprites.
240 lines • 10.2 kB
JavaScript
import { pathy, statSafe } from '@bscotch/pathy';
import { Yy } from '@bscotch/yy';
import { SpriteFrame } from './SpriteFrame.js';
import { computeFilesChecksum, computeStringChecksum } from './checksum.js';
import { retryOptions } from './constants.js';
import { readdirSafe } from './safeFs.js';
import { SpriteSourceError, assert } from './utility.js';
export class SpriteDir {
path;
logs;
issues;
_frames = [];
_isSpine = false;
_spinePaths;
constructor(path, logs, issues) {
this.path = path;
this.logs = logs;
this.issues = issues;
}
get isSpine() {
return this._isSpine;
}
get frames() {
return [...this._frames];
}
get spinePaths() {
assert(this.isSpine, 'Not a spine sprite');
return { ...this._spinePaths };
}
async updateCache(cache) {
// Do some initial cleanup of the cache
// Remove the cache if there has been a spine-type-change
if (cache.info[this.path.relative] &&
cache.info[this.path.relative].spine !== this.isSpine) {
delete cache.info[this.path.relative];
}
// Initialize the cache if it doesn't exist
cache.info[this.path.relative] ||= this.isSpine
? {
spine: true,
changed: 0,
checksum: '',
}
: {
spine: false,
frames: {},
checksum: '',
};
const spriteCache = cache.info[this.path.relative];
const frameWaits = [];
if (spriteCache.spine) {
// Handle the spine case
const pngs = [...this.spinePaths.pngs];
pngs.sort((a, b) => a.basename.localeCompare(b.basename, 'en-US'));
const lastChanged = Math.max(...(await Promise.all([
statSafe(this.spinePaths.atlas, retryOptions).then((s) => s.mtime.getTime(), () => 0),
statSafe(this.spinePaths.json, retryOptions).then((s) => s.mtime.getTime(), () => 0),
...pngs.map((png) => statSafe(png, retryOptions).then((s) => s.mtime.getTime(), () => 0)),
])));
if (lastChanged !== spriteCache.changed || !spriteCache.checksum) {
spriteCache.changed = lastChanged;
spriteCache.checksum = await computeFilesChecksum([
this.spinePaths.atlas.absolute,
this.spinePaths.json.absolute,
...pngs.map((p) => p.absolute),
]);
}
}
else {
// Remove any frames that no longer exist
const excessFrames = new Set(Object.keys(spriteCache.frames));
for (const frame of this.frames) {
excessFrames.delete(frame.path.relative);
frameWaits.push(frame.updateCache(spriteCache));
}
for (const frame of excessFrames) {
delete spriteCache.frames[frame];
}
}
const frames = await Promise.all(frameWaits);
if (!spriteCache.spine) {
// Sort the checksums by checksum since filenames will change between
// source and dest. Creates a problem if frame *order* changes, so we
// may need to deal with that later.
const frameChecksums = frames.map((f) => f.checksum).sort();
spriteCache.checksum = computeStringChecksum(frameChecksums.join('-'));
}
}
async bleed() {
if (this.isSpine) {
return;
}
await Promise.all(this.frames.map((frame) => frame.bleed()));
}
async crop() {
if (this.isSpine) {
return;
}
// Get the boundingbox that captures the bounding boxes of
// all frames.
const bboxes = await Promise.all(this.frames.map((frame) => frame.getBoundingBox()));
const bbox = bboxes.slice(1).reduce((bbox, frameBbox) => {
return {
left: Math.min(bbox.left, frameBbox.left),
right: Math.max(bbox.right, frameBbox.right),
top: Math.min(bbox.top, frameBbox.top),
bottom: Math.max(bbox.bottom, frameBbox.bottom),
};
}, bboxes[0]);
// Crop all frames to that bounding box
await Promise.all(this.frames.map((frame) => frame.crop(bbox)));
}
async moveTo(newSpriteDir) {
await newSpriteDir.ensureDirectory();
if (this.isSpine) {
const fromTo = [
[
this.spinePaths.atlas,
newSpriteDir.join(this.spinePaths.atlas.basename),
],
[
this.spinePaths.json,
newSpriteDir.join(this.spinePaths.json.basename),
],
...this.spinePaths.pngs.map((png) => [
png,
newSpriteDir.join(png.basename),
]),
];
await Promise.all(fromTo.map(([from, to]) => from.copy(to).then(() => from.delete({
...retryOptions,
force: true,
}))));
this.logs.push(...fromTo.map(([from, to]) => ({
action: 'moved',
path: from.absolute,
to: to.absolute,
})));
}
else {
const fromTo = this.frames.map((frame) => [
frame.path,
newSpriteDir.join(frame.path.basename),
]);
await Promise.all(this.frames.map((frame) => frame.saveTo(newSpriteDir.join(frame.path.basename)).then(() => frame.path.delete({
...retryOptions,
force: true,
}))));
this.logs.push(...fromTo.map(([from, to]) => ({
action: 'moved',
path: from.absolute,
to: to.absolute,
})));
}
// Try to delete the folder
try {
await this.path.delete({
recursive: true,
...retryOptions,
force: true,
});
}
catch (err) {
this.issues.push(new SpriteSourceError(`Could not delete source folder: ${this.path.relative}`, err));
}
}
/**
* If there are images in this directory, get a `SpriteDir`
* instance. Else get `undefined`.
*/
static async from(path, logs = [], issues = []) {
const files = (await readdirSafe(path.absolute)).map((file) => pathy(file, path.absolute));
let pngs = files.filter((file) => file.basename.match(/\.png$/i));
pngs.sort();
if (pngs.length === 0) {
return;
}
const sprite = new SpriteDir(path, logs, issues);
const hasAtlas = !!files.find((f) => f.hasExtension('atlas'));
if (hasAtlas) {
sprite._isSpine = true;
// Then it's either a spine export or a spine sprite folder in GameMaker
// If it's a spine export, all files will be named "{exportName}.*"
// If it's a GameMaker spine sprite, there will be a "{exportName}.png",
// but the other files will be named "{GUID}.*". If there are multiple
// identifiers (due to incomplete cleanup of a previous import), we'll
// have to open the yy file to determine the correct GUID.
// 1. See if we have multiple {name}.atlas files. If so, use the yy file (if there is one) to determine the correct GUID.
const atlasFiles = files.filter((f) => f.hasExtension('atlas'));
let frameId = atlasFiles[0].name;
if (atlasFiles.length > 1) {
const yyFiles = files.filter((f) => f.hasExtension('yy'));
assert(yyFiles.length === 1, 'Multiple .atlas files found in a single folder. There must be exactly one .yy file in this folder to determine the correct GUID.');
const yyFile = yyFiles[0];
const yy = await Yy.read(yyFile.absolute, 'sprites');
frameId = yy.frames[0]?.name;
assert(frameId, `No GUID found in yy file. Cannot determine which .atlas file to use for ${path.relative}`);
}
// 2. Get the {name}.atlas and {name}.json files
const skeletonJson = files.find((f) => f.name === frameId && f.hasExtension('json'));
const skeletonAtlas = files.find((f) => f.name === frameId && f.hasExtension('atlas'));
assert(skeletonJson, `No .json file found for ${path.relative}/${frameId}`);
assert(skeletonAtlas, `No .atlas file found for ${path.relative}/${frameId}`);
// 3. Set the spine paths. There can be multiple PNGs, and in the case
// of GameMaker assets there can be *other* PNGs (like thumbnails).
// So we need to read the atlas file to determine which PNGs are
// the ones we want.
const atlasContent = (await skeletonAtlas.read({
encoding: 'utf8',
}));
const pngNames = atlasContent.match(/^.*\.png$/gm);
pngs = pngs.filter((png) => pngNames?.includes(png.basename));
assert(pngs.length > 0, 'No PNGs found in atlas file.');
sprite._spinePaths = {
atlas: skeletonAtlas,
json: skeletonJson,
pngs,
};
}
else {
sprite._frames = pngs.map((png) => new SpriteFrame(png));
// Make sure all frames are the same size
const expectedSize = await sprite.frames[0].getSize();
for (const frame of sprite.frames.slice(1)) {
const size = await frame.getSize();
assert(size.width === expectedSize.width &&
size.height === expectedSize.height, 'Frames must all be the same size.');
}
}
return sprite;
}
toJSON() {
return {
path: this.path,
isSpine: this.isSpine,
framePaths: this.frames,
};
}
}
//# sourceMappingURL=SpriteDir.js.map