$(document).ready(function() { // todo define things like 16x16, 128x128 etc. as constants? // also script debounce/throttle times let animationTime = 400; // defined in bitsy.js let bitsyData = {}; let palette = { id: 0, background: { red: 62, green: 43, blue: 32 }, tile: { red: 208, green: 112, blue: 56 }, sprite: { red: 229, green: 92, blue: 68 } }; let room = []; let tileMatchThreshold = 64; let croptions = { url: 'https://i.imgur.com/ThQZ94v.jpg', viewport: {width: 128, height: 128, type: 'square'}, boundary: {width: 256, height: 256}, zoom: 0 }; let $croppie = $('#croppie'); $croppie.croppie(croptions); function colourDifference(colour1, colour2) { let difference = {}; _.each(['red', 'green', 'blue'], function(key) { difference[key] = Math.abs(colour1[key] - colour2[key]); }); return _.toInteger(_.sum(_.toArray(difference))); } function zeroPad(input, desiredLength) { while (input.length < desiredLength) { input = "0" + input; } return input; } function colourToHex(colour) { return '#' + zeroPad(Number(colour.red ).toString(16), 2) + zeroPad(Number(colour.green).toString(16), 2) + zeroPad(Number(colour.blue ).toString(16), 2); } function hexToColour(hex) { let rgb = hex.match(/[\da-f]{2}/gi); return { red: parseInt(rgb[0], 16), green: parseInt(rgb[1], 16), blue: parseInt(rgb[2], 16) }; } function getClosestColour(initialColour, colourOptions) { // ditch sprite colour as we're not using it atm delete colourOptions.sprite; _.each(palette, function(colour, name) { colourOptions[name].name = name; colourOptions[name].difference = colourDifference(initialColour, colour); }); // lowest difference (closest) wins return _.first(_.sortBy(colourOptions, 'difference')); } function newTileName() { let tileNames = _.map(bitsyData.tiles, 'name'); let i = 1; // start with 1 as 0 is an implicit tile while (tileNames.indexOf(i.toString(36)) > -1) { i++; } // base 36 = 0-9a-z return i.toString(36); } function handleBitsyGameData() { let input = $('#bitsy-data').val(); if ( ! input) { return; } bitsyData = {}; // get palettes let palettes = input.match(/PAL ([^\n]*)\n(NAME ([^\n]*)\n)?(([0-9,]+){3}\n){3,}/g); bitsyData.palettes = {}; // do palettes always go 0..n? // will this cause problems if not? _.each(palettes, function(palette, n) { let name = ""; if (palette.match(/NAME (.+)\n/)) { name = palette.match(/NAME (.+)\n/)[0].replace('NAME ', ''); } else if (palette.match(/PAL (\d+)\n/)) { name = palette.match(/PAL (\d+)\n/)[0].replace("PAL", "palette"); } let colours = palette.match(/\d+,\d+,\d+/g); colours = _.map(colours, function(colour) { let rgb = colour.split(','); return {red: rgb[0], green: rgb[1], blue: rgb[2]}; }); bitsyData.palettes[name] = { id: n, background: colours[0], tile: colours[1], sprite: colours[2] } }); // get tiles bitsyData.tiles = []; // tile 0 (background colour only) is implicit in bitsy rather than being stored in the game data // so, make our own version bitsyData.tiles.push({ name: "0", bitmap: _.chunk(_.times(64, _.constant(0)), 8), new: false // this could also be used to stop it from being added to the game data, wooo }); // everything after > is an optional second animation frame // todo: handle multiple animation frames! more than 2 are allowed (but not via the standard editor) let tiles = input.match(/TIL (.*)\n([01]{8}\n){8}(>\n([01]{8}\n){8})?/g); _.each(tiles, function(tile) { let name = tile.match(/TIL .*/)[0].replace('TIL ', ''); tile = tile.replace(/TIL .*\n/, ''); let bitmap = _.map(tile.match(/[01]/g), _.toInteger); let newTile = { name: name, new: false }; // todo make this agnostic? i.e. tile.frames = _.chunk(bitmap, 64) if (bitmap.length === 64) { // normal tile newTile.bitmap = _.chunk(bitmap, 8); } else if (bitmap.length === 128) { // animated tile newTile.bitmap = _.chunk(_.take( bitmap, 64), 8); newTile.secondAnimationFrame = _.chunk(_.takeRight(bitmap, 64), 8); } bitsyData.tiles.push(newTile); }); if (_.find(bitsyData.palettes, {'id': palette.id})) { // user has already selected a palette, leave it be // in case this is the first run: palette = _.find(bitsyData.palettes, {'id': palette.id}) // if we just set the palette to the newly imported palette with the same ID, // we will lose any changes the user has made to the palettes // is this a big issue considering that the palettes cannot be currently saved anyway? } else { // set palette to first imported palette and redraw palette = _.first(_.sortBy(bitsyData.palettes, 'id')); } renderDebounced(); // update palette picker $('tr.palette').remove(); _.each(bitsyData.palettes, function(palette, name) { $('#palette tbody').append( '' + '' + '' + '' + '' + '' + '' + '' + '' + '' ); }); $('input[name="id"][value="' + palette.id + '"]').siblings(':radio').trigger('click'); } function readFile(input, callback) { if (input.files && input.files[0]) { let reader = new FileReader(); reader.onload = callback; reader.readAsDataURL(input.files[0]); } } function readTextFile(input, callback) { if (input.files && input.files[0]) { let reader = new FileReader(); reader.onload = callback; reader.readAsText(input.files[0]); } } function render() { $croppie.croppie('result', { type: 'rawcanvas', size: 'viewport' }).then(function (result) { let imageData = result.getContext('2d').getImageData(0, 0, 128, 128); let rawData = imageData.data; let monochrome = []; let brightnessAdjustment = parseFloat($('#brightness').val()); // for each pixel for (let i = 0; i < rawData.length; i += 4) { // this brightness adjustment is pretty crude but whatever let pixel = { red: _.clamp(rawData[i ] + brightnessAdjustment, 0, 255), green: _.clamp(rawData[i + 1] + brightnessAdjustment, 0, 255), blue: _.clamp(rawData[i + 2] + brightnessAdjustment, 0, 255) }; let targetColour = getClosestColour(pixel, palette); if (targetColour.name === "background") { monochrome.push(0); } else { // tile monochrome.push(1) } rawData[i ] = targetColour.red; rawData[i + 1] = targetColour.green; rawData[i + 2] = targetColour.blue; rawData[i + 3] = 255; // alpha } // split monochrome bitmap into equal chunks for easier x:y access monochrome = _.chunk(monochrome, 128); document.getElementById('preview').getContext('2d').putImageData(imageData, 0, 0); // tiled output room = []; _.times(16, function(tileY) { _.times(16, function(tileX) { // make pseudo-tile from monochrome bitmap let pseudoTile = []; _.times(8, function(y) { pseudoTile.push( _.slice(monochrome[(tileY * 8) + y], (tileX * 8), (tileX * 8) + 8) ); }); let bestMatch; // if we want to always create new tiles, don't bother trying to check matches if (tileMatchThreshold === 64) { // even if we want to "always create new tiles" we still don't want to create duplicates bestMatch = _.find(bitsyData.tiles, function(tile) { return _.isEqual(tile.bitmap, pseudoTile); }); if (bestMatch) { bestMatch.match = 64; } } else { _.each(bitsyData.tiles, function(tile) { tile.match = 0; _.each(tile.bitmap, function(row, y) { _.each(row, function(pixel, x) { if (parseInt(pixel) === parseInt(pseudoTile[y][x])) { tile.match++; } }); }); if (tile.secondAnimationFrame) { _.each(tile.secondAnimationFrame, function(row, y) { _.each(row, function(pixel, x) { if (parseInt(pixel) === parseInt(pseudoTile[y][x])) { tile.match++; } }); }); tile.match /= 2; } }); // what if there are several equally good matches? // find highest match amount and find all of them let bestMatchAmount = _.last(_.sortBy(bitsyData.tiles, ['match'])).match; let bestMatches = _.filter(bitsyData.tiles, {'match': bestMatchAmount}); // sort by name in ascending order // earlier names are preferable bestMatch = _.first(_.sortBy(bestMatches, 'name')); } if ( ! bestMatch || bestMatch.match < tileMatchThreshold) { // turn pseudo-tile into a real tile and add it to the tile data let name = newTileName(); bitsyData.tiles.push({ name: name, bitmap: pseudoTile, new: true }); room.push(name); // issue with this approach: // what if a tile we add late in the loop is a better match for an earlier "good enough" match? // this would also cause different results if the user were to add the same room several times // we could keep iterating until the room no longer changes } else { room.push(bestMatch.name); } }); }); room = _.chunk(room, 16); // write room to output imageData = document.getElementById("room-output").getContext('2d').getImageData(0, 0, 128, 128); rawData = imageData.data; _.each(room, function(row, tileY) { _.each(row, function(tileName, tileX) { let tile = _.find(bitsyData.tiles, {'name' : tileName}); _.each(tile.bitmap, function(row, y) { _.each(row, function(pixel, x) { let position = (((tileY * 8) + y) * 128) + ((tileX * 8) + x); position *= 4; // 4 values (rgba) per pixel let pixelColour = {}; switch(parseInt(pixel)) { case 0: pixelColour = palette.background; break; case 1: pixelColour = palette.tile; break; default: console.log("error"); } rawData[position ] = pixelColour.red; rawData[position + 1] = pixelColour.green; rawData[position + 2] = pixelColour.blue; rawData[position + 3] = 255; }); }); }); }); document.getElementById('room-output').getContext('2d').putImageData(imageData, 0, 0); }); } let renderDebounced = _.debounce(render, 30); let renderThrottled = _.throttle(render, 30); $croppie.on('update', renderDebounced); let $brightness = $('#brightness'); $brightness.on('change', renderThrottled); $brightness.on('dblclick', function() { $(this).val(0); renderDebounced(); }); $('label[for="brightness"]').on('click touchdown', function() { $('#brightness').trigger('dblclick'); }); let $bitsyData = $('#bitsy-data'); $bitsyData.on('change blur keyup', handleBitsyGameData); $bitsyData.on('focus', function() { $(this).select(); }); handleBitsyGameData(); $('#imageUpload').on('change', function () { readFile(this, function (e) { $croppie.croppie('bind', { url: e.target.result, zoom: 0 }); }); }); $('input.game-data').on('change', function() { readTextFile(this, function (e) { $bitsyData.val(e.target.result); handleBitsyGameData(); }); }); // these inputs get added and removed from the DOM so the event handler needs to be on the document $(document).on('change', '#palette input', function() { let id = parseInt($(this).closest('.palette').find('input[name="id"]').val()); // if this is a colour input, update the palette if ($(this).attr('type') === 'color') { if (id === palette.id) { palette[$(this).attr('name')] = hexToColour($(this).val()); } } // if this is a radio button, pick this palette if ($(this).attr('type') === 'radio') { palette.id = id; palette.background = hexToColour($(this).closest('.palette').find('input[name="background"]').val()); palette.tile = hexToColour($(this).closest('.palette').find('input[name="tile"]' ).val()); // sprite colour is not currently used } renderDebounced(); }); $(document).on('change', '#threshold', function() { let newValue = parseInt($(this).val()); if (newValue < tileMatchThreshold) { // set tiles back to default bitsyData.tiles = _.filter(bitsyData.tiles, ['new', false]); } tileMatchThreshold = newValue; renderThrottled(); }); $('#never').on('click touchend', function() { $('#threshold').val(0).change(); }); $('#always').on('click touchend', function() { $('#threshold').val(64).change(); }); $('#save').on('click touchend', function() { $textArea = $('textarea'); let newGameData = $textArea.val(); // handle rooms // need to import IDs so we don't give the new room a conflicting ID let roomIds = newGameData.match(/ROOM \d+\n/g); roomIds = _.map(roomIds, function(roomId) { return parseInt(roomId.replace(/[^\d]+/g, "")); }); let newRoomId = _.max(roomIds) + 1; let newRoomName = $('#roomName').val(); // remove invalid chars? what's invalid? newlines? are those possible? let newRoom = "ROOM " + newRoomId + "\n"; _.each(room, function(row) { newRoom += _.toString(row) + "\n"; }); if (newRoomName) { newRoom += "NAME " + newRoomName + "\n"; } newRoom += "PAL " + palette.id + "\n"; newGameData = newGameData.replace(/(ROOM .*\n(.*\n)*PAL .*)/g, '$1\n\n' + newRoom); // handle tiles let newTiles = _.filter(bitsyData.tiles, 'new'); let tileText = ""; _.each(newTiles, function(tile, n) { tileText += "TIL " + tile.name + "\n"; //again, rename tile name to id... _.each(tile.bitmap, function(row) { tileText += row.join('') + "\n"; }); tileText += "NAME " + newRoomName + " " + (n + 1) + "\n"; // don't need to worry about animation right now tileText += "\n"; }); newGameData = newGameData.replace(/(TIL.*(.*\n)*)SPR/g, '$1\n\n' + tileText + 'SPR'); // write $textArea.val(newGameData); handleBitsyGameData(); // todo: give the user some nice "yay! it worked!" kinda feedback? }); });