Dusk texture creator
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) 2025 Dominic Msters
|
||||
#
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
add_subdirectory(run_python)
|
||||
add_subdirectory(env_to_h)
|
||||
add_subdirectory(run_python)
|
||||
@@ -0,0 +1,79 @@
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
|
||||
parser = argparse.ArgumentParser(description="Color CSV to .h defines")
|
||||
parser.add_argument("--csv", required=True, help="Path to color CSV file")
|
||||
parser.add_argument("--output", required=True, help="Path to output .h file")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load colors
|
||||
colors = {}
|
||||
with open(args.csv, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
if "name" not in reader.fieldnames:
|
||||
raise ValueError("CSV must have a 'name' column")
|
||||
for row in reader:
|
||||
name = row["name"]
|
||||
r, g, b = row["r"], row["g"], row["b"]
|
||||
a = row["a"] if "a" in row and row["a"] != "" else "1.0"
|
||||
for ch in (r, g, b, a):
|
||||
if not (0.0 <= float(ch) <= 1.0):
|
||||
raise ValueError(f"Color channel {ch!r} for '{name}' out of range [0.0, 1.0]")
|
||||
colors[name] = (r, g, b, a)
|
||||
|
||||
# Build output
|
||||
out = [
|
||||
"#pragma once",
|
||||
'#include "dusk.h"',
|
||||
"",
|
||||
"typedef float_t colorchannelf_t;",
|
||||
"typedef uint8_t colorchannel8_t;",
|
||||
"",
|
||||
"typedef struct { colorchannelf_t r, g, b; } color3f_t;",
|
||||
"typedef struct { colorchannelf_t r, g, b, a; } color4f_t;",
|
||||
"typedef struct { colorchannel8_t r, g, b; } color3b_t;",
|
||||
"typedef struct { colorchannel8_t r, g, b, a; } color4b_t;",
|
||||
"typedef color4b_t color_t;",
|
||||
"",
|
||||
"#define color3f(r, g, b) ((color3f_t){ r, g, b })",
|
||||
"#define color4f(r, g, b, a) ((color4f_t){ r, g, b, a })",
|
||||
"#define color3b(r, g, b) ((color3b_t){ r, g, b })",
|
||||
"#define color4b(r, g, b, a) ((color4b_t){ r, g, b, a })",
|
||||
"#define color(r, g, b, a) color4b(r, g, b, a)",
|
||||
"#define colorHex(hex) color4b(((hex >> 24) & 0xFF), ((hex >> 16) & 0xFF), ((hex >> 8) & 0xFF), (hex & 0xFF))",
|
||||
"",
|
||||
]
|
||||
|
||||
lua = []
|
||||
for name, (r, g, b, a) in colors.items():
|
||||
r8, g8, b8, a8 = (int(float(ch) * 255) for ch in (r, g, b, a))
|
||||
macro = "COLOR_" + name.upper()
|
||||
camel = "".join(p[0].upper() + p[1:].lower() for p in name.split("_"))
|
||||
|
||||
out += [
|
||||
f"// {name}",
|
||||
f"#define {macro}_4B color4b({r8}, {g8}, {b8}, {a8})",
|
||||
f"#define {macro}_3B color3b({r8}, {g8}, {b8})",
|
||||
f"#define {macro}_3F color3f({r}f, {g}f, {b}f)",
|
||||
f"#define {macro}_4F color4f({r}f, {g}f, {b}f, {a}f)",
|
||||
f"#define {macro} {macro}_4B",
|
||||
"",
|
||||
]
|
||||
lua += [
|
||||
f"function color{camel}()",
|
||||
f" return color({r8}, {g8}, {b8}, {a8})",
|
||||
"end",
|
||||
"",
|
||||
]
|
||||
|
||||
out.append("// Lua color functions")
|
||||
out.append("#define COLOR_SCRIPT \\")
|
||||
for line in "\n".join(lua).rstrip().splitlines():
|
||||
out.append(f' "{line}\\n" \\')
|
||||
out[-1] = out[-1].rstrip(" \\")
|
||||
out.append("")
|
||||
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out))
|
||||
@@ -1,106 +0,0 @@
|
||||
import argparse
|
||||
import os
|
||||
import csv
|
||||
|
||||
parser = argparse.ArgumentParser(description="Color CSV to .h defines")
|
||||
parser.add_argument("--csv", required=True, help="Path to color CSV file")
|
||||
parser.add_argument("--output", required=True, help="Path to output .h file")
|
||||
args = parser.parse_args()
|
||||
|
||||
def csvIdToEnumName(inputId):
|
||||
return "INPUT_ACTION_" + inputId.upper()
|
||||
|
||||
# Load up CSV file.
|
||||
outHeader = "#pragma once\n"
|
||||
outHeader += '#include "dusk.h"\n\n'
|
||||
|
||||
outHeader += f"typedef float_t colorchannelf_t;\n"
|
||||
|
||||
colors = {}
|
||||
|
||||
with open(args.csv, newline="", encoding="utf-8") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
|
||||
# CSV must have id column.
|
||||
if "name" not in reader.fieldnames:
|
||||
raise Exception("CSV file must have 'name' column")
|
||||
|
||||
# For each color, by name...
|
||||
for row in reader:
|
||||
name = row["name"]
|
||||
r = row["r"]
|
||||
g = row["g"]
|
||||
b = row["b"]
|
||||
a = row["a"] if "a" in row and row["a"] != "" else "1.0"
|
||||
|
||||
# Ensure values are between 0.0 and 1.0
|
||||
for channelValue in (r, g, b, a):
|
||||
fvalue = float(channelValue)
|
||||
if fvalue < 0.0 or fvalue > 1.0:
|
||||
raise Exception(f"Color channel value {channelValue} for color {name} is out of range (0.0 to 1.0)")
|
||||
|
||||
colors[name] = (r, g, b, a)
|
||||
|
||||
# Prep output header
|
||||
outHeader = "#pragma once\n"
|
||||
outHeader += '#include "dusk.h"\n\n'
|
||||
|
||||
# Typedefs for float and uin8t color channels
|
||||
outHeader += f"typedef float_t colorchannelf_t;\n"
|
||||
outHeader += f"typedef uint8_t colorchannel8_t;\n\n"
|
||||
|
||||
# Typedefs for 3 and 4 channel colors in both float and uint8_t
|
||||
outHeader += f"typedef struct {{ colorchannelf_t r, g, b; }} color3f_t;\n"
|
||||
outHeader += f"typedef struct {{ colorchannelf_t r, g, b, a; }} color4f_t;\n"
|
||||
outHeader += f"typedef struct {{ colorchannel8_t r, g, b; }} color3b_t;\n"
|
||||
outHeader += f"typedef struct {{ colorchannel8_t r, g, b, a; }} color4b_t;\n"
|
||||
outHeader += "typedef color4b_t color_t;\n\n"# Preferred format.
|
||||
|
||||
outHeader += "#define color3f(r, g, b) ((color3f_t){ r, g, b })\n"
|
||||
outHeader += "#define color4f(r, g, b, a) ((color4f_t){ r, g, b, a })\n"
|
||||
outHeader += "#define color3b(r, g, b) ((color3b_t){ r, g, b })\n"
|
||||
outHeader += "#define color4b(r, g, b, a) ((color4b_t){ r, g, b, a })\n\n"
|
||||
outHeader += "#define color(r, g, b, a) color4b(r, g, b, a)\n\n" # Preferred format.
|
||||
outHeader += "#define colorHex(hex) color4b(((hex >> 24) & 0xFF), ((hex >> 16) & 0xFF), ((hex >> 8) & 0xFF), (hex & 0xFF))\n\n"
|
||||
|
||||
luaScript = ""
|
||||
|
||||
# Define each color, in each format
|
||||
for color_name, (r, g, b, a) in colors.items():
|
||||
outHeader += f"// Color: {color_name}\n"
|
||||
|
||||
r8 = int(float(r) * 255)
|
||||
g8 = int(float(g) * 255)
|
||||
b8 = int(float(b) * 255)
|
||||
a8 = int(float(a) * 255)
|
||||
|
||||
macro_name = "COLOR_" + color_name.upper()
|
||||
|
||||
outHeader += f"#define {macro_name}_4B color4b({r8}, {g8}, {b8}, {a8})\n"
|
||||
outHeader += f"#define {macro_name}_3B color3b({r8}, {g8}, {b8})\n"
|
||||
outHeader += f"#define {macro_name}_3F color3f({r}f, {g}f, {b}f)\n"
|
||||
outHeader += f"#define {macro_name}_4F color4f({r}f, {g}f, {b}f, {a}f)\n"
|
||||
outHeader += f"#define {macro_name} {macro_name}_4B\n\n" # Preferred format.
|
||||
|
||||
nameSplit = color_name.split("_")
|
||||
camelFirstLetter = ""
|
||||
for part in nameSplit:
|
||||
camelFirstLetter += part[0].upper() + part[1:].lower()
|
||||
|
||||
luaName = "color" + camelFirstLetter
|
||||
|
||||
luaScript += f"function {luaName}()\n"
|
||||
luaScript += f" return color({r8}, {g8}, {b8}, {a8})\n"
|
||||
luaScript += "end\n\n"
|
||||
|
||||
outHeader += "// Lua color functions\n"
|
||||
outHeader += "#define COLOR_SCRIPT \\\n"
|
||||
# Stringify and put each line with a backslash
|
||||
for line in luaScript.splitlines():
|
||||
outHeader += f' "{line}\\n" \\\n'
|
||||
outHeader = outHeader.rstrip(" \\\n") + "\n" # Remove last backslash and add newline
|
||||
|
||||
# Write to output file
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as outFile:
|
||||
outFile.write(outHeader)
|
||||
@@ -0,0 +1,173 @@
|
||||
"use strict";
|
||||
|
||||
// DTF – Dusk Texture Format
|
||||
//
|
||||
// Header (13 bytes):
|
||||
// [0–2] "DTF" magic
|
||||
// [3] 0x01 version
|
||||
// [4–7] uint32 width (little-endian)
|
||||
// [8–11] uint32 height (little-endian)
|
||||
// [12] uint8 format
|
||||
//
|
||||
// Formats:
|
||||
// 0x01 Alpha – 1 byte per pixel (alpha channel only)
|
||||
// 0x03 RGB – 3 bytes per pixel (no alpha)
|
||||
// 0x04 RGBA – 4 bytes per pixel
|
||||
//
|
||||
// Followed by width × height × bpp bytes of tightly-packed pixel data.
|
||||
|
||||
const DTF = (() => {
|
||||
const MAGIC = [0x44, 0x54, 0x46]; // "DTF"
|
||||
const VERSION = 0x01;
|
||||
const FORMAT_ALPHA = 0x01;
|
||||
const FORMAT_RGB = 0x03;
|
||||
const FORMAT_RGBA = 0x04;
|
||||
const HEADER_SIZE = 13;
|
||||
|
||||
// Bytes per pixel for each format.
|
||||
const BPP = {
|
||||
[FORMAT_ALPHA]: 1,
|
||||
[FORMAT_RGB]: 3,
|
||||
[FORMAT_RGBA]: 4,
|
||||
};
|
||||
|
||||
// Encode RGBA source pixels into a DTF ArrayBuffer at the given format.
|
||||
// When format is FORMAT_ALPHA and redAsAlpha is true, the red channel is
|
||||
// used as the alpha value instead of the actual alpha channel.
|
||||
function encode(width, height, rgbaData, format, redAsAlpha) {
|
||||
if (format === undefined) format = FORMAT_RGBA;
|
||||
const bpp = BPP[format];
|
||||
if (bpp === undefined) throw new Error(`Unknown DTF format: 0x${format.toString(16)}`);
|
||||
|
||||
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
|
||||
const buf = new ArrayBuffer(HEADER_SIZE + width * height * bpp);
|
||||
const bytes = new Uint8Array(buf);
|
||||
const view = new DataView(buf);
|
||||
|
||||
bytes[0] = MAGIC[0];
|
||||
bytes[1] = MAGIC[1];
|
||||
bytes[2] = MAGIC[2];
|
||||
bytes[3] = VERSION;
|
||||
view.setUint32(4, width, true);
|
||||
view.setUint32(8, height, true);
|
||||
bytes[12] = format;
|
||||
|
||||
let dst = HEADER_SIZE;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const o = i * 4;
|
||||
switch (format) {
|
||||
case FORMAT_ALPHA:
|
||||
bytes[dst++] = redAsAlpha ? src[o] : src[o + 3];
|
||||
break;
|
||||
case FORMAT_RGB:
|
||||
bytes[dst++] = src[o];
|
||||
bytes[dst++] = src[o + 1];
|
||||
bytes[dst++] = src[o + 2];
|
||||
break;
|
||||
default: // FORMAT_RGBA
|
||||
bytes[dst++] = src[o];
|
||||
bytes[dst++] = src[o + 1];
|
||||
bytes[dst++] = src[o + 2];
|
||||
bytes[dst++] = src[o + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Decode a DTF ArrayBuffer. Always returns RGBA pixel data for internal use.
|
||||
// Alpha-format files decode as {R=0, G=0, B=0, A=alpha} so the alpha channel
|
||||
// is preserved and toImageData() can display it correctly.
|
||||
function decode(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
if (bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DTF");
|
||||
if (bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] || bytes[2] !== MAGIC[2]) {
|
||||
throw new Error("Invalid DTF magic bytes – not a DTF file");
|
||||
}
|
||||
|
||||
const version = bytes[3];
|
||||
if (version !== VERSION) {
|
||||
throw new Error(`Unsupported DTF version: 0x${version.toString(16).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
const width = view.getUint32(4, true);
|
||||
const height = view.getUint32(8, true);
|
||||
const format = bytes[12];
|
||||
const bpp = BPP[format];
|
||||
|
||||
if (bpp === undefined) {
|
||||
throw new Error(`Unsupported DTF format: 0x${format.toString(16).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
const expected = HEADER_SIZE + width * height * bpp;
|
||||
if (bytes.length < expected) {
|
||||
throw new Error(`DTF pixel data truncated (expected ${expected} bytes, got ${bytes.length})`);
|
||||
}
|
||||
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
let src = HEADER_SIZE;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const o = i * 4;
|
||||
switch (format) {
|
||||
case FORMAT_ALPHA:
|
||||
rgba[o] = rgba[o + 1] = rgba[o + 2] = 0;
|
||||
rgba[o + 3] = bytes[src++];
|
||||
break;
|
||||
case FORMAT_RGB:
|
||||
rgba[o] = bytes[src++];
|
||||
rgba[o + 1] = bytes[src++];
|
||||
rgba[o + 2] = bytes[src++];
|
||||
rgba[o + 3] = 255;
|
||||
break;
|
||||
default: // FORMAT_RGBA
|
||||
rgba[o] = bytes[src++];
|
||||
rgba[o + 1] = bytes[src++];
|
||||
rgba[o + 2] = bytes[src++];
|
||||
rgba[o + 3] = bytes[src++];
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, format, data: rgba };
|
||||
}
|
||||
|
||||
// Convert RGBA source pixels to a display-ready ImageData for the given format.
|
||||
// Shows exactly how the texture will look after a DTF encode/decode round-trip.
|
||||
// Alpha → grayscale from alpha channel (or red if redAsAlpha), out-alpha=255
|
||||
// RGB → discard alpha, fully opaque
|
||||
// RGBA → pass-through
|
||||
function toImageData(width, height, rgbaData, format, redAsAlpha) {
|
||||
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
|
||||
const out = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const o = i * 4;
|
||||
switch (format) {
|
||||
case FORMAT_ALPHA:
|
||||
out[o] = out[o + 1] = out[o + 2] = redAsAlpha ? src[o] : src[o + 3];
|
||||
out[o + 3] = 255;
|
||||
break;
|
||||
case FORMAT_RGB:
|
||||
out[o] = src[o];
|
||||
out[o + 1] = src[o + 1];
|
||||
out[o + 2] = src[o + 2];
|
||||
out[o + 3] = 255;
|
||||
break;
|
||||
default: // FORMAT_RGBA
|
||||
out[o] = src[o];
|
||||
out[o + 1] = src[o + 1];
|
||||
out[o + 2] = src[o + 2];
|
||||
out[o + 3] = src[o + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(out, width, height);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
encode, decode, toImageData,
|
||||
FORMAT_ALPHA, FORMAT_RGB, FORMAT_RGBA,
|
||||
VERSION, HEADER_SIZE, BPP,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
|
||||
// DuskPNG – PNG export using pngjs (window.PNG)
|
||||
// Falls back to canvas.toBlob if pngjs is unavailable.
|
||||
|
||||
const DuskPNG = (() => {
|
||||
function _pngAvailable() {
|
||||
return typeof PNG !== "undefined" && PNG.sync && typeof PNG.sync.write === "function";
|
||||
}
|
||||
|
||||
// Encode RGBA pixel data into a PNG Buffer via pngjs.
|
||||
// Returns a Uint8Array (Buffer) if pngjs is available, otherwise null.
|
||||
function encode(width, height, rgbaData) {
|
||||
if (!_pngAvailable()) return null;
|
||||
|
||||
const png = new PNG({ width, height });
|
||||
const src = rgbaData instanceof Uint8ClampedArray
|
||||
? rgbaData
|
||||
: new Uint8ClampedArray(rgbaData);
|
||||
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
png.data[i] = src[i];
|
||||
}
|
||||
|
||||
return PNG.sync.write(png); // returns a Buffer (Uint8Array subclass)
|
||||
}
|
||||
|
||||
// Trigger a browser download of the RGBA data as a PNG file.
|
||||
function download(filename, width, height, rgbaData) {
|
||||
const buf = encode(width, height, rgbaData);
|
||||
|
||||
if (buf) {
|
||||
// pngjs path
|
||||
const blob = new Blob([buf], { type: "image/png" });
|
||||
_triggerDownload(URL.createObjectURL(blob), filename);
|
||||
} else {
|
||||
// Canvas fallback
|
||||
const canvas = Object.assign(document.createElement("canvas"), { width, height });
|
||||
const ctx = canvas.getContext("2d");
|
||||
const src = rgbaData instanceof Uint8ClampedArray
|
||||
? rgbaData
|
||||
: new Uint8ClampedArray(rgbaData);
|
||||
ctx.putImageData(new ImageData(src, width, height), 0, 0);
|
||||
canvas.toBlob(blob => _triggerDownload(URL.createObjectURL(blob), filename), "image/png");
|
||||
}
|
||||
}
|
||||
|
||||
function _triggerDownload(url, filename) {
|
||||
const a = Object.assign(document.createElement("a"), { href: url, download: filename });
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return Object.freeze({ encode, download });
|
||||
})();
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dusk Editor Tools</title>
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="site-header">
|
||||
<span class="logo">Dusk <span>Editor</span></span>
|
||||
<nav>
|
||||
<a href="/" class="active">Home</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
|
||||
<section class="hero">
|
||||
<h1>Editor Tools</h1>
|
||||
<p>Asset creation and data authoring tools for the Dusk game engine.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-label">Tools</div>
|
||||
<div class="tool-grid" id="tool-grid">
|
||||
<a class="tool-card" href="/texture/">
|
||||
<div class="tool-name">Texture Creator</div>
|
||||
<div class="tool-desc">Load PNG, JPG, or DTF files and export to the Dusk Texture Format (.dtf) or PNG.</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Component – Buttons */
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.825rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
transition: background var(--speed), border-color var(--speed);
|
||||
}
|
||||
|
||||
.btn + .btn {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Component – Tool cards and empty state */
|
||||
|
||||
.tool-card {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
transition: border-color var(--speed);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tool-card .tool-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tool-card .tool-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border-style: dashed;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Component – File info and load area */
|
||||
|
||||
.file-name {
|
||||
font-size: 0.775rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 0.15rem 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table td:first-child {
|
||||
color: var(--text-muted);
|
||||
width: 50px;
|
||||
font-size: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.load-area {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.825rem;
|
||||
color: var(--text-muted);
|
||||
transition: border-color var(--speed), color var(--speed);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.load-area:hover,
|
||||
.load-area.drag-over {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Component – Site header */
|
||||
|
||||
.site-header {
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 var(--gap);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.site-header .logo {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.site-header .logo span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.site-header nav {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.site-header nav a {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
transition: background var(--speed), color var(--speed);
|
||||
}
|
||||
|
||||
.site-header nav a:hover,
|
||||
.site-header nav a.active {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Component – Hero section */
|
||||
|
||||
.hero {
|
||||
padding: 2.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
color: var(--text-muted);
|
||||
max-width: 540px;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* Component – Tool panel (sidebar) */
|
||||
|
||||
.tool-panel {
|
||||
width: 210px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
padding: 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.control-row label {
|
||||
color: var(--text-muted);
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.tool-panel select {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--speed);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-panel select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.tool-panel input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel input[type="number"] {
|
||||
width: 52px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--speed);
|
||||
}
|
||||
|
||||
.tool-panel input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Component – Preview area */
|
||||
|
||||
.tool-preview {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
transition: border-color var(--speed);
|
||||
}
|
||||
|
||||
.preview-scroll {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-scroll canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
display: block;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Component – Background swatches */
|
||||
|
||||
.bg-swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.bg-swatch {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--speed);
|
||||
}
|
||||
|
||||
.bg-swatch:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bg-swatch.active,
|
||||
.bg-swatch.active:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Component – Warnings panel */
|
||||
|
||||
.warnings-section {
|
||||
border-color: #6b4a00;
|
||||
}
|
||||
|
||||
.warning-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.warning-list li {
|
||||
font-size: 0.775rem;
|
||||
color: #e8a030;
|
||||
line-height: 1.4;
|
||||
padding-left: 1.1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.warning-list li::before {
|
||||
content: "!";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-weight: 700;
|
||||
color: #e8a030;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* Elements – Bare HTML */
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Generic – Reset */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* ITCSS entry point */
|
||||
|
||||
@import "settings.css";
|
||||
@import "generic.css";
|
||||
@import "elements.css";
|
||||
@import "objects.css";
|
||||
@import "components/header.css";
|
||||
@import "components/hero.css";
|
||||
@import "components/cards.css";
|
||||
@import "components/panel.css";
|
||||
@import "components/buttons.css";
|
||||
@import "components/swatches.css";
|
||||
@import "components/preview.css";
|
||||
@import "components/file-info.css";
|
||||
@import "components/warnings.css";
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Objects – Layout patterns and shared abstractions */
|
||||
|
||||
/* Surface base – background, border, and radius shared across raised UI */
|
||||
.tool-card,
|
||||
.tool-panel,
|
||||
.tool-preview,
|
||||
.empty-state {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Hover accent border – interactive surfaces that highlight on focus/drag */
|
||||
.tool-card:hover,
|
||||
.btn:hover:not(:disabled),
|
||||
.load-area:hover,
|
||||
.load-area.drag-over,
|
||||
.tool-preview.drag-over {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* Section label – small uppercase heading shared across page and panel contexts */
|
||||
.section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Page container */
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
/* Top-level sections share uniform vertical spacing */
|
||||
.section,
|
||||
.tool-workspace {
|
||||
margin-top: var(--gap);
|
||||
}
|
||||
|
||||
.section > .section-label {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Tool grid */
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Tool workspace */
|
||||
.tool-workspace {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Settings – Design tokens */
|
||||
|
||||
:root {
|
||||
--bg: #1a1a1e;
|
||||
--bg-surface: #25252b;
|
||||
--bg-raised: #2e2e36;
|
||||
--border: #3a3a44;
|
||||
--text: #e4e4ed;
|
||||
--text-muted: #7a7a90;
|
||||
--accent: #6e8efb;
|
||||
--accent-dim: #3a4a8a;
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
--gap: 1.5rem;
|
||||
--speed: 0.15s;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Texture Creator – Dusk Editor</title>
|
||||
<link rel="stylesheet" href="/styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="site-header">
|
||||
<a href="/" class="logo" style="text-decoration:none;color:inherit">Dusk <span>Editor</span></a>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/texture/" class="active">Texture Creator</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
|
||||
<div class="hero">
|
||||
<h1>Texture Creator</h1>
|
||||
<p>Load an image or existing .dtf file, then export as Dusk Texture Format (.dtf) or PNG.</p>
|
||||
</div>
|
||||
|
||||
<div class="tool-workspace">
|
||||
|
||||
<!-- ─── Sidebar ────────────────────────────────────────────────────── -->
|
||||
<aside class="tool-panel">
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="section-label">Load</div>
|
||||
<label class="load-area" id="load-label">
|
||||
<input type="file" id="file-input" accept=".png,.jpg,.jpeg,.gif,.bmp,.webp,.dtf" style="display:none">
|
||||
Click or drop a file<br>
|
||||
<small>PNG · JPG · GIF · BMP · WebP · DTF</small>
|
||||
</label>
|
||||
<div class="file-name" id="file-name">No file loaded</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="section-label">Format</div>
|
||||
<select id="format-select">
|
||||
<option value="1">Alpha (0x01)</option>
|
||||
<option value="3">RGB (0x03)</option>
|
||||
<option value="4" selected>RGBA (0x04)</option>
|
||||
</select>
|
||||
<div class="control-row" id="red-as-alpha-row" hidden>
|
||||
<input type="checkbox" id="red-as-alpha">
|
||||
<label for="red-as-alpha">Red channel as alpha</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="section-label">Preview</div>
|
||||
|
||||
<div class="control-row">
|
||||
<label for="scale-input">Scale</label>
|
||||
<input type="number" id="scale-input" min="1" max="10" value="1">
|
||||
<span class="unit">×</span>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Background</div>
|
||||
|
||||
<div class="bg-swatches" id="bg-swatches">
|
||||
<!-- Transparent grid -->
|
||||
<button class="bg-swatch active" data-bg="grid" title="Transparent (grid)">
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="13" height="13" fill="#c8c8c8"/>
|
||||
<rect x="13" width="13" height="13" fill="#fff"/>
|
||||
<rect y="13" width="13" height="13" fill="#fff"/>
|
||||
<rect x="13" y="13" width="13" height="13" fill="#c8c8c8"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="bg-swatch" data-bg="#ffffff" title="White" style="background:#ffffff"></button>
|
||||
<button class="bg-swatch" data-bg="#000000" title="Black" style="background:#000000"></button>
|
||||
<button class="bg-swatch" data-bg="#ff00ff" title="Magenta" style="background:#ff00ff"></button>
|
||||
<button class="bg-swatch" data-bg="#00ff00" title="Green" style="background:#00ff00"></button>
|
||||
<button class="bg-swatch" data-bg="#ff0000" title="Red" style="background:#ff0000"></button>
|
||||
<button class="bg-swatch" data-bg="#0000ff" title="Blue" style="background:#0000ff"></button>
|
||||
<button class="bg-swatch" data-bg="#ffff00" title="Yellow" style="background:#ffff00"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section" id="info-section" hidden>
|
||||
<div class="section-label">Info</div>
|
||||
<table class="info-table">
|
||||
<tr><td>File</td><td id="info-filename">—</td></tr>
|
||||
<tr><td>Size</td><td id="info-size">—</td></tr>
|
||||
<tr><td>Format</td><td id="info-format">—</td></tr>
|
||||
<tr><td>DTF</td><td id="info-dtf-size">—</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel-section warnings-section" id="warnings-section" hidden>
|
||||
<div class="section-label">Warnings</div>
|
||||
<ul class="warning-list" id="warning-list"></ul>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="section-label">Export</div>
|
||||
<button class="btn btn-primary" id="btn-dtf" disabled>Download .dtf</button>
|
||||
<button class="btn" id="btn-png" disabled>Download .png</button>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- ─── Preview ───────────────────────────────────────────────────── -->
|
||||
<div class="tool-preview" id="preview-area">
|
||||
<div class="preview-empty" id="preview-empty">Load an image or .dtf file to get started</div>
|
||||
<div class="preview-scroll" id="preview-scroll" hidden>
|
||||
<canvas id="canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/pngjs@6.0.0/browser.js"></script>
|
||||
<script src="/common/dtf.js"></script>
|
||||
<script src="/common/png.js"></script>
|
||||
<script src="/texture/texture.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,278 @@
|
||||
"use strict";
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
pixels: null, // Uint8ClampedArray RGBA at original resolution
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1,
|
||||
bg: "grid",
|
||||
format: DTF.FORMAT_RGBA, // output DTF format
|
||||
redAsAlpha: false, // Alpha format: use red channel instead of alpha
|
||||
filename: "texture", // basename without extension
|
||||
};
|
||||
|
||||
// ─── DOM refs ────────────────────────────────────────────────────────────────
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const loadLabel = document.getElementById("load-label");
|
||||
const scaleInput = document.getElementById("scale-input");
|
||||
const bgSwatches = document.getElementById("bg-swatches");
|
||||
const previewArea = document.getElementById("preview-area");
|
||||
const previewEmpty = document.getElementById("preview-empty");
|
||||
const previewScroll = document.getElementById("preview-scroll");
|
||||
const infoSection = document.getElementById("info-section");
|
||||
const fileNameEl = document.getElementById("file-name");
|
||||
const infoFilename = document.getElementById("info-filename");
|
||||
const infoSize = document.getElementById("info-size");
|
||||
const infoFormat = document.getElementById("info-format");
|
||||
const btnDtf = document.getElementById("btn-dtf");
|
||||
const btnPng = document.getElementById("btn-png");
|
||||
const formatSelect = document.getElementById("format-select");
|
||||
const infoDtfSize = document.getElementById("info-dtf-size");
|
||||
const warningsSection = document.getElementById("warnings-section");
|
||||
const warningList = document.getElementById("warning-list");
|
||||
const redAsAlphaRow = document.getElementById("red-as-alpha-row");
|
||||
const redAsAlphaCheck = document.getElementById("red-as-alpha");
|
||||
|
||||
// ─── Rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CHECKER_CELL = 8;
|
||||
|
||||
function drawCheckerboard(w, h) {
|
||||
for (let y = 0; y < h; y += CHECKER_CELL) {
|
||||
for (let x = 0; x < w; x += CHECKER_CELL) {
|
||||
ctx.fillStyle = ((x / CHECKER_CELL + y / CHECKER_CELL) % 2 === 0) ? "#c8c8c8" : "#ffffff";
|
||||
ctx.fillRect(x, y, Math.min(CHECKER_CELL, w - x), Math.min(CHECKER_CELL, h - y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.pixels) return;
|
||||
|
||||
const { pixels, width, height, scale, bg, format } = state;
|
||||
const cw = width * scale;
|
||||
const ch = height * scale;
|
||||
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
|
||||
// 1. Background
|
||||
if (bg === "grid") {
|
||||
drawCheckerboard(cw, ch);
|
||||
} else {
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
|
||||
// 2. Composite image on top at scaled size (pixelated)
|
||||
const src = Object.assign(document.createElement("canvas"), { width, height });
|
||||
src.getContext("2d").putImageData(DTF.toImageData(width, height, pixels, format, state.redAsAlpha), 0, 0);
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(src, 0, 0, cw, ch);
|
||||
}
|
||||
|
||||
// ─── Loading ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadStandardImage(file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const offscreen = Object.assign(document.createElement("canvas"), {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
offscreen.getContext("2d").drawImage(img, 0, 0);
|
||||
const imageData = offscreen.getContext("2d").getImageData(0, 0, img.naturalWidth, img.naturalHeight);
|
||||
|
||||
applyImageData(
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
imageData.data,
|
||||
file.name,
|
||||
file.type || "image",
|
||||
);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
showError("Failed to load image. The file may be corrupt or unsupported.");
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function loadDTF(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
try {
|
||||
const { width, height, format, data } = DTF.decode(e.target.result);
|
||||
const label = format === DTF.FORMAT_ALPHA ? "DTF (Alpha)"
|
||||
: format === DTF.FORMAT_RGB ? "DTF (RGB)"
|
||||
: "DTF (RGBA)";
|
||||
applyImageData(width, height, data, file.name, label, format);
|
||||
} catch (err) {
|
||||
showError(`Failed to load DTF: ${err.message}`);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
function updateWarnings() {
|
||||
const warnings = [];
|
||||
|
||||
if (state.pixels) {
|
||||
const { width, height } = state;
|
||||
const isPow2 = n => n > 0 && (n & (n - 1)) === 0;
|
||||
|
||||
if (width < 4) warnings.push(`Width is below 4 px (${width})`);
|
||||
if (height < 4) warnings.push(`Height is below 4 px (${height})`);
|
||||
if (!isPow2(width)) warnings.push(`Width is not a power of two (${width})`);
|
||||
if (!isPow2(height)) warnings.push(`Height is not a power of two (${height})`);
|
||||
|
||||
const bytes = DTF.HEADER_SIZE + width * height * DTF.BPP[state.format];
|
||||
if (bytes > 256 * 1024) {
|
||||
warnings.push(`Output exceeds 256 KB (${(bytes / 1024).toFixed(1)} KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
warningList.replaceChildren(
|
||||
...warnings.map(msg => Object.assign(document.createElement("li"), { textContent: msg })),
|
||||
);
|
||||
warningsSection.hidden = warnings.length === 0;
|
||||
}
|
||||
|
||||
function updateDtfSize() {
|
||||
if (!state.pixels) return;
|
||||
const bytes = DTF.HEADER_SIZE + state.width * state.height * DTF.BPP[state.format];
|
||||
infoDtfSize.textContent = `${(bytes / 1024).toFixed(1)} KB`;
|
||||
updateWarnings();
|
||||
}
|
||||
|
||||
function applyImageData(width, height, data, filename, formatLabel, format) {
|
||||
state.pixels = new Uint8ClampedArray(data); // defensive copy
|
||||
state.width = width;
|
||||
state.height = height;
|
||||
state.filename = filename.replace(/\.[^/.]+$/, "");
|
||||
|
||||
// Sync format selector when loading an existing DTF
|
||||
if (format !== undefined) {
|
||||
state.format = format;
|
||||
formatSelect.value = format;
|
||||
}
|
||||
|
||||
// Sidebar info
|
||||
fileNameEl.textContent = filename;
|
||||
infoFilename.textContent = filename;
|
||||
infoSize.textContent = `${width} × ${height}`;
|
||||
infoFormat.textContent = formatLabel;
|
||||
infoSection.hidden = false;
|
||||
updateDtfSize();
|
||||
|
||||
// Show canvas
|
||||
previewEmpty.hidden = true;
|
||||
previewScroll.hidden = false;
|
||||
|
||||
// Enable export
|
||||
btnDtf.disabled = false;
|
||||
btnPng.disabled = false;
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function handleFile(file) {
|
||||
if (!file) return;
|
||||
if (file.name.toLowerCase().endsWith(".dtf")) {
|
||||
loadDTF(file);
|
||||
} else {
|
||||
loadStandardImage(file);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function exportDTF() {
|
||||
if (!state.pixels) return;
|
||||
const buf = DTF.encode(state.width, state.height, state.pixels, state.format, state.redAsAlpha);
|
||||
const blob = new Blob([buf], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement("a"), {
|
||||
href: url,
|
||||
download: `${state.filename}.dtf`,
|
||||
});
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportPNG() {
|
||||
if (!state.pixels) return;
|
||||
DuskPNG.download(`${state.filename}.png`, state.width, state.height, state.pixels);
|
||||
}
|
||||
|
||||
// ─── Event listeners ─────────────────────────────────────────────────────────
|
||||
|
||||
fileInput.addEventListener("change", e => handleFile(e.target.files[0]));
|
||||
|
||||
scaleInput.addEventListener("input", () => {
|
||||
const v = Math.max(1, Math.min(10, parseInt(scaleInput.value, 10) || 1));
|
||||
scaleInput.value = v;
|
||||
state.scale = v;
|
||||
render();
|
||||
});
|
||||
|
||||
bgSwatches.addEventListener("click", e => {
|
||||
const btn = e.target.closest(".bg-swatch");
|
||||
if (!btn) return;
|
||||
bgSwatches.querySelectorAll(".bg-swatch").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
state.bg = btn.dataset.bg;
|
||||
render();
|
||||
});
|
||||
|
||||
btnDtf.addEventListener("click", exportDTF);
|
||||
btnPng.addEventListener("click", exportPNG);
|
||||
|
||||
formatSelect.addEventListener("change", () => {
|
||||
state.format = parseInt(formatSelect.value, 10);
|
||||
redAsAlphaRow.hidden = state.format !== DTF.FORMAT_ALPHA;
|
||||
updateDtfSize();
|
||||
render();
|
||||
});
|
||||
|
||||
redAsAlphaCheck.addEventListener("change", () => {
|
||||
state.redAsAlpha = redAsAlphaCheck.checked;
|
||||
render();
|
||||
});
|
||||
|
||||
// Drag-and-drop on the load label
|
||||
function setupDrop(target) {
|
||||
target.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
loadLabel.classList.add("drag-over");
|
||||
previewArea.classList.add("drag-over");
|
||||
});
|
||||
target.addEventListener("dragleave", () => {
|
||||
loadLabel.classList.remove("drag-over");
|
||||
previewArea.classList.remove("drag-over");
|
||||
});
|
||||
target.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
loadLabel.classList.remove("drag-over");
|
||||
previewArea.classList.remove("drag-over");
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
setupDrop(loadLabel);
|
||||
setupDrop(previewArea);
|
||||
@@ -1,15 +0,0 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
function(dusk_env_to_h INPUT_PATH OUTPUT_NAME_RELATIVE)
|
||||
set(DUSK_DEFS_TARGET_NAME "DUSK_DEFS_${OUTPUT_NAME_RELATIVE}")
|
||||
dusk_run_python(
|
||||
${DUSK_DEFS_TARGET_NAME}
|
||||
tools.env_to_h
|
||||
--env "${CMAKE_CURRENT_LIST_DIR}/${INPUT_PATH}"
|
||||
--output ${DUSK_GENERATED_HEADERS_DIR}/${OUTPUT_NAME_RELATIVE}
|
||||
)
|
||||
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} ${DUSK_DEFS_TARGET_NAME})
|
||||
endfunction()
|
||||
@@ -1,46 +0,0 @@
|
||||
import argparse
|
||||
import os
|
||||
from dotenv import load_dotenv, dotenv_values
|
||||
|
||||
parser = argparse.ArgumentParser(description="Convert .env to .h defines")
|
||||
parser.add_argument("--env", required=True, help="Path to .env file")
|
||||
parser.add_argument("--output", required=True, help="Path to output .h file")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load .env file
|
||||
load_dotenv(dotenv_path=args.env)
|
||||
fileDefs = dotenv_values(dotenv_path=args.env)
|
||||
|
||||
outHeader = ""
|
||||
outHeader += "#include \"dusk.h\"\n\n"
|
||||
for key, value in fileDefs.items():
|
||||
# Determine type and print out appropriate C type define.
|
||||
|
||||
# Integer
|
||||
try:
|
||||
asInt = int(value)
|
||||
outHeader += f"#define {key} {asInt}\n"
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
# Float
|
||||
try:
|
||||
asFloat = float(value)
|
||||
outHeader += f"#define {key} {asFloat}f\n"
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
# Boolean
|
||||
if value.lower() in ['true', 'false']:
|
||||
asBool = '1' if value.lower() == 'true' else '0'
|
||||
outHeader += f"#define {key} {asBool}\n"
|
||||
continue
|
||||
|
||||
# String
|
||||
outHeader += f'#define {key} "{value}"\n'
|
||||
|
||||
# Write to output file
|
||||
with open(args.output, 'w') as outFile:
|
||||
outFile.write(outHeader)
|
||||
+43
-45
@@ -1,60 +1,58 @@
|
||||
import argparse
|
||||
import os
|
||||
import csv
|
||||
import os
|
||||
|
||||
parser = argparse.ArgumentParser(description="Input CSV to .h defines")
|
||||
parser.add_argument("--csv", required=True, help="Path to Input CSV file")
|
||||
parser.add_argument("--output", required=True, help="Path to output .h file")
|
||||
args = parser.parse_args()
|
||||
|
||||
def csvIdToEnumName(inputId):
|
||||
return "INPUT_ACTION_" + inputId.upper()
|
||||
def id_enum(name):
|
||||
return "INPUT_ACTION_" + name.upper()
|
||||
|
||||
# Load up CSV file.
|
||||
outHeader = "#pragma once\n"
|
||||
outHeader += '#include "dusk.h"\n\n'
|
||||
|
||||
with open(args.csv, newline="", encoding="utf-8") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
|
||||
# CSV must have id column.
|
||||
# Load CSV
|
||||
input_ids = []
|
||||
with open(args.csv, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
if "id" not in reader.fieldnames:
|
||||
raise Exception("CSV file must have 'id' column")
|
||||
|
||||
# For each ID
|
||||
inputIds = []
|
||||
inputIdValues = {}
|
||||
raise ValueError("CSV must have an 'id' column")
|
||||
for row in reader:
|
||||
inputId = row["id"]
|
||||
if inputId not in inputIds:
|
||||
inputIds.append(inputId)
|
||||
input_id = row["id"]
|
||||
if input_id not in input_ids:
|
||||
input_ids.append(input_id)
|
||||
|
||||
# For each ID, create enum entry.
|
||||
count = 0
|
||||
outHeader += "typedef enum {\n"
|
||||
outHeader += f" INPUT_ACTION_NULL = 0x{count:x},\n\n"
|
||||
count += 1
|
||||
for inputId in inputIds:
|
||||
inputIdValues[inputId] = count
|
||||
outHeader += f" {csvIdToEnumName(inputId)} = 0x{count:x},\n"
|
||||
count += 1
|
||||
outHeader += f"\n INPUT_ACTION_COUNT = 0x{count:x}\n"
|
||||
outHeader += "} inputaction_t;\n\n"
|
||||
# Assign enum values
|
||||
id_values = {input_id: i + 1 for i, input_id in enumerate(input_ids)}
|
||||
|
||||
# Write IDs to char array.
|
||||
outHeader += f"static const char_t* INPUT_ACTION_IDS[] = {{\n"
|
||||
for inputId in inputIds:
|
||||
outHeader += f" [{csvIdToEnumName(inputId)}] = \"{inputId}\",\n"
|
||||
outHeader += f"}};\n\n"
|
||||
# Build output
|
||||
out = [
|
||||
"#pragma once",
|
||||
'#include "dusk.h"',
|
||||
"",
|
||||
"typedef enum {",
|
||||
" INPUT_ACTION_NULL = 0x0,",
|
||||
"",
|
||||
]
|
||||
for input_id in input_ids:
|
||||
out.append(f" {id_enum(input_id)} = 0x{id_values[input_id]:x},")
|
||||
out += [
|
||||
"",
|
||||
f" INPUT_ACTION_COUNT = 0x{len(input_ids) + 1:x}",
|
||||
"} inputaction_t;",
|
||||
"",
|
||||
"static const char_t *INPUT_ACTION_IDS[] = {",
|
||||
]
|
||||
for input_id in input_ids:
|
||||
out.append(f" [{id_enum(input_id)}] = \"{input_id}\",")
|
||||
out += [
|
||||
"};",
|
||||
"",
|
||||
"static const char_t *INPUT_ACTION_SCRIPT =",
|
||||
]
|
||||
for input_id in input_ids:
|
||||
out.append(f" \"{id_enum(input_id)} = {id_values[input_id]}\\n\"")
|
||||
out += [";", ""]
|
||||
|
||||
# Lua Script
|
||||
outHeader += f"static const char_t *INPUT_ACTION_SCRIPT = \n"
|
||||
for inputId in inputIds:
|
||||
# Reference the enum
|
||||
outHeader += f" \"{csvIdToEnumName(inputId)} = {inputIdValues[inputId]}\\n\"\n"
|
||||
outHeader += f";\n\n"
|
||||
|
||||
# Write to output file.
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as outFile:
|
||||
outFile.write(outHeader)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out))
|
||||
|
||||
+82
-72
@@ -1,94 +1,104 @@
|
||||
import argparse
|
||||
import os
|
||||
import csv
|
||||
import os
|
||||
|
||||
parser = argparse.ArgumentParser(description="Item CSV to .h defines")
|
||||
parser.add_argument("--csv", required=True, help="Path to item CSV file")
|
||||
parser.add_argument("--output", required=True, help="Path to output .h file")
|
||||
args = parser.parse_args()
|
||||
|
||||
def csvIdToEnumName(itemId):
|
||||
return "ITEM_ID_" + itemId.upper()
|
||||
def type_enum(name):
|
||||
return "ITEM_TYPE_" + name.upper()
|
||||
|
||||
itemIds = []
|
||||
itemTypes = []
|
||||
itemRowById = {}
|
||||
def id_enum(name):
|
||||
return "ITEM_ID_" + name.upper()
|
||||
|
||||
with open(args.csv, newline="", encoding="utf-8") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
# Load CSV
|
||||
item_ids = []
|
||||
item_types = []
|
||||
rows = {}
|
||||
|
||||
# CSV must have id and type columns.
|
||||
with open(args.csv, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
if "id" not in reader.fieldnames or "type" not in reader.fieldnames:
|
||||
raise Exception("CSV file must have 'id' and 'type' columns")
|
||||
|
||||
raise ValueError("CSV must have 'id' and 'type' columns")
|
||||
for row in reader:
|
||||
itemId = row["id"]
|
||||
itemType = row["type"]
|
||||
|
||||
if itemId not in itemIds:
|
||||
itemIds.append(itemId)
|
||||
item_id, item_type = row["id"], row["type"]
|
||||
if item_id not in item_ids:
|
||||
item_ids.append(item_id)
|
||||
if item_type not in item_types:
|
||||
item_types.append(item_type)
|
||||
rows[item_id] = row
|
||||
|
||||
if itemType not in itemTypes:
|
||||
itemTypes.append(itemType)
|
||||
|
||||
itemRowById[itemId] = row
|
||||
|
||||
# Now Prep output
|
||||
outHeader = "#pragma once\n"
|
||||
outHeader += '#include "dusk.h"\n\n'
|
||||
|
||||
itemTypeValues = {}
|
||||
itemIdValues = {}
|
||||
# Assign enum values — types and IDs share a single counter so values never collide
|
||||
count = 0
|
||||
type_values = {}
|
||||
id_values = {}
|
||||
|
||||
# Create enum for types and ids, include null and count.
|
||||
outHeader += "typedef enum {\n"
|
||||
outHeader += f" ITEM_TYPE_NULL = {count},\n"
|
||||
count += 1
|
||||
for itemType in itemTypes:
|
||||
itemTypeValues[itemType] = count
|
||||
outHeader += f" {csvIdToEnumName(itemType)} = {count},\n"
|
||||
count += 1 # 0 = NULL
|
||||
for t in item_types:
|
||||
type_values[t] = count
|
||||
count += 1
|
||||
outHeader += f" ITEM_TYPE_COUNT = {count}\n"
|
||||
outHeader += "} itemtype_t;\n\n"
|
||||
# ITEM_TYPE_COUNT = count; item IDs continue from here
|
||||
type_count = count
|
||||
|
||||
outHeader += "typedef enum {\n"
|
||||
outHeader += f" ITEM_ID_NULL = {count},\n"
|
||||
for itemId in itemIds:
|
||||
itemIdValues[itemId] = count
|
||||
outHeader += f" {csvIdToEnumName(itemId)} = {count},\n"
|
||||
count += 1 # type_count = ITEM_ID_NULL
|
||||
for i in item_ids:
|
||||
id_values[i] = count
|
||||
count += 1
|
||||
outHeader += f" ITEM_ID_COUNT = {count}\n"
|
||||
outHeader += "} itemid_t;\n\n"
|
||||
id_count = count
|
||||
|
||||
# Create struct for item data.
|
||||
outHeader += "typedef struct {\n"
|
||||
outHeader += " itemid_t id;\n"
|
||||
outHeader += " itemtype_t type;\n"
|
||||
outHeader += " const char_t *name;\n"
|
||||
outHeader += "} item_t;\n\n"
|
||||
# Build output
|
||||
out = [
|
||||
"#pragma once",
|
||||
'#include "dusk.h"',
|
||||
"",
|
||||
"typedef enum {",
|
||||
" ITEM_TYPE_NULL = 0,",
|
||||
]
|
||||
for t in item_types:
|
||||
out.append(f" {type_enum(t)} = {type_values[t]},")
|
||||
out += [
|
||||
f" ITEM_TYPE_COUNT = {type_count}",
|
||||
"} itemtype_t;",
|
||||
"",
|
||||
"typedef enum {",
|
||||
f" ITEM_ID_NULL = {type_count},",
|
||||
]
|
||||
for i in item_ids:
|
||||
out.append(f" {id_enum(i)} = {id_values[i]},")
|
||||
out += [
|
||||
f" ITEM_ID_COUNT = {id_count}",
|
||||
"} itemid_t;",
|
||||
"",
|
||||
"typedef struct {",
|
||||
" itemid_t id;",
|
||||
" itemtype_t type;",
|
||||
" const char_t *name;",
|
||||
"} item_t;",
|
||||
"",
|
||||
"static const item_t ITEMS[] = {",
|
||||
]
|
||||
for i in item_ids:
|
||||
row = rows[i]
|
||||
out += [
|
||||
f" [{id_enum(i)}] = {{",
|
||||
f" .id = {id_enum(i)},",
|
||||
f" .type = {type_enum(row['type'])},",
|
||||
f" .name = \"{i}\",",
|
||||
" },",
|
||||
]
|
||||
out += [
|
||||
"};",
|
||||
"",
|
||||
"static const char_t *ITEM_SCRIPT =",
|
||||
]
|
||||
for i in item_ids:
|
||||
out.append(f" \"{id_enum(i)} = {id_values[i]}\\n\"")
|
||||
for t in item_types:
|
||||
out.append(f" \"{type_enum(t)} = {type_values[t]}\\n\"")
|
||||
out += [";", ""]
|
||||
|
||||
# Create array of item data.
|
||||
outHeader += f"static const item_t ITEMS[] = {{\n"
|
||||
for itemId in itemIds:
|
||||
outHeader += f" [{csvIdToEnumName(itemId)}] = {{\n"
|
||||
outHeader += f" .id = {csvIdToEnumName(itemId)},\n"
|
||||
itemType = itemRowById[itemId]["type"]
|
||||
outHeader += f" .type = {csvIdToEnumName(itemType)},\n"
|
||||
itemName = itemRowById[itemId]["id"]
|
||||
outHeader += f" .name = \"{itemName}\",\n"
|
||||
outHeader += f" }},\n"
|
||||
outHeader += f"}};\n\n"
|
||||
|
||||
# Create lua script defining items.
|
||||
outHeader += f"static const char_t *ITEM_SCRIPT = \n"
|
||||
for itemId in itemIds:
|
||||
outHeader += f" \"{csvIdToEnumName(itemId)} = {itemIdValues[itemId]}\\n\"\n"
|
||||
for itemType in itemTypes:
|
||||
outHeader += f" \"{csvIdToEnumName(itemType)} = {itemTypeValues[itemType]}\\n\"\n"
|
||||
outHeader += f";\n\n"
|
||||
|
||||
# Write to output file.
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as outFile:
|
||||
outFile.write(outHeader)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out))
|
||||
|
||||
@@ -1,687 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dusk Tools / Palette Indexer</title>
|
||||
|
||||
<style type="text/css">
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Dusk Palette Indexer</h1>
|
||||
<p>
|
||||
This tool takes an input image and creates a palettized version of it.
|
||||
You will get two files, a .dpf (Dusk Palette File) and a .dpt
|
||||
(Dusk Palettized Texture). The first being the palette, which I recommend
|
||||
reusing across multiple images, and the second being the indexed image,
|
||||
which uses the colors from the palette.
|
||||
</p>
|
||||
<p>
|
||||
Also, dusk previously supported Alpha textures, but due to so many little
|
||||
platform differences I realized it is simpler and about as much work to
|
||||
get palettized textures working instead. As a result I suggest creating a
|
||||
single palette for your alpha textures, and reusing that globally.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h2>Input Image</h2>
|
||||
<div>
|
||||
<input type="file" data-file-input />
|
||||
</div>
|
||||
<p data-file-error style="color:red;display:none;"></p>
|
||||
<canvas data-input-preview style="border:1px solid black;"></canvas>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Palette</h2>
|
||||
<div>
|
||||
<button data-palette-add>Add Color</button>
|
||||
<button data-palette-append>Append Palette</button>
|
||||
<button data-palette-clear>Clear Palette</button>
|
||||
<button data-palette-optimize>Optimize Palette</button>
|
||||
</div>
|
||||
|
||||
<div data-palette-entries style="display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;margin-top:16px;"></div>
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<h2>Palette Preview</h2>
|
||||
|
||||
<h3>Palette</h3>
|
||||
<div>
|
||||
<button data-palette-download>Download Palette</button>
|
||||
</div>
|
||||
<canvas data-palette-preview style="border:1px solid black;"></canvas>
|
||||
<p data-palette-information></p>
|
||||
|
||||
<h3>Indexed Image</h3>
|
||||
<div>
|
||||
<button data-indexed-download>Download Indexed Image</button>
|
||||
</div>
|
||||
<div data-output-error style="color:red;display:none;"></div>
|
||||
<div>
|
||||
<label>
|
||||
Preview Scale:
|
||||
<input type="number" value="2" data-indexed-preview-scale min="1" step="1" />
|
||||
</label>
|
||||
</div>
|
||||
<canvas data-output-preview style="border:1px solid black;"></canvas>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script type="text/javascript">
|
||||
const elError = document.querySelector('[data-file-error]');
|
||||
const elFile = document.querySelector('[data-file-input]');
|
||||
const elInputPreview = document.querySelector('[data-input-preview]');
|
||||
const elPalettePreview = document.querySelector('[data-palette-preview]');
|
||||
const elOutputPreview = document.querySelector('[data-output-preview]');
|
||||
const elPaletteInformation = document.querySelector('[data-palette-information]');
|
||||
const elSourceFromInput = document.querySelector('[data-source-from-input]');
|
||||
const elPaletteAdd = document.querySelector('[data-palette-add]');
|
||||
const elPaletteEntries = document.querySelector('[data-palette-entries]');
|
||||
const elPreviewScale = document.querySelector('[data-indexed-preview-scale]');
|
||||
const elClearPalette = document.querySelector('[data-palette-clear]');
|
||||
const elAppendPalette = document.querySelector('[data-palette-append]');
|
||||
const elOutputError = document.querySelector('[data-output-error]');
|
||||
const elOptimizePalette = document.querySelector('[data-palette-optimize]');
|
||||
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 btnDownloadPalette = document.querySelector('[data-palette-download]');
|
||||
const btnDownloadImage = document.querySelector('[data-indexed-download]');
|
||||
|
||||
let imageWidth = 0;
|
||||
let imageHeight = 0;
|
||||
let palette = [];// Array of [ r, g, b, a ]
|
||||
let indexedImage = [ ];// Array of indexes
|
||||
|
||||
const nextPowerOfTwo = (x) => {
|
||||
return Math.pow(2, Math.ceil(Math.log2(x)));
|
||||
}
|
||||
|
||||
const sourcePaletteFromImage = (image) => {
|
||||
// Get unique colors
|
||||
const elCanvas = document.createElement('canvas');
|
||||
elCanvas.width = image.width;
|
||||
elCanvas.height = image.height;
|
||||
const ctx = elCanvas.getContext('2d');
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, image.width, image.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const palette = [];
|
||||
for(let i = 0; i < data.length; i += 4) {
|
||||
const color = [ data[i], data[i + 1], data[i + 2], data[i + 3] ];
|
||||
if(!palette.some(c => c[0] === color[0] && c[1] === color[1] && c[2] === color[2] && c[3] === color[3])) {
|
||||
palette.push(color);
|
||||
}
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
const updateOutput = () => {
|
||||
elOutputError.style.display = 'none';
|
||||
|
||||
// Update palette preview
|
||||
const paletteScale = 8;
|
||||
const uniquePalette = palette.filter((color, index) => {
|
||||
if(color[3] === 0) {
|
||||
// Find any other fully transparent pixel.
|
||||
return index === palette.findIndex(c => c[3] === 0);
|
||||
}
|
||||
return index === palette.findIndex(c => c[0] === color[0] && c[1] === color[1] && c[2] === color[2] && c[3] === color[3]);
|
||||
});
|
||||
elPalettePreview.width = uniquePalette.length * paletteScale;
|
||||
elPalettePreview.height = paletteScale;
|
||||
const paletteCtx = elPalettePreview.getContext('2d');
|
||||
uniquePalette.forEach((color, index) => {
|
||||
paletteCtx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
|
||||
paletteCtx.fillRect(index * paletteScale, 0, paletteScale, paletteScale);
|
||||
});
|
||||
elPaletteInformation.textContent = `Palette contains ${uniquePalette.length} colors (${palette.length - uniquePalette.length} duplicates).`;
|
||||
|
||||
// Update palette entries
|
||||
elPaletteEntries.innerHTML = '';
|
||||
palette.forEach((color, index) => {
|
||||
const entry = document.createElement('div');
|
||||
|
||||
const epar = document.createElement('span');
|
||||
epar.textContent = `Index ${index}`;
|
||||
|
||||
const ecolor = document.createElement('span');
|
||||
ecolor.style.display = 'inline-block';
|
||||
ecolor.style.width = '16px';
|
||||
ecolor.style.height = '16px';
|
||||
ecolor.style.marginLeft = '8px';
|
||||
ecolor.style.backgroundColor = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
|
||||
ecolor.style.border = '1px solid black';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.appendChild(epar);
|
||||
header.appendChild(ecolor);
|
||||
|
||||
const inputR = document.createElement('input');
|
||||
inputR.type = 'number';
|
||||
inputR.value = color[0];
|
||||
inputR.min = 0;
|
||||
inputR.max = 255;
|
||||
inputR.setAttribute('data-palette-index', index);
|
||||
inputR.setAttribute('data-palette-channel', '0');
|
||||
inputR.addEventListener('change', (event) => {
|
||||
const idx = parseInt(event.target.getAttribute('data-palette-index'));
|
||||
const channel = parseInt(event.target.getAttribute('data-palette-channel'));
|
||||
const value = parseInt(event.target.value);
|
||||
if(isNaN(value) || value < 0 || value > 255) {
|
||||
event.target.value = palette[idx][channel];
|
||||
return;
|
||||
}
|
||||
palette[idx][channel] = value;
|
||||
updateOutput();
|
||||
});
|
||||
|
||||
const inputG = document.createElement('input');
|
||||
inputG.type = 'number';
|
||||
inputG.value = color[1];
|
||||
inputG.min = 0;
|
||||
inputG.max = 255;
|
||||
inputG.setAttribute('data-palette-index', index);
|
||||
inputG.setAttribute('data-palette-channel', '1');
|
||||
inputG.addEventListener('change', (event) => {
|
||||
const idx = parseInt(event.target.getAttribute('data-palette-index'));
|
||||
const channel = parseInt(event.target.getAttribute('data-palette-channel'));
|
||||
const value = parseInt(event.target.value);
|
||||
if(isNaN(value) || value < 0 || value > 255) {
|
||||
event.target.value = palette[idx][channel];
|
||||
return;
|
||||
}
|
||||
palette[idx][channel] = value;
|
||||
updateOutput();
|
||||
});
|
||||
|
||||
const inputB = document.createElement('input');
|
||||
inputB.type = 'number';
|
||||
inputB.value = color[2];
|
||||
inputB.min = 0;
|
||||
inputB.max = 255;
|
||||
inputB.setAttribute('data-palette-index', index);
|
||||
inputB.setAttribute('data-palette-channel', '2');
|
||||
inputB.addEventListener('change', (event) => {
|
||||
const idx = parseInt(event.target.getAttribute('data-palette-index'));
|
||||
const channel = parseInt(event.target.getAttribute('data-palette-channel'));
|
||||
const value = parseInt(event.target.value);
|
||||
if(isNaN(value) || value < 0 || value > 255) {
|
||||
event.target.value = palette[idx][channel];
|
||||
return;
|
||||
}
|
||||
palette[idx][channel] = value;
|
||||
updateOutput();
|
||||
});
|
||||
|
||||
const inputA = document.createElement('input');
|
||||
inputA.type = 'number';
|
||||
inputA.value = color[3];
|
||||
inputA.min = 0;
|
||||
inputA.max = 255;
|
||||
inputA.setAttribute('data-palette-index', index);
|
||||
inputA.setAttribute('data-palette-channel', '3');
|
||||
inputA.addEventListener('change', (event) => {
|
||||
const idx = parseInt(event.target.getAttribute('data-palette-index'));
|
||||
const channel = parseInt(event.target.getAttribute('data-palette-channel'));
|
||||
const value = parseInt(event.target.value);
|
||||
if(isNaN(value) || value < 0 || value > 255) {
|
||||
event.target.value = palette[idx][channel];
|
||||
return;
|
||||
}
|
||||
palette[idx][channel] = value;
|
||||
updateOutput();
|
||||
});
|
||||
|
||||
const colorArea = document.createElement('div');
|
||||
colorArea.style.display = 'inline-block';
|
||||
colorArea.appendChild(inputR);
|
||||
colorArea.appendChild(inputG);
|
||||
colorArea.appendChild(inputB);
|
||||
colorArea.appendChild(inputA);
|
||||
|
||||
const buttonArea = document.createElement('div');
|
||||
buttonArea.style.display = 'inline-block';
|
||||
const btnRemove = document.createElement('button');
|
||||
btnRemove.textContent = '-';
|
||||
btnRemove.setAttribute('data-palette-index', index);
|
||||
btnRemove.addEventListener('click', () => {
|
||||
palette.splice(index, 1);
|
||||
updateOutput();
|
||||
});
|
||||
buttonArea.appendChild(btnRemove);
|
||||
|
||||
const btnUp = document.createElement('button');
|
||||
btnUp.textContent = '↑';
|
||||
btnUp.setAttribute('data-palette-index', index);
|
||||
btnUp.addEventListener('click', () => {
|
||||
if(index === 0) return;
|
||||
const temp = palette[index - 1];
|
||||
palette[index - 1] = palette[index];
|
||||
palette[index] = temp;
|
||||
updateOutput();
|
||||
});
|
||||
buttonArea.appendChild(btnUp);
|
||||
|
||||
const btnDown = document.createElement('button');
|
||||
btnDown.textContent = '↓';
|
||||
btnDown.setAttribute('data-palette-index', index);
|
||||
btnDown.addEventListener('click', () => {
|
||||
if(index === palette.length - 1) return;
|
||||
const temp = palette[index + 1];
|
||||
palette[index + 1] = palette[index];
|
||||
palette[index] = temp;
|
||||
updateOutput();
|
||||
});
|
||||
buttonArea.appendChild(btnDown);
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.appendChild(colorArea);
|
||||
footer.appendChild(buttonArea);
|
||||
|
||||
entry.appendChild(header);
|
||||
entry.appendChild(footer);
|
||||
elPaletteEntries.appendChild(entry);
|
||||
});
|
||||
|
||||
// Image ready?
|
||||
if(!imageWidth || !imageHeight) return;
|
||||
|
||||
// Update indexed image preview
|
||||
elOutputPreview.width = imageWidth;
|
||||
elOutputPreview.height = imageHeight;
|
||||
const outputCtx = elOutputPreview.getContext('2d');
|
||||
const outputImageData = outputCtx.createImageData(imageWidth, imageHeight);
|
||||
const outputData = outputImageData.data;
|
||||
indexedImage.forEach((index, i) => {
|
||||
let color;
|
||||
if(palette.length <= index) {
|
||||
elOutputError.textContent = `Indexed image references non-existent palette index ${index}.`;
|
||||
elOutputError.style.display = 'block';
|
||||
color = [0, 0, 0, 0];
|
||||
} else {
|
||||
color = palette[index];
|
||||
}
|
||||
outputData[i * 4] = color[0];
|
||||
outputData[i * 4 + 1] = color[1];
|
||||
outputData[i * 4 + 2] = color[2];
|
||||
outputData[i * 4 + 3] = color[3];
|
||||
});
|
||||
outputCtx.putImageData(outputImageData, 0, 0);
|
||||
|
||||
if(elOutputError.style.display === 'none') {;
|
||||
const unusedPaletteIndexes = palette.map((color, index) => {
|
||||
return indexedImage.includes(index) ? null : index;
|
||||
}).filter(Boolean);
|
||||
if(unusedPaletteIndexes.length > 0) {
|
||||
elOutputError.textContent = `Image does not use palette colors; ${unusedPaletteIndexes.join(', ')}.`;
|
||||
elOutputError.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Rescale canvas
|
||||
const scale = parseInt(elPreviewScale.value) || 1;
|
||||
elOutputPreview.style.width = `${imageWidth * scale}px`;
|
||||
elOutputPreview.style.height = `${imageHeight * scale}px`;
|
||||
}
|
||||
|
||||
const onImage = img => {
|
||||
if(!img) return onBadFile('Failed to load image');
|
||||
|
||||
elError.style.display = 'none';
|
||||
imageWidth = img.width;
|
||||
imageHeight = img.height;
|
||||
indexedImage = [];
|
||||
|
||||
// Update input preview
|
||||
elInputPreview.width = imageWidth;
|
||||
elInputPreview.height = imageHeight;
|
||||
const ctx = elInputPreview.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Source palette from input image
|
||||
palette = sourcePaletteFromImage(img);
|
||||
|
||||
// Index the image
|
||||
const imageData = ctx.getImageData(0, 0, imageWidth, imageHeight);
|
||||
const data = imageData.data;
|
||||
for(let i = 0; i < data.length; i += 4) {
|
||||
const color = [ data[i], data[i + 1], data[i + 2], data[i + 3] ];
|
||||
const colorIndex = palette.findIndex(c => c[0] === color[0] && c[1] === color[1] && c[2] === color[2] && c[3] === color[3]);
|
||||
indexedImage.push(colorIndex);
|
||||
}
|
||||
|
||||
updateOutput();
|
||||
}
|
||||
|
||||
const onPalettizedImage = dataView => {
|
||||
if(!dataView) return onBadFile('Failed to load image');
|
||||
|
||||
// Ensure DPT and ver is 1
|
||||
if(dataView.getUint8(0) !== 0x44 || dataView.getUint8(1) !== 0x50 || dataView.getUint8(2) !== 0x54 || dataView.getUint8(3) !== 0x01) {
|
||||
return onBadFile('Invalid DPT file. Expected header "DPT" and version 1.');
|
||||
}
|
||||
|
||||
// Width and Height
|
||||
elError.style.display = 'none';
|
||||
|
||||
// Convert from little endian
|
||||
imageWidth = dataView.getUint32(3);
|
||||
imageHeight = dataView.getUint32(7);
|
||||
|
||||
if(imageWidth <= 0 || imageHeight <= 0) {
|
||||
return onBadFile('Invalid image dimensions in DPT file.');
|
||||
}
|
||||
|
||||
if(dataView.byteLength < 11 + imageWidth * imageHeight) {
|
||||
return onBadFile('DPT file does not contain enough data for the specified dimensions.');
|
||||
}
|
||||
|
||||
const uniqueIndexes = []
|
||||
|
||||
// Image data
|
||||
indexedImage = [];
|
||||
for(let i = 11; i < dataView.byteLength; i++) {
|
||||
const index = dataView.getUint8(i);
|
||||
if(!uniqueIndexes.includes(index)) uniqueIndexes.push(index);
|
||||
indexedImage.push(index);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
elInputPreview.width = imageWidth;
|
||||
elInputPreview.height = imageHeight;
|
||||
const ctx = elInputPreview.getContext('2d');
|
||||
const imageData = ctx.createImageData(imageWidth, imageHeight);
|
||||
const data = imageData.data;
|
||||
indexedImage.forEach((index, i) => {
|
||||
const color = adhocPalette[index] || [0, 0, 0, 0];
|
||||
data[i * 4] = color[0];
|
||||
data[i * 4 + 1] = color[1];
|
||||
data[i * 4 + 2] = color[2];
|
||||
data[i * 4 + 3] = color[3];
|
||||
});
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
updateOutput();
|
||||
}
|
||||
|
||||
const onBadFile = err => {
|
||||
elError.textContent = err;
|
||||
elError.style.display = 'block';
|
||||
updateOutput();
|
||||
}
|
||||
|
||||
const onFile = file => {
|
||||
// Reset preview
|
||||
elInputPreview.width = 0;
|
||||
elInputPreview.height = 0;
|
||||
image = null;
|
||||
|
||||
if(!file) {
|
||||
onImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If file is image, load as image.
|
||||
if(file.type.startsWith('image/')) {
|
||||
elError.style.display = 'none';
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = () => onImage(img);
|
||||
img.onerror = () => onImage(null);
|
||||
img.src = event.target.result;
|
||||
};
|
||||
reader.onerror = () => onImage(null);
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
|
||||
} else if(file.name.endsWith('.dpt')) {
|
||||
elError.style.display = 'none';
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const arrayBuffer = event.target.result;
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
onPalettizedImage(dataView);
|
||||
};
|
||||
reader.onerror = () => onPalettizedImage(null);
|
||||
reader.readAsArrayBuffer(file);
|
||||
return;
|
||||
}
|
||||
|
||||
onBadFile('Selected file is not a supported image type.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Listeners
|
||||
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';
|
||||
});
|
||||
|
||||
elFile.addEventListener('change', (event) => {
|
||||
onFile(event?.target?.files[0]);
|
||||
});
|
||||
elPaletteAdd.addEventListener('click', () => {
|
||||
// Add new random color to palette
|
||||
const newColor = [ Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), 255 ];
|
||||
palette.push(newColor);
|
||||
updateOutput();
|
||||
});
|
||||
elPreviewScale.addEventListener('change', () => {
|
||||
updateOutput();
|
||||
});
|
||||
elClearPalette.addEventListener('click', () => {
|
||||
palette = [];
|
||||
updateOutput();
|
||||
});
|
||||
|
||||
elOptimizePalette.addEventListener('click', () => {
|
||||
// Remove unused colors and duplicates from the palette. Treat A=0 as equal
|
||||
const newPalette = [];
|
||||
const indexMap = {};
|
||||
palette.forEach((color, index) => {
|
||||
// Check if color is already in new palette
|
||||
const existingIndex = newPalette.findIndex(c => {
|
||||
if(c[3] === 0 && color[3] === 0) return true;
|
||||
return c[0] === color[0] && c[1] === color[1] && c[2] === color[2] && c[3] === color[3];
|
||||
});
|
||||
if(existingIndex !== -1) {
|
||||
indexMap[index] = existingIndex;
|
||||
} else {
|
||||
indexMap[index] = newPalette.length;
|
||||
newPalette.push(color);
|
||||
}
|
||||
});
|
||||
|
||||
palette = newPalette;
|
||||
|
||||
updateOutput();
|
||||
});
|
||||
|
||||
elAppendPalette.addEventListener('click', () => {
|
||||
// Open file picker
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.addEventListener('change', (event) => {
|
||||
const file = event.target.files[0];
|
||||
if(!file) return;
|
||||
|
||||
// Accept either images or .dpf files
|
||||
if(file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const newColors = sourcePaletteFromImage(img);
|
||||
palette = palette.concat(newColors);
|
||||
updateOutput();
|
||||
};
|
||||
img.onerror = () => {
|
||||
alert('Failed to load image for palette appending.');
|
||||
};
|
||||
img.src = event.target.result;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
alert('Failed to load image for palette appending.');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
} else if(file.name.endsWith('.dpf')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const arrayBuffer = event.target.result;
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
|
||||
// Validate header
|
||||
if(dataView.getUint8(0) !== 0x44 || dataView.getUint8(1) !== 0x50 || dataView.getUint8(2) !== 0x46) {
|
||||
alert('Invalid DPF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const colorCount = dataView.getUint32(4);
|
||||
const expectedLength = 4 * colorCount + 8;// 3 DPF, 1 ver, 4 color count
|
||||
if(arrayBuffer.byteLength !== expectedLength) {
|
||||
alert('DPF file size does not match expected color count.');
|
||||
return;
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
for(let i = 0; i < colorCount; i++) {
|
||||
const r = dataView.getUint8(offset);
|
||||
const g = dataView.getUint8(offset + 1);
|
||||
const b = dataView.getUint8(offset + 2);
|
||||
const a = dataView.getUint8(offset + 3);
|
||||
palette.push([r, g, b, a]);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
updateOutput();
|
||||
};
|
||||
reader.onerror = () => {
|
||||
alert('Failed to load DPF file for palette appending.');
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
return;
|
||||
} else {
|
||||
alert('Unsupported file type for palette appending. Please use an image file.');
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
});
|
||||
|
||||
btnDownloadPalette.addEventListener('click', () => {
|
||||
// Create Dusk Palette File (.dpf)
|
||||
const header = new Uint8Array([0x44, 0x50, 0x46, 0x01]); // 'DPF' + version 1 + number of colors (4 bytes)
|
||||
|
||||
// Color count (1 byte)
|
||||
const colorCountBytes = new Uint8Array(1);
|
||||
colorCountBytes[0] = palette.length;
|
||||
|
||||
// add color data (palette.length * 4 bytes)
|
||||
const colorData = new Uint8Array(palette.length * 4);
|
||||
palette.forEach((color, index) => {
|
||||
colorData[index * 4] = color[0];
|
||||
colorData[index * 4 + 1] = color[1];
|
||||
colorData[index * 4 + 2] = color[2];
|
||||
colorData[index * 4 + 3] = color[3];
|
||||
});
|
||||
|
||||
const blob = new Blob([header, colorCountBytes, colorData], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'palette.dpf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
btnDownloadImage.addEventListener('click', () => {
|
||||
const header = new Uint8Array([0x44, 0x50, 0x54, 0x01]); // 'DPT' + version 1
|
||||
|
||||
// Dimensions
|
||||
const widthBytes = new Uint32Array([ imageWidth ]);
|
||||
const heightBytes = new Uint32Array([ imageHeight ]);
|
||||
|
||||
// add indexed image data (imageWidth * imageHeight bytes)
|
||||
const imageData = new Uint8Array(indexedImage.length);
|
||||
indexedImage.forEach((index, i) => {
|
||||
imageData[i] = index;
|
||||
});
|
||||
|
||||
const blob = new Blob([header, widthBytes, heightBytes, imageData], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'indexed_image.dpt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
updateOutput();
|
||||
btnBackgroundCheckerboard.click();
|
||||
</script>
|
||||
</html>
|
||||
@@ -1,10 +1,5 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
@@ -16,4 +11,4 @@ function(dusk_run_python CMAKE_TARGET_NAME PYTHON_MODULE)
|
||||
COMMAND
|
||||
${Python3_EXECUTABLE} -m ${PYTHON_MODULE} ${ARGN}
|
||||
)
|
||||
endfunction()
|
||||
endfunction()
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import http.server
|
||||
import os
|
||||
|
||||
EDITOR_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor")
|
||||
|
||||
parser = argparse.ArgumentParser(description="Dusk editor tools server")
|
||||
parser.add_argument("--host", default="localhost", help="Host to bind to")
|
||||
parser.add_argument("--port", type=int, default=3000, help="Port to listen on")
|
||||
args = parser.parse_args()
|
||||
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=EDITOR_DIR)
|
||||
|
||||
with http.server.HTTPServer((args.host, args.port), handler) as server:
|
||||
print(f"Dusk editor tools: http://{args.host}:{args.port}/")
|
||||
server.serve_forever()
|
||||
+37
-36
@@ -1,48 +1,49 @@
|
||||
import argparse
|
||||
import os
|
||||
import csv
|
||||
import os
|
||||
|
||||
parser = argparse.ArgumentParser(description="Story CSV to .h defines")
|
||||
parser.add_argument("--csv", required=True, help="Path to story CSV file")
|
||||
parser.add_argument("--header-file", required=True, help="Path to output .h file")
|
||||
parser.add_argument("--output", required=True, help="Path to output .h file")
|
||||
args = parser.parse_args()
|
||||
|
||||
def idToEnum(id):
|
||||
return "STORY_FLAG_" + id.upper().replace(" ", "_")
|
||||
def flag_enum(name):
|
||||
return "STORY_FLAG_" + name.upper().replace(" ", "_")
|
||||
|
||||
# Load up CSV file.
|
||||
outHeader = "#pragma once\n"
|
||||
outHeader += '#include "story/storyflagdefs.h"\n\n'
|
||||
|
||||
with open(args.csv, newline="", encoding="utf-8") as csvfile:
|
||||
reader = csv.DictReader(csvfile)
|
||||
|
||||
# CSV must have id column
|
||||
# Load flags
|
||||
flags = []
|
||||
with open(args.csv, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
if "id" not in reader.fieldnames:
|
||||
raise Exception("CSV file must have 'id' column")
|
||||
|
||||
# Generate enum
|
||||
outHeader += "typedef enum {\n"
|
||||
outHeader += " STORY_FLAG_NULL,\n\n"
|
||||
raise ValueError("CSV must have an 'id' column")
|
||||
for row in reader:
|
||||
id = idToEnum(row["id"].strip())
|
||||
outHeader += f" {id},\n"
|
||||
outHeader += "\n STORY_FLAG_COUNT\n"
|
||||
outHeader += "} storyflag_t;\n\n"
|
||||
flags.append({
|
||||
"id": row["id"].strip(),
|
||||
"initial": (row.get("initial") or "0").strip(),
|
||||
})
|
||||
|
||||
# Generate flag values
|
||||
csvfile.seek(0)
|
||||
reader = csv.DictReader(csvfile)
|
||||
# Build output
|
||||
out = [
|
||||
"#pragma once",
|
||||
'#include "story/storyflagdefs.h"',
|
||||
"",
|
||||
"typedef enum {",
|
||||
" STORY_FLAG_NULL,",
|
||||
"",
|
||||
]
|
||||
for flag in flags:
|
||||
out.append(f" {flag_enum(flag['id'])},")
|
||||
out += [
|
||||
"",
|
||||
" STORY_FLAG_COUNT",
|
||||
"} storyflag_t;",
|
||||
"",
|
||||
"static storyflagvalue_t STORY_FLAG_VALUES[STORY_FLAG_COUNT] = {",
|
||||
]
|
||||
for flag in flags:
|
||||
out.append(f" [{flag_enum(flag['id'])}] = {flag['initial']},")
|
||||
out += ["};", ""]
|
||||
|
||||
outHeader += "static storyflagvalue_t STORY_FLAG_VALUES[STORY_FLAG_COUNT] = {\n"
|
||||
for row in reader:
|
||||
id = idToEnum(row["id"].strip())
|
||||
initial = row.get("initial", "0").strip() or "0"
|
||||
outHeader += f" [{id}] = {initial},\n"
|
||||
outHeader += "};\n"
|
||||
|
||||
os.makedirs(os.path.dirname(args.header_file), exist_ok=True)
|
||||
|
||||
# Write header
|
||||
with open(args.header_file, "w", encoding="utf-8") as headerFile:
|
||||
headerFile.write(outHeader)
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out))
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dusk Tools / Texture Creator</title>
|
||||
|
||||
<style type="text/css">
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Dusk Texture Creator</h1>
|
||||
<p>
|
||||
Creates texture files. This will not create palletized textures, use the
|
||||
palette-indexer.html tool for that. This will instead work for all other
|
||||
kinds of textures.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<input type="file" data-file-input />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Settings</h2>
|
||||
|
||||
<div>
|
||||
Texture Format:
|
||||
<div>
|
||||
<label>
|
||||
RGBA
|
||||
<input type="radio" name="texture-type" value="rgba" />
|
||||
</label><br />
|
||||
|
||||
<label>
|
||||
RGB
|
||||
<input type="radio" name="texture-type" value="rgb" />
|
||||
</label><br />
|
||||
|
||||
<label>
|
||||
RGB565
|
||||
<input type="radio" name="texture-type" value="rgb565" checked />
|
||||
</label><br />
|
||||
|
||||
<label>
|
||||
RGB5A3 (Dolphin Only)
|
||||
<input type="radio" name="texture-type" value="rgb5a3" />
|
||||
</label><br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pad-power-of-two">
|
||||
Pad to power of two
|
||||
<input type="checkbox" id="pad-power-of-two" name="pad-power-of-two" checked />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Preview</h2>
|
||||
<div>
|
||||
<label>
|
||||
Preview Scale:
|
||||
<input type="number" value="2" data-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 readonly rows="15" style="width: 500px;"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button data-download>Download Texture</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script type="text/javascript">
|
||||
const elFileInput = document.querySelector('[data-file-input]');
|
||||
const elFileError = document.querySelector('[data-file-error]');
|
||||
const elPreviewScale = document.querySelector('[data-preview-scale]');
|
||||
const elOutputPreview = document.querySelector('[data-output-preview]');
|
||||
const elOutputInformation = document.querySelector('[data-output-information]');
|
||||
const elTextureType = document.querySelectorAll('input[name="texture-type"]');
|
||||
const elPadPowerOfTwo = document.querySelector('#pad-power-of-two');
|
||||
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 image;
|
||||
let imageName;
|
||||
let textureData;
|
||||
let rawData;
|
||||
let width, height, paddedWidth, paddedHeight;
|
||||
|
||||
// Methods
|
||||
const nextPowerOfTwo = n => {
|
||||
if(n <= 0) return 1;
|
||||
return 2 ** Math.ceil(Math.log2(n));
|
||||
}
|
||||
|
||||
const updatePreview = () => {
|
||||
if(!image) return;
|
||||
|
||||
console.log('Updating preview with image:', image);
|
||||
|
||||
const scale = parseInt(elPreviewScale.value) || 1;
|
||||
const padToPowerOfTwo = elPadPowerOfTwo.checked;
|
||||
const textureType = Array.from(elTextureType).find(r => r.checked)?.value || 'rgba';
|
||||
|
||||
width = image.width;
|
||||
height = image.height;
|
||||
let scaledWidth = width * scale;
|
||||
let scaledHeight = height * scale;
|
||||
paddedWidth = padToPowerOfTwo ? nextPowerOfTwo(width) : width;
|
||||
paddedHeight = padToPowerOfTwo ? nextPowerOfTwo(height) : height;
|
||||
let paddedScaledWidth = paddedWidth * scale;
|
||||
let paddedScaledHeight = paddedHeight * scale;
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = paddedWidth;
|
||||
tempCanvas.height = paddedHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
tempCtx.imageSmoothingEnabled = false;
|
||||
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
tempCtx.drawImage(image, 0, 0, width, height);
|
||||
|
||||
const pixels = tempCtx.getImageData(0, 0, paddedWidth, paddedHeight);
|
||||
|
||||
let sizeBytes;
|
||||
let strTextureType = Array.from(elTextureType).find(r => r.checked)?.value || 'rgba';
|
||||
strTextureType = strTextureType.toLowerCase();
|
||||
rawData = new Uint8Array(paddedWidth * paddedHeight * 4);
|
||||
|
||||
if(strTextureType === 'rgba') {
|
||||
textureData = new Uint8Array(pixels.data.buffer);
|
||||
rawData = new Uint8Array(pixels.data.buffer);
|
||||
sizeBytes = paddedWidth * paddedHeight * 4;
|
||||
|
||||
} else if(strTextureType === 'rgb') {
|
||||
sizeBytes = paddedWidth * paddedHeight * 3;
|
||||
textureData = new Uint8Array(sizeBytes);
|
||||
|
||||
for(let i = 0, j = 0; i < textureData.length; i += 4, j += 3) {
|
||||
textureData[j] = pixels.data[i]; // R
|
||||
textureData[j + 1] = pixels.data[i + 1]; // G
|
||||
textureData[j + 2] = pixels.data[i + 2]; // B
|
||||
|
||||
rawData[i] = pixels.data[i]; // R
|
||||
rawData[i + 1] = pixels.data[i + 1]; // G
|
||||
rawData[i + 2] = pixels.data[i + 2]; // B
|
||||
rawData[i + 3] = 255;
|
||||
}
|
||||
} else if(strTextureType === 'rgb565') {
|
||||
sizeBytes = paddedWidth * paddedHeight * 2;
|
||||
textureData = new Uint8Array(sizeBytes);
|
||||
|
||||
let j = 0;
|
||||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||||
const r = pixels.data[i];
|
||||
const g = pixels.data[i + 1];
|
||||
const b = pixels.data[i + 2];
|
||||
|
||||
const value =
|
||||
((r & 0xf8) << 8) |
|
||||
((g & 0xfc) << 3) |
|
||||
(b >> 3);
|
||||
|
||||
textureData[j++] = (value >> 8) & 0xff; // high byte
|
||||
textureData[j++] = value & 0xff; // low byte
|
||||
}
|
||||
|
||||
// Now convert back to RGBA for preview
|
||||
for(let i = 0, j = 0; i < textureData.length; i += 2, j += 4) {
|
||||
const value = (textureData[i] << 8) | textureData[i + 1];
|
||||
const r = (value >> 11) & 0x1f;
|
||||
const g = (value >> 5) & 0x3f;
|
||||
const b = value & 0x1f;
|
||||
rawData[j] = (r << 3) | (r >> 2); // R
|
||||
rawData[j + 1] = (g << 2) | (g >> 4); // G
|
||||
rawData[j + 2] = (b << 3) | (b >> 2); // B
|
||||
rawData[j + 3] = 255; // A
|
||||
}
|
||||
|
||||
} else if(strTextureType === 'rgb5a3') {
|
||||
sizeBytes = paddedWidth * paddedHeight * 2;
|
||||
textureData = new Uint8Array(sizeBytes);
|
||||
|
||||
let j = 0;
|
||||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||||
const r8 = pixels.data[i];
|
||||
const g8 = pixels.data[i + 1];
|
||||
const b8 = pixels.data[i + 2];
|
||||
const a8 = pixels.data[i + 3];
|
||||
|
||||
let value;
|
||||
|
||||
// Opaque: 1RRRRRGGGGGBBBBB
|
||||
if (a8 >= 224) {
|
||||
const r5 = r8 >> 3;
|
||||
const g5 = g8 >> 3;
|
||||
const b5 = b8 >> 3;
|
||||
|
||||
value =
|
||||
0x8000 |
|
||||
(r5 << 10) |
|
||||
(g5 << 5) |
|
||||
b5;
|
||||
} else {
|
||||
// Transparent/translucent: 0AAARRRRGGGGBBBB
|
||||
const a3 = a8 >> 5;
|
||||
const r4 = r8 >> 4;
|
||||
const g4 = g8 >> 4;
|
||||
const b4 = b8 >> 4;
|
||||
|
||||
value =
|
||||
(a3 << 12) |
|
||||
(r4 << 8) |
|
||||
(g4 << 4) |
|
||||
b4;
|
||||
}
|
||||
|
||||
textureData[j++] = (value >> 8) & 0xff; // high byte
|
||||
textureData[j++] = value & 0xff; // low byte
|
||||
}
|
||||
|
||||
// Convert back to RGBA for preview
|
||||
for (let i = 0, j = 0; i < textureData.length; i += 2, j += 4) {
|
||||
const value = (textureData[i] << 8) | textureData[i + 1];
|
||||
|
||||
if (value & 0x8000) {
|
||||
// 1RRRRRGGGGGBBBBB
|
||||
const r5 = (value >> 10) & 0x1f;
|
||||
const g5 = (value >> 5) & 0x1f;
|
||||
const b5 = value & 0x1f;
|
||||
|
||||
rawData[j] = (r5 << 3) | (r5 >> 2);
|
||||
rawData[j + 1] = (g5 << 3) | (g5 >> 2);
|
||||
rawData[j + 2] = (b5 << 3) | (b5 >> 2);
|
||||
rawData[j + 3] = 255;
|
||||
} else {
|
||||
// 0AAARRRRGGGGBBBB
|
||||
const a3 = (value >> 12) & 0x7;
|
||||
const r4 = (value >> 8) & 0xf;
|
||||
const g4 = (value >> 4) & 0xf;
|
||||
const b4 = value & 0xf;
|
||||
|
||||
rawData[j] = (r4 << 4) | r4;
|
||||
rawData[j + 1] = (g4 << 4) | g4;
|
||||
rawData[j + 2] = (b4 << 4) | b4;
|
||||
rawData[j + 3] = (a3 << 5) | (a3 << 2) | (a3 >> 1);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
return alert('Unsupported texture type selected.');
|
||||
}
|
||||
|
||||
// Write out pixels.
|
||||
const imageData = new ImageData(new Uint8ClampedArray(rawData.buffer), paddedWidth, paddedHeight);
|
||||
tempCanvas.width = paddedWidth;
|
||||
tempCanvas.height = paddedHeight;
|
||||
const tempCtx2 = tempCanvas.getContext('2d');
|
||||
tempCtx2.imageSmoothingEnabled = false;
|
||||
tempCtx2.putImageData(imageData, 0, 0);
|
||||
|
||||
elOutputPreview.width = paddedScaledWidth;
|
||||
elOutputPreview.height = paddedScaledHeight;
|
||||
const ctx = elOutputPreview.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, elOutputPreview.width, elOutputPreview.height);
|
||||
ctx.drawImage(tempCanvas, 0, 0, paddedScaledWidth, paddedScaledHeight);
|
||||
|
||||
// Output information
|
||||
elOutputInformation.value = `Original Size: ${width}x${height}\n`;
|
||||
elOutputInformation.value += `New Size: ${paddedWidth}x${paddedHeight}\n`;
|
||||
elOutputInformation.value += `Texture Format: ${textureType}\n`;
|
||||
elOutputInformation.value += `Size: ${sizeBytes} bytes / (${(sizeBytes / 1024).toFixed(2)} KB)\n`;
|
||||
}
|
||||
|
||||
|
||||
const onFile = async file => {
|
||||
elFileInput.disabled = true;
|
||||
console.log('Selected file:', file);
|
||||
|
||||
imageName = file.name;
|
||||
|
||||
// Load as image
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
image = img;
|
||||
elFileInput.disabled = false;
|
||||
updatePreview();
|
||||
};
|
||||
img.onerror = () => {
|
||||
alert('Failed to load image. Please select a valid image file.');
|
||||
elFileInput.disabled = false;
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
// Listeners
|
||||
elFileInput.addEventListener('change', event => {
|
||||
if(!event.target.files || event.target.files.length <= 0) {
|
||||
return alert('No file selected.');
|
||||
}
|
||||
|
||||
const file = event.target.files[0];
|
||||
if(!file.type.startsWith('image/')) {
|
||||
return alert('Selected file is not an image.');
|
||||
}
|
||||
|
||||
onFile(file);
|
||||
});
|
||||
|
||||
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';
|
||||
});
|
||||
|
||||
elPreviewScale.addEventListener('input', () => {
|
||||
updatePreview();
|
||||
});
|
||||
elPadPowerOfTwo.addEventListener('change', () => {
|
||||
updatePreview();
|
||||
});
|
||||
elTextureType.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
updatePreview();
|
||||
});
|
||||
})
|
||||
|
||||
btnDownload.addEventListener('click', () => {
|
||||
if(!image) {
|
||||
return alert('Please select an image file before downloading.');
|
||||
}
|
||||
|
||||
// DTX, then texture type, then width, then height, then raw data
|
||||
const dtfHeader = new TextEncoder().encode('DTX');
|
||||
|
||||
const versionHeader = new Uint8Array([1]); // Version 1
|
||||
|
||||
let strTextureType = Array.from(elTextureType).find(r => r.checked)?.value || 'rgba';
|
||||
strTextureType = strTextureType.toLowerCase();
|
||||
|
||||
let typeHeader;
|
||||
if(strTextureType === 'rgba') {
|
||||
typeHeader = new Uint8Array([0]);
|
||||
} else if(strTextureType === 'rgb') {
|
||||
typeHeader = new Uint8Array([1]);
|
||||
} else if(strTextureType === 'rgb565') {
|
||||
typeHeader = new Uint8Array([2]);
|
||||
} else if(strTextureType === 'rgb5a3') {
|
||||
typeHeader = new Uint8Array([3]);
|
||||
} else {
|
||||
return alert('Unsupported texture type selected.');
|
||||
}
|
||||
|
||||
const widthBytes = new Uint32Array([ paddedWidth ]);
|
||||
const heightBytes = new Uint32Array([ paddedHeight ]);
|
||||
|
||||
const blob = new Blob([dtfHeader, versionHeader, typeHeader, widthBytes, heightBytes, textureData], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${imageName.replace(/\.[^/.]+$/, "")}.dtx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
btnBackgroundCheckerboard.click();
|
||||
</script>
|
||||
</html>
|
||||
@@ -1,235 +0,0 @@
|
||||
<!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.');
|
||||
}
|
||||
|
||||
// Sort images by name to ensure consistent output
|
||||
images = Object.fromEntries(Object.entries(images).sort(([nameA], [nameB]) => nameA.localeCompare(nameB)));
|
||||
|
||||
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 {
|
||||
if(allImagesShareWidth) {
|
||||
outputWidth = nextPowerOfTwo(firstWidth);
|
||||
outputHeight = nextPowerOfTwo(Object.values(images).reduce((sum, img) => sum + img.height, 0));
|
||||
} else if(allImagesShareHeight) {
|
||||
outputHeight = nextPowerOfTwo(firstHeight);
|
||||
outputWidth = nextPowerOfTwo(Object.values(images).reduce((sum, img) => sum + img.width, 0));
|
||||
} else {
|
||||
onBadImages('All images must share the same width or height to be joined together.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,546 +0,0 @@
|
||||
<!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;
|
||||
|
||||
const right = parseInt(elRight.value) || 0;
|
||||
const bottom = parseInt(elBottom.value) || 0;
|
||||
|
||||
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 - right) / tileWidth);
|
||||
rowCount = Math.floor((imageHeight - bottom) / tileHeight);
|
||||
} else {
|
||||
console.log('Defining by count');
|
||||
columnCount = parseInt(elColumnCount.value) || 0;
|
||||
rowCount = parseInt(elRowCount.value) || 0;
|
||||
tileWidth = Math.floor((imageWidth - right) / columnCount);
|
||||
tileHeight = Math.floor((imageHeight - bottom) / rowCount);
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user