223 lines
7.3 KiB
HTML
223 lines
7.3 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Dusk Tools / Tile Joiner</title>
|
|
|
|
<style type="text/css">
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-size: 16px;
|
|
}
|
|
|
|
canvas {
|
|
image-rendering: pixelated;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<h1>Dusk Tile Joiner</h1>
|
|
<p>
|
|
Joins multiple tiles together into a single tileset image. Optimizing and
|
|
allowing it to work on multiple platforms. Output width and height will
|
|
always be a power of two. This tool will take a while to process images,
|
|
since all images need to be loaded into memory.
|
|
</p>
|
|
|
|
<div>
|
|
<div>
|
|
<input type="file" data-file-input multiple />
|
|
</div>
|
|
<p data-file-error style="color:red;display:none;"></p>
|
|
<textarea readonly data-input-information rows="10" style="width:500px"></textarea>
|
|
</div>
|
|
|
|
<div>
|
|
<h2>Settings</h2>
|
|
|
|
<div>
|
|
<label>
|
|
Preview Background:
|
|
<button data-page-bg-white>White</button>
|
|
<button data-page-bg-transparent>Black</button>
|
|
<button data-page-bg-checkerboard>Checkerboard</button>
|
|
<button data-page-bg-magenta>Magenta</button>
|
|
<button data-page-bg-blue>Blue</button>
|
|
<button data-page-bg-green>Green</button>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h2>Joined Preview</h2>
|
|
<div>
|
|
<button data-download>Download Joined Image</button>
|
|
</div>
|
|
<canvas data-preview-canvas style="border:1px solid black;"></canvas>
|
|
</div>
|
|
</body>
|
|
|
|
<script type="text/javascript">
|
|
const elFileInput = document.querySelector('[data-file-input]');
|
|
const elFileError = document.querySelector('[data-file-error]');
|
|
const elInputInformation = document.querySelector('[data-input-information]');
|
|
const elPreviewCanvas = document.querySelector('[data-preview-canvas]');
|
|
const btnDownload = document.querySelector('[data-download]');
|
|
const btnBackgroundWhite = document.querySelector('[data-page-bg-white]');
|
|
const btnBackgroundTransparent = document.querySelector('[data-page-bg-transparent]');
|
|
const btnBackgroundCheckerboard = document.querySelector('[data-page-bg-checkerboard]');
|
|
const btnBackgroundMagenta = document.querySelector('[data-page-bg-magenta]');
|
|
const btnBackgroundBlue = document.querySelector('[data-page-bg-blue]');
|
|
const btnBackgroundGreen = document.querySelector('[data-page-bg-green]');
|
|
|
|
let images = {};
|
|
|
|
// Methods
|
|
const nextPowerOfTwo = n => {
|
|
if(n <= 0) return 1;
|
|
return 2 ** Math.ceil(Math.log2(n));
|
|
}
|
|
|
|
const onBadImages = error => {
|
|
elInputInformation.value = '';
|
|
elFileError.style.display = 'block';
|
|
elFileError.textContent = error;
|
|
}
|
|
|
|
const onImages = () => {
|
|
if(!images || Object.keys(images).length <= 1) {
|
|
return onBadImages('Please select 2 or more image images.');
|
|
}
|
|
|
|
elFileError.style.display = 'none';
|
|
|
|
let strInfo = `Selected ${Object.keys(images).length} images:\n`;
|
|
for(const [name, img] of Object.entries(images)) {
|
|
strInfo += `- ${name}: ${img.width}x${img.height}\n`;
|
|
}
|
|
elInputInformation.value = strInfo;
|
|
|
|
// Determine output width and height to pack images together, must be a
|
|
// power of two. If all images share a given axis (width/height) and that
|
|
// axis is already a power of two, use that.
|
|
const firstWidth = Object.values(images)[0].width;
|
|
const firstHeight = Object.values(images)[0].height;
|
|
let allImagesShareWidth = Object.values(images).every(img => img.width === firstWidth);
|
|
let allImagesShareHeight = Object.values(images).every(img => img.height === firstHeight);
|
|
|
|
let outputHeight, outputWidth;
|
|
|
|
if(allImagesShareWidth && nextPowerOfTwo(firstWidth) === firstWidth) {
|
|
outputWidth = firstWidth;
|
|
outputHeight = nextPowerOfTwo(Object.values(images).reduce((sum, img) => sum + img.height, 0));
|
|
} else if(allImagesShareHeight && nextPowerOfTwo(firstHeight) === firstHeight) {
|
|
outputHeight = firstHeight;
|
|
outputWidth = nextPowerOfTwo(Object.values(images).reduce((sum, img) => sum + img.width, 0));
|
|
} else {
|
|
onBadImages('All images must share the same width or height, and that dimension must be a power of two.');
|
|
}
|
|
|
|
// Update preview
|
|
elPreviewCanvas.width = outputWidth;
|
|
elPreviewCanvas.height = outputHeight;
|
|
const ctx = elPreviewCanvas.getContext('2d');
|
|
let currentX = 0;
|
|
let currentY = 0;
|
|
for(const img of Object.values(images)) {
|
|
ctx.drawImage(img, currentX, currentY);
|
|
if(allImagesShareWidth) {
|
|
currentY += img.height;
|
|
} else {
|
|
currentX += img.width;
|
|
}
|
|
}
|
|
}
|
|
|
|
const onFiles = async files => {
|
|
images = {};
|
|
|
|
if(!files || files.length <= 1) {
|
|
return onBadImages('Please select 2 or more image files.');
|
|
}
|
|
|
|
let strError = '';
|
|
for(const file of files) {
|
|
if(!file.type.startsWith('image/')) {
|
|
strError += `File is not an image: ${file.name}\n`;
|
|
}
|
|
}
|
|
if(strError) {
|
|
return onBadImages(strError);
|
|
}
|
|
|
|
try {
|
|
const fileImages = await Promise.all(Array.from(files).map(file => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = event => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
images[file.name] = img;
|
|
resolve();
|
|
}
|
|
img.onerror = () => {
|
|
reject(`Failed to load image: ${file.name}`);
|
|
}
|
|
img.src = event.target.result;
|
|
}
|
|
reader.onerror = () => {
|
|
reject(`Failed to read file: ${file.name}`);
|
|
}
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}));
|
|
} catch(error) {
|
|
return onBadImages(error);
|
|
}
|
|
|
|
onImages();
|
|
}
|
|
|
|
// Listeners
|
|
elFileInput.addEventListener('change', event => {
|
|
onFiles(event?.target?.files);
|
|
});
|
|
|
|
btnBackgroundWhite.addEventListener('click', () => {
|
|
document.body.style.background = 'white';
|
|
});
|
|
btnBackgroundTransparent.addEventListener('click', () => {
|
|
document.body.style.background = 'black';
|
|
});
|
|
btnBackgroundCheckerboard.addEventListener('click', () => {
|
|
document.body.style.background = 'repeating-conic-gradient(#ccc 0% 25%, #eee 0% 50%) 50% / 20px 20px';
|
|
});
|
|
btnBackgroundMagenta.addEventListener('click', () => {
|
|
document.body.style.background = 'magenta';
|
|
});
|
|
btnBackgroundBlue.addEventListener('click', () => {
|
|
document.body.style.background = 'blue';
|
|
});
|
|
btnBackgroundGreen.addEventListener('click', () => {
|
|
document.body.style.background = 'green';
|
|
});
|
|
|
|
btnDownload.addEventListener('click', () => {
|
|
if(!images || Object.keys(images).length <= 1) {
|
|
return onBadImages('Please select 2 or more image files before downloading.');
|
|
}
|
|
|
|
const link = document.createElement('a');
|
|
link.download = 'joined.png';
|
|
link.href = elPreviewCanvas.toDataURL();
|
|
link.click();
|
|
});
|
|
|
|
btnBackgroundCheckerboard.click();
|
|
</script>
|
|
</html> |