UNPKG

@bscotch/stitch

Version:

Stitch: The GameMaker Studio 2 Asset Pipeline Development Kit.

591 lines 23.4 kB
import { pathy, Pathy } from '@bscotch/pathy'; import { explode, oneline } from '@bscotch/utility'; import { yypAudioGroupSchema, yypTextureGroupSchema } from '@bscotch/yy'; import archiver from 'archiver'; import { kebabCase } from 'change-case'; import fse from 'fs-extra'; import { basename, join, parse as parsePath } from 'path'; import { assert } from '../utility/errors.js'; import fs, { compareFilesByChecksum, } from '../utility/files.js'; import { debug, info } from '../utility/log.js'; import paths from '../utility/paths.js'; import { AssetSourcesConfig, } from './assetSource/assetSource.js'; import { Gms2AudioGroup } from './components/Gms2AudioGroup.js'; import { Gms2ComponentArray } from './components/Gms2ComponentArray.js'; import { Gms2Config } from './components/Gms2Config.js'; import { Gms2Folder } from './components/Gms2Folder.js'; import { Gms2IncludedFile } from './components/Gms2IncludedFile.js'; import { Gms2IncludedFileArray } from './components/Gms2IncludedFileArray.js'; import { Gms2Option } from './components/Gms2Option.js'; import { Gms2ResourceArray } from './components/Gms2ResourceArray.js'; import { Gms2RoomOrder } from './components/Gms2RoomOrder.js'; import { Gms2TextureGroup } from './components/Gms2TextureGroup.js'; import { GameMakerEngine } from './GameMakerEngine.js'; import { GameMakerIssue } from './GameMakerIssue.js'; import { Gms2FolderArray } from './Gms2FolderArray.js'; import { Linter } from './Linter.js'; import { GmlTokenSummary } from './parser/GmlTokenSummary.js'; import { addSprites } from './StitchProject.addSprites.js'; import { addAudioGroupAssignment, addTextureGroupAssignment, ensureResourceGroupAssignments, } from './StitchProject.groups.js'; import { mergeFromGithub, mergeFromUrl } from './StitchProject.merge.js'; import { StitchProjectStatic } from './StitchProject.static.js'; import { setProjectVersion, versionOnPlatform, } from './StitchProject.version.js'; import { StitchProjectConfig } from './StitchProjectConfig.js'; import { Gms2ProjectMerger, } from './StitchProjectMerger.js'; import { StitchStorage } from './StitchStorage.js'; export * from './StitchProject.static.js'; export * from './StitchProject.types.js'; const inDev = process.env.BSCOTCH_REPO === '@bscotch/tech'; if (inDev) { Error.stackTraceLimit = 50; } /** * Convert a GameMaker Studio 2.3+ project * into an internal representation that can * be manipulated programmatically. */ export class StitchProject extends StitchProjectStatic { /** * The content of the YYP file, mirroring the data structure * in the file but with components replaced by model instances. */ components; config; plugins = []; storage; yypRaw; /** * A representation of an "Issue" for submission * to GameMaker. Its methods can be used to create * an issue form, fetch log info, and compile a * report. */ issue; static async load(options) { const yypPath = await StitchProject.findYypFile(options?.projectPath || process.cwd()); const project = new StitchProject({ ...options, projectPath: yypPath }); await project.reload(); return project; } static from = StitchProject.load; constructor(options) { super(); // Normalize options debug(`loading project with options`, options); this.plugins = options.plugins || []; // Load up all the project files into class instances for manipulation this.storage = new StitchStorage(paths.resolve(options.projectPath), options.readOnly, options.dangerouslyAllowDirtyWorkingDir); this.config = StitchProjectConfig.from(this.storage); this.issue = new GameMakerIssue(this); } /** * Compile the project using Igor. * @alpha */ async build(options) { return await this.engine().build(this, options); } /** * Run the project using Igor. * @alpha */ async run(options) { return await this.engine().run(this, options); } /** * Change the name of the project stored in its * YYP file. * * (Not the name of the file itself.) */ get name() { return parsePath(this.storage.yypPathAbsolute).name; } set name(newProjectName) { if (this.name === newProjectName) { return; } this.storage.renameYypFile(newProjectName); } get resourceVersion() { return this.components.resourceVersion; } get io() { const io = { storage: this.storage, plugins: this.plugins, project: this, }; return Object.freeze(io); } get yypPathAbsolute() { return this.storage.yypPathAbsolute; } get yypDirAbsolute() { return this.storage.yypDirAbsolute; } get folders() { return this.components.Folders; } get resources() { return this.components.resources; } get textureGroups() { return this.components.TextureGroups; } get audioGroups() { return this.components.AudioGroups; } get includedFiles() { return this.components.IncludedFiles; } get rooms() { return this.resources.rooms; } get roomOrder() { return this.components.RoomOrderNodes; } get configs() { return this.components.configs; } get ideVersion() { return this.components.MetaData.IDEVersion; } /** * For GameMaker versions starting in 2022, * returns `true` if the version indicates * that the IDE in use is a beta IDE. * * For versions before that, always returns `undefined`. */ get ideVersionIsBeta() { return GameMakerEngine.isBetaVersion(this.ideVersion); } /** * The local path where the GameMaker engine stores * runtimes, IDE configs, and other information. * * Uses the project's IDE Version and the current * OS to determine this directory. * * (Only works on Windows.) */ engine() { return new GameMakerEngine({ beta: this.ideVersionIsBeta }); } listScriptGmlFiles() { return this.resources.scripts.map((s) => s.codeFilePathAbsolute); } listObjectGmlFiles() { return this.resources.objects.map((o) => o.codeFilePathsAbsolute).flat(1); } getGlobalFunctions() { return this.resources.getGlobalFunctions(); } /** * Set the project version in all options files. * (Note that the Switch options files do not include the version * -- that must be set outside of GameMaker in the *.nmeta file). * Can use one of: * + "0.0.0.0" syntax (exactly as GameMaker stores versions) * + "0.0.0" syntax (semver without prereleases -- the 4th value will always be 0) * + "0.0.0-rc.0" syntax (the 4th number will be the RC number) * The four numbers will appear in all cases as the string "major.minor.patch.candidate" */ set version(versionString) { setProjectVersion(this, versionString); } versionOnPlatform(platform) { return versionOnPlatform(this, platform); } /** * Bundle the project into a single zip file with the `.yyz` extension. */ async exportYyz(options) { const yyz = archiver('zip'); const resolver = new Promise((resolve, reject) => { yyz.on('finish', resolve); yyz.on('error', reject); }); const projectBasename = basename(this.yypPathAbsolute); const yyzBasename = projectBasename.replace(/\.yyp$/, '.yyz'); const outputPath = join(options?.outputDirectory || this.yypDirAbsolute, yyzBasename); const output = fse.createWriteStream(outputPath); yyz.pipe(output); // Whitelist files and folders to zip up const folders = [ 'animcurves', 'datafiles', 'extensions', 'fonts', 'notes', 'objects', 'options', 'paths', 'rooms', 'scripts', 'sequences', 'shaders', 'sounds', 'sprites', 'tilesets', 'timelines', ]; const yyDir = new Pathy(this.yypDirAbsolute); for (const folder of folders) { if (await yyDir.join(folder).exists()) { yyz.directory(yyDir.join(folder).absolute, folder); } } yyz.file(this.yypPathAbsolute, { name: projectBasename }); await yyz.finalize(); return await resolver.then(() => ({ filePath: outputPath, })); } async mergeFromUrl(url, options, headers) { return await mergeFromUrl.bind(this)(url, options, headers); } async mergeFromGithub(options) { return await mergeFromGithub.bind(this)(options); } /** * Import modules from one GMS2 project into this one. * @param fromProject A directory containing a single .yyp file somwhere, * or the path directly to a .yyp file. */ async merge(fromProjectPath, options) { const fromProject = await StitchProject.load({ projectPath: fromProjectPath, readOnly: true, dangerouslyAllowDirtyWorkingDir: true, }); await new Gms2ProjectMerger(fromProject, this, options).merge(); return this; } /** * Get all references to global functions. * * @alpha */ findGlobalFunctionReferences(options) { const onlyFunctions = options?.functions ? typeof options.functions == 'string' ? explode(options.functions) : options.functions : null; const functions = this.getGlobalFunctions().filter((func) => { if (onlyFunctions) { return onlyFunctions.includes(func.name); } else if (options?.allowNamePattern) { return new RegExp(options.allowNamePattern).test(func.name); } else if (options?.excludeNamePattern) { return !new RegExp(options.excludeNamePattern).test(func.name); } return true; }); assert(functions.length, `No function names provided or found in the project.`); const summaries = []; for (const func of functions) { const summary = new GmlTokenSummary(func, this, { versionSuffix: options?.versionSuffix, }); summaries.push(summary); } return summaries; } /** Lint this project, resulting in a report of potential issues. */ lint(options) { return new Linter(this, options); } /** Ensure that a texture group exists in the project. */ addTextureGroup(textureGroupName) { this.components.TextureGroups.addIfNew(yypTextureGroupSchema.parse({ name: textureGroupName, }), 'name', textureGroupName) && this.save(); // So only save if changed return this; } /** Add a texture group assignment if it doesn't already exist. */ addTextureGroupAssignment(folder, textureGroupName) { return addTextureGroupAssignment(this, folder, textureGroupName); } /** Ensure an audio group exists in the project */ addAudioGroup(audioGroupName) { this.components.AudioGroups.addIfNew(yypAudioGroupSchema.parse({ name: audioGroupName, }), 'name', audioGroupName) && this.save(); // So only save if changed return this; } /** Add a texture group assignment if it doesn't already exist. */ addAudioGroupAssignment(folder, audioGroupName) { return addAudioGroupAssignment(this, folder, audioGroupName); } async addRoom(name, options) { assert(name.match(/^[a-zA-Z0-9_]+$/), `Invalid room name: ${name}`); const room = await this.resources.addRoom(name, this.io); if (options?.first) { this.components.RoomOrderNodes.removeByField('name', name); this.components.RoomOrderNodes.addNew({ roomId: room.id, }, { prepend: true }); } this.save(); return room; } /** * Ensure that a folder path exists, so that assets can be assigned to it. */ addFolder(path, tags) { // Clean up messy seperators path = path .replace(/[/\\]+/, '/') .replace(/^\//, '') .replace(/\/$/, ''); // Get all subpaths const heirarchy = paths.heirarchy(path); for (const subPath of heirarchy) { this.folders.addIfNew({ ...Gms2Folder.defaultDataValues, name: Gms2Folder.nameFromPath(subPath), folderPath: Gms2Folder.folderPathFromPath(subPath), tags: tags || [], }, 'path', subPath); } this.save(); return this; } /** Does not save the project. */ async addSoundByFile(source) { const fileExt = paths.extname(source).slice(1); assert(StitchProject.supportedSoundFileExtensions.includes(fileExt), oneline ` Cannot import sound file with extension: ${fileExt}. Only supports: ${StitchProject.supportedSoundFileExtensions.join(',')} `); await this.resources.addSound(source, this.io); return this; } /** * Diff an audio source against the project's current audio resources. */ async checkSoundSource(sourceConfigPath, sourceId) { const stitchSrc = AssetSourcesConfig.from(sourceConfigPath); const audioSources = (await stitchSrc.listAudioSources()).filter((s) => !sourceId || s.id === sourceId); const soundsRoot = pathy(this.yypDirAbsolute).join('sounds'); const waits = []; for (let audioSource of audioSources) { audioSource = await stitchSrc.refreshAudioSource(audioSource.id); for (const file of audioSource.files) { // Create what should be the path to corresponding sound resource const { base, name } = paths.parse(file.path); const soundPath = soundsRoot.join(name, base); waits.push(compareFilesByChecksum({ path: soundPath, }, { path: audioSource.absoluteFilePath(file), checksum: 'checksum' in file ? file.checksum : undefined, }).then((result) => ({ areSame: result.areSame, change: result.change, source: file, }))); } } return await Promise.all(waits); } /** * Add or update audio files from a file or a directory. * The name is taken from * the source. If there already exists a sound asset * with this name, its file will be replaced. Otherwise * the asset will be created and placed into folder "/NEW". * Support the following extensions: * 1. mp3 * 2. ogg * 3. wav * 4. wma * * If the file is a `stitch.src.json` file, its importable audio source(s) * will be used. */ async addSounds(source, options) { let targetFiles = []; const sourcePath = pathy(source); if (sourcePath.basename === AssetSourcesConfig.basename) { const changes = await this.checkSoundSource(sourcePath, options?.sourceId); const stitchSrc = AssetSourcesConfig.from(sourcePath); for (const change of changes) { if (change.source.deleted || !change.source.importable) { continue; } if (!change.areSame && (change.change === 'added' || change.change === 'modified')) { targetFiles.push(stitchSrc.dir.join(change.source.path).absolute); } } } else { const extensions = options?.extensions?.length ? options.extensions : StitchProject.supportedSoundFileExtensions; for (const extension of extensions) { assert(StitchProject.supportedSoundFileExtensions.includes(extension), oneline ` Cannot batch import sound file with extension: ${extension}. Only supports: ${StitchProject.supportedSoundFileExtensions.join(',')} `); } if (fs.statSync(source).isFile()) { targetFiles.push(source); } else { targetFiles = fs.listFilesByExtensionSync(source, extensions, true); } } const waits = []; for (const targetFile of targetFiles) { waits.push(this.addSoundByFile(targetFile)); } await Promise.all(waits); return targetFiles.length ? this.save() : this; } /** * Add or update a script resource. Unless you're trying to make * global variables, your code should be wrapped in a function! */ async addScript(name, code) { await this.resources.addScript(name, code, this.io); return this.save(); } /** * Given a source folder that is either a sprite or a * a folder containing sprites (where a 'sprite' is a folder * containing one or more immediate child PNGs that are * all the same size -- nesting is allowed), add or update * the game project sprites using those images. This completely * replaces the existing images for that sprite. The folder * name is used directly as the sprite name (parent folders * are ignored for this.) */ async addSprites(sourceFolder, options) { return await addSprites(this, sourceFolder, options); } addObject(name) { const object = this.resources.addObject(name, this.io); this.save(); return object; } deleteResourceByName(name) { this.resources.deleteByName(name); return this.save(); } deleteIncludedFileByName(baseName) { this.includedFiles.deleteByName(baseName); return this.save(); } /** * Import a new IncludedFile based on an external file. * By default will appear in "datafiles/NEW" folder, but you can specificy * a subdirectory path. If an included file with this name already exists * in **ANY** subdirectory it will be overwritten. (Names must be unique due to * an iOS bug wherein all included files are effectively in a flat heirarchy. * {@see https://docs2.yoyogames.com/source/_build/3_scripting/4_gml_reference/sprites/sprite_add.html} * @param path Direct filepath or a directory from which all files (recursively) should be loaded * @param content If set, will create a new file instead of copying content from an existing one. * If the content is a string or buffer it will be written as-is. All other cases are * JSON stringified. Must not be null or undefined in order to take effect. * @param subdirectory Subdirectory inside the Datafiles folder in which to place this resource. */ addIncludedFiles(path, options) { const file = Gms2IncludedFile.import(this, path, options?.content, options?.subdirectory, options?.allowedExtensions); info(`included file upserted`, { path }); return file; } addConfig(name) { if (!this.components.configs.findChild(name)) { this.components.configs.addChild(name); this.save(); } return this; } /** Write *any* changes to disk. (Does nothing if readonly is true.) */ save() { this.storage.writeYySync(this.yypPathAbsolute, this.toJSON(), 'project'); return this; } ensureResourceGroupAssignments() { return ensureResourceGroupAssignments(this); } /** * Recreate in-memory representations of the GameMaker Project * using its files. */ async reload() { this.io.plugins.forEach((plugin) => plugin.beforeProjectLoaded?.(this)); // Load the YYP file, store RAW (ensure field resourceType: "GMProject" exists) const yyp = await StitchProject.parseYypFile(this.storage.yypPathAbsolute); this.yypRaw = yyp; // TODO: Figure out how to safely manage different typings due // TODO: to changes in the YYP (and potentially YY) files with // TODO: different IDE versions. // @ts-ignore this.components = { ...yyp, Options: new Gms2ComponentArray(yyp.Options || [], Gms2Option), configs: new Gms2Config(yyp.configs), Folders: new Gms2FolderArray(yyp.Folders), RoomOrderNodes: new Gms2ComponentArray(yyp.RoomOrderNodes, Gms2RoomOrder), TextureGroups: new Gms2ComponentArray(yyp.TextureGroups, Gms2TextureGroup), AudioGroups: new Gms2ComponentArray(yyp.AudioGroups, Gms2AudioGroup), IncludedFiles: new Gms2IncludedFileArray(yyp.IncludedFiles, this.storage), resources: new Gms2ResourceArray(this, yyp.resources), }; await this.ensureResourceGroupAssignments(); this.addFolder('NEW'); // Imported assets should go into a NEW folder. // DEBORK // TODO: Ensure that parent groups (folders) for all subgroups exist as separate entities. this.io.plugins.forEach((plugin) => plugin.afterProjectLoaded?.(this)); } /** * A deep copy of the project's YYP content with everything as plain primitives (no custom class instances). * Perfect for writing to JSON. */ toJSON() { const fields = Object.keys(this.components); const asObject = {}; for (const field of fields) { const component = this.components[field]; asObject[field] = component?.toJSON?.() ?? component; } return asObject; } static async cloneProject(options) { // Ensure that the template project actually // exists, and get access to its details. console.info('Loading template project...'); const template = await StitchProject.load({ projectPath: options.templatePath, readOnly: true, dangerouslyAllowDirtyWorkingDir: true, }); console.info('Template project loaded!'); const templateFolder = new Pathy(template.yypDirAbsolute); const where = (options.where ? new Pathy(options.where) : templateFolder.up()).join(kebabCase(options.name || template.name)); await where.ensureDirectory(); await where.isEmptyDirectory({ assert: true }); console.info('Cloning from', templateFolder.absolute, 'to', where.absolute); // Copy over all of the project's files await templateFolder.copy(where, { filter: (p) => !p.match(/\b(node_modules(?![\\/]+@bscotch[\\/]+stitch\b)|.git|tmp|logs?)\b/), }); const project = await StitchProject.load({ projectPath: where.absolute, dangerouslyAllowDirtyWorkingDir: true, }); if (options.name && options.name !== template.name) { project.name = options.name; } return project; } } //# sourceMappingURL=StitchProject.js.map