UNPKG

@bscotch/yy

Version:

Stringify, parse, read, and write GameMaker yy and yyp files.

303 lines 12.8 kB
import { GameMakerVersionString } from './types/GameMakerVersionString.js'; import { FixedNumber, isObjectWithField, nameField, yyIsNewFormat, } from './types/utility.js'; const escapable = // eslint-disable-next-line no-control-regex, no-misleading-character-class /[\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; const meta = { // table of character substitutions '\b': '\\b', '\t': '\\t', '\n': '\\n', '\f': '\\f', '\r': '\\r', '"': '\\"', '\\': '\\\\', }; function quote(string) { // If the string contains no control characters, no quote characters, and no // backslash characters, then we can safely slap some quotes around it. // Otherwise we must also replace the offending characters with safe escape // sequences. escapable.lastIndex = 0; return escapable.test(string) ? '"' + string.replace(escapable, function (a) { const c = meta[a]; return typeof c === 'string' ? c : '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); }) + '"' : '"' + string + '"'; } /** * Jsonify, with GameMaker-like JSON and allowing for BigInts. * Based on {@link https://github.com/sidorares/json-bigint/blob/master/lib/stringify.js json-bigint}. * * The yyp file can be passed in to use as a reference, * e.g. to ensure the write format is used for new files. */ export function stringifyYy(yyData, yyp) { const isNewFormat = yyIsNewFormat(yyData) || yyIsNewFormat(yyp); const eol = isNewFormat ? '\n' : `\r\n`; let gap = ''; let level = 0; // Level 1 are root elements const indent = ' '; let arrayLevel = 0; yyData = prepareForStringification(yyData, yyp); function stringify(key, holder, pointer = []) { // Produce a string from holder[key]. let value = holder[key]; if (key !== '') { pointer.push(key); } const mind = gap; const startingLevel = level; if (value instanceof FixedNumber) { return value.toFixed(value.digits); } // If the value has a toJSON method, call it to obtain a replacement value. value = value?.toJSON?.(key) ?? value; // What happens next depends on the value's type. switch (typeof value) { case 'string': return quote(value); case 'number': case 'boolean': case 'bigint': // If the value is a boolean or null, convert it to a string. Note: // typeof null does not produce 'null'. The case is included here in // the remote chance that this gets fixed someday. return String(value); // If the type is 'object', we might be dealing with an object or an array or // null. case 'object': { // Due to a specification blunder in ECMAScript, typeof null is 'object', // so watch out for that case. if (!value) { return 'null'; } // Make an array to hold the partial results of stringifying this object value. gap += indent; level++; // Is the value an array? if (Array.isArray(value)) { // Stringify every element. Use null as a placeholder // for non-JSON values. arrayLevel++; const jsonifiedValues = value.map((_, i) => stringify(i, value, [...pointer]) || 'null'); // Join all of the elements together, separated with commas, and wrap them in // brackets. const v = jsonifiedValues.length === 0 ? '[]' : `[${eol}` + gap + jsonifiedValues.join(`,${eol}` + gap) + `,${eol}` + mind + ']'; gap = mind; level = startingLevel; arrayLevel--; return v; } let includeGaps = level <= 2; if (isNewFormat && arrayLevel > 0) { includeGaps = false; } else if (isNewFormat) { includeGaps = true; } const partial = []; Object.keys(value).forEach(function (k) { const v = stringify(k, value, [...pointer]); if (v) { partial.push(quote(k) + (includeGaps && !isNewFormat ? ': ' : ':') + v); } }); // Join all of the member texts together, separated with commas, // and wrap them in braces. // In the new format, the Channels object deep within a sprite has its keys newlined const needsLineBreak = isNewFormat && (pointer.at(-1) === 'Channels' || pointer.find((p) => p === 'ConfigValues')); const v = partial.length === 0 ? '{}' : includeGaps || needsLineBreak ? `{${eol}` + gap + partial.join(`,${eol}` + gap) + `,${eol}` + mind + '}' : '{' + partial.join(',') + ',}'; gap = mind; level = startingLevel; return v; } } return; } // If there is a replacer, it must be a function or an array. // Otherwise, throw an error. // Make a fake root object containing our value under the key of ''. // Return the result of stringifying the value. return stringify('', { '': yyData, }, []); } /** * Get a clone of some yyData, ready for stringification * (keys in the right order, the right keys, etc) * * Sort keys GameMaker-style (which does change over time!). * For the new format (where the '%Name' key exists), sort order * is just alphabetical (case-insensitive). * * Prior to the new format, the final sort order was: * - "resourceType": "GMSprite", * - "resourceVersion": "1.0", * - "name": "barrel_tendraam", * - Everything else, in alphabetical order (case-insensitive). */ function prepareForStringification(yyData, yyp, __meta = { root: yyData, isNewFormat: false, path: [] }) { const ideVersion = yyp?.MetaData?.IDEVersion ? new GameMakerVersionString(yyp.MetaData.IDEVersion) : null; const isNewFormat = __meta.isNewFormat || yyIsNewFormat(yyData) || yyIsNewFormat(yyp); __meta = { ...__meta, isNewFormat, path: [...__meta.path], }; if (isObjectWithField(yyData, '$GMScript')) { if (ideVersion?.gte('2024.800.0.618')) { // Then we need to set this to "v1" instead of "" yyData[`$GMScript`] = 'v1'; } } else if (isObjectWithField(yyData, '$GMSound')) { if (ideVersion?.gte('2024.1400.0.815')) { yyData['$GMSound'] = 'v2'; // The 'type' field has been replaced with 'channelFormat' // @ts-expect-error Type isn't known here yyData['channelFormat'] ||= yyData['type'] || 1; // @ts-expect-error Type isn't known here yyData['compressionQuality'] ||= 4; // @ts-expect-error Type isn't known here yyData['exportDir'] ||= ''; delete yyData.type; delete yyData.bitRate; } } if (Array.isArray(yyData)) { const prepared = yyData.map((item, i) => { const meta = { ...__meta, path: [...__meta.path, i] }; return prepareForStringification(item, yyp, meta); }); if (isNewFormat && '$GMProject' in __meta.root) { // Then we need to sort the resources, folders, and included files arrays const currentPath = __meta.path.join('/'); if (currentPath === 'resources') { // Sort based on the path const resources = prepared; resources.sort((a, b) => a.id.path.toLowerCase().localeCompare(b.id.path.toLowerCase())); } else if (currentPath === 'IncludedFiles') { const includedFiles = prepared; includedFiles.sort((a, b) => `${a.filePath}/${a.name}` .toLowerCase() .localeCompare(`${b.filePath}/${b.name}`.toLowerCase())); } else if (currentPath === 'Folders') { const folders = prepared; folders.sort((a, b) => a.folderPath.toLowerCase().localeCompare(b.folderPath.toLowerCase())); } } return prepared; } else if (yyData instanceof FixedNumber) { return yyData; } else if (typeof yyData === 'object' && yyData !== null) { const yyDataCopy = { ...yyData }; const hasResourceType = 'resourceType' in yyData && typeof yyData.resourceType === 'string'; if (isNewFormat && hasResourceType) { // Then we need to ensure that the file has the `${resourceType}` key, // because we may be converting an old format to the new one. yyDataCopy[`$${yyData.resourceType}`] ||= ''; if (yyDataCopy[`$${yyData.resourceType}`] === '' && ['GMScript', 'GMRoom', 'GMRInstance', 'GMEvent'].includes(yyData.resourceType) && ideVersion?.gte('2024.800.0.618')) { // Then we need to set this to "v1" instead of "" yyDataCopy[`$${yyData.resourceType}`] = 'v1'; } } if (isNewFormat && 'name' in yyData && typeof yyData.name === 'string' && hasResourceType && // Otherwise it's just a different kind of 'name' field !('$GMSpriteFramesTrack' in yyDataCopy) // Special case ) { // Then we need to ensure that the file has the `%Name` key, // because we may be converting an old format to the new one. // Since older code updates the 'name' field when and doesn't know // about the '%Name' field, the safest thing is to ALWAYS set // the '%Name' field to the 'name' field. yyDataCopy[nameField] = yyData.name; } if (isNewFormat && hasResourceType) { // Then there should always be a resourceVersion key with value "2.0" yyDataCopy['resourceVersion'] = '2.0'; } if ('$GMSpriteFramesTrack' in yyDataCopy) { // Make sure it doesn't have a '%Name' key, since that causes build failures delete yyDataCopy[nameField]; } const keys = Object.keys(yyDataCopy); keys.sort((a, b) => { if (!isNewFormat && hasResourceType) { if (a === 'resourceType') { return -1; } if (b === 'resourceType') { return 1; } if (a === 'resourceVersion') { return -1; } if (b === 'resourceVersion') { return 1; } if (a === 'name') { return -1; } if (b === 'name') { return 1; } } if (a === b) return 0; // The GameMaker sort algorithm treats '_' as greater than all letters (no matter the case), so we have to force that behavior by replacing those chars with something that *actually* is (like `|`) a = a.toLowerCase(); b = b.toLowerCase(); if (isNewFormat) { a = a.replace(/_/g, '|'); b = b.replace(/_/g, '|'); } if (a < b) return -1; return 1; }); // Delete each entry and re-add it in the sorted order. const reference = { ...yyDataCopy }; keys.forEach((key) => delete yyDataCopy[key]); keys.forEach((key) => { const meta = { ...__meta, path: [...__meta.path, key] }; yyDataCopy[key] = prepareForStringification(reference[key], yyp, meta); }); return yyDataCopy; } return yyData; } //# sourceMappingURL=Yy.stringify.js.map