UNPKG

atlast

Version:

Texture atlas generator for webgl sprite sheets

344 lines (309 loc) 13.5 kB
#!/usr/bin/env node // atlast by Spencer J. Beckwith. MIT license const fs = require(`fs`); const Jimp = require(`jimp`); const readlineSync = require(`readline-sync`); let config = {}; try { config = JSON.parse(fs.readFileSync(`${__dirname}/atlastconfig.json`)); } catch (error) { config = {}; console.log(`There was a problem loading atlastconfig.json: ${error}. It will be recreated.`); } const sprites = []; let totalImages = 0; let loadedImages = 0; let totalPixels = 0; let changedConfig = false; let time, loadTime, arrangeTime; class Sprite { constructor(fileNameArray,spriteName) { this.spriteName = spriteName; this.width = 0; this.height = 0; this.images = []; for (let f = 0; f < fileNameArray.length; f++) { totalImages++; Jimp.read(fileNameArray[f]) .then(this.pushImage.bind({ sprite: this, iteration: f })) .catch(function(error) { throw error; }); } console.log(`Loading: ${this.spriteName} (${fileNameArray.length} images)`); } pushImage(image) { //bound to object {sprite, iteration} in constructor this.sprite.images[this.iteration] = image; this.sprite.width = Math.max(this.sprite.width,image.bitmap.width); this.sprite.height = Math.max(this.sprite.height,image.bitmap.height); totalPixels += this.sprite.width*this.sprite.height; loadedImages++; if (loadedImages >= totalImages) { loadTime = Date.now()-time; arrangeAtlas(); } } getSize() { return this.width*this.height; } } function compile() { //Handle the configuration configure(false); console.log(`Loading sprite images from directory: ${config.directory}...`); console.group(); //Find all our file paths const directory = fs.opendirSync(config.directory); let dirent = directory.readSync(); time = Date.now(); while (dirent !== null) { if (dirent.isDirectory()) { //Open inner directory const innerDirectory = fs.opendirSync(`${config.directory}\\${dirent.name}`); let innerDirent = innerDirectory.readSync(); let fnArray = []; while (innerDirent !== null) { if (innerDirent.isDirectory()) { console.warn(`Too many nested directories! Folders nested inside this directory (${dirent.name}) will not be read.`); } else if (innerDirent.isFile()) { if (innerDirent.name.endsWith(`.png`)) { fnArray.push(`${config.directory}\\${dirent.name}\\${innerDirent.name}`); } } innerDirent = innerDirectory.readSync(); } if (fnArray.length > 0) { sprites.push(new Sprite(fnArray,dirent.name)); } else { console.warn(`Directory (${dirent.name}) has no images!`); } innerDirectory.closeSync(); } else if (dirent.isFile()) { if (dirent.name.endsWith(`.png`)) { let fnArray = [ `${config.directory}\\${dirent.name}` ]; sprites.push(new Sprite(fnArray,dirent.name.replace(`.png`,``))); } } dirent = directory.readSync(); } directory.closeSync(); console.groupEnd(); } function arrangeAtlas() { //Called by a sprite instance when all images are loaded by JIMP. console.log(`All sprite images loaded. Writing to the atlas...`); console.group(); //Preliminary check to see if we have enough space const maxPixels = config.atlasWidth*config.atlasHeight; console.log(`Atlas size: ${config.atlasWidth}, ${config.atlasHeight}, totalling ${maxPixels} pixels.`); console.log(`Image expected to use ${totalPixels} pixels, which is ${Math.round((totalPixels/maxPixels)*100)}% of the atlas.`); if (totalPixels > maxPixels) { throw `Atlas size is too small! Increase the size with "atlast set atlasWidth/atlasHeight <value>"`; } //Sort the sprite array: largest to smallest sprites.sort((sprite1,sprite2) => { return (sprite2.getSize() - sprite1.getSize()); }); //Do the thing time = Date.now(); new Jimp(config.atlasWidth,config.atlasHeight,(error,image) => { //Find open places for each sprite and each of its images, doing larger sprites first let placeX = 0; placeY = 0; const outputObject = []; const occupied = new Array(config.atlasWidth/config.sepW); for (let o = 0; o < occupied.length; o++) { occupied[o] = new Array(config.atlasHeight/config.sepH); occupied[o].fill(false); } const checkOccupied = function(spr) { if (spr.width <= config.sepW && spr.height <= config.sepH) { return (occupied[placeX/config.sepW][placeY/config.sepH]); } if (placeX+spr.width > config.atlasWidth || placeY+spr.height > config.atlasHeight) { return true; } for (let xx = placeX/config.sepW; xx < (placeX+spr.width)/config.sepW; xx++) { for (let yy = placeY/config.sepH; yy < (placeY+spr.height)/config.sepH; yy++) { if (occupied[xx][yy]) { return true; } } } return false; } const setOccupied = function(spr) { if (spr.width <= config.sepW && spr.height <= config.sepH) { occupied[placeX/config.sepW][placeY/config.sepH] = true; } else { for (let xx = placeX/config.sepW; xx < (placeX+spr.width)/config.sepW; xx++) { for (let yy = placeY/config.sepH; yy < (placeY+spr.height)/config.sepH; yy++) { occupied[xx][yy] = true; } } } } for (let s = 0; s < sprites.length; s++) { // For each sprite let spriteTime = Date.now(); const spriteOutput = { name: sprites[s].spriteName, width: sprites[s].width, height: sprites[s].height, images: [] } for (let i = 0; i < sprites[s].images.length; i++) { // For each image // Cycle through positions on the atlas while (checkOccupied(spriteOutput)) { if (config.verticalPlacement) { // Vertical placeY += config.sepH; if (placeY >= config.atlasHeight) { placeX += config.sepW; placeY = 0; if (placeX >= config.atlasWidth) { throw 'Atlas size too small! Increase the dimensions and try again.'; } } } else { // Horizontal placeX += config.sepW; if (placeX >= config.atlasWidth) { placeY += config.sepH; placeX = 0; if (placeY >= config.atlasHeight) { throw 'Atlas size too small! Increase the dimensions and try again.'; } } } } // If we got here, we found an unoccupied spot. image.composite(sprites[s].images[i],placeX,placeY); setOccupied(spriteOutput); spriteOutput.images.push({ x: placeX, y: placeY }); } // Record full sprite outputObject.push(spriteOutput); console.log(`${Math.round((s/sprites.length)*100)}% Composited: ${spriteOutput.name} (${Date.now()-spriteTime}ms)`) } image.write(config.outputImageName); if (!config.outputAsJS) { fs.writeFileSync(config.outputJSONName,JSON.stringify(outputObject,null,Number(config.outputWhitespace))); } else { fs.writeFileSync(config.outputJSONName,`ATLAST = `+JSON.stringify(outputObject,null,Number(config.outputWhitespace))); } console.groupEnd(); arrangeTime = Date.now()-time; console.log(`Load Time: ${loadTime}ms`); console.log(`Arrange Time: ${arrangeTime}ms`); console.log(`Atlas complete! Image output: ${config.outputImageName}, JSON output: ${config.outputJSONName}`); }); } function set(key,value) { //Update a value in our config, rewrite the config file if (key === undefined || value === undefined) { throw `Cannot update config: both a key and value must be specified.`; } config[key] = value; saveConfig(); } function saveConfig() { console.log(`Saving configuration...`); if (!config.outputWhitespace) { config.outputWhitespace = readlineSync.questionInt(`Please enter a number of spaces to use as whitespace for atlastconfig.json: `); } fs.writeFileSync(`${__dirname}/atlastconfig.json`,JSON.stringify(config,null,Number(config.outputWhitespace))); console.log(`Atlast configuration has been updated.`); } function help() { //Display help console.log(`atlast by Spencer Beckwith. Version ${require(`./package.json`).version}.`); console.log(`Not sure how to get started? There are only two commands you need:`); console.group(); console.log(`atlast config - allows you to set or reset your configuration, such as the locations of your images.`); console.log(`atlast - compiles your texture atlas from the specified configuration.`); console.groupEnd(); } function configure(dontCheck) { if (config.directory === undefined || dontCheck) { const old = config.directory; config.directory = readlineSync.question(`Please enter the directory containing all images you wish to compile (${(config.directory || "")}): `); if (config.directory === '') { config.directory = old; } changedConfig = true; } if (config.atlasWidth === undefined || dontCheck) { config.atlasWidth = readlineSync.questionInt(`Please enter a width for the texture atlas (${config.atlasWidth}): `); changedConfig = true; } if (config.atlasHeight === undefined || dontCheck) { config.atlasHeight = readlineSync.questionInt(`Please enter a height for the texture atlas (${config.atlasHeight}): `); changedConfig = true; } if (config.sepW === undefined || dontCheck) { config.sepW = readlineSync.questionInt(`Enter a width for the placement grid (${config.sepW}): `); changedConfig = true; } if (config.sepH === undefined || dontCheck) { config.sepH = readlineSync.questionInt(`Enter a height for the placement grid (${config.sepH}): `); changedConfig = true; } if (config.verticalPlacement === undefined || dontCheck) { config.verticalPlacement = readlineSync.keyInYN(`Do you want images to be placed vertically instead of horizontally? `); changedConfig = true; } if (config.outputImageName === undefined || dontCheck) { const old = config.outputImageName; config.outputImageName = readlineSync.question(`Please enter an output image filename, including extension (${config.outputImageName}): `); if (config.outputImageName === '') { config.outputImageName = old; } changedConfig = true; } if (config.outputJSONName === undefined || dontCheck) { const old = config.outputJSONName; config.outputJSONName = readlineSync.question(`Please enter an output JavaScript or JSON filename, including extension (${config.outputJSONName}): `); if (config.outputJSONName === '') { config.outputJSONName = old; } changedConfig = true; } if (config.outputAsJS === undefined || dontCheck) { config.outputAsJS = readlineSync.keyInYN(`Would you like to export as a JavaScript file instead of JSON? `); changedConfig = true; } //More config options would go here. if (changedConfig) { if (dontCheck || readlineSync.keyInYN(`Configuration has changed. Would you like to save it for later use? `)) { saveConfig(); } } } const args = process.argv.slice(2); switch (args[0]) { case (`set`): { set(args[1],args[2]); break; } case (`help`): { help(); break; } case (`config`): { configure(true); break; } //More commands here default: { if (args[0] === undefined) { compile(); } else { console.error(`Invalid atlast argument: ${args[0]}`); } break; } }