Remove all existing code for fresh rewrite on new branch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
3948607b1e
commit
3710924503
1107 files changed
-78347
No files matched your search
@@ -1,6 +0,0 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
add_subdirectory(run_python)
|
||||
@@ -1,624 +0,0 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
"""
|
||||
Generates DCF + companion DMF files from raw chunk JSON files, or upgrades
|
||||
legacy DCF files (version 1 or 2) to the current version.
|
||||
|
||||
JSON input (assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json):
|
||||
{
|
||||
"tiles": [
|
||||
{ "pos": [x, y, z], "type": <tile_shape_int>, "tile": <uv_tile_int> }
|
||||
],
|
||||
"meshes": [
|
||||
{ "file": "house_5_3.dmf", "pos": [x, y, z] }
|
||||
],
|
||||
"entities": [
|
||||
{ "type": "global", "globalId": <int>, "pos": [x, y, z] },
|
||||
{ "type": "item", "itemId": <int>, "quantity": <int>, "pos": [x, y, z] }
|
||||
],
|
||||
"areas": [
|
||||
{
|
||||
"min": [x, y, z], "max": [x, y, z],
|
||||
"callbackId": <int>, "notify": <int>, "trigger": <int>
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Tiles absent from the array default to TILE_SHAPE_NULL. Each (x, y)
|
||||
column may only have ONE real tile - chunks store one tile per column,
|
||||
not one per (x, y, z), and that tile records its own local z (see the
|
||||
version 4 tile format below). If two entries share the same (x, y) with
|
||||
different z, the later one in the array wins and a warning is printed.
|
||||
Mesh files are located by searching under assets/meshes/ and referenced by
|
||||
path from the assets root in the DCF.
|
||||
|
||||
<map> is the map's name, up to 9 characters (MAP_NAME_MAX in
|
||||
src/dusk/rpg/overworld/map.h reserves 1 byte for the null terminator).
|
||||
|
||||
"entities" spawns things into the world when this chunk loads. A "global"
|
||||
entity is spawned via mapSpawnEntity() - globalId indexes
|
||||
ENTITY_GLOBAL_LIST (src/dusk/rpg/entity/global/entitygloballist.h) and is
|
||||
deduped automatically if already spawned, so it's safe to declare on a
|
||||
chunk that streams in more than once. An "item" entity has no persistent
|
||||
identity - it respawns fresh every time this chunk (re)loads, including
|
||||
after being picked up, since nothing tracks "already collected" yet.
|
||||
itemId is a raw ITEM_ID_* value (see src/dusk/rpg/item/item.json for the
|
||||
name -> id mapping, same convention as the tile "type" ints above).
|
||||
|
||||
"areas" declares map trigger regions (see rpg/overworld/maparea.h) owned
|
||||
by this chunk - they're removed when the chunk unloads and re-added if it
|
||||
streams back in. callbackId indexes MAP_AREA_CALLBACK_LIST
|
||||
(src/dusk/rpg/overworld/global/mapareagloballist.h; 0 is reserved and
|
||||
invalid). notify is bitwise MAP_AREA_NOTIFY_PLAYER(1)|NOTIFY_NPC(2).
|
||||
trigger is bitwise MAP_TRIGGER_STEP(1)|ENTER(2)|EXIT(4).
|
||||
|
||||
Output DCF is derived automatically:
|
||||
assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json
|
||||
-> assets/maps/<map>/chunks/X_Y_Z.dcf
|
||||
|
||||
Version 5 DCF format (after 8-byte header):
|
||||
tile_t tiles[CHUNK_WIDTH * CHUNK_HEIGHT] (one per x/y column)
|
||||
each tile: uint32_t shape, uint8_t z, 3 padding bytes (8 bytes total,
|
||||
matching the C tile_t struct's layout: { tileshape_t shape; uint8_t z; })
|
||||
uint8_t meshCount
|
||||
for each model:
|
||||
null-terminated string (relative asset path to .json model)
|
||||
float32[3] (x, y, z offset)
|
||||
uint8_t entitySpawnCount
|
||||
for each entity spawn:
|
||||
uint8_t kind (0 = global entity, 1 = item entity)
|
||||
uint16_t a (globalId if kind 0, itemId if kind 1)
|
||||
uint8_t b (unused if kind 0, quantity if kind 1)
|
||||
int16_t x, y, z (world position, 3 fields)
|
||||
uint8_t areaSpawnCount
|
||||
for each area spawn:
|
||||
int16_t minX, minY, minZ (3 fields)
|
||||
int16_t maxX, maxY, maxZ (3 fields)
|
||||
uint16_t callbackId
|
||||
uint8_t notify
|
||||
uint8_t trigger
|
||||
|
||||
DMF format:
|
||||
Bytes 0-3: DMF\\x00
|
||||
Bytes 4-7: uint32_t version = 1 (little-endian)
|
||||
Bytes 8-11: uint32_t vertCount (little-endian)
|
||||
Bytes 12+: meshvertex_t vertices[vertCount]
|
||||
Each vertex: uv[2] + pos[3] = 5 x float32 LE = 20 bytes
|
||||
|
||||
Usage:
|
||||
python3 -m tools.asset.chunk # process all assetsraw/maps/*/chunks/*.json
|
||||
python3 -m tools.asset.chunk <file.json> # process one JSON file
|
||||
python3 -m tools.asset.chunk <file.dcf> # upgrade legacy (v1/v2) DCF in-place
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..', '..'))
|
||||
ASSETSRAW_DIR = os.path.join(PROJECT_ROOT, 'assetsraw')
|
||||
ASSETS_DIR = os.path.join(PROJECT_ROOT, 'assets')
|
||||
|
||||
CHUNK_WIDTH = 16
|
||||
CHUNK_HEIGHT = 16
|
||||
CHUNK_DEPTH = 8
|
||||
# One tile per (x, y) column - the column's local Z (0..CHUNK_DEPTH-1) is
|
||||
# carried on the tile itself instead of storing a tile per (x, y, z).
|
||||
CHUNK_TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT # 256
|
||||
|
||||
# World-space Z distance of one Z-layer/story, in world-float space.
|
||||
# Must match WORLD_LAYER_HEIGHT in src/dusk/rpg/overworld/worldpos.h.
|
||||
WORLD_LAYER_HEIGHT = 1.0 / math.sqrt(2)
|
||||
|
||||
CHUNK_MESH_COUNT_MAX = 10
|
||||
CHUNK_MESH_NAME_MAX = 64
|
||||
CHUNK_ENTITY_SPAWN_COUNT_MAX = 8
|
||||
CHUNK_AREA_COUNT_MAX = 4
|
||||
|
||||
# Matches MAP_NAME_MAX in src/dusk/rpg/overworld/map.h (buffer size,
|
||||
# including the null terminator - so map names are at most 9 characters).
|
||||
MAP_NAME_MAX = 10
|
||||
|
||||
ENTITY_SPAWN_KIND_GLOBAL = 0
|
||||
ENTITY_SPAWN_KIND_ITEM = 1
|
||||
|
||||
# Matches sizeof(tile_t) on the C side: uint32_t shape + uint8_t z, padded
|
||||
# to 8 bytes ({ tileshape_t shape; uint8_t z; } with 4-byte enum alignment).
|
||||
TILE_STRUCT_FORMAT = '<IB3x'
|
||||
TILE_SIZE = struct.calcsize(TILE_STRUCT_FORMAT)
|
||||
VERTEX_SIZE = 20
|
||||
|
||||
# Legacy (v1/v2) DCFs stored one uint32 shape per (x, y, z) triple across
|
||||
# the full 3D grid, at whatever CHUNK_DEPTH was in effect when they were
|
||||
# written (4). Frozen here, independent of the live CHUNK_DEPTH above, so
|
||||
# a future depth change can't corrupt parsing of genuinely old files.
|
||||
_LEGACY_CHUNK_DEPTH = 4
|
||||
_LEGACY_TILE_SIZE = 4
|
||||
_LEGACY_CHUNK_TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * _LEGACY_CHUNK_DEPTH
|
||||
|
||||
TILE_SHAPE_NULL = 0
|
||||
TILE_SHAPE_GROUND = 1
|
||||
TILE_SHAPE_RAMP_NORTH = 2
|
||||
TILE_SHAPE_RAMP_EAST = 3
|
||||
TILE_SHAPE_RAMP_SOUTH = 4
|
||||
TILE_SHAPE_RAMP_WEST = 5
|
||||
TILE_SHAPE_RAMP_NORTHEAST = 6
|
||||
TILE_SHAPE_RAMP_NORTHWEST = 7
|
||||
TILE_SHAPE_RAMP_SOUTHEAST = 8
|
||||
TILE_SHAPE_RAMP_SOUTHWEST = 9
|
||||
TILE_SHAPE_RAMP_NORTHEAST_INNER = 10
|
||||
TILE_SHAPE_RAMP_NORTHWEST_INNER = 11
|
||||
TILE_SHAPE_RAMP_SOUTHEAST_INNER = 12
|
||||
TILE_SHAPE_RAMP_SOUTHWEST_INNER = 13
|
||||
|
||||
FILE_MAGIC = b'DCF'
|
||||
DMF_MAGIC = b'DMF\x00'
|
||||
VERSION_OUT = 5
|
||||
DMF_VERSION = 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tile_index(x, y):
|
||||
return x + y * CHUNK_WIDTH
|
||||
|
||||
|
||||
def find_model(filename):
|
||||
"""Search ASSETS_DIR/models/ recursively for filename.
|
||||
Returns the path relative to ASSETS_DIR (forward slashes), or None."""
|
||||
models_root = os.path.join(ASSETS_DIR, 'models')
|
||||
for dirpath, _, filenames in os.walk(models_root):
|
||||
if filename in filenames:
|
||||
rel = os.path.relpath(
|
||||
os.path.join(dirpath, filename), ASSETS_DIR
|
||||
)
|
||||
return rel.replace('\\', '/')
|
||||
return None
|
||||
|
||||
|
||||
def write_model_json(path, mesh_rel, texture_rel, color):
|
||||
"""Write a model JSON descriptor file."""
|
||||
obj = {'mesh': mesh_rel, 'color': color}
|
||||
if texture_rel:
|
||||
obj['texture'] = texture_rel
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
json.dump(obj, f, indent=2)
|
||||
print(f' Wrote model JSON {path}')
|
||||
|
||||
|
||||
def chunk_map_name(json_path):
|
||||
"""assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json -> <map>
|
||||
|
||||
Raises ValueError if json_path isn't under that structure, or if <map>
|
||||
is longer than MAP_NAME_MAX - 1 characters."""
|
||||
rel = os.path.relpath(os.path.abspath(json_path), ASSETSRAW_DIR)
|
||||
parts = rel.split(os.sep)
|
||||
if len(parts) != 4 or parts[0] != 'maps' or parts[2] != 'chunks':
|
||||
raise ValueError(
|
||||
f"Chunk JSON must live at assetsraw/maps/<map>/chunks/*.json, got: "
|
||||
f"{json_path}"
|
||||
)
|
||||
map_name = parts[1]
|
||||
if len(map_name) > MAP_NAME_MAX - 1:
|
||||
raise ValueError(
|
||||
f"Map name too long (> {MAP_NAME_MAX - 1} characters): {map_name}"
|
||||
)
|
||||
return map_name
|
||||
|
||||
|
||||
def derive_dcf_path(json_path):
|
||||
"""assetsraw/maps/<map>/chunks/chunk_X_Y_Z.json
|
||||
-> assets/maps/<map>/chunks/X_Y_Z.dcf"""
|
||||
base = os.path.splitext(os.path.basename(json_path))[0]
|
||||
if base.startswith('chunk_'):
|
||||
base = base[len('chunk_'):]
|
||||
rel_dir = os.path.relpath(os.path.dirname(os.path.abspath(json_path)), ASSETSRAW_DIR)
|
||||
return os.path.join(ASSETS_DIR, rel_dir, base + '.dcf')
|
||||
|
||||
|
||||
def write_dmf(path, vertex_bytes):
|
||||
vert_count = len(vertex_bytes) // VERTEX_SIZE
|
||||
buf = bytearray()
|
||||
buf += DMF_MAGIC
|
||||
buf += struct.pack('<I', DMF_VERSION)
|
||||
buf += struct.pack('<I', vert_count)
|
||||
buf += vertex_bytes
|
||||
with open(path, 'wb') as f:
|
||||
f.write(buf)
|
||||
print(f' Wrote DMF {path}: {vert_count} vertices, {len(buf)} bytes')
|
||||
|
||||
|
||||
def write_dcf(
|
||||
dcf_path, tiles, mesh_names, mesh_offsets=None,
|
||||
entity_spawns=None, area_spawns=None
|
||||
):
|
||||
"""Write a current-version DCF referencing the given DMF asset paths."""
|
||||
mesh_count = len(mesh_names)
|
||||
if mesh_offsets is None:
|
||||
mesh_offsets = [(0.0, 0.0, 0.0)] * mesh_count
|
||||
if entity_spawns is None:
|
||||
entity_spawns = []
|
||||
if area_spawns is None:
|
||||
area_spawns = []
|
||||
|
||||
if len(entity_spawns) > CHUNK_ENTITY_SPAWN_COUNT_MAX:
|
||||
raise ValueError(
|
||||
f"Too many entity spawns ({len(entity_spawns)}) - max "
|
||||
f"{CHUNK_ENTITY_SPAWN_COUNT_MAX}"
|
||||
)
|
||||
if len(area_spawns) > CHUNK_AREA_COUNT_MAX:
|
||||
raise ValueError(
|
||||
f"Too many area spawns ({len(area_spawns)}) - max "
|
||||
f"{CHUNK_AREA_COUNT_MAX}"
|
||||
)
|
||||
|
||||
buf = bytearray()
|
||||
buf += FILE_MAGIC
|
||||
buf += b'\x00'
|
||||
buf += struct.pack('<I', VERSION_OUT)
|
||||
buf += tiles
|
||||
buf += struct.pack('<B', mesh_count)
|
||||
for name, offset in zip(mesh_names, mesh_offsets):
|
||||
encoded = name.encode('ascii')
|
||||
if len(encoded) >= CHUNK_MESH_NAME_MAX:
|
||||
raise ValueError(
|
||||
f"Mesh name too long (>= {CHUNK_MESH_NAME_MAX}): {name}"
|
||||
)
|
||||
buf += encoded + b'\x00'
|
||||
buf += struct.pack('<3f', offset[0], offset[1], offset[2])
|
||||
|
||||
buf += struct.pack('<B', len(entity_spawns))
|
||||
for spawn in entity_spawns:
|
||||
kind = spawn['kind']
|
||||
x, y, z = spawn['pos']
|
||||
if kind == ENTITY_SPAWN_KIND_GLOBAL:
|
||||
a, b = spawn['globalId'], 0
|
||||
else:
|
||||
a, b = spawn['itemId'], spawn['quantity']
|
||||
buf += struct.pack('<BHB3h', kind, a, b, x, y, z)
|
||||
|
||||
buf += struct.pack('<B', len(area_spawns))
|
||||
for area in area_spawns:
|
||||
minX, minY, minZ = area['min']
|
||||
maxX, maxY, maxZ = area['max']
|
||||
buf += struct.pack(
|
||||
'<6hHBB',
|
||||
minX, minY, minZ, maxX, maxY, maxZ,
|
||||
area['callbackId'], area['notify'], area['trigger']
|
||||
)
|
||||
|
||||
with open(dcf_path, 'wb') as f:
|
||||
f.write(buf)
|
||||
print(
|
||||
f' Wrote DCF {dcf_path}: '
|
||||
f'version {VERSION_OUT}, {mesh_count} mesh(es), '
|
||||
f'{len(entity_spawns)} entity spawn(s), {len(area_spawns)} '
|
||||
f'area(s), {len(buf)} bytes'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON -> DCF + DMF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tile_quad(u0, u1, v0, v1, fx, fy, sw_z, se_z, ne_z, nw_z):
|
||||
"""Six vertices (2 CCW triangles) for a quad with per-corner Z heights.
|
||||
Corner order: SW=(fx,fy), SE=(fx+1,fy), NE=(fx+1,fy+1), NW=(fx,fy+1).
|
||||
NORTH=+Y, EAST=+X (matches entityDirGetRelative)."""
|
||||
verts = [
|
||||
(u0, v0, fx, fy, sw_z),
|
||||
(u1, v0, fx+1, fy, se_z),
|
||||
(u1, v1, fx+1, fy+1, ne_z),
|
||||
(u0, v0, fx, fy, sw_z),
|
||||
(u1, v1, fx+1, fy+1, ne_z),
|
||||
(u0, v1, fx, fy+1, nw_z),
|
||||
]
|
||||
buf = bytearray()
|
||||
for v in verts:
|
||||
buf += struct.pack('<5f', *v)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
# Per-shape corner heights as (sw, se, ne, nw) offsets from tile base Z.
|
||||
# NORTH=+Y, EAST=+X. Cardinal ramps: low face at 0, high face at 1.
|
||||
# Diagonal outer-corner ramps: only the named corner raised, rest at 0.
|
||||
# Diagonal inner-corner ramps: only the corner opposite the named one is
|
||||
# lowered to 0, the rest (including the named corner) stay raised at 1.
|
||||
_RAMP_CORNERS = {
|
||||
TILE_SHAPE_GROUND: (0.0, 0.0, 0.0, 0.0),
|
||||
TILE_SHAPE_RAMP_NORTH: (0.0, 0.0, 1.0, 1.0), # south=low, north=high
|
||||
TILE_SHAPE_RAMP_SOUTH: (1.0, 1.0, 0.0, 0.0), # south=high, north=low
|
||||
TILE_SHAPE_RAMP_EAST: (0.0, 1.0, 1.0, 0.0), # west=low, east=high
|
||||
TILE_SHAPE_RAMP_WEST: (1.0, 0.0, 0.0, 1.0), # west=high, east=low
|
||||
TILE_SHAPE_RAMP_NORTHEAST: (0.0, 0.0, 1.0, 0.0), # only NE raised
|
||||
TILE_SHAPE_RAMP_NORTHWEST: (0.0, 0.0, 0.0, 1.0), # only NW raised
|
||||
TILE_SHAPE_RAMP_SOUTHEAST: (0.0, 1.0, 0.0, 0.0), # only SE raised
|
||||
TILE_SHAPE_RAMP_SOUTHWEST: (1.0, 0.0, 0.0, 0.0), # only SW raised
|
||||
TILE_SHAPE_RAMP_NORTHEAST_INNER: (0.0, 1.0, 1.0, 1.0), # only SW low
|
||||
TILE_SHAPE_RAMP_NORTHWEST_INNER: (1.0, 0.0, 1.0, 1.0), # only SE low
|
||||
TILE_SHAPE_RAMP_SOUTHEAST_INNER: (1.0, 1.0, 1.0, 0.0), # only NW low
|
||||
TILE_SHAPE_RAMP_SOUTHWEST_INNER: (1.0, 1.0, 0.0, 1.0), # only NE low
|
||||
}
|
||||
|
||||
|
||||
def build_terrain_verts(tiles_bytes):
|
||||
"""Generate quads for every non-null tile, shaped to match tile type."""
|
||||
buf = bytearray()
|
||||
for y in range(CHUNK_HEIGHT):
|
||||
for x in range(CHUNK_WIDTH):
|
||||
idx = tile_index(x, y)
|
||||
tile_type, z = struct.unpack_from(
|
||||
'<IB', tiles_bytes, idx * TILE_SIZE
|
||||
)
|
||||
corners = _RAMP_CORNERS.get(tile_type)
|
||||
if corners is None:
|
||||
continue
|
||||
fx, fy, fz = float(x), float(y), float(z)
|
||||
u0 = x / CHUNK_WIDTH
|
||||
u1 = (x + 1) / CHUNK_WIDTH
|
||||
v0 = y / CHUNK_HEIGHT
|
||||
v1 = (y + 1) / CHUNK_HEIGHT
|
||||
sw, se, ne, nw = corners
|
||||
buf += _tile_quad(
|
||||
u0, u1, v0, v1, fx, fy,
|
||||
(fz + sw) * WORLD_LAYER_HEIGHT,
|
||||
(fz + se) * WORLD_LAYER_HEIGHT,
|
||||
(fz + ne) * WORLD_LAYER_HEIGHT,
|
||||
(fz + nw) * WORLD_LAYER_HEIGHT
|
||||
)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def from_json(json_path, dcf_path):
|
||||
map_name = chunk_map_name(json_path)
|
||||
|
||||
with open(json_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
tiles = bytearray(CHUNK_TILE_COUNT * TILE_SIZE)
|
||||
column_z = {}
|
||||
for tile in data.get('tiles', []):
|
||||
pos = tile['pos']
|
||||
x, y, z = int(pos[0]), int(pos[1]), int(pos[2])
|
||||
if (x, y) in column_z and column_z[(x, y)] != z:
|
||||
print(
|
||||
f' WARNING: {json_path}: column ({x}, {y}) has tiles at '
|
||||
f'z={column_z[(x, y)]} and z={z} - only one tile per '
|
||||
f'column is kept, z={z} wins (last one in the array)'
|
||||
)
|
||||
column_z[(x, y)] = z
|
||||
struct.pack_into(
|
||||
TILE_STRUCT_FORMAT, tiles, tile_index(x, y) * TILE_SIZE,
|
||||
int(tile['type']), z
|
||||
)
|
||||
|
||||
terrain_verts = build_terrain_verts(tiles)
|
||||
model_names = []
|
||||
mesh_offsets = []
|
||||
|
||||
dcf_base = os.path.splitext(os.path.basename(dcf_path))[0]
|
||||
|
||||
# Terrain mesh + model (only written if non-empty). Namespaced under the
|
||||
# owning map so two maps can each have a chunk at the same (x, y, z)
|
||||
# without their generated terrain assets colliding.
|
||||
terrain_dir = os.path.join(ASSETS_DIR, 'maps', map_name, 'meshes', 'chunks')
|
||||
models_dir = os.path.join(ASSETS_DIR, 'maps', map_name, 'models', 'chunks')
|
||||
os.makedirs(terrain_dir, exist_ok=True)
|
||||
os.makedirs(models_dir, exist_ok=True)
|
||||
if terrain_verts:
|
||||
dmf_name = f'chunk_{dcf_base}_0.dmf'
|
||||
write_dmf(os.path.join(terrain_dir, dmf_name), terrain_verts)
|
||||
mesh_rel = f'maps/{map_name}/meshes/chunks/{dmf_name}'
|
||||
json_name = f'chunk_{dcf_base}_0.json'
|
||||
write_model_json(
|
||||
os.path.join(models_dir, json_name),
|
||||
mesh_rel,
|
||||
'tiles.png',
|
||||
[255, 255, 255, 255]
|
||||
)
|
||||
model_names.append(f'maps/{map_name}/models/chunks/{json_name}')
|
||||
mesh_offsets.append((0.0, 0.0, 0.0))
|
||||
|
||||
# External mesh references -> look up matching model JSON
|
||||
for mesh in data.get('meshes', []):
|
||||
filename = mesh['file']
|
||||
pos = mesh.get('pos', [0, 0, 0])
|
||||
model_filename = os.path.splitext(filename)[0] + '.json'
|
||||
rel = find_model(model_filename)
|
||||
if rel is None:
|
||||
raise ValueError(
|
||||
f"Model not found under assets/models/: {model_filename}"
|
||||
)
|
||||
model_names.append(rel)
|
||||
mesh_offsets.append((float(pos[0]), float(pos[1]), float(pos[2])))
|
||||
print(f' Resolved {filename} -> {rel}')
|
||||
|
||||
entity_spawns = []
|
||||
for spawn in data.get('entities', []):
|
||||
pos = tuple(int(v) for v in spawn['pos'])
|
||||
if spawn['type'] == 'global':
|
||||
entity_spawns.append({
|
||||
'kind': ENTITY_SPAWN_KIND_GLOBAL,
|
||||
'globalId': int(spawn['globalId']),
|
||||
'pos': pos,
|
||||
})
|
||||
elif spawn['type'] == 'item':
|
||||
entity_spawns.append({
|
||||
'kind': ENTITY_SPAWN_KIND_ITEM,
|
||||
'itemId': int(spawn['itemId']),
|
||||
'quantity': int(spawn['quantity']),
|
||||
'pos': pos,
|
||||
})
|
||||
else:
|
||||
raise ValueError(f"Unknown entity spawn type: {spawn['type']}")
|
||||
|
||||
area_spawns = []
|
||||
for area in data.get('areas', []):
|
||||
area_spawns.append({
|
||||
'min': tuple(int(v) for v in area['min']),
|
||||
'max': tuple(int(v) for v in area['max']),
|
||||
'callbackId': int(area['callbackId']),
|
||||
'notify': int(area['notify']),
|
||||
'trigger': int(area['trigger']),
|
||||
})
|
||||
|
||||
write_dcf(
|
||||
dcf_path, bytes(tiles), model_names, mesh_offsets,
|
||||
entity_spawns, area_spawns
|
||||
)
|
||||
|
||||
|
||||
def process_json(json_path):
|
||||
dcf_path = derive_dcf_path(json_path)
|
||||
os.makedirs(os.path.dirname(dcf_path), exist_ok=True)
|
||||
print(f"{json_path} -> {dcf_path}")
|
||||
from_json(json_path, dcf_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy DCF (v1/v2) -> current version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collapse_legacy_tiles(legacy_tiles, path):
|
||||
"""Collapse the old one-tile-per-(x,y,z) grid into the current
|
||||
one-tile-per-(x,y) format. If a column has more than one non-null tile
|
||||
across its Z layers, the highest Z wins and a warning is printed."""
|
||||
tiles = bytearray(CHUNK_TILE_COUNT * TILE_SIZE)
|
||||
for y in range(CHUNK_HEIGHT):
|
||||
for x in range(CHUNK_WIDTH):
|
||||
found = []
|
||||
for z in range(_LEGACY_CHUNK_DEPTH):
|
||||
legacy_idx = x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT
|
||||
shape = struct.unpack_from(
|
||||
'<I', legacy_tiles, legacy_idx * _LEGACY_TILE_SIZE
|
||||
)[0]
|
||||
if shape != TILE_SHAPE_NULL:
|
||||
found.append((z, shape))
|
||||
if not found:
|
||||
continue
|
||||
if len(found) > 1:
|
||||
print(
|
||||
f' WARNING: {path}: column ({x}, {y}) has tiles at '
|
||||
f'z={[z for z, _ in found]} - only one tile per column '
|
||||
f'is kept, z={found[-1][0]} wins (highest Z)'
|
||||
)
|
||||
z, shape = found[-1]
|
||||
struct.pack_into(
|
||||
TILE_STRUCT_FORMAT, tiles, tile_index(x, y) * TILE_SIZE,
|
||||
shape, z
|
||||
)
|
||||
return bytes(tiles)
|
||||
|
||||
|
||||
def read_legacy_dcf(path):
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
if data[:3] != FILE_MAGIC:
|
||||
raise ValueError(f"{path}: not a DCF file")
|
||||
version = struct.unpack_from('<I', data, 4)[0]
|
||||
if version not in (1, 2):
|
||||
raise ValueError(f"{path}: expected version 1 or 2, got {version}")
|
||||
|
||||
offset = 8
|
||||
tiles_size = _LEGACY_CHUNK_TILE_COUNT * _LEGACY_TILE_SIZE
|
||||
tiles = collapse_legacy_tiles(data[offset:offset + tiles_size], path)
|
||||
offset += tiles_size
|
||||
|
||||
meshes = []
|
||||
if version == 1:
|
||||
vert_count = struct.unpack_from('<I', data, offset)[0]
|
||||
offset += 4
|
||||
verts = data[offset:offset + vert_count * VERTEX_SIZE]
|
||||
if len(verts) != vert_count * VERTEX_SIZE:
|
||||
raise ValueError(f"{path}: truncated vertex data")
|
||||
if vert_count > 0:
|
||||
meshes.append(verts)
|
||||
else:
|
||||
mesh_count = data[offset]
|
||||
offset += 1
|
||||
for _ in range(mesh_count):
|
||||
vert_count = struct.unpack_from('<I', data, offset)[0]
|
||||
offset += 4
|
||||
verts = data[offset:offset + vert_count * VERTEX_SIZE]
|
||||
if len(verts) != vert_count * VERTEX_SIZE:
|
||||
raise ValueError(f"{path}: truncated vertex data")
|
||||
offset += vert_count * VERTEX_SIZE
|
||||
if vert_count > 0:
|
||||
meshes.append(verts)
|
||||
return tiles, meshes
|
||||
|
||||
|
||||
def upgrade_dcf(path):
|
||||
print(f"Upgrading legacy DCF {path} ...")
|
||||
tiles, meshes = read_legacy_dcf(path)
|
||||
print(
|
||||
f" tiles={CHUNK_TILE_COUNT}, meshes={len(meshes)}, "
|
||||
f"total_verts={sum(len(m) // VERTEX_SIZE for m in meshes)}"
|
||||
)
|
||||
base = os.path.splitext(os.path.basename(path))[0]
|
||||
terrain_dir = os.path.join(ASSETS_DIR, 'meshes', 'chunks')
|
||||
os.makedirs(terrain_dir, exist_ok=True)
|
||||
mesh_names = []
|
||||
for idx, verts in enumerate(meshes):
|
||||
dmf_name = f'chunk_{base}_{idx}.dmf'
|
||||
write_dmf(os.path.join(terrain_dir, dmf_name), verts)
|
||||
mesh_names.append(f'meshes/chunks/{dmf_name}')
|
||||
write_dcf(path, tiles, mesh_names)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate DCF + companion DMF files from raw chunk JSON, "
|
||||
"or upgrade legacy DCF files to the current version"
|
||||
)
|
||||
parser.add_argument(
|
||||
'path', nargs='?',
|
||||
help='Chunk JSON file to process, or legacy DCF file to upgrade. '
|
||||
'If omitted, processes all assetsraw/maps/*/chunks/*.json'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.path is None:
|
||||
maps_dir = os.path.join(ASSETSRAW_DIR, 'maps')
|
||||
if not os.path.isdir(maps_dir):
|
||||
print(f"No directory found: {maps_dir}")
|
||||
sys.exit(1)
|
||||
json_files = []
|
||||
for map_name in sorted(os.listdir(maps_dir)):
|
||||
chunks_dir = os.path.join(maps_dir, map_name, 'chunks')
|
||||
if not os.path.isdir(chunks_dir):
|
||||
continue
|
||||
json_files.extend(sorted(
|
||||
os.path.join(chunks_dir, f)
|
||||
for f in os.listdir(chunks_dir)
|
||||
if f.endswith('.json')
|
||||
))
|
||||
if not json_files:
|
||||
print(f"No JSON files found under {maps_dir}")
|
||||
sys.exit(0)
|
||||
for p in json_files:
|
||||
process_json(p)
|
||||
return
|
||||
|
||||
if os.path.splitext(args.path)[1].lower() == '.json':
|
||||
process_json(args.path)
|
||||
else:
|
||||
upgrade_dcf(args.path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,4 +0,0 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
@@ -1,63 +0,0 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
"""
|
||||
Writes DMF (Dusk Mesh Format) files.
|
||||
|
||||
DMF format:
|
||||
Bytes 0-3: DMF\x00 (magic)
|
||||
Bytes 4-7: uint32_t version = 1 (little-endian)
|
||||
Bytes 8-11: uint32_t vertCount (little-endian)
|
||||
Bytes 12+: meshvertex_t vertices[vertCount]
|
||||
Each vertex is 20 bytes: uv[2] + pos[3] (5 floats, LE)
|
||||
|
||||
Usage:
|
||||
python3 -m tools.asset.dmf <output.dmf> <vertices.bin>
|
||||
Reads raw vertex bytes from vertices.bin and writes a DMF file.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import os
|
||||
|
||||
MAGIC = b'DMF\x00'
|
||||
VERSION = 1
|
||||
VERTEX_SIZE = 20
|
||||
|
||||
|
||||
def write_dmf(path, vertex_bytes):
|
||||
if len(vertex_bytes) % VERTEX_SIZE != 0:
|
||||
raise ValueError(
|
||||
f"Vertex data size {len(vertex_bytes)} is not a "
|
||||
f"multiple of {VERTEX_SIZE}"
|
||||
)
|
||||
vert_count = len(vertex_bytes) // VERTEX_SIZE
|
||||
buf = bytearray()
|
||||
buf += MAGIC
|
||||
buf += struct.pack('<I', VERSION)
|
||||
buf += struct.pack('<I', vert_count)
|
||||
buf += vertex_bytes
|
||||
with open(path, 'wb') as f:
|
||||
f.write(buf)
|
||||
print(
|
||||
f'Wrote {path}: {vert_count} vertices, {len(buf)} bytes'
|
||||
)
|
||||
return vert_count
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Write a DMF (Dusk Mesh Format) file")
|
||||
parser.add_argument('output', help='Path to output .dmf file')
|
||||
parser.add_argument('vertices', help='Path to raw vertex bytes file')
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.vertices, 'rb') as f:
|
||||
vertex_bytes = f.read()
|
||||
|
||||
write_dmf(args.output, vertex_bytes)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,132 +0,0 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
"""
|
||||
Packs the assets directory into a dusk.dsk (DSK2) archive.
|
||||
|
||||
A small fixed header (magic/version/size/checksum) followed by a single
|
||||
uncompressed (ZIP_STORED) zip archive of every asset. Uncompressed rather
|
||||
than DEFLATEd because the bulk of what's packed (audio, images) is
|
||||
already compressed at the file level, so a second, zip-level DEFLATE
|
||||
pass buys negligible space back for real CPU cost, and it lets every
|
||||
entry - not just a hand-picked subset - support the reliable repeated
|
||||
seeking/re-opening (e.g. locale line-by-line reads, MP3 loop restarts)
|
||||
that libzip only supports for uncompressed entries.
|
||||
|
||||
DSK2 format:
|
||||
Bytes 0-3: "DSK2" (magic)
|
||||
Bytes 4-7: uint32_t version = 2 (little-endian)
|
||||
Bytes 8-11: uint32_t size (byte length of the zip archive that follows)
|
||||
Bytes 12-15: uint32_t checksum (CRC32 of the zip archive that follows)
|
||||
Bytes 16+: the zip archive itself
|
||||
|
||||
Usage:
|
||||
python3 -m tools.asset.pack --input <assets_dir> --output <dusk.dsk>
|
||||
Every file under <assets_dir> is added, using its path relative to
|
||||
<assets_dir> as the zip entry name.
|
||||
|
||||
Called unconditionally on every CMake build (see the DUSK_ASSETS_BUILT
|
||||
target) rather than gated behind file-level build-system dependencies -
|
||||
CMake's CONFIGURE_DEPENDS glob only notices added/removed files on the
|
||||
NEXT configure, so a plain custom_command DEPENDS list can miss edits to
|
||||
existing files or lag a build behind on adds/removes. Instead this script
|
||||
itself decides whether anything actually needs repacking: it writes a
|
||||
manifest of every input file's (mtime, size) next to the output on each
|
||||
successful pack, and skips the real work if a fresh manifest compares
|
||||
equal to it and the output still exists. The manifest also carries the
|
||||
format version, so a manifest written by an older version of this script
|
||||
(a stale single/dual-archive dusk.dsk sitting from before a format
|
||||
change) never compares equal and always triggers a fresh pack.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
import io
|
||||
|
||||
MAGIC = b'DSK'
|
||||
VERSION = 2
|
||||
HEADER_FORMAT = '<4sIII'
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
|
||||
|
||||
|
||||
def build_zip_blob(root, relative_paths):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_STORED) as zf:
|
||||
for relative_path in sorted(relative_paths):
|
||||
zf.write(os.path.join(root, relative_path), arcname=relative_path)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def manifest_path(output_path):
|
||||
return output_path + '.manifest.json'
|
||||
|
||||
|
||||
def compute_manifest(input_dir, relative_paths):
|
||||
entries = {}
|
||||
for relative_path in relative_paths:
|
||||
st = os.stat(os.path.join(input_dir, relative_path))
|
||||
entries[relative_path] = [st.st_mtime_ns, st.st_size]
|
||||
return {'version': VERSION, 'files': entries}
|
||||
|
||||
|
||||
def load_manifest(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def pack(input_dir, output_path):
|
||||
relative_paths = []
|
||||
for dirpath, _dirnames, filenames in os.walk(input_dir):
|
||||
for filename in filenames:
|
||||
full_path = os.path.join(dirpath, filename)
|
||||
relative_paths.append(
|
||||
os.path.relpath(full_path, input_dir).replace(os.sep, '/')
|
||||
)
|
||||
|
||||
manifest = compute_manifest(input_dir, relative_paths)
|
||||
manifest_file = manifest_path(output_path)
|
||||
if os.path.isfile(output_path) and load_manifest(manifest_file) == manifest:
|
||||
print(f'{output_path} is up to date, skipping pack')
|
||||
return
|
||||
|
||||
blob = build_zip_blob(input_dir, relative_paths)
|
||||
|
||||
header = struct.pack(
|
||||
HEADER_FORMAT, MAGIC, VERSION, len(blob), zlib.crc32(blob) & 0xFFFFFFFF
|
||||
)
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(header)
|
||||
f.write(blob)
|
||||
|
||||
with open(manifest_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest, f)
|
||||
|
||||
print(
|
||||
f'Wrote {output_path}: {len(relative_paths)} file(s) '
|
||||
f'({len(blob)} bytes), {len(header) + len(blob)} bytes total'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Pack the assets directory into a dusk.dsk (DSK2) archive.'
|
||||
)
|
||||
parser.add_argument('--input', required=True, help='Assets directory to pack')
|
||||
parser.add_argument('--output', required=True, help='Path to write dusk.dsk to')
|
||||
args = parser.parse_args()
|
||||
|
||||
pack(args.input, args.output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,79 +0,0 @@
|
||||
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))",
|
||||
"",
|
||||
]
|
||||
|
||||
js = []
|
||||
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",
|
||||
"",
|
||||
]
|
||||
js += [
|
||||
f"Color.{name.lower()} = function() {{",
|
||||
f" return new Color({r8}, {g8}, {b8}, {a8});",
|
||||
"};",
|
||||
"",
|
||||
]
|
||||
|
||||
out.append("// JS color functions")
|
||||
out.append("#define COLOR_SCRIPT \\")
|
||||
for line in "\n".join(js).rstrip().splitlines():
|
||||
out.append(f' "{line}\\n" \\')
|
||||
out[-1] = out[-1].rstrip(" \\")
|
||||
out.append("")
|
||||
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out))
|
||||
@@ -1,175 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// DTF – Dusk Texture Format
|
||||
//
|
||||
// Header (13 bytes):
|
||||
// [0–2] "DTF" magic
|
||||
// [3] 0x01 version
|
||||
// [4–7] uint32 width (little-endian)
|
||||
// [8–11] uint32 height (little-endian)
|
||||
// [12] uint8 format
|
||||
//
|
||||
// Formats:
|
||||
// 0x01 Alpha – 1 byte per pixel (alpha channel only)
|
||||
// 0x03 RGB – 3 bytes per pixel (no alpha)
|
||||
// 0x04 RGBA – 4 bytes per pixel
|
||||
//
|
||||
// Followed by width × height × bpp bytes of tightly-packed pixel data.
|
||||
|
||||
const DTF = (() => {
|
||||
const MAGIC = [0x44, 0x54, 0x46]; // "DTF"
|
||||
const VERSION = 0x01;
|
||||
const FORMAT_ALPHA = 0x01;
|
||||
const FORMAT_RGB = 0x03;
|
||||
const FORMAT_RGBA = 0x04;
|
||||
const HEADER_SIZE = 13;
|
||||
|
||||
// Bytes per pixel for each format.
|
||||
const BPP = {
|
||||
[FORMAT_ALPHA]: 1,
|
||||
[FORMAT_RGB]: 3,
|
||||
[FORMAT_RGBA]: 4,
|
||||
};
|
||||
|
||||
// Encode RGBA source pixels into a DTF ArrayBuffer at the given format.
|
||||
// When format is FORMAT_ALPHA and redAsAlpha is true, the red channel is
|
||||
// used as the alpha value instead of the actual alpha channel.
|
||||
function encode(width, height, rgbaData, format, redAsAlpha) {
|
||||
if(format === undefined) format = FORMAT_RGBA;
|
||||
const bpp = BPP[format];
|
||||
if(bpp === undefined) throw new Error(`Unknown DTF format: 0x${format.toString(16)}`);
|
||||
|
||||
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
|
||||
const buf = new ArrayBuffer(HEADER_SIZE + width * height * bpp);
|
||||
const bytes = new Uint8Array(buf);
|
||||
const view = new DataView(buf);
|
||||
|
||||
bytes[0] = MAGIC[0];
|
||||
bytes[1] = MAGIC[1];
|
||||
bytes[2] = MAGIC[2];
|
||||
bytes[3] = VERSION;
|
||||
view.setUint32(4, width, true);
|
||||
view.setUint32(8, height, true);
|
||||
bytes[12] = format;
|
||||
|
||||
let dst = HEADER_SIZE;
|
||||
for(let i = 0; i < width * height; i++) {
|
||||
const o = i * 4;
|
||||
switch (format) {
|
||||
case FORMAT_ALPHA:
|
||||
bytes[dst++] = redAsAlpha ? src[o] : src[o + 3];
|
||||
break;
|
||||
case FORMAT_RGB:
|
||||
bytes[dst++] = src[o];
|
||||
bytes[dst++] = src[o + 1];
|
||||
bytes[dst++] = src[o + 2];
|
||||
break;
|
||||
default: // FORMAT_RGBA
|
||||
bytes[dst++] = src[o];
|
||||
bytes[dst++] = src[o + 1];
|
||||
bytes[dst++] = src[o + 2];
|
||||
bytes[dst++] = src[o + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Decode a DTF ArrayBuffer. Always returns RGBA pixel data for internal use.
|
||||
// Alpha-format files decode as {R=0, G=0, B=0, A=alpha} so the alpha channel
|
||||
// is preserved and toImageData() can display it correctly.
|
||||
function decode(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
if(bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DTF");
|
||||
if(bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] || bytes[2] !== MAGIC[2]) {
|
||||
throw new Error("Invalid DTF magic bytes – not a DTF file");
|
||||
}
|
||||
|
||||
const version = bytes[3];
|
||||
if(version !== VERSION) {
|
||||
throw new Error(`Unsupported DTF version: 0x${version.toString(16).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
const width = view.getUint32(4, true);
|
||||
const height = view.getUint32(8, true);
|
||||
const format = bytes[12];
|
||||
const bpp = BPP[format];
|
||||
|
||||
if(bpp === undefined) {
|
||||
throw new Error(`Unsupported DTF format: 0x${format.toString(16).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
const expected = HEADER_SIZE + width * height * bpp;
|
||||
if(bytes.length < expected) {
|
||||
throw new Error(`DTF pixel data truncated (expected ${expected} bytes, got ${bytes.length})`);
|
||||
}
|
||||
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
let src = HEADER_SIZE;
|
||||
for(let i = 0; i < width * height; i++) {
|
||||
const o = i * 4;
|
||||
switch (format) {
|
||||
case FORMAT_ALPHA:
|
||||
rgba[o] = rgba[o + 1] = rgba[o + 2] = 0;
|
||||
rgba[o + 3] = bytes[src++];
|
||||
break;
|
||||
case FORMAT_RGB:
|
||||
rgba[o] = bytes[src++];
|
||||
rgba[o + 1] = bytes[src++];
|
||||
rgba[o + 2] = bytes[src++];
|
||||
rgba[o + 3] = 255;
|
||||
break;
|
||||
default: // FORMAT_RGBA
|
||||
rgba[o] = bytes[src++];
|
||||
rgba[o + 1] = bytes[src++];
|
||||
rgba[o + 2] = bytes[src++];
|
||||
rgba[o + 3] = bytes[src++];
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, format, data: rgba };
|
||||
}
|
||||
|
||||
// Convert RGBA source pixels to a display-ready ImageData for the given format.
|
||||
// Shows exactly how the texture will look after a DTF encode/decode round-trip.
|
||||
// Alpha → grayscale from alpha channel (or red if redAsAlpha), out-alpha=255
|
||||
// RGB → discard alpha, fully opaque
|
||||
// RGBA → pass-through
|
||||
function toImageData(width, height, rgbaData, format, redAsAlpha) {
|
||||
const src = rgbaData instanceof Uint8ClampedArray ? rgbaData : new Uint8ClampedArray(rgbaData);
|
||||
const out = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for(let i = 0; i < width * height; i++) {
|
||||
const o = i * 4;
|
||||
switch (format) {
|
||||
case FORMAT_ALPHA: {
|
||||
const v = redAsAlpha ? src[o] : src[o + 3];
|
||||
out[o] = out[o + 1] = out[o + 2] = v;
|
||||
out[o + 3] = v; // use value as canvas alpha so background shows through transparent areas
|
||||
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,
|
||||
});
|
||||
})();
|
||||
@@ -1,55 +0,0 @@
|
||||
"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 });
|
||||
})();
|
||||
@@ -1,42 +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 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>
|
||||
<a class="tool-card" href="/texture-padder/">
|
||||
<div class="tool-name">Texture Padder</div>
|
||||
<div class="tool-desc">Pad texture dimensions to power-of-two or enforce minimum size requirements.</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/* Component – Button */
|
||||
|
||||
.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:hover:not(:disabled) {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/* Component – Control row */
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.control-row label {
|
||||
color: var(--text-muted);
|
||||
min-width: 44px;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* Component – Empty state */
|
||||
|
||||
.empty-state {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-style: dashed;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/* Component – File name */
|
||||
|
||||
.file-name {
|
||||
font-size: 0.775rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* Component – Hero section */
|
||||
|
||||
.hero {
|
||||
padding: var(--gap) 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
color: var(--text-muted);
|
||||
max-width: 540px;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* Component – Info table */
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* Component – Load area */
|
||||
|
||||
.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 {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/* Component – Panel form inputs */
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/* Component – Panel section */
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/* Component – Preview empty state */
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* Component – Preview scroll */
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/* Component – Tool card */
|
||||
|
||||
.tool-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
transition: border-color var(--speed);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
border-color: var(--accent-dim);
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/* Component – Tool panel */
|
||||
|
||||
.tool-panel {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
width: 210px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/* Component – Tool preview */
|
||||
|
||||
.tool-preview {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
transition: border-color var(--speed);
|
||||
}
|
||||
|
||||
.tool-preview.drag-over {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/* Component – Warning list */
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/* Component – Warnings section */
|
||||
|
||||
.warnings-section {
|
||||
border-color: #6b4a00;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/* Generic – Reset */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
@@ -1,32 +0,0 @@
|
||||
/* ITCSS entry point */
|
||||
|
||||
@import "settings.css";
|
||||
@import "generic.css";
|
||||
@import "elements.css";
|
||||
|
||||
/* Objects */
|
||||
@import "objects/section-label.css";
|
||||
@import "objects/page.css";
|
||||
@import "objects/section.css";
|
||||
@import "objects/tool-grid.css";
|
||||
@import "objects/tool-workspace.css";
|
||||
|
||||
/* Components */
|
||||
@import "components/site-header.css";
|
||||
@import "components/hero.css";
|
||||
@import "components/tool-card.css";
|
||||
@import "components/empty-state.css";
|
||||
@import "components/tool-panel.css";
|
||||
@import "components/panel-section.css";
|
||||
@import "components/control-row.css";
|
||||
@import "components/panel-inputs.css";
|
||||
@import "components/btn.css";
|
||||
@import "components/bg-swatches.css";
|
||||
@import "components/tool-preview.css";
|
||||
@import "components/preview-scroll.css";
|
||||
@import "components/preview-empty.css";
|
||||
@import "components/load-area.css";
|
||||
@import "components/file-name.css";
|
||||
@import "components/info-table.css";
|
||||
@import "components/warnings-section.css";
|
||||
@import "components/warning-list.css";
|
||||
@@ -1,7 +0,0 @@
|
||||
/* Object – Page container */
|
||||
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--gap);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/* Object – Section label */
|
||||
|
||||
.section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section > .section-label {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/* Object – Page section */
|
||||
.section {
|
||||
margin-top: var(--gap);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/* Object – Tool grid */
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/* Object – Tool workspace */
|
||||
|
||||
.tool-workspace {
|
||||
margin-top: var(--gap);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
:root {
|
||||
--bg: #121217; /* deep dark base */
|
||||
--bg-surface: #1a1a22; /* subtle elevation */
|
||||
--bg-raised: #23232d; /* layered surfaces */
|
||||
--border: #2e2e3a; /* soft border contrast */
|
||||
|
||||
--text: #e6e6f0; /* high readability */
|
||||
--text-muted: #9a9ab0; /* muted secondary text */
|
||||
|
||||
--accent: #8b5cf6; /* vibrant purple */
|
||||
--accent-dim: #6d46c7; /* deeper purple for hover/active */
|
||||
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
--gap: 1.5rem;
|
||||
--speed: 0.15s;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Texture Padder – 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-padder/" class="active">Texture Padder</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
|
||||
<div class="hero">
|
||||
<h1>Texture Padder</h1>
|
||||
<p>Load an image or .dtf file and pad its dimensions to meet power-of-two or minimum size requirements.</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">Padding</div>
|
||||
<div class="control-row">
|
||||
<input type="checkbox" id="chk-pad-width-pow2" checked>
|
||||
<label for="chk-pad-width-pow2">Pad width to power of two</label>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<input type="checkbox" id="chk-pad-height-pow2" checked>
|
||||
<label for="chk-pad-height-pow2">Pad height to power of two</label>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<input type="checkbox" id="chk-min-width-4" checked>
|
||||
<label for="chk-min-width-4">Ensure minimum width (4 px)</label>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<input type="checkbox" id="chk-min-height-4" checked>
|
||||
<label for="chk-min-height-4">Ensure minimum height (4 px)</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">
|
||||
<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">N/A</td></tr>
|
||||
<tr><td>Input</td><td id="info-input-size">N/A</td></tr>
|
||||
<tr><td>Output</td><td id="info-output-size">N/A</td></tr>
|
||||
<tr><td>DTF</td><td id="info-dtf-size">N/A</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<div class="section-label">Export</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>
|
||||
<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/[email protected]/browser.js"></script>
|
||||
<script src="/common/dtf.js"></script>
|
||||
<script src="/common/png.js"></script>
|
||||
<script src="/texture-padder/texture-padder.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,275 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns the smallest power of two >= n.
|
||||
function nextPow2(n) {
|
||||
if(n <= 0) return 1;
|
||||
let p = 1;
|
||||
while (p < n) p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
pixels: null, // Uint8ClampedArray RGBA at original resolution
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1,
|
||||
bg: "grid",
|
||||
padWidthPow2: true,
|
||||
padHeightPow2: true,
|
||||
minWidth4: true,
|
||||
minHeight4: true,
|
||||
format: DTF.FORMAT_RGBA,
|
||||
filename: "texture",
|
||||
};
|
||||
|
||||
// ─── 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 infoInputSize = document.getElementById("info-input-size");
|
||||
const infoOutputSize = document.getElementById("info-output-size");
|
||||
const formatSelect = document.getElementById("format-select");
|
||||
const btnDtf = document.getElementById("btn-dtf");
|
||||
const btnPng = document.getElementById("btn-png");
|
||||
const infoDtfSize = document.getElementById("info-dtf-size");
|
||||
const chkPadWidthPow2 = document.getElementById("chk-pad-width-pow2");
|
||||
const chkPadHeightPow2 = document.getElementById("chk-pad-height-pow2");
|
||||
const chkMinWidth4 = document.getElementById("chk-min-width-4");
|
||||
const chkMinHeight4 = document.getElementById("chk-min-height-4");
|
||||
|
||||
// ─── Padding logic ───────────────────────────────────────────────────────────
|
||||
|
||||
function computeOutputSize() {
|
||||
let outW = state.width;
|
||||
let outH = state.height;
|
||||
// Apply minimum-size constraints first so pow2 rounding accounts for them.
|
||||
if(state.minWidth4 && outW < 4) outW = 4;
|
||||
if(state.minHeight4 && outH < 4) outH = 4;
|
||||
if(state.padWidthPow2) outW = nextPow2(outW);
|
||||
if(state.padHeightPow2) outH = nextPow2(outH);
|
||||
return { outW, outH };
|
||||
}
|
||||
|
||||
function buildPaddedPixels() {
|
||||
const { outW, outH } = computeOutputSize();
|
||||
const src = state.pixels;
|
||||
// Zero-filled Uint8ClampedArray = fully transparent black padding.
|
||||
const out = new Uint8ClampedArray(outW * outH * 4);
|
||||
for(let y = 0; y < state.height; y++) {
|
||||
for(let x = 0; x < state.width; x++) {
|
||||
const si = (y * state.width + x) * 4;
|
||||
const di = (y * outW + x) * 4;
|
||||
out[di] = src[si];
|
||||
out[di + 1] = src[si + 1];
|
||||
out[di + 2] = src[si + 2];
|
||||
out[di + 3] = src[si + 3];
|
||||
}
|
||||
}
|
||||
return { pixels: out, width: outW, height: outH };
|
||||
}
|
||||
|
||||
// ─── 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 } = buildPaddedPixels();
|
||||
const cw = width * state.scale;
|
||||
const ch = height * state.scale;
|
||||
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
|
||||
if(state.bg === "grid") {
|
||||
drawCheckerboard(cw, ch);
|
||||
} else {
|
||||
ctx.fillStyle = state.bg;
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
|
||||
const src = Object.assign(document.createElement("canvas"), { width, height });
|
||||
src.getContext("2d").putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(src, 0, 0, cw, ch);
|
||||
}
|
||||
|
||||
// ─── Info update ─────────────────────────────────────────────────────────────
|
||||
|
||||
function updateInfo() {
|
||||
if(!state.pixels) return;
|
||||
const { outW, outH } = computeOutputSize();
|
||||
const bytes = DTF.HEADER_SIZE + outW * outH * DTF.BPP[state.format];
|
||||
infoInputSize.textContent = `${state.width} × ${state.height}`;
|
||||
infoOutputSize.textContent = `${outW} × ${outH}`;
|
||||
infoDtfSize.textContent = `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
alert("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, data } = DTF.decode(e.target.result);
|
||||
applyImageData(width, height, data, file.name);
|
||||
} catch (err) {
|
||||
alert(`Failed to load DTF: ${err.message}`);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
function applyImageData(width, height, data, filename) {
|
||||
state.pixels = new Uint8ClampedArray(data);
|
||||
state.width = width;
|
||||
state.height = height;
|
||||
state.filename = filename.replace(/\.[^/.]+$/, "");
|
||||
|
||||
fileNameEl.textContent = filename;
|
||||
infoFilename.textContent = filename;
|
||||
infoSection.hidden = false;
|
||||
updateInfo();
|
||||
|
||||
previewEmpty.hidden = true;
|
||||
previewScroll.hidden = false;
|
||||
|
||||
btnDtf.disabled = false;
|
||||
btnPng.disabled = false;
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function handleFile(file) {
|
||||
if(!file) return;
|
||||
if(file.name.toLowerCase().endsWith(".dtf")) {
|
||||
loadDTF(file);
|
||||
} else {
|
||||
loadStandardImage(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function exportDTF() {
|
||||
if(!state.pixels) return;
|
||||
const { pixels, width, height } = buildPaddedPixels();
|
||||
const buf = DTF.encode(width, height, pixels, state.format);
|
||||
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;
|
||||
const { pixels, width, height } = buildPaddedPixels();
|
||||
DuskPNG.download(`${state.filename}.png`, width, height, 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();
|
||||
});
|
||||
|
||||
formatSelect.addEventListener("change", () => {
|
||||
state.format = parseInt(formatSelect.value, 10);
|
||||
updateInfo();
|
||||
});
|
||||
|
||||
btnDtf.addEventListener("click", exportDTF);
|
||||
btnPng.addEventListener("click", exportPNG);
|
||||
|
||||
chkPadWidthPow2.addEventListener("change", () => { state.padWidthPow2 = chkPadWidthPow2.checked; updateInfo(); render(); });
|
||||
chkPadHeightPow2.addEventListener("change", () => { state.padHeightPow2 = chkPadHeightPow2.checked; updateInfo(); render(); });
|
||||
chkMinWidth4.addEventListener("change", () => { state.minWidth4 = chkMinWidth4.checked; updateInfo(); render(); });
|
||||
chkMinHeight4.addEventListener("change", () => { state.minHeight4 = chkMinHeight4.checked; updateInfo(); render(); });
|
||||
|
||||
// Drag-and-drop
|
||||
function setupDrop(target) {
|
||||
target.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
loadLabel.classList.add("drag-over");
|
||||
previewArea.classList.add("drag-over");
|
||||
});
|
||||
target.addEventListener("dragleave", () => {
|
||||
loadLabel.classList.remove("drag-over");
|
||||
previewArea.classList.remove("drag-over");
|
||||
});
|
||||
target.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
loadLabel.classList.remove("drag-over");
|
||||
previewArea.classList.remove("drag-over");
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
setupDrop(loadLabel);
|
||||
setupDrop(previewArea);
|
||||
@@ -1,124 +0,0 @@
|
||||
<!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">N/A</td></tr>
|
||||
<tr><td>Size</td><td id="info-size">N/A</td></tr>
|
||||
<tr><td>Format</td><td id="info-format">N/A</td></tr>
|
||||
<tr><td>DTF</td><td id="info-dtf-size">N/A</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/[email protected]/browser.js"></script>
|
||||
<script src="/common/dtf.js"></script>
|
||||
<script src="/common/png.js"></script>
|
||||
<script src="/texture/texture.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,279 +0,0 @@
|
||||
"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;
|
||||
redAsAlphaRow.hidden = format !== DTF.FORMAT_ALPHA;
|
||||
}
|
||||
|
||||
// Sidebar info
|
||||
fileNameEl.textContent = filename;
|
||||
infoFilename.textContent = filename;
|
||||
infoSize.textContent = `${width} × ${height}`;
|
||||
infoFormat.textContent = formatLabel;
|
||||
infoSection.hidden = false;
|
||||
updateDtfSize();
|
||||
|
||||
// Show canvas
|
||||
previewEmpty.hidden = true;
|
||||
previewScroll.hidden = false;
|
||||
|
||||
// Enable export
|
||||
btnDtf.disabled = false;
|
||||
btnPng.disabled = false;
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function handleFile(file) {
|
||||
if(!file) return;
|
||||
if(file.name.toLowerCase().endsWith(".dtf")) {
|
||||
loadDTF(file);
|
||||
} else {
|
||||
loadStandardImage(file);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function exportDTF() {
|
||||
if(!state.pixels) return;
|
||||
const buf = DTF.encode(state.width, state.height, state.pixels, state.format, state.redAsAlpha);
|
||||
const blob = new Blob([buf], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement("a"), {
|
||||
href: url,
|
||||
download: `${state.filename}.dtf`,
|
||||
});
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportPNG() {
|
||||
if(!state.pixels) return;
|
||||
DuskPNG.download(`${state.filename}.png`, state.width, state.height, state.pixels);
|
||||
}
|
||||
|
||||
// ─── Event listeners ─────────────────────────────────────────────────────────
|
||||
|
||||
fileInput.addEventListener("change", e => handleFile(e.target.files[0]));
|
||||
|
||||
scaleInput.addEventListener("input", () => {
|
||||
const v = Math.max(1, Math.min(10, parseInt(scaleInput.value, 10) || 1));
|
||||
scaleInput.value = v;
|
||||
state.scale = v;
|
||||
render();
|
||||
});
|
||||
|
||||
bgSwatches.addEventListener("click", e => {
|
||||
const btn = e.target.closest(".bg-swatch");
|
||||
if(!btn) return;
|
||||
bgSwatches.querySelectorAll(".bg-swatch").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
state.bg = btn.dataset.bg;
|
||||
render();
|
||||
});
|
||||
|
||||
btnDtf.addEventListener("click", exportDTF);
|
||||
btnPng.addEventListener("click", exportPNG);
|
||||
|
||||
formatSelect.addEventListener("change", () => {
|
||||
state.format = parseInt(formatSelect.value, 10);
|
||||
redAsAlphaRow.hidden = state.format !== DTF.FORMAT_ALPHA;
|
||||
updateDtfSize();
|
||||
render();
|
||||
});
|
||||
|
||||
redAsAlphaCheck.addEventListener("change", () => {
|
||||
state.redAsAlpha = redAsAlphaCheck.checked;
|
||||
render();
|
||||
});
|
||||
|
||||
// Drag-and-drop on the load label
|
||||
function setupDrop(target) {
|
||||
target.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
loadLabel.classList.add("drag-over");
|
||||
previewArea.classList.add("drag-over");
|
||||
});
|
||||
target.addEventListener("dragleave", () => {
|
||||
loadLabel.classList.remove("drag-over");
|
||||
previewArea.classList.remove("drag-over");
|
||||
});
|
||||
target.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
loadLabel.classList.remove("drag-over");
|
||||
previewArea.classList.remove("drag-over");
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
setupDrop(loadLabel);
|
||||
setupDrop(previewArea);
|
||||
@@ -1,58 +0,0 @@
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
|
||||
parser = argparse.ArgumentParser(description="Input CSV to .h defines")
|
||||
parser.add_argument("--csv", required=True, help="Path to Input CSV file")
|
||||
parser.add_argument("--output", required=True, help="Path to output .h file")
|
||||
args = parser.parse_args()
|
||||
|
||||
def id_enum(name):
|
||||
return "INPUT_ACTION_" + name.upper()
|
||||
|
||||
# Load CSV
|
||||
input_ids = []
|
||||
with open(args.csv, newline="", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
if "id" not in reader.fieldnames:
|
||||
raise ValueError("CSV must have an 'id' column")
|
||||
for row in reader:
|
||||
input_id = row["id"]
|
||||
if input_id not in input_ids:
|
||||
input_ids.append(input_id)
|
||||
|
||||
# Assign enum values
|
||||
id_values = {input_id: i + 1 for i, input_id in enumerate(input_ids)}
|
||||
|
||||
# Build output
|
||||
out = [
|
||||
"#pragma once",
|
||||
'#include "dusk.h"',
|
||||
"",
|
||||
"typedef enum {",
|
||||
" INPUT_ACTION_NULL = 0x0,",
|
||||
"",
|
||||
]
|
||||
for input_id in input_ids:
|
||||
out.append(f" {id_enum(input_id)} = 0x{id_values[input_id]:x},")
|
||||
out += [
|
||||
"",
|
||||
f" INPUT_ACTION_COUNT = 0x{len(input_ids) + 1:x}",
|
||||
"} inputaction_t;",
|
||||
"",
|
||||
"static const char_t *INPUT_ACTION_IDS[] = {",
|
||||
]
|
||||
for input_id in input_ids:
|
||||
out.append(f" [{id_enum(input_id)}] = \"{input_id}\",")
|
||||
out += [
|
||||
"};",
|
||||
"",
|
||||
"static const char_t *INPUT_ACTION_SCRIPT =",
|
||||
]
|
||||
for input_id in input_ids:
|
||||
out.append(f" \"var {id_enum(input_id)} = {id_values[input_id]};\\n\"")
|
||||
out += [";", ""]
|
||||
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out))
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Builds GameCube / Wii disc images (NTSC-J, NTSC-U, PAL) using xorrisofs /
|
||||
mkisofs + Swiss-GC system-area headers.
|
||||
|
||||
The Swiss-GC headers contain a working GCN apploader that boots any DOL via
|
||||
the El Torito catalog - no custom PPC assembly needed.
|
||||
|
||||
Headers are cached in <script_dir>/../buildtools/iso/ and downloaded from the
|
||||
Swiss-GC repository on first use.
|
||||
|
||||
Disc layout (produced by xorrisofs):
|
||||
sectors 0-15 system area (boot.bin + bi2 + apploader, from Swiss header)
|
||||
sector 16 ISO 9660 PVD
|
||||
sector 17 El Torito Boot Record
|
||||
... directory records, file data
|
||||
Dusk.dol (El Torito boot image - loaded by apploader)
|
||||
dusk.dsk (asset archive - found at runtime via ISO 9660)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
SWISS_BASE = (
|
||||
"https://raw.githubusercontent.com/emukidid/swiss-gc/"
|
||||
"6f78e41940d2699b69ec62636d57411ae2857f1e/buildtools/iso/"
|
||||
)
|
||||
|
||||
REGIONS = [
|
||||
('j', 'J', 'NTSC-J', 0),
|
||||
('u', 'E', 'NTSC-U', 1),
|
||||
('e', 'P', 'PAL', 2),
|
||||
]
|
||||
|
||||
|
||||
def _buildtools_iso_dir():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(here, '..', 'buildtools', 'iso')
|
||||
|
||||
|
||||
def _get_hdr(region_suffix):
|
||||
"""Return the raw bytes of the Swiss system-area header for *region_suffix*
|
||||
('j', 'u', or 'e'), downloading it if necessary."""
|
||||
cache_dir = _buildtools_iso_dir()
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
path = os.path.join(cache_dir, f'eltorito-{region_suffix}.hdr')
|
||||
if not os.path.exists(path):
|
||||
url = SWISS_BASE + f'eltorito-{region_suffix}.hdr'
|
||||
print(f'Downloading {url} ...', flush=True)
|
||||
with urllib.request.urlopen(url) as resp:
|
||||
data = resp.read()
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _patch_hdr(hdr_data, game_id, title):
|
||||
"""Patch game ID (bytes 0-5) and title (bytes 0x20-0x3FF) into the
|
||||
system-area header. Everything else (magic, bi2, apploader) is unchanged."""
|
||||
hdr = bytearray(hdr_data)
|
||||
hdr[0:6] = game_id.encode('ascii')
|
||||
tb = title.encode('ascii')[:0x3E0]
|
||||
hdr[0x20:0x20 + len(tb)] = tb
|
||||
# zero remaining title bytes so stale Swiss title doesn't bleed through
|
||||
hdr[0x20 + len(tb):0x400] = bytes(0x3E0 - len(tb))
|
||||
return bytes(hdr)
|
||||
|
||||
|
||||
def _find_mkisofs():
|
||||
for tool in ('xorrisofs', 'mkisofs'):
|
||||
if shutil.which(tool):
|
||||
return tool
|
||||
sys.exit('Error: neither xorrisofs nor mkisofs found. '
|
||||
'Install xorriso (apt install xorriso) or mkisofs.')
|
||||
|
||||
|
||||
def _build_iso(platform, game_id, title, region_suffix, dol_path, dsk_path,
|
||||
out_path):
|
||||
hdr_data = _patch_hdr(_get_hdr(region_suffix), game_id, title)
|
||||
|
||||
# Write patched header to a temp file alongside the output so we have a
|
||||
# stable path (avoids tempfile cleanup races on some systems).
|
||||
hdr_tmp = out_path + '.hdr.tmp'
|
||||
try:
|
||||
with open(hdr_tmp, 'wb') as f:
|
||||
f.write(hdr_data)
|
||||
|
||||
mkisofs = _find_mkisofs()
|
||||
dol_name = os.path.basename(dol_path)
|
||||
|
||||
cmd = [
|
||||
mkisofs,
|
||||
'-R', '-J',
|
||||
'-G', hdr_tmp,
|
||||
'-no-emul-boot',
|
||||
'-eltorito-platform', 'PPC',
|
||||
'-b', dol_name,
|
||||
'-o', out_path,
|
||||
dol_path,
|
||||
dsk_path,
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
finally:
|
||||
if os.path.exists(hdr_tmp):
|
||||
os.remove(hdr_tmp)
|
||||
|
||||
size = os.path.getsize(out_path)
|
||||
print(f'Created {out_path} ({size:,} bytes)')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description='Build GCN/Wii disc images from a DOL and asset file.')
|
||||
ap.add_argument('platform', choices=['GCN', 'WII'])
|
||||
ap.add_argument('dol', help='Path to Dusk.dol')
|
||||
ap.add_argument('dsk', help='Path to dusk.dsk asset archive')
|
||||
ap.add_argument('title', help='Game title string')
|
||||
ap.add_argument('build_dir', help='Output directory')
|
||||
args = ap.parse_args()
|
||||
|
||||
id_prefix = 'G' if args.platform == 'GCN' else 'R'
|
||||
|
||||
for region_suffix, region_char, region_name, _ in REGIONS:
|
||||
game_id = f'{id_prefix}DK{region_char}01'
|
||||
out_path = os.path.join(args.build_dir, f'Dusk-{region_name}.iso')
|
||||
_build_iso(args.platform, game_id, args.title,
|
||||
region_suffix, args.dol, args.dsk, out_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,14 +0,0 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
function(dusk_run_python CMAKE_TARGET_NAME PYTHON_MODULE)
|
||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||
|
||||
add_custom_target(${CMAKE_TARGET_NAME} ALL
|
||||
WORKING_DIRECTORY ${DUSK_ROOT_DIR}
|
||||
COMMAND
|
||||
${Python3_EXECUTABLE} -m ${PYTHON_MODULE} ${ARGN}
|
||||
)
|
||||
endfunction()
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user