diff --git a/shuffle.js b/shuffle.js index c64485f..f371746 100644 --- a/shuffle.js +++ b/shuffle.js @@ -1,141 +1,183 @@ +//@ts-check +'use strict'; const fs = require('fs') const plist = require('plist'); const assets = require('./assets.json') -try { // god-tier crash prevention system - -Array.prototype.shuffle = function() { - let length = this.length; let unshuffled = this; let shuffled = []; - while (shuffled.length !== length) { - let index = Math.floor(Math.random() * unshuffled.length); - shuffled.push(unshuffled[index]); - unshuffled = unshuffled.filter((x, y) => y !== (index))} - return shuffled; -} - -function plistToJson(file) { - let data = plist.parse(file) - for (let key in data.frames) { - let fileData = data.frames[key]; - for (let innerKey in fileData) { - if (typeof fileData[innerKey] == 'string') { - if (!fileData[innerKey].length) delete fileData[innerKey] - else fileData[innerKey] = JSON.parse(fileData[innerKey].replace(/{/g, '[').replace(/}/g, ']')); +/** + * returns a pseudo-random 32bit unsigned integer + * in the interval [0, `n`) + */ +const randU32 = (n = 2**32) => Math.random() * n >>> 0; + +/** + * https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm + * https://github.com/steventhomson/array-generic-shuffle/blob/master/shuffle.js + * @param {any[]} a + */ +const shuffle = a => { + let len = a.length; + while (len > 0) { + const i = randU32(len); + len--; + [a[len], a[i]] = [a[i], a[len]]; // swap + } +}; + +/** + * get unique/distinct elements (same order) + * @param {any[]} arr + */ +const undupe = arr => arr.filter((x, y) => arr.indexOf(x) == y); + +/** + * convert plist string to JSON, then JSON to `Object` + * @param {string} file + * @return {import('plist').PlistObject} + */ +const plistToJson = file => { + const {frames: datFrames} = plist.parse(file); + // not using `Object.values`, because we want to mutate in-place + for (const out_k in datFrames) { + const fileData = datFrames[out_k]; + for (const in_k in fileData) { + const fdik = fileData[in_k]; + if (typeof fdik == 'string') { + if (fdik.length == 0) delete fileData[in_k] + else fileData[in_k] = JSON.parse(fdik.replace(/{/g, '[').replace(/}/g, ']')); } }} - return data.frames + return datFrames } -if (!fs.existsSync('./pack')) fs.mkdirSync('./pack'); - -function glow(name) { return name.replace("_001.png", "_glow_001.png") } -function undupe (arr) { return arr.filter((x, y) => arr.indexOf(x) == y) } -//function spriteRegex(name) { return new RegExp(`(${name.replace(".", "\\.")}<\/key>\\s*)((.|\\n)+?<\\/dict>)`) } -let iconRegex = /^.+?_(\d+?)_.+/ - -let forms = assets.forms -let sheetList = Object.keys(assets.sheets) -let glowName = sheetList.filter(x => x.startsWith('GJ_GameSheetGlow')) - -// newlines/CRs are usually present in text files, strip them out so they aren't part of the pathname -let gdPath = process.argv[2] ?? fs.readFileSync('directory.txt', 'utf8').replace(/[\n\r]/g, '') - -if (!fs.existsSync(gdPath)) throw "Couldn't find your GD directory! Make sure to enter the correct file path in directory.txt" -let glowPlist = fs.readFileSync(`${gdPath}/${glowName[0]}.plist`, 'utf8') -let sheetNames = sheetList.filter(x => !glowName.includes(x)) -let resources = fs.readdirSync(gdPath) - -let plists = [] -let sheets = [] -let glowBackups = [] -let glowSheet = plistToJson(glowPlist) - -resources.forEach(x => { - if (x.startsWith('PlayerExplosion_') && x.endsWith('-uhd.plist')) sheetNames.push(x.slice(0, -6)) -}) - -sheetNames.forEach(x => { - let file = fs.readFileSync(`${gdPath}/${x}.plist`, 'utf8') - plists.push(file) - try { sheets.push(plistToJson(file)) } - catch(e) { throw `Error parsing ${x}.plist - ${e.message}` } -}) - -sheets.forEach((gameSheet, sheetNum) => { - let plist = plists[sheetNum] - let name = sheetNames[sheetNum] - if (!name.startsWith('PlayerExplosion_')) console.log("Shuffling " + name) - else if (name == "PlayerExplosion_01-uhd") console.log("Shuffling death effects") - - let sizes = {} - Object.keys(gameSheet).forEach(x => { - let obj = gameSheet[x] - obj.name = x - if (sheetNum == sheetNames.findIndex(y => y.startsWith('GJ_GameSheet02')) && forms.some(y => x.startsWith(y))) { - let form = forms.find(y => x.startsWith(y)) - if (!sizes[form]) sizes[form] = [obj] - else sizes[form].push(obj) - } - else { - let sizeDiff = assets.sheets[name] || 30 - let size = obj.textureRect[1].map(x => Math.round(x / sizeDiff) * sizeDiff).join() - if (name.startsWith('PlayerExplosion')) size = "deatheffect" - if (!sizes[size]) sizes[size] = [obj] - else sizes[size].push(obj) - } +/** working directory */ +const wd = './pack/'; + +try { // god-tier crash prevention system + + fs.mkdirSync(wd, { recursive: true, mode: 0o766 }); + + const glow = (/**@type {string}*/ name) => name.replace("_001.png", "_glow_001.png"); + //const spriteRegex = name => new RegExp(`(${name.replace(".", "\\.")}<\/key>\\s*)((.|\\n)+?<\\/dict>)`); + const iconRegex = /^.+?_(\d+?)_.+/ + + const + {forms} = assets, + sheetList = Object.keys(assets.sheets), + glowName = sheetList.filter(x => x.startsWith('GJ_GameSheetGlow')); + + // newlines/CRs are usually present in text files, strip them out so they aren't part of the pathname + const gdPath = process.argv[2] ?? fs.readFileSync('directory.txt', 'utf8').replace(/[\n\r]/g, '') + + if (!fs.existsSync(gdPath)) + throw "Couldn't find your GD directory! Make sure to enter the correct file path in directory.txt" + let glowPlist = fs.readFileSync(`${gdPath}/${glowName[0]}.plist`, 'utf8') + const sheetNames = sheetList.filter(x => !glowName.includes(x)) + const resources = fs.readdirSync(gdPath) + + /**@type {string[]}*/ + const plists = [] + const sheets = [] + const glowBackups = [] + const glowSheet = plistToJson(glowPlist) + + resources.forEach(x => { + if (x.startsWith('PlayerExplosion_') && x.endsWith('-uhd.plist')) + sheetNames.push(x.slice(0, -6)) // -6 removes ".plist", efficiently }) - - Object.keys(sizes).forEach(obj => { - let objects = sizes[obj] - if (objects.length == 1) return delete sizes[obj] - let iconMode = forms.includes(obj) - let oldNames = objects.map(x => x.name) - if (iconMode) oldNames = undupe(oldNames.map(x => x.replace(iconRegex, "$1"))) - let newNames = oldNames.shuffle() - if (iconMode) { - let iconList = {} - oldNames.forEach((x, y) => iconList[x] = newNames[y]) - newNames = iconList - } - - oldNames.forEach((x, y) => { - let newName = newNames[iconMode ? x : y] - if (iconMode) { - plist = plist.replace(new RegExp(`${obj}_${x}_`, "g"), `###${obj}_${newName}_`) - glowPlist = glowPlist.replace(`${obj}_${x}_`, `###${obj}_${newName}_`) + + sheetNames.forEach(x => { + const file = fs.readFileSync(`${gdPath}/${x}.plist`, 'utf8') + plists.push(file) + try { sheets.push(plistToJson(file)) } + catch(e) { throw `Error parsing ${x}.plist - ${e.message}` } + }) + + sheets.forEach((gameSheet, sheetNum) => { + let plist = plists[sheetNum] + let name = sheetNames[sheetNum] + if (!name.startsWith('PlayerExplosion_')) console.log("Shuffling " + name) + else if (name == "PlayerExplosion_01-uhd") console.log("Shuffling death effects") + + let sizes = {} + Object.keys(gameSheet).forEach(x => { + let obj = gameSheet[x] + obj.name = x + if (sheetNum == sheetNames.findIndex(y => y.startsWith('GJ_GameSheet02')) && forms.some(y => x.startsWith(y))) { + let form = forms.find(y => x.startsWith(y)) + if (!sizes[form]) sizes[form] = [obj] + else sizes[form].push(obj) } else { - plist = plist.replace(`${x}`, `###${newName}`) - if (glowSheet[glow(x)]) { - glowBackups.push(glow(x)) - glowPlist = glowPlist.replace(`${glow(x)}`, `###${glow(newName)}`) - } + /**@type {number}*/ + let sizeDiff = assets.sheets[name] || 30 + let size = obj.textureRect[1].map(x => Math.round(x / sizeDiff) * sizeDiff).join() + if (name.startsWith('PlayerExplosion')) size = "deatheffect" + if (!sizes[size]) sizes[size] = [obj] + else sizes[size].push(obj) } }) + + Object.keys(sizes).forEach(k => { + /**@type {{name: string}[]}*/ + const objects = sizes[k] + if (objects.length == 1) return delete sizes[k] + const iconMode = forms.includes(k) + let oldNames = objects.map(x => x.name) + if (iconMode) oldNames = undupe(oldNames.map(x => x.replace(iconRegex, "$1"))) + let newNames = shuffle(oldNames) + if (iconMode) { + let iconList = {} + oldNames.forEach((x, y) => iconList[x] = newNames[y]) + newNames = iconList + } + + oldNames.forEach((x, y) => { + let newName = newNames[iconMode ? x : y] + if (iconMode) { + plist = plist.replace(new RegExp(`${k}_${x}_`, "g"), `###${k}_${newName}_`) + glowPlist = glowPlist.replace(`${k}_${x}_`, `###${k}_${newName}_`) + } + else { + plist = plist.replace(`${x}`, `###${newName}`) + if (glowSheet[glow(x)]) { + glowBackups.push(glow(x)) + glowPlist = glowPlist.replace(`${glow(x)}`, `###${glow(newName)}`) + } + } + }) + }) + plist = plist.replace(/###/g, '') + fs.writeFileSync(wd + sheetNames[sheetNum] + '.plist', plist, 'utf8') }) - plist = plist.replace(/###/g, "") - fs.writeFileSync('./pack/' + sheetNames[sheetNum] + '.plist', plist, 'utf8') -}) -console.log("Shuffling misc textures") -let specialGrounds = [] -assets.sprites.forEach(img => { - let spriteMatch = img.split("|") - let foundTextures = resources.filter(x => x.match(new RegExp(`^${spriteMatch[0].replace("#", "\\d+?")}-uhd\\.${spriteMatch[1] || "png"}`))) + console.log("Shuffling misc textures") + /**@type {string[]}*/ + const specialGrounds = [] + assets.sprites.forEach(img => { + const spriteMatch = img.split("|") + let foundTextures = resources.filter(x => x.match(new RegExp(`^${spriteMatch[0].replace("#", "\\d+?")}-uhd\\.${spriteMatch[1] || "png"}`))) - if (spriteMatch[2] == "*") specialGrounds = specialGrounds.concat(foundTextures.map(x => x.slice(0, 15))) - if (spriteMatch[2] == "g1") foundTextures = foundTextures.filter(x => !specialGrounds.some(y => x.startsWith(y))) - if (spriteMatch[2] == "g2") foundTextures = foundTextures.filter(x => specialGrounds.some(y => x.startsWith(y))) + if (spriteMatch[2] == "*") specialGrounds.push(...foundTextures.map(x => x.slice(0, 15))) // in-place `concat` + if (spriteMatch[2] == "g1") foundTextures = foundTextures.filter(x => !specialGrounds.some(y => x.startsWith(y))) + if (spriteMatch[2] == "g2") foundTextures = foundTextures.filter(x => specialGrounds.some(y => x.startsWith(y))) - let shuffledTextures = foundTextures.shuffle() - foundTextures.forEach((x, y) => fs.copyFileSync(`${gdPath}/${x}`, `./pack/${shuffledTextures[y]}`)) -}) + let shuffledTextures = shuffle(foundTextures) + foundTextures.forEach((x, y) => fs.copyFileSync(`${gdPath}/${x}`, wd + shuffledTextures[y])) + }) -let emptyDict = glowPlist.match(/\s*aliases<\/key>(.|\n)+?<\/dict>/)[0].replace(/{\d+,\d+}/g, "{0, 0}") -let mappedBackups = glowBackups.reverse().map(x => `${x}${emptyDict}`).join("") -glowPlist = fs.writeFileSync('./pack/GJ_GameSheetGlow-uhd.plist', glowPlist.replace(/###/g, "").replace(/\s*frames<\/key>\s*/g, "$&" + mappedBackups), 'utf8') -console.log("Randomization complete!") + let emptyDict = glowPlist.match(/\s*aliases<\/key>(.|\n)+?<\/dict>/)[0].replace(/{\d+,\d+}/g, "{0, 0}") + let mappedBackups = glowBackups.reverse().map(x => `${x}${emptyDict}`).join('') + glowPlist = fs.writeFileSync(wd + 'GJ_GameSheetGlow-uhd.plist', glowPlist.replace(/###/g, "").replace(/\s*frames<\/key>\s*/g, "$&" + mappedBackups), 'utf8') + console.log("Randomization complete!") } -catch(e) { console.log(e); fs.writeFileSync('crash_log.txt', e.stack ? `Something went wrong! Send this error to Colon and he'll get around to fixing it at some point.\n\n${e.stack}` : e, 'utf8') } +catch(e) { + console.error(e); + fs.writeFileSync( + 'crash_log.txt', + e.stack ? `Something went wrong! Send this error to Colon and he'll get around to fixing it at some point.\n\n${e.stack}` : e, + 'utf8' + ) +}