UNPKG

@bscotch/gml-parser

Version:

A parser for GML (GameMaker Language) files for programmatic manipulation and analysis of GameMaker projects.

1,200 lines 50.6 kB
import { __decorate, __metadata } from "tslib"; import { pathy } from '@bscotch/pathy'; import { getDefaultsForNewSound, isValidSoundName, isValidSpriteName, stitchConfigFilename, stitchConfigSchema, } from '@bscotch/stitch-config'; import { sequential } from '@bscotch/utility'; import { SoundChannel, SpriteType, Yy, yypFolderSchema, yyRoomSchema, yySpriteSchema, } from '@bscotch/yy'; import { EventEmitter } from 'events'; import { logger } from './logger.js'; import { importAssets } from './modules.js'; import { Asset, isAssetOfKind } from './project.asset.js'; import { Code } from './project.code.js'; import { Native } from './project.native.js'; import { fshDefault, vshDefault } from './shaderDefaults.js'; import { Signifier } from './signifiers.js'; import { Type } from './types.js'; import { assert, assertIsValidIdentifier, getPngSize, groupPathToPosix, ok, throwError, } from './util.js'; export { setLogger } from './logger.js'; export class Project { yypPath; options; yyp; /** Until this resolves, assume that this.yyp is not yet read */ yypWaiter; config; assets = new Map(); /** * Store the "native" functions, constants, and enums on * a per-project basis, but separately from the project-specific * symbols. The native symbols and types are loaded from the spec, * so they can vary between projects. */ native; helpLinks; /** * When resolved, the GML spec has been loaded and the * `native` property has been populated. */ nativeWaiter; /** * The type of the 'global' struct, which contains all globalvars * and globally defined functions. */ self; /** * The `global` symbol, which has type `self`. */ symbol; /** * Non-native global types, which can be referenced in JSDocs * and in a symbol's types. */ types = new Map(); emitter = new EventEmitter(); /** Code that needs to be reprocessed, for one reason or another. */ dirtyFiles = new Set(); constructor(yypPath, options) { this.yypPath = yypPath; this.options = options; } /** * @internal For tracking changed code files that will need to be re-parsed. */ queueDirtyFileUpdate(code) { this.dirtyFiles.add(code); } /** * @internal Drain the queue of dirty files, updating their diagnostics */ drainDirtyFileUpdateQueue() { for (const code of this.dirtyFiles) { code.updateDiagnostics(); // await code.reload(code.content); } this.dirtyFiles.clear(); } /** * Update the YYP file to list a specific GameMaker IDE version. * Note that the GameMaker IDE will overwrite this with whatever * its own version is -- this feature is useful for external tools * like Stitch that can manage multiple GameMaker IDE versions. */ async setIdeVersion(version) { assert(version.match(/^\d+\.\d+\.\d+\.\d+$/), 'Invalid version string'); this.yyp.MetaData.IDEVersion = version; await this.saveYyp(); } /** * The current version of the GameMaker IDE listed in * this project's YYP file. This is the GameMaker version that * the project was last opened with. */ get ideVersion() { return this.yyp.MetaData.IDEVersion; } /** * The directory in which the project lives. */ get dir() { return pathy(this.yypPath).up(); } /** * Get the Stitch config for this project, which defines * various settings that may impact rule around adding * assets, parsing logs, etc. */ get stitchConfig() { return this.dir .join(stitchConfigFilename) .withValidator(stitchConfigSchema); } /** List the names of the GameMaker configs defined by this project. */ get configs() { const configs = []; let configTree = [this.yyp.configs]; while (configTree.length) { const nextTree = []; for (const config of configTree) { configs.push(config.name); nextTree.push(...(config.children || [])); } configTree = nextTree; } return configs; } /** List the project's "datafiles" (a.k.a. "Included Files"), in the same format as they appear in the YYP file. */ get datafiles() { return this.yyp.IncludedFiles; } /** List the Folders in this project, normalized to regular POSIX paths * @example ['my/folder', 'my/other/folder'] */ get folders() { return this.yyp.Folders.map((f) => groupPathToPosix(f.folderPath)); } /** * Run a callback when diagnostics are emitted. Returns an unsubscribe function. */ onDiagnostics(callback) { this.emitter.on('diagnostics', callback); return () => this.emitter.off('diagnostics', callback); } /** @internal Method that can be called after some code has been parsed to report diagnostics to listeners. */ emitDiagnostics(code, diagnostics) { // Ensure they are valid diagnostics for (const diagnostic of diagnostics) { ok(diagnostic.$tag === 'diagnostic'); ok(diagnostic.location); } this.emitter.emit('diagnostics', { code: code instanceof Code ? code : undefined, filePath: code instanceof Code ? code.path.absolute : code, diagnostics, }); } /** * Since GameMaker assets are global they must have unique names independent of their type. Find an asset give it's name. Note that this is case-insensitive! * @param name The name of the asset to find, case-insensitive. */ getAssetByName(name, options) { assert(name || !options?.assertExists, 'No asset name provided'); if (!name) { return undefined; } const asset = this.assets.get(name.toLocaleLowerCase()); assert(asset || !options?.assertExists, `Asset "${name}" does not exist.`); return asset; } /** * @param name The name of the asset to find and remove, case-insensitive. */ async removeAssetByName(name) { if (!name) return; name = name.toLocaleLowerCase(); const asset = this.assets.get(name); if (!asset) return; // Remove the asset from the yyp const resourceIdx = this.yyp.resources.findIndex((r) => r.id.name.toLocaleLowerCase() === name); // If it's a room, remove it from the room order list if (isAssetOfKind(asset, 'rooms')) { this.yyp.RoomOrderNodes = this.yyp.RoomOrderNodes.filter((node) => { node.roomId.path.toLowerCase() !== asset.resource.id.path.toLowerCase(); }); } // If it'll be referenced in other assets, remove those references else if (isAssetOfKind(asset, 'objects') || isAssetOfKind(asset, 'sprites')) { for (const other of this.assets.values()) { if (isAssetOfKind(asset, 'sprites') && isAssetOfKind(other, 'objects')) { // If this object has this sprite, unset it! if (other.sprite?.name === asset.name) { other.sprite = undefined; } } else if (isAssetOfKind(asset, 'objects') && isAssetOfKind(other, 'objects')) { // Then this object might be referenced in a collision event // with the other object. const yy = other.yy; const [keepEvents, removeEvents] = yy.eventList.reduce((acc, event) => { if (event.collisionObjectId?.name === asset.name) { acc[1].push(event); } else { acc[0].push(event); } return acc; }, [[], []]); if (removeEvents.length) { // Remove the collision events other.yy.eventList = keepEvents; await other.saveYy(); // Remove the leftover collision event file const eventFile = other.dir.join(`Collision_${asset.name}.gml`); await eventFile.delete({ recursive: true }); await other.reload(); } // It could also be listed as the parent if (other.parent?.name === asset.name) { other.parent = undefined; } } } } this.assets.delete(name); if (resourceIdx > -1) { this.yyp.resources.splice(resourceIdx, 1); await this.saveYyp(); } // Clean up await asset.onRemove(); } getAsset(path) { return this.assets.get(this.assetNameFromPath(path)); } getGmlFile(path) { const resource = this.getAsset(path); if (!resource) { return; } return resource.getGmlFile(path); } /** Normalize path information for a datafile ("Included File") */ parseIncludedFilePath(filePath, name) { filePath.replace(/[/\\]+$/, '/').replace(/\/$/, ''); if (!name) { ({ folder: filePath, name } = filePath.match(/^(?<folder>.*)[/\\](?<name>[^/\\]+)$/)?.groups || {}); } assert(filePath, `Invalid folder: ${filePath}`); assert(name, `Invalid name: ${name}`); assert(filePath === 'datafiles' || filePath.startsWith('datafiles/'), `Folder must be in datafiles: ${filePath}`); return { filePath, name }; } findIncludedFile(filePath, name) { ({ filePath, name } = this.parseIncludedFilePath(filePath, name)); return this.datafiles.find((f) => f.name.toLowerCase() === name.toLowerCase() && f.filePath.toLowerCase() === filePath.toLowerCase()); } /** * Ensure that the included files listed in the YYP exactly match * the files in the `datafiles` directory. */ async syncIncludedFiles() { const includedFiles = (await this.dir.join('datafiles').listChildrenRecursively()).map((f) => { /** The filepath relative to the project dir (starts with 'datafiles') */ const fullPath = f.relativeFrom(this.dir); // Will throw with unexpected paths, preventing anything from being // overwritten. This is a better outcome than skipping those files. const { filePath, name } = this.parseIncludedFilePath(fullPath); const existing = this.findIncludedFile(filePath, name); return existing || { filePath, name }; }); // Note: Should check if there have been any changes, and only write if not! // No need to compare with what's already in there, just overwrite it! // GameMaker seems to sort these by full path, so we'll do the same to // prevent git noise. // @ts-expect-error The schema will ensure it's written correctly this.yyp.IncludedFiles = includedFiles; await this.saveYyp(); } /** @internal Load an Asset instance into the project's data model. For use by methods that load the project, add assets, etc. */ registerAsset(resource) { const name = this.assetNameFromPath(resource.dir); ok(!this.assets.has(name), `Resource ${name} already exists`); this.assets.set(name, resource); } /** * @param from The name of the asset to rename, case-insensitive. * @param to The new name for the asset, which must be a valid identifier that doesn't already have an associated asset. The name will be set in the provided casing, but must be unique case-insensitively. */ async renameAsset(from, to) { const asset = this.getAssetByName(from, { assertExists: true }); assertIsValidIdentifier(to); const toAsset = this.getAssetByName(to); assert(!toAsset, `Cannot rename. An asset named "${to}" already exists`); // Create a new asset with the new name, copying over the old asset's files and updating them as needed const newAssetDir = asset.dir.up().join(to); const reset = async () => await newAssetDir.delete({ force: true, recursive: true }); await newAssetDir.ensureDirectory(); await newAssetDir.isEmptyDirectory({ assert: true }); await asset.dir.copy(newAssetDir); // The yy files contain the old 'name' field, and there may be // other files named after the old asset name. // Rename all copied files that have the old asset name in them const oldNamePattern = new RegExp(`\\b${from}\\b`, 'gi'); let newYyFile; await newAssetDir.listChildrenRecursively({ filter: async (p) => { if (await p.isDirectory()) return; // Get the relative path const relative = p.relativeFrom(newAssetDir); const newRelative = relative.replaceAll(oldNamePattern, to); if (newRelative === relative) return; // Rename! const newFile = newAssetDir.join(newRelative); if (newRelative === `${to}.yy`) { newYyFile = newFile; } await p.copy(newFile); await p.delete(); }, }); if (!newYyFile) { await reset(); throwError(`Could not find yy after copying files`); } // Update the "name" field const yy = await Yy.read(newYyFile.absolute, asset.assetKind); yy.name = to; if (isAssetOfKind(asset, 'sounds')) { // Then we've renamed the sound file and need to update that in the yy! const yySound = yy; yySound.soundFile = yySound.soundFile.replace(oldNamePattern, to); } else if (isAssetOfKind(asset, 'sprites')) { const yySprite = yy; // Update the sequence track references. // They're in an absurd, deeply nested structure that changes // periodically. So the easiest thing is to stringify it, replace all, // and reparse it. if (yySprite.sequence?.tracks?.length) { for (let i = 0; i < yySprite.sequence.tracks.length; i++) { const track = yySprite.sequence.tracks[i]; let stringified = Yy.stringify(track); stringified = stringified.replaceAll(oldNamePattern, to); yySprite.sequence.tracks[i] = Yy.parse(stringified); } } } await Yy.write(newYyFile.absolute, yy, asset.assetKind, this.yyp); // Register the new asset const info = await this.addAssetToYyp(newYyFile.absolute); const newAsset = await Asset.from(this, info); assert(newAsset, `Could not create new asset ${to}`); this.registerAsset(newAsset); if (isAssetOfKind(newAsset, 'sprites')) { // Then find any object that had its sprite set to the old one, // and set it to the new one for (const obj of this.assets.values()) { if (!isAssetOfKind(obj, 'objects')) continue; if (!obj.sprite) continue; console.log('Checking old sprite name', obj.sprite.name, asset.name, newAsset.name); if (obj.sprite?.name === asset.name) { console.log('UPDATING SPRITE'); obj.sprite = newAsset; } } } // Remove the old asset await this.removeAssetByName(from); // Fully process the change await this.initiallyParseAssetCode([newAsset]); // Update the code from all refs to have the new name await this.renameSignifier(asset.signifier, to); if (isAssetOfKind(newAsset, 'objects')) { // Update immediate children to have the new asset as the parent for (const child of asset.children) { child.parent = newAsset; } // Update any rooms that reference the old object name for (const room of this.assets.values()) { if (!isAssetOfKind(room, 'rooms')) continue; await room.renameRoomInstanceObjects(from, to); } } } async renameSignifier(signifier, newName) { assertIsValidIdentifier(newName); // Rename the signifier const files = new Set(); signifier.refs.forEach((ref) => files.add(ref.start.file)); const waits = []; for (const file of files) { waits.push(file.renameSignifier(signifier, newName)); } await Promise.all(waits); } async import(fromProject, options = {}) { if (typeof fromProject === 'string') { fromProject = await Project.initialize(fromProject); } return await importAssets(fromProject, this, options); } async duplicateAsset(sourceName, newPath) { const source = this.getAssetByName(sourceName, { assertExists: true }); const parsed = await this.parseNewAssetPath(newPath); assert(parsed, `Invalid new asset path: ${newPath}`); // Copy all files in the source's directory to a new directory named // after the new name. const kind = source.assetKind; const cloneDir = this.dir.join(`${kind}/${parsed.name}`); await cloneDir.ensureDirectory(); await source.dir.copy(cloneDir); // Rename any files named after the original asset const oldNamePattern = new RegExp(`\\b${sourceName}\\b`, 'gi'); let yyFile; await cloneDir.listChildrenRecursively({ filter: async (p) => { if (await p.isDirectory()) return; // Get the relative path const relative = p.relativeFrom(cloneDir); const newRelative = relative.replaceAll(oldNamePattern, parsed.name); if (newRelative === relative) return; // Rename! const newFile = cloneDir.join(newRelative); await p.copy(newFile); await p.delete(); if (newFile.hasExtension('yy') && newFile.name.toLowerCase() === parsed.name.toLowerCase()) { yyFile = newFile; } }, }); assert(yyFile, `Could not find yy file for new asset ${parsed.name}`); // Update the yy files to replace the old name with the new // Just read them as text so we don't have to deal with parsing const content = await yyFile.read({ encoding: 'utf8' }); const newContent = content.replaceAll(new RegExp(`"${sourceName}"`, 'gi'), `"${parsed.name}"`); await yyFile.write(newContent); // Add the new asset to the yyp file const info = await this.addAssetToYyp(yyFile.absolute); const newAsset = await Asset.from(this, info); assert(newAsset, `Could not create new asset ${parsed.name}`); this.registerAsset(newAsset); return newAsset; } /** * Create a new sound asset. Will not do anything if the asset by this name already exists (but will log an error). * @param newSoundPath The POSIX-style path within the asset tree where you want this sound to be created, where the last component is the name of the sound asset. * @param fromFile The path to the source sound file to copy into the new asset's directory. * @example project.createSound('folder/of/sounds/snd_my_new_sound', 'path/to/sound.mp3'); */ async createSound(newSoundPath, fromFile) { // Create the yy file const parsed = await this.parseNewAssetPath(newSoundPath); if (!parsed) { return; } const { name, folder } = parsed; assert(isValidSoundName(name, this.config), `Sound name '${name}' does not match allowed patterns`); const defaults = getDefaultsForNewSound(name, this.config); const soundDir = this.dir.join(`sounds/${name}`); await soundDir.ensureDirectory(); const soundYy = soundDir.join(`${name}.yy`); // Copy the sound file over fromFile = pathy(fromFile); const soundFileName = `${name}${fromFile.extname}`; await fromFile.copy(soundDir.join(soundFileName)); await Yy.write(soundYy.absolute, { name, parent: { name: folder.name, path: folder.folderPath, }, type: defaults?.mono ? SoundChannel.Mono : SoundChannel.Stereo, soundFile: soundFileName, resourceVersion: '2.0', }, 'sounds', this.yyp); // Update the yyp file const info = await this.addAssetToYyp(soundYy.absolute); // Create and add the asset const asset = await Asset.from(this, info); if (asset) { this.registerAsset(asset); } return asset; } /** * Create a new room asset. Will not do anything if the asset by this name already exists (but will log an error). * @param newRoomPath The POSIX-style path within the asset tree where you want this asset to be created, where the last component is the name of the asset. * @example project.createRoom('folder/of/rooms/rm_my_room'); */ async createRoom(newRoomPath) { const parsed = await this.parseNewAssetPath(newRoomPath); if (!parsed) { return; } const { name, folder } = parsed; const roomDir = this.dir.join(`rooms/${name}`); await roomDir.ensureDirectory(); const roomYy = roomDir.join(`${name}.yy`); const yy = yyRoomSchema.parse({ name, parent: { name: folder.name, path: folder.folderPath, }, layers: [{ resourceType: 'GMRBackgroundLayer' }], views: [...Array(8)].map(() => ({})), }); yy.views[0].visible = true; await Yy.write(roomYy.absolute, yy, 'rooms', this.yyp); // Update the yyp file const info = await this.addAssetToYyp(roomYy.absolute, { skipSave: true }); this.yyp.RoomOrderNodes.push({ roomId: info.id }); await this.saveYyp(); // Create and add the asset const asset = await Asset.from(this, info); if (asset) { this.registerAsset(asset); } return asset; } /** * Create a new sprite asset. Will not do anything if the asset by this name already exists (but will log an error). * @param newSpritePath The POSIX-style path within the asset tree where you want this asset to be created, where the last component is the name of the asset. * @param fromImageFile Path to a source PNG image to use as the first frame of the sprite. * @example project.createSprite('folder/of/sprites/sp_my_sprite', 'path/to/sprite.png'); */ async createSprite(newSpritePath, fromImageFile) { // Create the yy file const parsed = await this.parseNewAssetPath(newSpritePath); if (!parsed) { return; } const { name, folder } = parsed; assert(isValidSpriteName(name, this.config), `Sprite name '${name}' does not match allowed patterns`); const spriteDir = this.dir.join(`sprites/${name}`); await spriteDir.ensureDirectory(); const spriteYy = spriteDir.join(`${name}.yy`); // Get the source image dimensions fromImageFile = pathy(fromImageFile); assert(fromImageFile.hasExtension('png'), `Expected a .png file`); const { width, height } = await getPngSize(fromImageFile); const xorigin = Math.floor(width / 2) - 1; const yorigin = Math.floor(height / 2) - 1; const frames = []; frames.length = 1; const yy = yySpriteSchema.parse({ name, parent: { name: folder.name, path: folder.folderPath, }, type: SpriteType.Default, width, height, sequence: { xorigin, yorigin }, frames, }); // Now we'll have a frameId const frameId = yy.frames[0].name; assert(frameId, `Expected a frameId`); await fromImageFile.copy(spriteDir.join(`${frameId}.png`)); await Yy.write(spriteYy.absolute, yy, 'sprites', this.yyp); // Update the yyp file const info = await this.addAssetToYyp(spriteYy.absolute); // Create and add the asset const asset = await Asset.from(this, info); if (asset) { this.registerAsset(asset); } return asset; } /** * Add an object to the yyp file. The string can include separators, * in which case folders will be ensured up to the final component. * @param newObjectName The POSIX-style path within the asset tree where you want this asset to be created, where the last component is the name of the asset. */ async createObject(newObjectName) { // Create the yy file const parsed = await this.parseNewAssetPath(newObjectName); if (!parsed) { return; } const { name, folder } = parsed; const objectDir = this.dir.join(`objects/${name}`); await objectDir.ensureDirectory(); const objectYy = objectDir.join(`${name}.yy`); const objectCreateFile = objectDir.join('Create_0.gml'); await objectCreateFile.write('/// '); await Yy.write(objectYy.absolute, { name, parent: { name: folder.name, path: folder.folderPath, }, // Include the Create event by default eventList: [{ eventNum: 0, eventType: 0 }], }, 'objects', this.yyp); // Update the yyp file const info = await this.addAssetToYyp(objectYy.absolute); // Create and add the asset const asset = await Asset.from(this, info); if (asset) { this.registerAsset(asset); } return asset; } async createShader(path) { // Create the yy file const parsed = await this.parseNewAssetPath(path); if (!parsed) { return; } const { name, folder } = parsed; const shaderDir = this.dir.join(`shaders/${name}`); await shaderDir.ensureDirectory(); const shaderYy = shaderDir.join(`${name}.yy`); await Yy.write(shaderYy.absolute, { name, parent: { name: folder.name, path: folder.folderPath, }, }, 'shaders', this.yyp); // Create the fsh and vsh files const fsh = shaderYy.changeExtension('fsh'); await fsh.write(fshDefault); const vsh = shaderYy.changeExtension('vsh'); await vsh.write(vshDefault); // Update the yyp file const info = await this.addAssetToYyp(shaderYy.absolute); // Create and add the asset const asset = await Asset.from(this, info); if (asset) { this.registerAsset(asset); } return asset; } /** * Add a script to the yyp file. The path string can include separators, * in which case folders will be ensured up to the final component. */ async createScript(path) { // Create the yy file const parsed = await this.parseNewAssetPath(path); if (!parsed) { return; } const { name, folder } = parsed; assertIsValidIdentifier(name); const scriptDir = this.dir.join(`scripts/${name}`); await scriptDir.ensureDirectory(); const scriptYy = scriptDir.join(`${name}.yy`); await Yy.write(scriptYy.absolute, { name, parent: { name: folder.name, path: folder.folderPath, }, }, 'scripts', this.yyp); // Create the gml file const scriptGml = scriptYy.changeExtension('gml'); await scriptGml.write('/// '); // Update the yyp file const info = await this.addAssetToYyp(scriptYy.absolute); // Create and add the asset const asset = await Asset.from(this, info); if (asset) { this.registerAsset(asset); } return asset; } async parseNewAssetPath(path) { const parts = path.split(/[/\\]+/); const name = parts.pop(); if (!name) { logger.error(`Attempted to add script with no name: ${path}`); return; } assertIsValidIdentifier(name); const existingAsset = this.getAssetByName(name); if (existingAsset) { logger.error(`An asset named ${path} (${existingAsset.assetKind}) already exists`); return; } if (!parts.length) { logger.error(`Adding scripts to the root directory is not supported.`); return; } const folder = (await this.createFolder(parts)); return { folder, name }; } /** * Given the path to a yy file for an asset, ensure * it has an entry in the yyp file. */ async addAssetToYyp(yyPath, options) { assert(yyPath.endsWith('.yy'), `Expected yy file, got ${yyPath}`); const parts = yyPath.split(/[/\\]+/).slice(-3); assert(parts.length === 3, `Expected path with at least 3 parts, got ${yyPath}`); const [type, name, basename] = parts; const resourceEntry = { id: { name, path: `${type}/${name}/${basename}`, }, }; // Insert the resource into a random spot in the list to avoid git conflicts, // avoiding the last spot because that's where changes are most likely to be. // (This only matters for older project versions -- the newer version ) const lastAllowed = Math.max(0, this.yyp.resources.length - 1); const insertAt = Math.floor(Math.random() * lastAllowed); this.yyp.resources.splice(insertAt, 0, resourceEntry); if (!options?.skipSave) { await this.saveYyp(); } return resourceEntry; } parseFolderPath(path) { const parts = Array.isArray(path) ? path : path.split(/[/\\]+/); const full = `folders/${parts.join('/')}.yy`; const prefix = `folders/${parts.join('/')}/`; return { parts, full, prefix }; } listAssetsInFolder(path, options) { const { full, prefix } = this.parseFolderPath(path); const foundAssets = []; for (const asset of this.assets.values()) { const assetFolder = asset.yy?.parent; if (assetFolder.path === full) { foundAssets.push(asset); } else if (options?.recursive && assetFolder.path.startsWith(prefix)) { foundAssets.push(asset); } } return foundAssets; } /** * Delete a folder recursively. Only allowed if there are no assets * in this or any subfolder. */ async deleteFolder(path) { const assets = this.listAssetsInFolder(path, { recursive: true }); const { full, prefix } = this.parseFolderPath(path); assert(!assets.length, 'Cannot delete folder containing assets!'); for (let f = this.yyp.Folders.length - 1; f >= 0; f--) { const currentFolder = this.yyp.Folders[f]; // If this is the "old" folder, delete it if (full === currentFolder.folderPath || currentFolder.folderPath.startsWith(prefix)) { this.yyp.Folders.splice(f, 1); continue; } } await this.saveYyp(); } /** * Rename an existing folder. Allows for renaming any part of * the path (useful both "moving" and "renaming" a folder). * Array inputs are interpreted as pre-split paths. If the new * name matches an existing folder, it will in effect be "merged" * with that existing folder. * * Returns the list of folders and assets that are now in a new * location. */ async renameFolder(oldPath, newPath) { const oldParts = Array.isArray(oldPath) ? oldPath : oldPath.split(/[/\\]+/); const newParts = Array.isArray(newPath) ? newPath : newPath.split(/[/\\]+/); if (!oldParts.length) { logger.warn(`Cannot rename root folder`); return; } if (oldParts.join('/') === newParts.join('/')) { logger.warn(`Folder is already named that. Skipping rename.`); return; } // Ensure the new folder exists const targetFolder = await this.createFolder(newParts); if (!targetFolder) return; // Move subfolders from the old folder to the new folder const oldPathFull = `folders/${oldParts.join('/')}.yy`; const oldPathPrefix = `folders/${oldParts.join('/')}/`; const newPathPrefix = `folders/${newParts.join('/')}/`; const movedFolders = []; // Start from the end so we can delete as we go for (let f = this.yyp.Folders.length - 1; f >= 0; f--) { const currentFolder = this.yyp.Folders[f]; // If this is the "old" folder, delete it if (oldPathFull === currentFolder.folderPath) { this.yyp.Folders.splice(f, 1); movedFolders.push([currentFolder, undefined]); continue; } // If this is a subfolder of the old folder, move it if (currentFolder.folderPath.startsWith(oldPathPrefix)) { const newPath = currentFolder.folderPath.replace(oldPathPrefix, newPathPrefix); this.yyp.Folders[f] = { ...currentFolder, folderPath: newPath, }; movedFolders.push([currentFolder, this.yyp.Folders[f]]); } } await this.saveYyp(); // Move assets from the old folder to the new folder const movedAssets = []; for (const asset of this.assets.values()) { const assetFolder = asset.yy?.parent; let moved = false; if (assetFolder.path === oldPathFull) { asset.yy.parent = { name: targetFolder.name, path: targetFolder.folderPath, }; moved = true; } else if (assetFolder.path.startsWith(oldPathPrefix)) { // The name comes from a subfolder, so just need to update the path asset.yy.parent = { name: assetFolder.name, path: assetFolder.path.replace(oldPathPrefix, newPathPrefix), }; moved = true; } if (moved) { movedAssets.push(asset); await asset.saveYy(); } } return { movedFolders, movedAssets }; } /** * Add a folder to the yyp file. The string can include separators, * in which case nested folders will be created. If an array is provided, * it is interpreted as a pre-split path. */ async createFolder(path, options) { const parts = Array.isArray(path) ? path : path.split(/[/\\]+/); const folders = this.yyp.Folders; let current = 'folders/'; let folder; /** A random location in the list where this new folder should be put, * to reduce git conflicts.*/ const insertAt = Math.max(Math.floor(Math.random() * folders.length - 1), 0); for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (!part) { continue; } const thisFolderPath = current + part + '.yy'; folder = folders.find((f) => f.folderPath === thisFolderPath); if (!folder) { folder = yypFolderSchema.parse({ folderPath: thisFolderPath, name: part, }); folders.splice(insertAt, 0, folder); } current += part + '/'; } if (!options?.skipSave) { await this.saveYyp(); } return folder; } async saveYyp() { await Yy.write(this.yypPath.absolute, this.yyp, 'project'); } /** * The name of a resource, *in lower case*, from * a path. This is used as the key for looking up resources. * * The path can be to the asset's folder, or to any file within * that folder. */ assetNameFromPath(path) { const parts = path.relativeFrom(this.dir).split(/[/\\]+/); return parts[1]?.toLocaleLowerCase?.(); } /** * When first creating an instance, we need to get all project file * content into memory for fast access. In particular, we need all * yyp, yy, and gml files for scripts and objects. For other asset types * we just need their names and yyp filepaths. * * Can be called at any time -- will only operate on new assets. * * Returns the list of added assets. Assets are instanced and registered but their * code is not parsed! */ async loadAssets(options) { const t = Date.now(); // Load AudioGroup assets for (const audioGroup of this.yyp.AudioGroups) { if (!this.self.getMember(audioGroup.name)) { const signifier = new Signifier(this.self, audioGroup.name, new Type('Asset.GMAudioGroup')); signifier.global = true; signifier.writable = false; this.self.addMember(signifier); } } // We'll say that resources take 80% of loading, // and distribute that across the number of resources. const perAssetIncrement = this.yyp.resources.length / 80; const resourceWaits = []; for (const resourceInfo of this.yyp.resources) { assert(resourceInfo.id.name, `Resource ${resourceInfo.id.path} has no name`); const name = resourceInfo.id.name.toLocaleLowerCase(); // Skip it if we already have it if (this.assets.has(name)) { continue; } resourceWaits.push(Asset.from(this, resourceInfo).then((r) => { if (!r) { logger.warn(`Resource ${resourceInfo.id.name} has no yy file`); return; } this.registerAsset(r); options?.onLoadProgress?.(perAssetIncrement, `Loading assets...`); return r; })); } const addedAssets = await Promise.all(resourceWaits); options?.onLoadProgress?.(1, `Loaded ${this.assets.size} resources`); logger.log(`Loaded ${this.assets.size} resources in ${Date.now() - t}ms`); return addedAssets.filter((x) => x); } async loadHelpLinks() { // Need the path to the IDE folder. Can probably get by with the default // installation location... await this.nativeWaiter; const file = await Native.findHelpLinksFile(this.ideVersion); const content = await file?.read({ fallback: {} }); this.helpLinks = new Proxy(content || {}, { get: (target, key) => { const baseUrl = `https://beta-manual.yoyogames.com/#t=`; if (typeof key === 'string' && key in target) { return `${baseUrl}${encodeURIComponent(target[key])}.htm`; } else if (typeof key === 'string') { return `${baseUrl}Content.htm&rhsearch=${encodeURIComponent(key)}&ux=${encodeURIComponent(key)}`; } return `${baseUrl}Content.htm`; }, }); } async reloadConfig() { this.config = await this.stitchConfig.read({ fallback: {} }); return this.config; } async getWindowsName() { const windowOptionsFile = this.dir.join('options/windows/options_windows.yy'); if (!(await windowOptionsFile.exists())) { return; } const content = (await Yy.read(windowOptionsFile.absolute)); return content.option_windows_display_name; } /** * Load the GML spec for the project's runtime version, falling * back on the included spec if necessary. */ async loadGmlSpec() { const t = Date.now(); this.self = new Type('Struct').named('global'); this.symbol = new Signifier(this.self, 'global', this.self); this.symbol.global = true; this.symbol.writable = false; this.symbol.def = {}; let runtimeVersion; // Check for a stitch config file that specifies the runtime version. // If it exists, use that version. It's likely that it is correct, and this // way we don't have to download the releases summary. if (this.config.runtimeVersion) { logger.info('Found stitch config'); runtimeVersion = this.config.runtimeVersion; } await this.yypWaiter; // To ensure that `this.ideVersion` exists const specFiles = await Native.listSpecFiles({ ideVersion: this.ideVersion, runtimeVersion, }); this.native = await Native.from(specFiles, this.self, this.types); logger.log(`Loaded GML spec in ${Date.now() - t}ms`); } /** * Call to reload the project's yyp file (e.g. because it has changed * on disk) and add/remove any resources. */ async reloadYyp() { // Update the YYP and identify new/deleted assets // const oldYyp = this.yyp; assert(this.yypPath, 'Cannot reload YYP without a path'); this.yyp = await Yy.read(this.yypPath.absolute, 'project'); // // NOTE: This is disabled because it's doesn't behave well // // Remove old assets // const assetIds = new Map( // this.yyp.resources.map((r) => [r.id.path, r.id]), // ); // const removedAssets = oldYyp.resources.filter( // (r) => !assetIds.has(r.id.path), // ); // for (const removedAsset of removedAssets) { // await this.removeAssetByName(removedAsset.id.name); // } // Add new assets const newAssets = await this.loadAssets(); await this.initiallyParseAssetCode(newAssets); // Try to keep anything that got touched *clean* this.drainDirtyFileUpdateQueue(); } /** * @internal * Initialize a collection of new assets by parsing their GML */ initiallyParseAssetCode(assets) { // Do scripts before objects assets = [...assets.values()].sort((a, b) => { if (a.assetKind === b.assetKind) { return a.name.localeCompare(b.name); } if (a.assetKind === 'scripts') { return -1; } if (b.assetKind === 'scripts') { return 1; } if (a.assetKind === 'objects') { return -1; } if (b.assetKind === 'objects') { return 1; } return a.name.localeCompare(b.name); }); logger.info('Discovering globals...'); for (const asset of assets) { asset.updateGlobals(true); } // Discover all symbols and their references logger.info('Discovering symbols...'); for (const asset of assets) { asset.updateAllSymbols(true); } // Second pass // TODO: Find a better way than brute-forcing to resolve cross-file references for (const pass of [1]) { logger.info(`Re-processing pass ${pass}...`); // const reloads: Promise<any>[] = []; for (const asset of assets) { asset.updateGlobals(); asset.updateAllSymbols(); // for (const file of asset.gmlFilesArray) { // reloads.push(file.reload(file.content)); // } } // await Promise.all(reloads); } // But for now, that's what we'll do! logger.info('Updating diagnostics...'); for (const asset of assets) { asset.updateDiagnostics(); } } async initialize(options) { logger.info('Initializing project...'); if (options?.onDiagnostics) { this.onDiagnostics(options.onDiagnostics); } let t = Date.now(); await this.reloadConfig(); assert(this.yypPath, 'Cannot initialize without a path'); this.yypWaiter = Yy.read(this.yypPath.absolute, 'project').then((yyp) => { this.yyp = yyp; options?.onLoadProgress?.(5, 'Loaded project file'); logger.info('Loaded yyp file!'); }); this.nativeWaiter = this.loadGmlSpec(); void this.nativeWaiter.then(() => { options?.onLoadProgress?.(5, 'Loaded GML spec'); }); logger.info('Loading asset files...'); await Promise.all([ this.nativeWaiter, this.yypWaiter, this.loadHelpLinks(), ]); const assets = await this.loadAssets(options); logger.log('Resources', this.assets.size, 'loaded files in', Date.now() - t, 'ms'); t = Date.now(); // Discover all globals // Sort assets by type, with objects 2nd to last and scripts last // to minimize the number of things that need to be updated after // loading. options?.onLoadProgress?.(1, 'Parsing resource code...'); await this.initiallyParseAssetCode(assets); } /** * Create a new project instance and initialize it. */ static async initialize(yypPath, options) { let path = pathy(yypPath); if (await path.isDirectory()) { const children = await path.listChildren(); path = children.find((p) => p.hasExtension('yyp')); ok(path, 'No yyp file found in project directory'); } await path.exists({ assert: true }); const project = new Project(path, options); await project.initialize(options); return project; } static fallbackGmlSpecPath = pathy(import.meta.url).resolveTo('../../assets/GmlSpec.xml'); } __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], Project.prototype, "removeAssetByName", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], Project.prototype, "syncIncludedFiles", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String, String]), __metadata("design:returntype", Promise) ], Project.prototype, "renameAsset", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], Project.prototype, "import", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String, String]), __metadata("design:returntype", Promise) ], Project.prototype, "duplicateAsset", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], Project.prototype, "createSound", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], Project.prototype, "createRoom", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], Project.prototype, "createSprite", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], Project.prototype, "createObject", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], Project.prototype, "createShader", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], Project.prototype, "createScript", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], Project.prototype, "deleteFolder", null); __decorate([ sequential, __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], Project.prototype, "renameFolder", null); __decorate([ sequential, __metadata("desi