@bscotch/stitch
Version:
Stitch: The GameMaker Studio 2 Asset Pipeline Development Kit.
217 lines • 8.4 kB
JavaScript
import { __decorate, __metadata } from "tslib";
import { pathy } from '@bscotch/pathy';
import { Spritely, SpritelySubimage } from '@bscotch/spritely';
import { memoize, pick } from '@bscotch/utility';
import { SpriteBoundingBoxMode, Yy } from '@bscotch/yy';
import { z } from 'zod';
import { assert, assertIsNumber, StitchError, } from '../../../utility/errors.js';
import { debug, info } from '../../../utility/log.js';
import paths from '../../../utility/paths.js';
import { uuidV4 } from '../../../utility/uuid.js';
/**
* Ensure that the dimensions of the sprite and its bounding
* box match the dimensions of the source image. If not, assuming
* linear scaling and adjust all dims.
*/
export function setSpriteDims(width, height, isNew) {
for (const dim of ['width', 'height']) {
const value = { width, height }[dim];
assertIsNumber(value, `${dim} is not a number: ${value}`);
assert(value > 0, `${dim} must be > 0: ${value}`);
}
// Get the old height/width and origin for reference
const oldOriginX = this.yyData.sequence.xorigin;
const oldOriginY = this.yyData.sequence.yorigin;
const oldHeight = this.yyData.height;
const oldWidth = this.yyData.width;
const oldBbox = pick(this.yyData, [
'bbox_bottom',
'bbox_right',
'bbox_top',
'bbox_left',
]);
this.yyData.height = height;
this.yyData.width = width;
this.yyData.bbox_bottom ||= height;
this.yyData.bbox_right ||= width;
const _scaleCoord = (oldPos, oldMax, newMax) => {
if ([oldPos, oldMax, newMax].some((val) => val == 0)) {
return 0;
}
return Math.floor((oldPos / oldMax) * newMax);
};
const dimsHaveChanged = !isNew && (width != oldWidth || height != oldHeight);
if (isNew) {
this.yyData.sequence.xorigin = Math.floor(width / 2);
this.yyData.sequence.yorigin = Math.floor(height / 2);
}
else if (dimsHaveChanged) {
this.yyData.sequence.xorigin = _scaleCoord(oldOriginX, oldWidth, width);
this.yyData.sequence.yorigin = _scaleCoord(oldOriginY, oldHeight, height);
}
if (dimsHaveChanged &&
this.yyData.bboxMode == SpriteBoundingBoxMode.FullImage) {
// Adjust to dims
this.yyData.bbox_left = 0;
this.yyData.bbox_top = 0;
this.yyData.bbox_right = width;
this.yyData.bbox_bottom = height;
}
else if (dimsHaveChanged) {
// Adjust *relatively*
const bboxScaleInfo = [
{
oldMax: oldWidth,
max: width,
fields: ['bbox_right', 'bbox_left'],
},
{
oldMax: oldHeight,
max: height,
fields: ['bbox_top', 'bbox_bottom'],
},
];
for (const bbox of bboxScaleInfo) {
for (const field of bbox.fields) {
this.yyData[field] = _scaleCoord(oldBbox[field], bbox.oldMax, bbox.max);
}
}
}
}
export function deleteExtraneousSpriteImages() {
const framePaths = this.storage.listFilesSync(this.yyDirAbsolute, true, [
'png',
]);
// Composite frames
for (const framePath of framePaths) {
// Since frameIds are GUIDs, we can just check for it as
// a substring without worrying about exactly where it appears
// in the path.
if (this.frameIds.some((frameId) => framePath.includes(frameId))) {
continue;
}
this.storage.deleteFileSync(framePath);
debug(`deleted old frame ${framePath}`);
}
return this;
}
class SpineSpriteUpdater {
srcJson;
srcAtlas;
srcDir;
constructor(jsonSourcePath, name) {
this.srcJson = pathy(jsonSourcePath).withValidator(z.object({
skeleton: z.object({
spine: z.string(),
}),
}));
this.srcAtlas = this.srcJson.changeExtension('atlas');
this.srcDir = this.srcJson.up();
}
async srcFiles(ext) {
return (await this.srcDir.listChildren()).filter((p) => !ext || p.hasExtension(ext));
}
async assertValid() {
const validations = await Promise.allSettled([
this.assertValidJson(),
this.srcAtlas.exists({ assert: true }),
// There must be at one image with exactly the same name as the JSON file,
// though there may also be additional images.
this.srcJson.changeExtension('png').exists({ assert: true }),
]);
assert(validations.every((v) => v.status === 'fulfilled'), 'Invalid Spine source files.');
}
async assertValidJson() {
// Will throw if it cannot be parsed.
try {
await this.srcJson.read();
}
catch (err) {
throw new StitchError(`Not a valid Spine JSON file: ${this.srcJson}`);
}
}
}
__decorate([
memoize,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], SpineSpriteUpdater.prototype, "srcFiles", null);
export async function syncSpineSource(spineSourceJson) {
assert(this.isSpine, 'This method can only be used for Spine sprites');
const result = {
changed: false,
yyChanged: false,
filesChanged: [],
};
const spineUpdater = new SpineSpriteUpdater(spineSourceJson, this.name);
await spineUpdater.assertValid();
const frameId = this.yyData.frames[0]?.name || uuidV4();
this.yyData.frames[0] = { ...this.yyData.frames[0], name: frameId };
this.yyData.frames.splice(1);
const srcFiles = await spineUpdater.srcFiles(['png', 'json', 'atlas']);
const destDir = pathy(this.yyDirAbsolute);
for (const srcFile of srcFiles) {
const destFile = srcFile.hasExtension('png')
? destDir.join(srcFile.basename)
: destDir.join(`${frameId}${srcFile.extname}`);
let overwrite = !(await destFile.exists());
overwrite ||=
srcFile.hasExtension('png') &&
!(await new SpritelySubimage(srcFile.absolute).equals(destFile.absolute));
overwrite ||=
!srcFile.hasExtension('png') &&
!Yy.areEqual(await srcFile.read(), await destFile.read());
if (overwrite) {
result.filesChanged.push(destFile);
await srcFile.copy(destFile);
}
}
const saveResult = { changed: false };
this.save(saveResult);
result.yyChanged = saveResult.changed;
result.changed = result.yyChanged || result.filesChanged.length > 0;
if (result.changed) {
info(`spine sprite ${this.name} changed`, result);
}
return result;
}
export async function syncSpriteSource(spriteDirectory, isNew) {
assert(!this.isSpine, 'This method cannot be used for Spine sprites');
debug(`syncing sprite with source ${spriteDirectory}`);
const result = {
changed: false,
yyChanged: false,
filesChanged: [],
};
const sprite = new Spritely(spriteDirectory);
// Ensure that the sizes match
setSpriteDims.bind(this)(sprite.width, sprite.height, isNew);
// Replace all frames, but keep the existing IDs and ID
// order where possible. (Minimizes useless git history changes.)
const layersRoot = paths.join(this.yyDirAbsolute, 'layers');
this.storage.ensureDirSync(layersRoot);
// Remove excess frame data if needed
this.yyData.frames.splice(sprite.paths.length);
for (const [i, subimagePath] of sprite.paths.entries()) {
if (!this.yyData.frames[i]) {
this.yyData.frames[i] = { name: uuidV4() };
}
const frameId = this.yyData.frames[i].name;
debug(`adding frame ${i} using id ${frameId} from image at ${subimagePath}`);
const updatedFrame = await this.updateFrameImage(subimagePath, frameId);
if (updatedFrame) {
result.filesChanged.push(pathy(updatedFrame.path));
}
}
deleteExtraneousSpriteImages.bind(this)();
const saveResult = { changed: false };
this.save(saveResult);
result.yyChanged = saveResult.changed;
result.changed = result.yyChanged || result.filesChanged.length > 0;
if (result.changed) {
info(`sprite ${this.name} changed`, result);
}
return result;
}
//# sourceMappingURL=Gms2Sprite.update.js.map