Dusk texture creator

This commit is contained in:
2026-04-13 19:51:11 -05:00
parent 4b3826edd9
commit 5a651d2d1f
39 changed files with 1402 additions and 2659 deletions
+2 -2
View File
@@ -83,7 +83,6 @@ assets/borrowed
.VSCode* .VSCode*
/vita /vita
._* ._*
*~ *~
@@ -105,4 +104,5 @@ yarn.lock
/build2 /build2
/build* /build*
/assets/test /assets/test
/tools_old
-3
View File
@@ -58,9 +58,6 @@ target_sources(${DUSK_BINARY_TARGET_NAME}
main.c main.c
) )
# Defs
dusk_env_to_h(duskdefs.env duskdefs.h)
# Subdirs # Subdirs
add_subdirectory(assert) add_subdirectory(assert)
add_subdirectory(asset) add_subdirectory(asset)
+1 -1
View File
@@ -21,7 +21,7 @@ add_subdirectory(texture)
# Color definitions # Color definitions
dusk_run_python( dusk_run_python(
dusk_color_defs dusk_color_defs
tools.display.color.csv tools.color.csv
--csv ${CMAKE_CURRENT_SOURCE_DIR}/color.csv --csv ${CMAKE_CURRENT_SOURCE_DIR}/color.csv
--output ${DUSK_GENERATED_HEADERS_DIR}/display/color.h --output ${DUSK_GENERATED_HEADERS_DIR}/display/color.h
) )
-45
View File
@@ -1,45 +0,0 @@
# Copyright (c) 2025 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
ENTITY_DIR_SOUTH = 0
ENTITY_DIR_WEST = 1
ENTITY_DIR_EAST = 2
ENTITY_DIR_NORTH = 3
ENTITY_COUNT = 128
ENTITY_TYPE_NULL = 0
ENTITY_TYPE_PLAYER = 1
ENTITY_TYPE_NPC = 2
ENTITY_TYPE_COUNT = 3
CHUNK_WIDTH = 16
CHUNK_HEIGHT = 16
CHUNK_DEPTH = 4
# CHUNK_VERTEX_COUNT_MAX = QUAD_VERTEXES * CHUNK_WIDTH * CHUNK_HEIGHT * 4
CHUNK_VERTEX_COUNT_MAX=6144
CHUNK_MESH_COUNT_MAX = 14
CHUNK_ENTITY_COUNT_MAX = 8
TILE_WIDTH = 16.0
TILE_HEIGHT = 16.0
TILE_DEPTH = 16.0
TILE_SHAPE_NULL = 0
TILE_SHAPE_FLOOR = 1
TILE_SHAPE_RAMP_SOUTH = 2
TILE_SHAPE_RAMP_WEST = 3
TILE_SHAPE_RAMP_EAST = 4
TILE_SHAPE_RAMP_NORTH = 5
TILE_SHAPE_RAMP_SOUTHWEST = 6
TILE_SHAPE_RAMP_SOUTHEAST = 7
TILE_SHAPE_RAMP_NORTHWEST = 8
TILE_SHAPE_RAMP_NORTHEAST = 9
RPG_CAMERA_FOV = 70
RPG_CAMERA_PIXELS_PER_UNIT = 1.0
RPG_CAMERA_Z_OFFSET = 24.0
ASSET_LANG_CHUNK_CHAR_COUNT = 6144
+1 -1
View File
@@ -14,6 +14,6 @@ dusk_run_python(
dusk_story_defs dusk_story_defs
tools.story.csv tools.story.csv
--csv ${CMAKE_CURRENT_SOURCE_DIR}/storyflag.csv --csv ${CMAKE_CURRENT_SOURCE_DIR}/storyflag.csv
--header-file ${DUSK_GENERATED_HEADERS_DIR}/story/storyflagvalue.h --output ${DUSK_GENERATED_HEADERS_DIR}/story/storyflagvalue.h
) )
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} dusk_story_defs) add_dependencies(${DUSK_LIBRARY_TARGET_NAME} dusk_story_defs)
+3 -4
View File
@@ -1,7 +1,6 @@
# Copyright (c) 2025 Dominic Msters # Copyright (c) 2026 Dominic Masters
# #
# This software is released under the MIT License. # This software is released under the MIT License.
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
add_subdirectory(run_python) add_subdirectory(run_python)
add_subdirectory(env_to_h)
+79
View File
@@ -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))
-106
View File
@@ -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)
+173
View File
@@ -0,0 +1,173 @@
"use strict";
// DTF Dusk Texture Format
//
// Header (13 bytes):
// [02] "DTF" magic
// [3] 0x01 version
// [47] uint32 width (little-endian)
// [811] 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,
});
})();
+55
View File
@@ -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 });
})();
+38
View File
@@ -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);
}
+38
View File
@@ -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);
}
+43
View File
@@ -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;
}
+16
View File
@@ -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;
}
+79
View File
@@ -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;
}
+19
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
/* Generic Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
+15
View File
@@ -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";
+59
View File
@@ -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;
}
+16
View File
@@ -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;
}
+124
View File
@@ -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>
+278
View File
@@ -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);
-15
View File
@@ -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()
-46
View File
@@ -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
View File
@@ -1,60 +1,58 @@
import argparse import argparse
import os
import csv import csv
import os
parser = argparse.ArgumentParser(description="Input CSV to .h defines") parser = argparse.ArgumentParser(description="Input CSV to .h defines")
parser.add_argument("--csv", required=True, help="Path to Input CSV file") parser.add_argument("--csv", required=True, help="Path to Input CSV file")
parser.add_argument("--output", required=True, help="Path to output .h file") parser.add_argument("--output", required=True, help="Path to output .h file")
args = parser.parse_args() args = parser.parse_args()
def csvIdToEnumName(inputId): def id_enum(name):
return "INPUT_ACTION_" + inputId.upper() return "INPUT_ACTION_" + name.upper()
# Load up CSV file. # Load CSV
outHeader = "#pragma once\n" input_ids = []
outHeader += '#include "dusk.h"\n\n' with open(args.csv, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
with open(args.csv, newline="", encoding="utf-8") as csvfile:
reader = csv.DictReader(csvfile)
# CSV must have id column.
if "id" not in reader.fieldnames: if "id" not in reader.fieldnames:
raise Exception("CSV file must have 'id' column") raise ValueError("CSV must have an 'id' column")
# For each ID
inputIds = []
inputIdValues = {}
for row in reader: for row in reader:
inputId = row["id"] input_id = row["id"]
if inputId not in inputIds: if input_id not in input_ids:
inputIds.append(inputId) input_ids.append(input_id)
# For each ID, create enum entry. # Assign enum values
count = 0 id_values = {input_id: i + 1 for i, input_id in enumerate(input_ids)}
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"
# Write IDs to char array. # Build output
outHeader += f"static const char_t* INPUT_ACTION_IDS[] = {{\n" out = [
for inputId in inputIds: "#pragma once",
outHeader += f" [{csvIdToEnumName(inputId)}] = \"{inputId}\",\n" '#include "dusk.h"',
outHeader += f"}};\n\n" "",
"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) os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as outFile: with open(args.output, "w", encoding="utf-8") as f:
outFile.write(outHeader) f.write("\n".join(out))
+82 -72
View File
@@ -1,94 +1,104 @@
import argparse import argparse
import os
import csv import csv
import os
parser = argparse.ArgumentParser(description="Item CSV to .h defines") parser = argparse.ArgumentParser(description="Item CSV to .h defines")
parser.add_argument("--csv", required=True, help="Path to item CSV file") parser.add_argument("--csv", required=True, help="Path to item CSV file")
parser.add_argument("--output", required=True, help="Path to output .h file") parser.add_argument("--output", required=True, help="Path to output .h file")
args = parser.parse_args() args = parser.parse_args()
def csvIdToEnumName(itemId): def type_enum(name):
return "ITEM_ID_" + itemId.upper() return "ITEM_TYPE_" + name.upper()
itemIds = [] def id_enum(name):
itemTypes = [] return "ITEM_ID_" + name.upper()
itemRowById = {}
with open(args.csv, newline="", encoding="utf-8") as csvfile: # Load CSV
reader = csv.DictReader(csvfile) 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: 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: for row in reader:
itemId = row["id"] item_id, item_type = row["id"], row["type"]
itemType = row["type"] if item_id not in item_ids:
item_ids.append(item_id)
if itemId not in itemIds: if item_type not in item_types:
itemIds.append(itemId) item_types.append(item_type)
rows[item_id] = row
if itemType not in itemTypes: # Assign enum values — types and IDs share a single counter so values never collide
itemTypes.append(itemType)
itemRowById[itemId] = row
# Now Prep output
outHeader = "#pragma once\n"
outHeader += '#include "dusk.h"\n\n'
itemTypeValues = {}
itemIdValues = {}
count = 0 count = 0
type_values = {}
id_values = {}
# Create enum for types and ids, include null and count. count += 1 # 0 = NULL
outHeader += "typedef enum {\n" for t in item_types:
outHeader += f" ITEM_TYPE_NULL = {count},\n" type_values[t] = count
count += 1
for itemType in itemTypes:
itemTypeValues[itemType] = count
outHeader += f" {csvIdToEnumName(itemType)} = {count},\n"
count += 1 count += 1
outHeader += f" ITEM_TYPE_COUNT = {count}\n" # ITEM_TYPE_COUNT = count; item IDs continue from here
outHeader += "} itemtype_t;\n\n" type_count = count
outHeader += "typedef enum {\n" count += 1 # type_count = ITEM_ID_NULL
outHeader += f" ITEM_ID_NULL = {count},\n" for i in item_ids:
for itemId in itemIds: id_values[i] = count
itemIdValues[itemId] = count
outHeader += f" {csvIdToEnumName(itemId)} = {count},\n"
count += 1 count += 1
outHeader += f" ITEM_ID_COUNT = {count}\n" id_count = count
outHeader += "} itemid_t;\n\n"
# Create struct for item data. # Build output
outHeader += "typedef struct {\n" out = [
outHeader += " itemid_t id;\n" "#pragma once",
outHeader += " itemtype_t type;\n" '#include "dusk.h"',
outHeader += " const char_t *name;\n" "",
outHeader += "} item_t;\n\n" "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) os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as outFile: with open(args.output, "w", encoding="utf-8") as f:
outFile.write(outHeader) f.write("\n".join(out))
-687
View File
@@ -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>
+2 -7
View File
@@ -1,10 +1,5 @@
# Copyright (c) 2026 Dominic Masters # 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. # This software is released under the MIT License.
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
@@ -16,4 +11,4 @@ function(dusk_run_python CMAKE_TARGET_NAME PYTHON_MODULE)
COMMAND COMMAND
${Python3_EXECUTABLE} -m ${PYTHON_MODULE} ${ARGN} ${Python3_EXECUTABLE} -m ${PYTHON_MODULE} ${ARGN}
) )
endfunction() endfunction()
+23
View File
@@ -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
View File
@@ -1,48 +1,49 @@
import argparse import argparse
import os
import csv import csv
import os
parser = argparse.ArgumentParser(description="Story CSV to .h defines") parser = argparse.ArgumentParser(description="Story CSV to .h defines")
parser.add_argument("--csv", required=True, help="Path to story CSV file") 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() args = parser.parse_args()
def idToEnum(id): def flag_enum(name):
return "STORY_FLAG_" + id.upper().replace(" ", "_") return "STORY_FLAG_" + name.upper().replace(" ", "_")
# Load up CSV file. # Load flags
outHeader = "#pragma once\n" flags = []
outHeader += '#include "story/storyflagdefs.h"\n\n' with open(args.csv, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
with open(args.csv, newline="", encoding="utf-8") as csvfile:
reader = csv.DictReader(csvfile)
# CSV must have id column
if "id" not in reader.fieldnames: if "id" not in reader.fieldnames:
raise Exception("CSV file must have 'id' column") raise ValueError("CSV must have an 'id' column")
# Generate enum
outHeader += "typedef enum {\n"
outHeader += " STORY_FLAG_NULL,\n\n"
for row in reader: for row in reader:
id = idToEnum(row["id"].strip()) flags.append({
outHeader += f" {id},\n" "id": row["id"].strip(),
outHeader += "\n STORY_FLAG_COUNT\n" "initial": (row.get("initial") or "0").strip(),
outHeader += "} storyflag_t;\n\n" })
# Generate flag values # Build output
csvfile.seek(0) out = [
reader = csv.DictReader(csvfile) "#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" os.makedirs(os.path.dirname(args.output), exist_ok=True)
for row in reader: with open(args.output, "w", encoding="utf-8") as f:
id = idToEnum(row["id"].strip()) f.write("\n".join(out))
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)
-417
View File
@@ -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>
-235
View File
@@ -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
-546
View File
@@ -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>