pixsy/includes/script.js

552 lines
18 KiB
JavaScript

$(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(
'<tr class="palette">'
+ '<td>'
+ '<input type="radio" name="palette" id="palette-' + name + '">'
+ '<input type="hidden" name="id" value="' + palette.id + '">'
+ '</td>'
+ '<td><label for="palette-' + name + '">' + name + '</label></td>'
+ '<td><input type="color" name="background" value="' + colourToHex(palette.background) + '"></td>'
+ '<td><input type="color" name="tile" value="' + colourToHex(palette.tile) + '"></td>'
+ '<td><input type="color" name="sprite" value="' + colourToHex(palette.sprite) + '" disabled></td>'
+ '</tr>'
);
});
$('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?
});
});