545 lines
18 KiB
HTML
545 lines
18 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 / Tileset Creator</title>
|
|
|
|
<style type="text/css">
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-size: 16px;
|
|
}
|
|
|
|
canvas {
|
|
image-rendering: pixelated;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<h1>Dusk Tileset Creator</h1>
|
|
<p>
|
|
Tool to create tilesets for textures. Currently only supports well sliced
|
|
tilesets (those with fixed dimensions essentially). In the future, may
|
|
support more freeform tilesets.
|
|
</p>
|
|
|
|
<div>
|
|
<h2>Tileset Settings</h2>
|
|
<div>
|
|
<button data-load-tileset>Load Tileset</button>
|
|
</div>
|
|
|
|
<div>
|
|
Define tile count by:
|
|
<div>
|
|
<label>
|
|
Tile Size
|
|
<input type="radio" name="define-by" value="size" checked />
|
|
</label>
|
|
|
|
<label>
|
|
Tile Count
|
|
<input type="radio" name="define-by" value="count" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div data-tile-sizes>
|
|
<div>
|
|
<label>Tile Width:</label>
|
|
<input type="number" value="8" data-tile-width min="1" step="1" />
|
|
</div>
|
|
<div>
|
|
<label>Tile Height:</label>
|
|
<input type="number" value="8" data-tile-height min="1" step="1" />
|
|
</div>
|
|
</div>
|
|
<div data-tile-counts style="display: none;">
|
|
<div>
|
|
<label>Column Count:</label>
|
|
<input type="number" value="10" data-column-count min="1" step="1" />
|
|
</div>
|
|
<div>
|
|
<label>Row Count:</label>
|
|
<input type="number" value="10" data-row-count min="1" step="1" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label>Unused Space on Right of Texture:</label>
|
|
<input type="number" value="0" data-right min="0" step="1" />
|
|
</div>
|
|
<div>
|
|
<label>Unused Space on Bottom of Texture:</label>
|
|
<input type="number" value="0" data-bottom min="0" step="1" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h2>Preview</h2>
|
|
<div>
|
|
<input type="file" data-texture-input />
|
|
</div>
|
|
<div data-output-error style="color:red;display:none;"></div>
|
|
<div>
|
|
<label>
|
|
Preview Scale:
|
|
<input type="number" value="4" data-indexed-preview-scale min="1" step="1" />
|
|
</label>
|
|
</div>
|
|
<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>
|
|
<canvas data-output-preview style="border:1px solid black;"></canvas>
|
|
</div>
|
|
<div>
|
|
<textarea data-output-information rows="15" style="width: 500px;"></textarea>
|
|
</div>
|
|
<div>
|
|
<button data-tileset-download>Download Tileset</button>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
|
|
<script type="text/javascript">
|
|
// Element selectors
|
|
const elDefineBySize = document.querySelector('input[name="define-by"][value="size"]');
|
|
const elDefineByCount = document.querySelector('input[name="define-by"][value="count"]');
|
|
const elTileSizes = document.querySelector('[data-tile-sizes]');
|
|
const elTileCounts = document.querySelector('[data-tile-counts]');
|
|
const elTileWidth = document.querySelector('[data-tile-width]');
|
|
const elTileHeight = document.querySelector('[data-tile-height]');
|
|
const elColumnCount = document.querySelector('[data-column-count]');
|
|
const elRowCount = document.querySelector('[data-row-count]');
|
|
const elFileInput = document.querySelector('[data-texture-input]');
|
|
const elOutputError = document.querySelector('[data-output-error]');
|
|
const elOutputInformation = document.querySelector('[data-output-information]');
|
|
const elOutputPreview = document.querySelector('[data-output-preview]');
|
|
const elScale = document.querySelector('[data-indexed-preview-scale]');
|
|
const elRight = document.querySelector('[data-right]');
|
|
const elBottom = document.querySelector('[data-bottom]');
|
|
const btnDownloadTileset = document.querySelector('[data-tileset-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]');
|
|
const btnLoadTileset = document.querySelector('[data-load-tileset]');
|
|
|
|
// State
|
|
let imageWidth = 0;
|
|
let imageHeight = 0;
|
|
let pixels = null;
|
|
let hoveredX = -1;
|
|
let hoveredY = -1;
|
|
|
|
const getValues = () => {
|
|
if(!pixels) return null;
|
|
|
|
let tileWidth, tileHeight, columnCount, rowCount;
|
|
if(elDefineBySize.checked) {
|
|
console.log('Defining by size');
|
|
tileWidth = parseInt(elTileWidth.value) || 0;
|
|
tileHeight = parseInt(elTileHeight.value) || 0;
|
|
columnCount = Math.floor(imageWidth / tileWidth);
|
|
rowCount = Math.floor(imageHeight / tileHeight);
|
|
} else {
|
|
console.log('Defining by count');
|
|
columnCount = parseInt(elColumnCount.value) || 0;
|
|
rowCount = parseInt(elRowCount.value) || 0;
|
|
tileWidth = Math.floor(imageWidth / columnCount);
|
|
tileHeight = Math.floor(imageHeight / rowCount);
|
|
}
|
|
|
|
const right = parseInt(elRight.value) || 0;
|
|
const bottom = parseInt(elBottom.value) || 0;
|
|
|
|
const scale = parseInt(elScale.value) || 1;
|
|
const scaledWidth = imageWidth * scale;
|
|
const scaledHeight = imageHeight * scale;
|
|
const scaledTileWidth = tileWidth * scale;
|
|
const scaledTileHeight = tileHeight * scale;
|
|
const scaledRight = right * scale;
|
|
const scaledBottom = bottom * scale;
|
|
|
|
const u0 = (tileWidth / imageWidth);
|
|
const v0 = (tileHeight / imageHeight);
|
|
|
|
const hoveredTileX = isNaN(hoveredX) || hoveredX < 0 ? 0 : hoveredX;
|
|
const hoveredTileY = isNaN(hoveredY) || hoveredY < 0 ? 0 : hoveredY;
|
|
const hoveredU0 = hoveredTileX * u0;
|
|
const hoveredV0 = hoveredTileY * v0;
|
|
const hoveredU1 = hoveredU0 + u0;
|
|
const hoveredV1 = hoveredV0 + v0;
|
|
const hoveredTileIndex = hoveredTileY * columnCount + hoveredTileX;
|
|
|
|
return {
|
|
tileWidth,
|
|
tileHeight,
|
|
columnCount,
|
|
rowCount,
|
|
right,
|
|
bottom,
|
|
scale,
|
|
scaledWidth,
|
|
scaledHeight,
|
|
scaledTileWidth,
|
|
scaledTileHeight,
|
|
scaledRight,
|
|
scaledBottom,
|
|
u0,
|
|
v0,
|
|
hoveredU0,
|
|
hoveredV0,
|
|
hoveredU1,
|
|
hoveredV1,
|
|
hoveredTileX,
|
|
hoveredTileY,
|
|
hoveredTileIndex,
|
|
}
|
|
}
|
|
|
|
const updatePreview = () => {
|
|
const v = getValues();
|
|
if(!v) return;
|
|
// console.log('Updating preview with values', v);
|
|
|
|
// Prepare canvas
|
|
elOutputPreview.width = v.scaledWidth;
|
|
elOutputPreview.height = v.scaledHeight;
|
|
const ctx = elOutputPreview.getContext('2d');
|
|
ctx.clearRect(0, 0, elOutputPreview.width, elOutputPreview.height);
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
// Resize pixels
|
|
const tempCanvas = document.createElement('canvas');
|
|
tempCanvas.width = imageWidth;
|
|
tempCanvas.height = imageHeight;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
const imageData = tempCtx.createImageData(imageWidth, imageHeight);
|
|
imageData.data.set(pixels);
|
|
tempCtx.putImageData(imageData, 0, 0);
|
|
ctx.drawImage(tempCanvas, 0, 0, elOutputPreview.width, elOutputPreview.height);
|
|
|
|
// Draw blue overflow area for right and bottom cutoff
|
|
ctx.fillStyle = 'rgba(0,0,255,0.5)';
|
|
if(v.right > 0) {
|
|
ctx.fillRect(v.scaledWidth - v.scaledRight, 0, v.scaledRight, elOutputPreview.height);
|
|
}
|
|
if(v.bottom > 0) {
|
|
ctx.fillRect(0, v.scaledHeight - v.scaledBottom, elOutputPreview.width, v.scaledBottom);
|
|
}
|
|
|
|
// Draw red grid lines for tile boundaries
|
|
ctx.strokeStyle = 'rgba(255,0,0,1)';
|
|
for (let x = v.scaledTileWidth; x < elOutputPreview.width; x += v.scaledTileWidth) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, elOutputPreview.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = v.scaledTileHeight; y < elOutputPreview.height; y += v.scaledTileHeight) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(elOutputPreview.width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
elOutputInformation.value = [
|
|
v.hoveredX != -1 ? `Hovered Tile: ${v.hoveredTileX}, ${v.hoveredTileY} (${v.hoveredTileIndex})` : 'Hovered Tile: None',
|
|
v.hoveredX != -1 ? `Hovered UV: ${(v.hoveredU0).toFixed(4)}, ${(v.hoveredV0).toFixed(4)} -> ${(v.hoveredU1).toFixed(4)}, ${(v.hoveredV1).toFixed(4)}` : 'Hovered UV: None',
|
|
`Image Width: ${imageWidth}`,
|
|
`Image Height: ${imageHeight}`,
|
|
`Tile Width: ${v.tileWidth}`,
|
|
`Tile Height: ${v.tileHeight}`,
|
|
`Column Count: ${v.columnCount}`,
|
|
`uv: ${v.u0.toFixed(4)}, ${v.v0.toFixed(4)}`,
|
|
`Row Count: ${v.rowCount}`,
|
|
`Tile count: ${v.columnCount * v.rowCount}`,
|
|
].join('\n');
|
|
}
|
|
|
|
elTileWidth.addEventListener('input', updatePreview);
|
|
elTileHeight.addEventListener('input', updatePreview);
|
|
elColumnCount.addEventListener('input', updatePreview);
|
|
elRowCount.addEventListener('input', updatePreview);
|
|
elRight.addEventListener('input', updatePreview);
|
|
elBottom.addEventListener('input', updatePreview);
|
|
elScale.addEventListener('input', updatePreview);
|
|
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');
|
|
|
|
elDefineBySize.addEventListener('change', () => {
|
|
if (elDefineBySize.checked) {
|
|
elTileSizes.style.display = '';
|
|
elTileCounts.style.display = 'none';
|
|
}
|
|
updatePreview();
|
|
});
|
|
|
|
elDefineByCount.addEventListener('change', () => {
|
|
if (elDefineByCount.checked) {
|
|
elTileSizes.style.display = 'none';
|
|
elTileCounts.style.display = '';
|
|
}
|
|
updatePreview();
|
|
});
|
|
|
|
elOutputPreview.addEventListener('mousemove', (e) => {
|
|
const values = getValues();
|
|
if(!values) return;
|
|
|
|
const rect = elOutputPreview.getBoundingClientRect();
|
|
const x = Math.floor((e.clientX - rect.left) / values.scale);
|
|
const y = Math.floor((e.clientY - rect.top) / values.scale);
|
|
hoveredX = Math.floor(x / values.tileWidth);
|
|
hoveredY = Math.floor(y / values.tileHeight);
|
|
if(hoveredX < 0 || hoveredX >= values.columnCount || hoveredY < 0 || hoveredY >= values.rowCount) {
|
|
hoveredX = -1;
|
|
hoveredY = -1;
|
|
}
|
|
|
|
updatePreview();
|
|
});
|
|
|
|
elOutputPreview.addEventListener('mouseleave', () => {
|
|
hoveredX = -1;
|
|
hoveredY = -1;
|
|
updatePreview();
|
|
});
|
|
|
|
// File
|
|
elFileInput.addEventListener('change', (e) => {
|
|
elOutputError.style.display = 'none';
|
|
pixels = null;
|
|
|
|
if (!elFileInput.files.length) {
|
|
elOutputError.textContent = 'No file selected';
|
|
elOutputError.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
const file = elFileInput.files[0];
|
|
|
|
if (file.name.endsWith('.dpt')) {
|
|
// Load DPT file
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = () => {
|
|
const arrayBuffer = reader.result;
|
|
const data = new Uint8Array(arrayBuffer);
|
|
if (data[0] !== 'D'.charCodeAt(0) || data[1] !== 'P'.charCodeAt(0) || data[2] !== 'T'.charCodeAt(0)) {
|
|
elOutputError.textContent = 'Invalid DPT file';
|
|
elOutputError.style.display = 'block';
|
|
return;
|
|
} else if (data[3] !== 0x01) {
|
|
elOutputError.textContent = 'Unsupported DPT version';
|
|
elOutputError.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Begin color indexes
|
|
const width = (
|
|
data[4] |
|
|
(data[5] << 8) |
|
|
(data[6] << 16) |
|
|
(data[7] << 24)
|
|
)
|
|
const height = (
|
|
data[8] |
|
|
(data[9] << 8) |
|
|
(data[10] << 16) |
|
|
(data[11] << 24)
|
|
);
|
|
|
|
imageWidth = width;
|
|
imageHeight = height;
|
|
|
|
if(data.length < 12 + width * height) {
|
|
elOutputError.textContent = 'Invalid DPT file: not enough pixel data';
|
|
elOutputError.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
const uniqueIndexes = [];
|
|
for (let i = 0; i < width * height; i++) {
|
|
const colorIndex = data[12 + i];
|
|
if (!uniqueIndexes.includes(colorIndex)) {
|
|
uniqueIndexes.push(colorIndex);
|
|
}
|
|
}
|
|
|
|
const adhocPalette = [];
|
|
for (let i = 0; i < uniqueIndexes.length; i++) {
|
|
const index = uniqueIndexes[i];
|
|
// Get the most different possible color for this index
|
|
const color = [
|
|
(index * 37) % 256,
|
|
(index * 61) % 256,
|
|
(index * 97) % 256,
|
|
255
|
|
];
|
|
adhocPalette[index] = color;
|
|
}
|
|
|
|
pixels = new Uint8Array(width * height * 4);
|
|
for (let i = 0; i < width * height; i++) {
|
|
const colorIndex = data[12 + i];
|
|
const color = adhocPalette[colorIndex];
|
|
pixels[i * 4] = color[0];
|
|
pixels[i * 4 + 1] = color[1];
|
|
pixels[i * 4 + 2] = color[2];
|
|
pixels[i * 4 + 3] = color[3];
|
|
}
|
|
|
|
updatePreview();
|
|
}
|
|
|
|
reader.onerror = () => {
|
|
elOutputError.textContent = 'Failed to read file';
|
|
elOutputError.style.display = 'block';
|
|
}
|
|
|
|
reader.readAsArrayBuffer(file);
|
|
} else {
|
|
const image = new Image();
|
|
image.onload = () => {
|
|
imageWidth = image.width;
|
|
imageHeight = image.height;
|
|
|
|
// Pixels
|
|
const tempCanvas = document.createElement('canvas');
|
|
tempCanvas.width = imageWidth;
|
|
tempCanvas.height = imageHeight;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
tempCtx.drawImage(image, 0, 0);
|
|
const imageData = tempCtx.getImageData(0, 0, imageWidth, imageHeight);
|
|
pixels = imageData.data;
|
|
|
|
updatePreview();
|
|
};
|
|
image.onerror = () => {
|
|
pixels = null;
|
|
elOutputError.textContent = 'Failed to load image';
|
|
elOutputError.style.display = 'block';
|
|
updatePreview();
|
|
};
|
|
image.src = URL.createObjectURL(file);
|
|
}
|
|
});
|
|
|
|
btnDownloadTileset.addEventListener('click', () => {
|
|
const v = getValues();
|
|
if(!v) {
|
|
alert('No valid tileset to download');
|
|
return;
|
|
}
|
|
|
|
// Header: DTF0, tileWidth, tileHeight, columnCount, rowCount, right, bottom, u0, v0
|
|
const headerBytes = new Uint8Array([
|
|
'D'.charCodeAt(0), // Dusk
|
|
'T'.charCodeAt(0), // Tileset
|
|
'F'.charCodeAt(0), // File/Format
|
|
0x00, // version
|
|
v.tileWidth & 0xFF, (v.tileWidth >> 8) & 0xFF,
|
|
v.tileHeight & 0xFF, (v.tileHeight >> 8) & 0xFF,
|
|
v.columnCount & 0xFF, (v.columnCount >> 8) & 0xFF,
|
|
v.rowCount & 0xFF, (v.rowCount >> 8) & 0xFF,
|
|
v.right & 0xFF, (v.right >> 8) & 0xFF,
|
|
v.bottom & 0xFF, (v.bottom >> 8) & 0xFF,
|
|
...new Uint8Array(new Float32Array([v.u0]).buffer),
|
|
...new Uint8Array(new Float32Array([v.v0]).buffer),
|
|
]);
|
|
|
|
// Download file
|
|
const blob = new Blob([headerBytes], { type: 'application/octet-stream' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'tileset.dtf';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
btnLoadTileset.addEventListener('click', () => {
|
|
// Browse for file.
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.dtf';
|
|
input.addEventListener('change', (e) => {
|
|
const files = e?.target?.files;
|
|
if (!files || !files.length || !files[0]) {
|
|
alert('No file selected');
|
|
return;
|
|
}
|
|
|
|
const file = files[0];
|
|
if (!file.name.endsWith('.dtf')) {
|
|
alert('Invalid file type. Please select a .dtf file.');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const arrayBuffer = reader.result;
|
|
const data = new Uint8Array(arrayBuffer);
|
|
if (data[0] !== 'D'.charCodeAt(0) || data[1] !== 'T'.charCodeAt(0) || data[2] !== 'F'.charCodeAt(0)) {
|
|
alert('Invalid DTF file');
|
|
return;
|
|
}
|
|
|
|
if (data[3] !== 0x00) {
|
|
alert('Unsupported DTF version');
|
|
return;
|
|
}
|
|
|
|
const tileWidth = data[4] | (data[5] << 8);
|
|
const tileHeight = data[6] | (data[7] << 8);
|
|
const columnCount = data[8] | (data[9] << 8);
|
|
const rowCount = data[10] | (data[11] << 8);
|
|
const right = data[12] | (data[13] << 8);
|
|
const bottom = data[14] | (data[15] << 8);
|
|
|
|
// Switch to using size definition
|
|
elDefineBySize.checked = true;
|
|
elTileWidth.value = tileWidth;
|
|
elTileHeight.value = tileHeight;
|
|
elTileSizes.style.display = '';
|
|
elTileCounts.style.display = 'none';
|
|
elRight.value = right;
|
|
elBottom.value = bottom;
|
|
|
|
updatePreview();
|
|
};
|
|
reader.onerror = () => {
|
|
alert('Failed to read file');
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
input.click();
|
|
});
|
|
|
|
// Init
|
|
btnBackgroundCheckerboard.click();
|
|
updatePreview();
|
|
</script>
|
|
</html> |