@bscotch/sprite-source
Version:
Art pipeline scripting module for GameMaker sprites.
359 lines • 16.6 kB
JavaScript
import { __decorate, __metadata } from "tslib";
import { pathy } from '@bscotch/pathy';
import { sequential } from '@bscotch/utility';
import { Yy } from '@bscotch/yy';
import { SpriteCache } from './SpriteCache.js';
import { isNewer, } from './SpriteCache.schemas.js';
import { applySpriteAction, } from './SpriteDest.actions.js';
import { spriteDestConfigSchema, } from './SpriteDest.schemas.js';
import { SpriteSource } from './SpriteSource.js';
import { retryOptions, spriteCacheFilename, spriteDestConfigFilename, } from './constants.js';
import { SpriteSourceError, assert, rethrow } from './utility.js';
export class SpriteDest extends SpriteCache {
yypPath;
constructor(spritesRoot, yypPath) {
super(spritesRoot, 1);
this.yypPath = yypPath;
}
get configFile() {
return this.stitchDir
.join(spriteDestConfigFilename)
.withValidator(spriteDestConfigSchema);
}
async inferChangeActions(sourceConfig, destSpritesCache) {
const destSpritesInfo = destSpritesCache.info;
// Get the most up-to-date source and dest info
const ignorePatterns = (sourceConfig.ignore || []).map((x) => new RegExp(x));
const cleanSpriteName = (sourcePath) => `${sourceConfig.prefix || ''}${sourcePath
.split('/')
.pop()
.replace(/[^a-z0-9_]/gi, '_')}`;
// The source pathy is either absolute or relative to the project root
const sourceRoot = pathy(sourceConfig.source, this.yypPath.up());
const collaboratorSourceRoots = (sourceConfig.collaboratorSources || []).map((s) => pathy(s, this.yypPath.up()));
const collaboratorSourcesWait = Promise.allSettled(collaboratorSourceRoots.map((s) => SpriteSource.from(s).then((s) => s.update().then((x) => x.info))));
const source = await SpriteSource.from(sourceRoot);
const sourceSpritesInfo = await source.update().then((x) => {
this.logs.push(...source.logs);
this.issues.push(...source.issues);
return x.info;
});
const collaboratorSources = (await collaboratorSourcesWait)
.map((r) => (r.status === 'fulfilled' ? r.value : undefined))
.filter((x) => x);
// Get all of the sprite names and last-updated dates from the collaborator
// sources, so that we can check against them later.
const collaboratorSprites = new Map();
/**
* Returns true if the left sprite is newer than the right sprite.
*/
const isNewerThanCollaboratorSprites = (potentiallyNewer, replaceIfNewer) => {
const currentNewest = collaboratorSprites.get(potentiallyNewer.name.toLowerCase());
if (currentNewest && !isNewer(potentiallyNewer, currentNewest)) {
return false;
}
if (replaceIfNewer) {
collaboratorSprites.set(potentiallyNewer.name.toLowerCase(), potentiallyNewer);
}
return true;
};
for (const collaboratorSource of collaboratorSources) {
for (const [path, sprite] of Object.entries(collaboratorSource)) {
const name = cleanSpriteName(path);
isNewerThanCollaboratorSprites({
...sprite,
path,
name,
}, true);
}
}
/** Map of destName.toLower() to the source info */
const sourceSprites = new Map();
for (const [sourcePath, sourceSprite] of Object.entries(sourceSpritesInfo)) {
// Skip it if it matches the ignore patterns
if (ignorePatterns.some((x) => x.test(sourcePath))) {
continue;
}
// Get the name it should have in the project
const name = cleanSpriteName(sourcePath);
// Check for name collisions. If found, they should be reported as issues.
if (sourceSprites.get(name.toLowerCase())) {
this.issues.push(new SpriteSourceError(`Source sprite name collision: ${name}`));
}
sourceSprites.set(name.toLowerCase(), {
...sourceSprite,
path: sourcePath,
name,
});
}
/** Map of destName.toLower() to the dest info */
const destSprites = new Map();
for (const [destPath, destSprite] of Object.entries(destSpritesInfo)) {
destSprites.set(destPath.toLowerCase(), {
...destSprite,
path: destPath,
name: destPath,
});
}
// For each source sprite, diff with the dest sprite to determine what actions need to be performed. Create a list of actions to perform for later execution.
const actions = [];
for (const [normalizedName, sourceSprite] of sourceSprites) {
const destSprite = destSprites.get(normalizedName);
const sourceDir = source.spritesRoot.join(sourceSprite.path).absolute;
const destDir = this.spritesRoot.join(destSprite?.path || sourceSprite.name).absolute;
if (!isNewerThanCollaboratorSprites(sourceSprite, false)) {
this.logs.push({
action: 'skipped-collaborator-owned',
path: sourceDir,
});
continue;
}
if (!destSprite || sourceSprite.spine !== destSprite.spine) {
actions.push({
kind: 'create',
spine: sourceSprite.spine,
name: sourceSprite.name,
source: sourceDir,
dest: destDir,
sourceRoot: source.spritesRoot.absolute,
});
}
else if (sourceSprite.spine &&
destSprite.spine &&
sourceSprite.checksum !== destSprite.checksum) {
actions.push({
kind: 'update',
spine: true,
name: destSprite.name,
source: sourceDir,
dest: destDir,
sourceRoot: source.spritesRoot.absolute,
});
}
else if (!sourceSprite.spine &&
!destSprite.spine &&
sourceSprite.checksum !== destSprite.checksum) {
actions.push({
kind: 'update',
spine: false,
name: destSprite.name,
source: sourceDir,
dest: destDir,
sourceRoot: source.spritesRoot.absolute,
});
}
}
return actions;
}
/**
* @param overrides Optionally override the configuration file (if it exists)
*/
async import(overrides, reporter, options) {
let percentComplete = 0;
const report = (byPercent, message) => {
percentComplete += byPercent;
reporter?.report({ message, increment: byPercent });
};
report(0, 'Updating project cache...');
const [configResult, destSpritesInfoResult, yypResult] = await Promise.allSettled([
this.loadConfig(overrides),
this.updateSpriteInfo(),
Yy.read(this.yypPath.absolute, 'project'),
]);
assert(yypResult.status === 'fulfilled', 'Project file is invalid', yypResult.status === 'rejected' ? yypResult.reason : undefined);
assert(configResult.status === 'fulfilled', 'Could not load config', configResult.status === 'rejected' ? configResult.reason : undefined);
assert(destSpritesInfoResult.status === 'fulfilled', 'Could not load sprites info', destSpritesInfoResult.status === 'rejected'
? destSpritesInfoResult.reason
: undefined);
const config = configResult.value;
const destSpritesInfo = destSpritesInfoResult.value;
const yyp = yypResult.value;
// Collect info from the yyp about existing folders, sprites,
// and assets. Goals are:
// - Faster lookups (e.g. using sets)
// - Ensure we won't have an asset name clash
const existingFolders = new Set();
const existingSprites = new Set();
const existingNonSpriteAssets = new Set();
yyp.resources.forEach((r) => {
if (r.id.path.startsWith('sprites')) {
existingSprites.add(r.id.name);
}
else {
existingNonSpriteAssets.add(r.id.name);
}
});
yyp.Folders.forEach((f) => {
existingFolders.add(f.folderPath);
});
// Try to do it all at the same time for perf. Race conditions
// and order-of-ops issues indicate some kind of user
// config failure, so we'll let that be their problem.
report(10, 'Applying staging actions and computing changes...');
const actions = [];
const getActionsWaits = [];
for (const sourceConfig of config.sources || []) {
// Report errors but do not throw. We want to continue
// to subsequent sources even if one fails.
getActionsWaits.push(this.inferChangeActions(sourceConfig, destSpritesInfo).then((a) => {
actions.push(...a);
}, (err) => {
this.issues.push(new SpriteSourceError(`Failed to infer actions for "${sourceConfig.source}"`, err));
}));
}
await Promise.allSettled(getActionsWaits);
// Apply the actions (in parellel)
report(10, `Applying changes to ${actions.length} sprites...`);
const appliedActions = [];
const applyActionsWaits = [];
const percentForYypUpdate = 5;
const percentPerAction = (100 - percentComplete - percentForYypUpdate) / actions.length;
for (const action of actions) {
if (existingNonSpriteAssets.has(action.name)) {
this.issues.push(new SpriteSourceError(`Asset name collision: ${action.name}`));
continue;
}
// If we're trying to create a new asset with an invalid name, error!
if (action.kind === 'create' && options?.allowedNamePatterns?.length) {
const isValidName = options.allowedNamePatterns.some((pattern) => {
pattern = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
return pattern.test(action.name);
});
if (!isValidName) {
this.issues.push(new SpriteSourceError(`Sprite name violates rules: ${action.name}`));
continue;
}
}
applyActionsWaits.push(applySpriteAction({
projectYypPath: this.yypPath.absolute,
action,
yyp,
})
.then((result) => {
appliedActions.push(result);
})
.catch((err) => {
this.issues.push(new SpriteSourceError(`Error applying action: ${JSON.stringify(action)}`, err));
})
.finally(() => {
report(percentPerAction);
}));
}
await Promise.allSettled(applyActionsWaits);
if (!appliedActions.length) {
return;
}
// Ensure the .yyp is up to date
report(0, 'Updating project file...');
for (const appliedAction of appliedActions) {
if (!existingSprites.has(appliedAction.resource.name)) {
// Add to a random spot in the resources array to reduce git conflicts,
// skipping the last spot entirely if possible
const insertAt = Math.max(Math.floor(Math.random() * yyp.resources.length) - 1, 0);
yyp.resources.splice(insertAt, 0, {
id: appliedAction.resource,
});
existingSprites.add(appliedAction.resource.name);
}
if (!existingFolders.has(appliedAction.folder.folderPath)) {
// Also add to a random spot in the Folders array
const insertAt = Math.max(Math.floor(Math.random() * yyp.Folders.length) - 1, 0);
// @ts-expect-error The object is partial, but gets validated and completed on write
yyp.Folders.splice(insertAt, 0, appliedAction.folder);
existingFolders.add(appliedAction.folder.folderPath);
}
}
await Yy.write(this.yypPath.absolute, yyp, 'project');
// Refresh the cache
report(percentForYypUpdate, 'Updating project cache...');
await this.updateSpriteInfo();
return appliedActions;
}
/**
* Load the config, ensuring it exists on disk. If overrides
* are provided the config will be updated with those values.
*/
async loadConfig(overrides = {}) {
// Validate options. Show error out if invalid.
try {
overrides = spriteDestConfigSchema.parse(overrides);
}
catch (err) {
rethrow(err, 'Invalid SpriteDest options');
}
assert(await this.spritesRoot.isDirectory(), 'Source must be an existing directory.');
// Update the config
await this.stitchDir.ensureDirectory();
const config = await this.configFile.read({
fallback: { sources: [] },
...retryOptions,
});
let wasEmpty = !config.sources?.length;
if (overrides?.sources?.length) {
config.sources = overrides.sources;
}
if ((wasEmpty && config.sources?.length) || !wasEmpty) {
await this.configFile.write(config, {
...retryOptions,
});
}
return config;
}
static async from(projectYypPath) {
// Ensure the project file exists
assert(projectYypPath.toString().endsWith('.yyp'), 'The project path must be to a .yyp file');
const projectYyp = pathy(projectYypPath);
assert(await projectYyp.exists(), `Project file does not exist: ${projectYyp}`);
// Ensure the project has a sprites folder
const projectFolder = projectYyp.up();
const spritesRoot = projectFolder.join('sprites');
await spritesRoot.ensureDirectory();
// Create the cache in the sprites folder
const cache = new SpriteDest(spritesRoot, projectYyp);
await cache.loadConfig(); // Ensure a config file exists
// Keep the config and cache out of gitignore
const gitDb = await cache.stitchDir.findInParents('.git');
if (!gitDb) {
console.warn("WARNING: Your GameMaker projects is not in a Git repo! It's dangerous to use this tool without protecting your files with version control.");
}
else {
const gitIgnorePath = gitDb.up().join('.gitignore');
let gitignoreContent = (await gitIgnorePath.read({
fallback: '',
encoding: 'utf8',
}));
const lines = gitignoreContent.split(/[\r\n+]/g);
for (const toIgnore of [
{
name: cache.configFile.basename,
comment: 'If all of your sprite sources are in this repo you can stick a "!" in front of this to track this file. Otherwise this file is machine-specific and should be ignored.',
},
{
name: spriteCacheFilename,
comment: 'This is a cache file for speeding up subsequent pipeline operations. It should not be tracked in git.',
},
]) {
if (!lines.includes(toIgnore.name) &&
!lines.includes('!' + toIgnore.name)) {
gitignoreContent += `\n# ${toIgnore.comment}\n${toIgnore.name}`;
console.log(' added', toIgnore, 'to .gitignore');
}
}
await gitIgnorePath.write(gitignoreContent);
}
return cache;
}
}
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, Object]),
__metadata("design:returntype", Promise)
], SpriteDest.prototype, "inferChangeActions", null);
__decorate([
sequential,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, Object, Object]),
__metadata("design:returntype", Promise)
], SpriteDest.prototype, "import", null);
//# sourceMappingURL=SpriteDest.js.map