625 lines
15 KiB
HTML
625 lines
15 KiB
HTML
<!--
|
|
Author: bitluni 2019
|
|
License:
|
|
Creative Commons Attribution ShareAlike 4.0
|
|
https://creativecommons.org/licenses/by-sa/4.0/
|
|
|
|
For further details check out:
|
|
https://youtube.com/bitlunislab
|
|
https://github.com/bitluni
|
|
http://bitluni.net
|
|
-->
|
|
<head>
|
|
<title>bitluni's Sprite Editor</title>
|
|
<script>
|
|
|
|
var project = {
|
|
name: "sprites",
|
|
zoom: 4,
|
|
sprites: []
|
|
};
|
|
|
|
function objFromId(id)
|
|
{
|
|
for(var i = 0; i < project.sprites.length; i++)
|
|
if(project.sprites[i].id === id)
|
|
return project.sprites[i];
|
|
return null;
|
|
}
|
|
|
|
function upSprite(id)
|
|
{
|
|
var sprites = project.sprites;
|
|
for(var i = 1; i < sprites.length; i++)
|
|
if(sprites[i].id === id)
|
|
{
|
|
var w = sprites[i - 1];
|
|
sprites[i - 1] = sprites[i];
|
|
sprites[i] = w;
|
|
update();
|
|
return;
|
|
}
|
|
}
|
|
|
|
function downSprite(id)
|
|
{
|
|
var sprites = project.sprites;
|
|
for(var i = 0; i < sprites.length - 1; i++)
|
|
if(sprites[i].id === id)
|
|
{
|
|
var w = sprites[i + 1];
|
|
sprites[i + 1] = sprites[i];
|
|
sprites[i] = w;
|
|
update();
|
|
return;
|
|
}
|
|
}
|
|
|
|
function deleteSprite(id)
|
|
{
|
|
var sprites = project.sprites;
|
|
for(var i = 0; i < sprites.length; i++)
|
|
if(sprites[i].id === id)
|
|
{
|
|
sprites.splice(i, 1);
|
|
update();
|
|
return;
|
|
}
|
|
}
|
|
|
|
function newButton(text, cb)
|
|
{
|
|
var b = document.createElement("button");
|
|
b.innerHTML = text;
|
|
b.onclick = cb;
|
|
return b;
|
|
}
|
|
|
|
function checker(ctx, xres, yres)
|
|
{
|
|
for(var y = 0; y < yres; y++)
|
|
for(var x = 0; x < xres; x++)
|
|
{
|
|
ctx.fillStyle = (x + y) & 1 ? "#eeeeee" : "#ffffff";
|
|
ctx.fillRect(x * 10, y * 10, 10, 10);
|
|
}
|
|
}
|
|
|
|
function draw(obj)
|
|
{
|
|
checker(obj.ctx, obj.xres, obj.yres);
|
|
var img = obj.ctx.getImageData(0, 0, obj.xres, obj.yres);
|
|
var p = 0;
|
|
for(var y = 0; y < obj.yres; y++)
|
|
for(var x = 0; x < obj.xres; x++)
|
|
{
|
|
var a = obj.source[p + 3];
|
|
var ra = 255 - a;
|
|
img.data[p + 0] = Math.round((img.data[p + 0] * ra + obj.source[p + 0] * a) / 255.);
|
|
img.data[p + 1] = Math.round((img.data[p + 1] * ra + obj.source[p + 1] * a) / 255.);
|
|
img.data[p + 2] = Math.round((img.data[p + 2] * ra + obj.source[p + 2] * a) / 255.);
|
|
img.data[p + 3] = 255;
|
|
p += 4;
|
|
}
|
|
obj.ctx.putImageData(img, 0, 0);
|
|
obj.ctx.fillStyle = "rgba(0, 255, 0, 0.7)";
|
|
for(var i = 0; i < obj.points.length; i++)
|
|
{
|
|
var p = obj.points[i];
|
|
obj.ctx.fillRect(p[0] - 3, p[1], 7, 1);
|
|
obj.ctx.fillRect(p[0], p[1] - 3, 1, 7);
|
|
}
|
|
}
|
|
|
|
function getXY(obj, e)
|
|
{
|
|
var r = obj.canvas.getBoundingClientRect();
|
|
return [Math.floor(e.offsetX / project.zoom), Math.floor(e.offsetY / project.zoom)];
|
|
}
|
|
|
|
function canvasClick(obj, e)
|
|
{
|
|
var pos = getXY(obj, e);
|
|
obj.points.push(pos);
|
|
draw(obj);
|
|
updatePoints(obj);
|
|
}
|
|
|
|
function canvasMove(obj, e)
|
|
{
|
|
var pos = getXY(obj, e);
|
|
draw(obj);
|
|
obj.ctx.fillStyle = "rgba(255, 0, 0, 0.7)";
|
|
obj.ctx.fillRect(0, pos[1], pos[0], 1);
|
|
obj.ctx.fillRect(pos[0] + 1, pos[1], obj.xres - pos[0] - 1, 1);
|
|
obj.ctx.fillRect(pos[0], 0, 1, pos[1]);
|
|
obj.ctx.fillRect(pos[0], pos[1] + 1, 1, obj.yres - pos[1] - 1);
|
|
}
|
|
|
|
function canvasOut(obj)
|
|
{
|
|
draw(obj);
|
|
}
|
|
|
|
function createCanvas(obj)
|
|
{
|
|
var canvas = obj.canvas = document.createElement('canvas');
|
|
var ctx = obj.ctx = canvas.getContext('2d');
|
|
canvas.width = obj.xres;
|
|
canvas.height = obj.yres;
|
|
canvas.addEventListener("mousedown", function(e){ canvasClick(obj, e); }, false);
|
|
canvas.addEventListener("mouseout", function(){ canvasOut(obj); }, false);
|
|
canvas.addEventListener("mousemove", function(e){ canvasMove(obj, e); }, false);
|
|
}
|
|
|
|
function addFile(file, id)
|
|
{
|
|
var obj = {"id": id, "name": file.name, "type": file.type, "xres": 0, "yres": 0, "points":[]};
|
|
project.sprites.push(obj);
|
|
var reader = new FileReader();
|
|
reader.onload = function()
|
|
{
|
|
var img = document.createElement('img');
|
|
img.onload = function()
|
|
{
|
|
obj.xres = img.width;
|
|
obj.yres = img.height;
|
|
createCanvas(obj);
|
|
obj.ctx.drawImage(img, 0, 0);
|
|
obj.source = Array.from(obj.ctx.getImageData(0, 0, obj.xres, obj.yres).data);
|
|
obj.points.push([Math.floor(obj.xres * 0.5), Math.floor(obj.yres * 0.5)]);
|
|
update();
|
|
};
|
|
img.src = reader.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function addFiles(event)
|
|
{
|
|
for(var i = 0; i < event.target.files.length; i++)
|
|
{
|
|
var file = event.target.files[i];
|
|
addFile(file, "f" + file.name.replace(/[^A-Z0-9]/ig, "_") + Math.floor(Math.random() * 1000000))
|
|
}
|
|
event.target.value = "";
|
|
}
|
|
|
|
function spritesToHeader(pixelformat)
|
|
{
|
|
var name = project.name;
|
|
var sprites = project.sprites;
|
|
var offsets = [0];
|
|
var pointOffsets = [0];
|
|
var rf, rshift, gf, gshift, bf, bshift, af, ashift, type, bytesPerPixel;
|
|
switch(pixelformat)
|
|
{
|
|
case "R2G2B2A2":
|
|
{
|
|
rf = gf = bf = af = 3.;
|
|
rshift = 0;
|
|
gshift = 2;
|
|
bshift = 4;
|
|
ashift = 6;
|
|
type = "unsigned char";
|
|
bytesPerPixel = 1;
|
|
break;
|
|
}
|
|
case "R4G4B4A4":
|
|
{
|
|
rf = gf = bf = af = 15.;
|
|
rshift = 0;
|
|
gshift = 4;
|
|
bshift = 8;
|
|
ashift = 12;
|
|
type = "unsigned short";
|
|
bytesPerPixel = 2;
|
|
break;
|
|
}
|
|
case "R5G5B4A2":
|
|
{
|
|
rf = gf = 31.;
|
|
bf = 15.;
|
|
af = 3.;
|
|
rshift = 0;
|
|
gshift = 5;
|
|
bshift = 10;
|
|
ashift = 14;
|
|
type = "unsigned short";
|
|
bytesPerPixel = 2;
|
|
break;
|
|
}
|
|
default:
|
|
alert("Unsupported pixel format " + pixelformat);
|
|
return;
|
|
}
|
|
|
|
for(var i = 0; i < sprites.length; i++)
|
|
{
|
|
offsets.push(offsets[i] + (sprites[i].source.length / 4) * bytesPerPixel);
|
|
pointOffsets.push(pointOffsets[i] + sprites[i].points.length);
|
|
}
|
|
var text = "const int " + name + "Offsets[] = {"
|
|
for(var i = 0; i < offsets.length; i++)
|
|
text += offsets[i] + ", ";
|
|
text += "};\r\n";
|
|
text += "const short " + name + "PointOffsets[] = {"
|
|
for(var i = 0; i < pointOffsets.length; i++)
|
|
text += pointOffsets[i] + ", ";
|
|
text += "};\r\n";
|
|
|
|
text += "const unsigned short " + name + "Res[][2] = {"
|
|
for(var i = 0; i < sprites.length; i++)
|
|
{
|
|
text += "{" + sprites[i].xres + ", " + sprites[i].yres + "}, ";
|
|
}
|
|
text += "};\r\n";
|
|
|
|
text += "const signed short " + name + "Points[][2] = {"
|
|
for(var i = 0; i < sprites.length; i++)
|
|
for(var j = 0; j < sprites[i].points.length; j++)
|
|
text += "{" + sprites[i].points[j][0] + ", " + sprites[i].points[j][1] + "}, ";
|
|
text += "};\r\n";
|
|
|
|
var k = 0;
|
|
text += "const " + type + " " + name + "Pixels[] = {"
|
|
for(var i = 0; i < sprites.length; i++)
|
|
{
|
|
if((i & 63) == 0) text += "\r\n";
|
|
var s = sprites[i].source;
|
|
for(var j = 0; j < s.length; j+=4)
|
|
{
|
|
var r = s[j + 0];
|
|
var g = s[j + 1];
|
|
var b = s[j + 2];
|
|
var a = s[j + 3];
|
|
var c = 0;
|
|
c = (Math.round(r / 255. * rf) << rshift) + (Math.round(g / 255. * gf) << gshift) + (Math.round(b / 255. * bf) << bshift) + (Math.round(a / 255. * af) << ashift);
|
|
text += c + ", ";
|
|
k++;
|
|
if((k & 31) == 0)
|
|
text += "\n";
|
|
}
|
|
}
|
|
text += "};\r\n";
|
|
text += "Sprites " + name + "(" + sprites.length + ", " + name + "Pixels, " + name + "Offsets, " + name + "Res, " + name + "Points, " + name + "PointOffsets, Sprites::PixelFormat::" + pixelformat + ");\r\n";
|
|
return text;
|
|
}
|
|
|
|
function cropSprite(obj)
|
|
{
|
|
var bx = [obj.xres, 0];
|
|
var by = [obj.yres, 0];
|
|
var p = 3;
|
|
for(var y = 0; y < obj.yres; y++)
|
|
for(var x = 0; x < obj.xres; x++)
|
|
{
|
|
if(obj.source[p] > 0)
|
|
{
|
|
bx[0] = Math.min(bx[0], x);
|
|
bx[1] = Math.max(bx[1], x);
|
|
by[0] = Math.min(by[0], y);
|
|
by[1] = Math.max(by[1], y);
|
|
}
|
|
p += 4;
|
|
}
|
|
if(bx[0] > bx[1])
|
|
{
|
|
bx = [0, 0];
|
|
by = [0, 0];
|
|
}
|
|
var xres = bx[1] - bx[0] + 1;
|
|
var yres = by[1] - by[0] + 1;
|
|
var source = new Array(xres * yres * 4);
|
|
var p = 0;
|
|
for(var y = by[0]; y <= by[1]; y++)
|
|
for(var x = bx[0]; x <= bx[1]; x++)
|
|
{
|
|
var p0 = (y * obj.xres + x) * 4;
|
|
source[p++] = obj.source[p0++];
|
|
source[p++] = obj.source[p0++];
|
|
source[p++] = obj.source[p0++];
|
|
source[p++] = obj.source[p0++];
|
|
}
|
|
for(var i = 0; i < obj.points.length; i++)
|
|
{
|
|
obj.points[i][0] -= bx[0];
|
|
obj.points[i][1] -= by[0];
|
|
}
|
|
obj.source = source;
|
|
obj.xres = xres;
|
|
obj.yres = yres;
|
|
obj.canvas.width = xres;
|
|
obj.canvas.height = yres;
|
|
}
|
|
|
|
function crop()
|
|
{
|
|
for(var i = 0; i < project.sprites.length; i++)
|
|
cropSprite(project.sprites[i]);
|
|
update();
|
|
}
|
|
|
|
function getMeta()
|
|
{
|
|
var text = "";
|
|
for(var i = 0; i < project.sprites.length; i++)
|
|
text += i + ", " + project.sprites[i].name + "\r\n";
|
|
return text;
|
|
}
|
|
|
|
function deletePoint(id, i)
|
|
{
|
|
var obj = objFromId(id);
|
|
obj.points.splice(i, 1);
|
|
draw(obj);
|
|
updatePoints(obj);
|
|
}
|
|
|
|
function changePointX(id, i, value)
|
|
{
|
|
var obj = objFromId(id);
|
|
obj.points[i][0] = value;
|
|
draw(obj);
|
|
}
|
|
|
|
function changePointY(id, i, value)
|
|
{
|
|
var obj = objFromId(id);
|
|
obj.points[i][1] = value;
|
|
draw(obj);
|
|
}
|
|
|
|
function addPointItem(parent, id, i, point)
|
|
{
|
|
var pointElement = document.createElement("div");
|
|
pointElement.className = "point";
|
|
var coord = document.createElement("span");
|
|
coord.innerHTML =
|
|
'<input type="number" value="' + point[0] + '" style="width: 50px" onchange="changePointX(\'' + id + '\', ' + i + ', this.value)">' +
|
|
'<input type="number" value="' + point[1] + '" style="width: 50px" onchange="changePointY(\'' + id + '\', ' + i + ', this.value)">';
|
|
pointElement.appendChild(coord);
|
|
pointElement.appendChild(newButton("✘", function(){ deletePoint(id, i)}));
|
|
parent.appendChild(pointElement);
|
|
}
|
|
|
|
function updatePoints(obj)
|
|
{
|
|
var pointList = document.querySelector("#" + obj.id + " .points");
|
|
pointList.innerHTML = "";
|
|
for(var j = 0; j < obj.points.length; j++)
|
|
addPointItem(pointList, obj.id, j, obj.points[j]);
|
|
}
|
|
|
|
function addListItem(i, id, name, canvas)
|
|
{
|
|
var spriteDiv = document.createElement("div");
|
|
spriteDiv.id = id;
|
|
var index = document.createElement("span");
|
|
index.className = "num";
|
|
index.innerHTML = i;
|
|
spriteDiv.appendChild(index);
|
|
spriteDiv.appendChild(canvas);
|
|
canvas.style.zoom = project.zoom;
|
|
spriteDiv.appendChild(newButton("✘", function(){ deleteSprite(id)}));
|
|
spriteDiv.appendChild(newButton("⬇", function(){ downSprite(id)}));
|
|
spriteDiv.appendChild(newButton("⬆", function(){ upSprite(id)}));
|
|
var points = document.createElement("span");
|
|
points.className = "points block right";
|
|
spriteDiv.appendChild(points);
|
|
var span = document.createElement("span");
|
|
span.className = "right";
|
|
span.innerHTML = name;
|
|
spriteDiv.appendChild(span);
|
|
document.getElementById("sprites").appendChild(spriteDiv);
|
|
}
|
|
|
|
function loadProject(event)
|
|
{
|
|
var reader = new FileReader();
|
|
reader.onload = function(e){
|
|
project = JSON.parse(e.target.result);
|
|
document.getElementById("name").value = project.name;
|
|
document.getElementById("zoom").value = project.zoom;
|
|
for(var i = 0; i < project.sprites.length; i++)
|
|
createCanvas(project.sprites[i]);
|
|
update();
|
|
};
|
|
reader.readAsText(event.target.files[0]);
|
|
document.getElementById("files").className = "hidden";
|
|
document.getElementById("filearea").innerHTML = "";
|
|
}
|
|
|
|
function saveProject()
|
|
{
|
|
document.getElementById("filearea").innerHTML = "";
|
|
var fileArea = document.getElementById("filearea");
|
|
var file = document.createElement("a");
|
|
file.className = "block file";
|
|
file.download = file.innerHTML = document.getElementById("name").value + ".json";
|
|
for(var i = 0; i < project.sprites.length; i++)
|
|
if(project.sprites[i].bs)
|
|
delete project.sprites[i].bs;
|
|
file.href = URL.createObjectURL(new Blob([JSON.stringify(project)], {type: "application/json"}));
|
|
fileArea.appendChild(file);
|
|
document.getElementById("files").className = "menu";
|
|
}
|
|
|
|
function saveHeader()
|
|
{
|
|
document.getElementById("filearea").innerHTML = "";
|
|
var fileArea = document.getElementById("filearea");
|
|
var file = document.createElement("a");
|
|
var meta = document.createElement("a");
|
|
meta.className = file.className = "block file";
|
|
file.download = file.innerHTML = project.name + ".h";
|
|
meta.download = meta.innerHTML = project.name + ".txt";
|
|
var e = document.getElementById("pixelformat");
|
|
var pixelformat = e.options[e.selectedIndex].value;
|
|
file.href = URL.createObjectURL(new Blob([spritesToHeader(pixelformat)], {type: "text/plain"}));
|
|
meta.href = URL.createObjectURL(new Blob([getMeta()], {type: "text/plain"}));
|
|
fileArea.appendChild(file);
|
|
fileArea.appendChild(meta);
|
|
document.getElementById("files").className = "menu";
|
|
}
|
|
|
|
function update()
|
|
{
|
|
for(var i = 0; i < project.sprites.length; i++)
|
|
if(!project.sprites[i].canvas)
|
|
return;
|
|
project.name = document.getElementById("name").value;
|
|
project.zoom = document.getElementById("zoom").value;
|
|
|
|
document.getElementById("sprites").innerHTML = "";
|
|
for(var i = 0; i < project.sprites.length; i++)
|
|
{
|
|
addListItem(i, project.sprites[i].id, project.sprites[i].name, project.sprites[i].canvas);
|
|
updatePoints(project.sprites[i]);
|
|
draw(project.sprites[i]);
|
|
}
|
|
document.getElementById("files").className = "hidden";
|
|
document.getElementById("filearea").innerHTML = "";
|
|
}
|
|
|
|
</script>
|
|
<style>
|
|
#sprites
|
|
{
|
|
}
|
|
.num
|
|
{
|
|
width: 20px;
|
|
display: inline-block;
|
|
}
|
|
.option
|
|
{
|
|
margin: 3px;
|
|
}
|
|
.options
|
|
{
|
|
background-color: #aeaeee;
|
|
}
|
|
.spritefiles
|
|
{
|
|
background-color: #aeeeee;
|
|
}
|
|
.menu
|
|
{
|
|
background-color: #eeeeee;
|
|
padding: 1px;
|
|
display: block;
|
|
margin: 3px;
|
|
padding: 5px;
|
|
border-radius: 3px;
|
|
}
|
|
#sprites > div
|
|
{
|
|
margin: 2px;
|
|
border-radius: 3px;
|
|
background-color: #aeeeae;
|
|
padding: 10px;
|
|
overflow: hidden;
|
|
}
|
|
#sprites > div > *
|
|
{
|
|
margin-right: 5px;
|
|
vertical-align: top;
|
|
}
|
|
#sprites button
|
|
{
|
|
float: right;
|
|
}
|
|
.right
|
|
{
|
|
float: right;
|
|
}
|
|
h1
|
|
{
|
|
color: white;
|
|
background: #800000;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
}
|
|
.block
|
|
{
|
|
padding: 5px;
|
|
display: inline-block;
|
|
border-radius: 3px;
|
|
}
|
|
.project
|
|
{
|
|
background-color: #eeeeae;
|
|
}
|
|
.hidden
|
|
{
|
|
display: none;
|
|
}
|
|
.file
|
|
{
|
|
background-color: #ffdddd;
|
|
margin-right: 5px;
|
|
}
|
|
h1
|
|
{
|
|
margin-bottom: 5px;
|
|
}
|
|
canvas
|
|
{
|
|
cursor: none;
|
|
image-rendering: pixelated;
|
|
}
|
|
.points
|
|
{
|
|
background-color: #eeddcc;
|
|
}
|
|
.point
|
|
{
|
|
background-color: #ccddee;
|
|
margin: 2px;
|
|
border-radius: 3px;
|
|
padding: 10px;
|
|
overflow: hidden;
|
|
}
|
|
.point input
|
|
{
|
|
height: 23px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body style="font-family: arial">
|
|
<h1>bitluni's Sprite Editor</h1>
|
|
<div style="max-width: 800px">
|
|
<div class="menu">
|
|
<span class="project block">
|
|
<button title="Open project file" onclick="document.getElementById('openproject').click()">📂</button>
|
|
<input id="openproject" type="file" onchange="loadProject(event)" accept=".json" hidden>
|
|
<button title="Generate project file" onclick="saveProject();">💾</button>
|
|
</span>
|
|
<span class="block spritefiles">
|
|
<button title="Add image files" onclick="document.getElementById('addimages').click()">🌆</button>
|
|
<input id="addimages" type="file" onchange="addFiles(event)" accept="image/*" multiple hidden>
|
|
<button title="Export sprite header" onclick="saveHeader();" style="font-size: 15px; font-weight:bold">.h</button>
|
|
<select id="pixelformat" title="Export pixel format">
|
|
<option vlaue="R2G2B2A2">R2G2B2A2</option>
|
|
<option vlaue="R5G5B4A2">R5G5B4A2</option>
|
|
<option vlaue="R4G4B4A4">R4G4B4A4</option>
|
|
</select>
|
|
</span>
|
|
<span class="block options">
|
|
<span class="option">name <input id="name" type="text" value="sprites" style="width: 80px" onchange="update()"></span>
|
|
<span class="option">🔎<input id="zoom" type="number" value="4" style="width: 50px" onchange="update()"></span>
|
|
<span class="option"><button title="Crop sprites" onclick="crop();">⌗</button></span>
|
|
</span>
|
|
</div>
|
|
<div class="hidden" id="files">
|
|
Files
|
|
<div id="filearea">
|
|
</div>
|
|
</div>
|
|
<div id="sprites"></div>
|
|
</div>
|
|
|
|
<small>check out <a href="https://youtube.com/bitlunislab">bitluni's lab</a></small>
|
|
</body></html>
|