Add collision view/edit tool

This commit is contained in:
Retnuhytnuob
2026-05-21 04:25:05 -05:00
parent c75a93182f
commit dd7efbf409
48 changed files with 940 additions and 40 deletions

View File

@@ -72,7 +72,7 @@ FIX := tools/gbafix/gbafix$(EXE)
SHA1 := $(shell { command -v sha1sum || command -v shasum; } 2>/dev/null) -c
TOOLS_DIR = tools
TOOLDIRS = $(filter-out $(TOOLS_DIR)/agbcc $(TOOLS_DIR)/m2ctx.py $(TOOLS_DIR)/scripts,$(wildcard $(TOOLS_DIR)/*))
TOOLDIRS = $(filter-out $(TOOLS_DIR)/agbcc $(TOOLS_DIR)/m2ctx.py $(TOOLS_DIR)/scripts $(TOOLS_DIR)/collision_viewer,$(wildcard $(TOOLS_DIR)/*))
infoshell = $(foreach line, $(shell $1 | sed "s/ /__SPACE__/g"), $(info $(subst __SPACE__, ,$(line))))

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,13 +8,13 @@ gDusclopsBoardBG0Tilemap:: @ 0x082EE4C0
.incbin "baserom.gba", 0x2EE4C0, 0x800
gDusclopsBoardCollisionTileDataAllLayers:: @ 0x082EECC0
.incbin "baserom.gba", 0x2EECC0, 0x2000
.incbin "data/board_data/collision/dusclops/tiledata.bin"
gDusclopsBoardCollisionAngleDataAllLayers:: @ 0x082F0CC0
.incbin "baserom.gba", 0x2F0CC0, 0xB180
.incbin "data/board_data/collision/dusclops/angledata.bin"
gDusclopsBoardCollisionTypeDataAllLayers:: @ 0x082FBE40
.incbin "baserom.gba", 0x2FBE40, 0x58C0
.incbin "data/board_data/collision/dusclops/typedata.bin"
gDusclopsBoardBG1Tilemap:: @ 0x08301700
.incbin "baserom.gba", 0x301700, 0x800

View File

@@ -5,13 +5,13 @@ gKecleonBoardBG1Tilemap:: @ 0x0830CDD4
.incbin "baserom.gba", 0x30CDD4, 0x800
gKecleonBoardCollisionTileDataAllLayers:: @ 0x0830D5D4
.incbin "baserom.gba", 0x30D5D4, 0x2000
.incbin "data/board_data/collision/kecleon/tiledata.bin"
gKecleonBoardCollisionAngleDataAllLayers:: @ 0x0830F5D4
.incbin "baserom.gba", 0x30F5D4, 0x10280
.incbin "data/board_data/collision/kecleon/angledata.bin"
gKecleonBoardCollisionTypeDataAllLayers:: @ 0x0831F854
.incbin "baserom.gba", 0x31F854, 0x8140
.incbin "data/board_data/collision/kecleon/typedata.bin"
gKecleonBerryOverlayTilemap:: @ 0x08327994
.incbin "baserom.gba", 0x327994, 0x800

View File

@@ -9,13 +9,13 @@ gKyogreWaterBackgroundTilemap:: @ 0x08333004
.incbin "baserom.gba", 0x333004, 0x800
gKyrGrouBoardCollisionTileDataAllLayers:: @ 0x08333804
.incbin "baserom.gba", 0x333804, 0x2000
.incbin "data/board_data/collision/kyogre/tiledata.bin"
gKyrGrouBoardCollisionAngleDataAllLayers:: @ 0x08335804
.incbin "baserom.gba", 0x335804, 0x5580
.incbin "data/board_data/collision/kyogre/angledata.bin"
gKyrGrouBoardCollisionTypeDataAllLayers:: @ 0x0833AD84
.incbin "baserom.gba", 0x33AD84, 0x2AC0
.incbin "data/board_data/collision/kyogre/typedata.bin"
gKyogreBoardBG1Tilemap:: @ 0x0833D844
.incbin "baserom.gba", 0x33D844, 0x800

View File

@@ -6,13 +6,13 @@ gRayquazaBoardBG0Tilemap:: @ 0x083640C8
.incbin "baserom.gba", 0x3640C8, 0x1000
gRayquazaBoardCollisionTileDataAllLayers:: @ 0x083650C8
.incbin "baserom.gba", 0x3650C8, 0x2000
.incbin "data/board_data/collision/rayquaza/tiledata.bin"
gRayquazaBoardCollisionAngleDataAllLayers:: @ 0x083670C8
.incbin "baserom.gba", 0x3670C8, 0x4F80
.incbin "data/board_data/collision/rayquaza/angledata.bin"
gRayquazaBoardCollisionTypeDataAllLayers:: @ 0x0836C048
.incbin "baserom.gba", 0x36C048, 0x27C0
.incbin "data/board_data/collision/rayquaza/typedata.bin"
gRayquazaBoardBGScrollTilemap:: @ 0x0836E808
.incbin "baserom.gba", 0x36E808, 0x1000

View File

@@ -5,43 +5,43 @@ gRubyBoardBG0Tilemap:: @ 0x081C08E4
.incbin "baserom.gba", 0x1C08E4, 0x1000
gRubyBoardCollisionTileDataLayer0:: @ 0x081C18E4
.incbin "baserom.gba", 0x1C18E4, 0x2000
.incbin "data/board_data/collision/ruby/boardtop_launch_tiledata.bin"
gRubyBoardCollisionAngleDataLayer0:: @ 0x081C38E4
.incbin "baserom.gba", 0x1C38E4, 0x18780
.incbin "data/board_data/collision/ruby/boardtop_launch_angledata.bin"
gRubyBoardCollisionTypeDataLayer0:: @ 0x081DC064
.incbin "baserom.gba", 0x1DC064, 0xC3C0
.incbin "data/board_data/collision/ruby/boardtop_launch_typedata.bin"
gRubyBoardCollisionTileDataLayer0Alt:: @ 0x081E8424
.incbin "baserom.gba", 0x1E8424, 0x2000
.incbin "data/board_data/collision/ruby/boardtop_normal_tiledata.bin"
gRubyBoardCollisionAngleDataLayer0Alt:: @ 0x081EA424
.incbin "baserom.gba", 0x1EA424, 0x18380
.incbin "data/board_data/collision/ruby/boardtop_normal_angledata.bin"
gRubyBoardCollisionTypeDataLayer0Alt:: @ 0x082027A4
.incbin "baserom.gba", 0x2027A4, 0xC1C0
.incbin "data/board_data/collision/ruby/boardtop_normal_typedata.bin"
gRubyBoardCollisionTileDataLayer1:: @ 0x0820E964
.incbin "baserom.gba", 0x20E964, 0x2000
.incbin "data/board_data/collision/ruby/boardbottom_tiledata.bin"
gRubyBoardCollisionAngleDataLayer1:: @ 0x08210964
.incbin "baserom.gba", 0x210964, 0x13C80
.incbin "data/board_data/collision/ruby/boardbottom_angledata.bin"
gRubyBoardCollisionTypeDataLayer1:: @ 0x082245E4
.incbin "baserom.gba", 0x2245E4, 0x9E40
.incbin "data/board_data/collision/ruby/boardbottom_typedata.bin"
gRubyBoardBG1Tilemap:: @ 0x0822E424
.incbin "baserom.gba", 0x22E424, 0x1000
gRubyBoardCollisionTileDataLayer2:: @ 0x0822F424
.incbin "baserom.gba", 0x22F424, 0x2000
.incbin "data/board_data/collision/ruby/topramps_tiledata.bin"
gRubyBoardCollisionAngleDataLayer2:: @ 0x08231424
.incbin "baserom.gba", 0x231424, 0x11E80
.incbin "data/board_data/collision/ruby/topramps_angledata.bin"
gRubyBoardCollisionTypeDataLayer2:: @ 0x082432A4
.incbin "baserom.gba", 0x2432A4, 0x8F40
.incbin "data/board_data/collision/ruby/topramps_typedata.bin"
gRubyBoardCompressedTiles1:: @ 0x0824C1E4
.incbin "baserom.gba", 0x24C1E4, 0x1B7C

View File

@@ -7,43 +7,43 @@ gSapphireBoardBG0Tilemap:: @ 0x0826EE10
.incbin "baserom.gba", 0x26EE10, 0x1000
gSapphireBoardCollisionTileDataLayer0:: @ 0x0826FE10
.incbin "baserom.gba", 0x26FE10, 0x2000
.incbin "data/board_data/collision/sapphire/boardtop_tiledata.bin"
gSapphireBoardCollisionAngleDataLayer0:: @ 0x08271E10
.incbin "baserom.gba", 0x271E10, 0x14C00
.incbin "data/board_data/collision/sapphire/boardtop_angledata.bin"
gSapphireBoardCollisionTypeDataLayer0:: @ 0x08286A10
.incbin "baserom.gba", 0x286A10, 0xA600
.incbin "data/board_data/collision/sapphire/boardtop_typedata.bin"
gSapphireBoardCollisionTileDataLayer1:: @ 0x08291010
.incbin "baserom.gba", 0x291010, 0x2000
.incbin "data/board_data/collision/sapphire/boardbottom_tiledata.bin"
gSapphireBoardCollisionAngleDataLayer1:: @ 0x08293010
.incbin "baserom.gba", 0x293010, 0x12480
.incbin "data/board_data/collision/sapphire/boardbottom_angledata.bin"
gSapphireBoardCollisionTypeDataLayer1:: @ 0x082A5490
.incbin "baserom.gba", 0x2A5490, 0x9240
.incbin "data/board_data/collision/sapphire/boardbottom_typedata.bin"
gSapphireBoardBG1Tilemap:: @ 0x082AE6D0
.incbin "baserom.gba", 0x2AE6D0, 0x1000
gSapphireBoardCollisionTileDataLayer2:: @ 0x082AF6D0
.incbin "baserom.gba", 0x2AF6D0, 0x2000
.incbin "data/board_data/collision/sapphire/topramp_tiledata.bin"
gSapphireBoardCollisionAngleDataLayer2:: @ 0x082B16D0
.incbin "baserom.gba", 0x2B16D0, 0xD100
.incbin "data/board_data/collision/sapphire/topramp_angledata.bin"
gSapphireBoardCollisionTypeDataLayer2:: @ 0x082BE7D0
.incbin "baserom.gba", 0x2BE7D0, 0x6880
.incbin "data/board_data/collision/sapphire/topramp_typedata.bin"
gSapphireBoardCollisionTileDataLayer3:: @ 0x082C5050
.incbin "baserom.gba", 0x2C5050, 0x2000
.incbin "data/board_data/collision/sapphire/hatchexitramp_tiledata.bin"
gSapphireBoardCollisionAngleDataLayer3:: @ 0x082C7050
.incbin "baserom.gba", 0x2C7050, 0x8300
.incbin "data/board_data/collision/sapphire/hatchexitramp_angledata.bin"
gSapphireBoardCollisionTypeDataLayer3:: @ 0x082CF350
.incbin "baserom.gba", 0x2CF350, 0x4180
.incbin "data/board_data/collision/sapphire/hatchexitramp_typedata.bin"
gSapphireBoardCompressedTiles1:: @ 0x082D34D0
.incbin "baserom.gba", 0x2D34D0, 0x1D6C

View File

@@ -6,13 +6,13 @@ gSphealWaterBackgroundTilemap:: @ 0x0837A4E8
.incbin "baserom.gba", 0x37A4E8, 0x800
gSphealBoardCollisionTileDataAllLayers:: @ 0x0837ACE8
.incbin "baserom.gba", 0x37ACE8, 0x2000
.incbin "data/board_data/collision/spheal/tiledata.bin"
gSphealBoardCollisionAngleDataAllLayers:: @ 0x0837CCE8
.incbin "baserom.gba", 0x37CCE8, 0xB780
.incbin "data/board_data/collision/spheal/angledata.bin"
gSphealBoardCollisionTypeDataAllLayers:: @ 0x08388468
.incbin "baserom.gba", 0x388468, 0x5BC0
.incbin "data/board_data/collision/spheal/typedata.bin"
gSphealBoardBG1Tilemap:: @ 0x0838E028
.incbin "baserom.gba", 0x38E028, 0x800

View File

@@ -0,0 +1,900 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pinball Collision Data Viewer</title>
<style>
:root {
--bg: #12141a;
--panel: #1d2028;
--panel2: #272b35;
--text: #eef1f7;
--muted: #9da6b8;
--accent: #77a7ff;
--danger: #ff7b7b;
--grid: #3b4252;
--cell: #202532;
--selected: #ffd166;
--selectedQuad: #ffffff;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 16px 20px;
border-bottom: 1px solid #2f3542;
background: #171a21;
}
h1 { margin: 0 0 6px; font-size: 20px; }
.subtle { color: var(--muted); font-size: 13px; }
main {
display: grid;
grid-template-columns: minmax(560px, 1fr) 440px;
gap: 14px;
padding: 14px;
}
section, .panel {
background: var(--panel);
border: 1px solid #2d3340;
border-radius: 12px;
padding: 14px;
}
.file-row {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 8px;
margin: 8px 0;
}
input[type="file"], input[type="number"], input[type="text"], select, button, textarea {
font: inherit;
}
input[type="number"], input[type="text"], select {
width: 100%;
color: var(--text);
background: #11151e;
border: 1px solid #3b4252;
border-radius: 8px;
padding: 6px 8px;
}
button {
color: var(--text);
background: var(--panel2);
border: 1px solid #41495a;
border-radius: 8px;
padding: 8px 10px;
cursor: pointer;
}
button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 12px;
}
.board-wrap {
overflow: auto;
max-height: calc(100vh - 250px);
border: 1px solid #343a46;
border-radius: 10px;
background: #0e1118;
padding: 10px;
}
canvas {
display: block;
image-rendering: pixelated;
background: #11151e;
cursor: crosshair;
}
.meta {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin: 10px 0 14px;
color: var(--muted);
font-size: 13px;
}
.meta div {
background: #151922;
border: 1px solid #2d3340;
border-radius: 8px;
padding: 8px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin: 8px 0 14px;
}
.subcell {
min-width: 0;
aspect-ratio: 1 / 1;
text-align: center;
color: var(--text);
background: var(--cell);
border: 1px solid var(--grid);
border-radius: 5px;
font-size: 11px;
padding: 0;
text-transform: uppercase;
}
.subcell:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.editor-title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-top: 14px;
}
.editor-title h2 {
font-size: 16px;
margin: 0;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.status {
color: var(--muted);
font-size: 13px;
min-height: 20px;
margin-top: 8px;
white-space: pre-wrap;
}
.warning { color: var(--danger); }
.legend {
display: flex;
gap: 8px 14px;
flex-wrap: wrap;
color: var(--muted);
font-size: 12px;
margin-top: 8px;
}
.swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
vertical-align: -2px;
margin-right: 4px;
border: 1px solid #777;
}
.type-key {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
</style>
</head>
<body>
<header>
<h1>Pinball Collision Data Viewer</h1>
<div class="subtle">Single-layer first pass: visualize collision data as 4×4 sub-quads, edit the selected 4×4 source segment, save regenerated tile map.</div>
</header>
<main>
<section>
<div class="toolbar">
<button id="loadSampleBtn">Use blank/sample buffers</button>
<button id="saveSetBtn" disabled>Save edited set</button>
<label class="subtle">Board zoom <input id="zoomInput" type="number" value="16" min="8" max="48" style="width:72px"></label>
<label class="subtle">Color by
<select id="colorMode">
<option value="wall">Type</option>
<option value="angle">Angles</option>
<option value="tile">Tile index</option>
</select>
</label>
<label class="subtle">Detail granularity
<select id="detailGranularity">
<option value="4" selected>4×4 quad</option>
<option value="8">8×8 tile</option>
</select>
</label>
</div>
<div class="file-row">
<label>Data set</label>
<input id="setFiles" type="file" accept=".4bpp,.bin,*/*" multiple>
</div>
<div class="status" id="status">Load all three files to begin. tileData is used as a placement map/sanity check and is regenerated on save.</div>
<div class="board-wrap" id="boardWrap" tabindex="0">
<canvas id="boardCanvas" width="1024" height="1024"></canvas>
</div>
<div class="legend">
<span><span class="swatch" style="background:#ffd166"></span>selected 8×8 board tile</span>
<span><span class="swatch" style="background:#ffffff"></span>selected 4×4 segment</span>
<span><span class="swatch" style="background:hsl(20,70%,46%)"></span>type 1: wall inner edge</span>
<span><span class="swatch" style="background:hsl(210,70%,46%)"></span>type 4: wall outer edge</span>
<span>Click any quarter of a board tile to inspect/edit that 4×4 segment.</span>
</div>
</section>
<aside class="panel">
<div class="editor-title">
<h2>Selected board segment</h2>
<span class="subtle" id="selectedLabel">none</span>
</div>
<div class="meta">
<div>Board X/Y<br><strong id="boardXY"></strong></div>
<div>Quad X/Y<br><strong id="quadXY"></strong></div>
<div>Collision tile index<br><strong id="tileIndexLabel"></strong></div>
<div>Segment cell range<br><strong id="segmentRangeLabel"></strong></div>
<div>Angle byte offset<br><strong id="angleOffsetLabel"></strong></div>
<div>Type byte offset<br><strong id="typeOffsetLabel"></strong></div>
</div>
<label class="subtle">Edit placement map index for selected board tile, hex</label>
<div class="two-col" style="margin: 6px 0 10px;">
<input id="tileIndexInput" type="text" disabled placeholder="0000">
<button id="applyTileIndexBtn" disabled>Apply</button>
</div>
<div class="subtle">Note: edited placement indices affect the current session; saving tileData regenerates a compact placement map from non-empty board cells.</div>
<div class="editor-title">
<h2>Angle data, selected 4×4 u16 hex</h2>
<span class="subtle">0000FFFF</span>
</div>
<div id="angleGrid" class="grid-4"></div>
<div class="editor-title">
<h2>Type data, selected 4×4 u8 hex</h2>
<span class="subtle">00FF</span>
</div>
<div id="typeGrid" class="grid-4"></div>
<div class="type-key">
<span><span class="swatch" style="background:hsl(20,70%,46%)"></span>01 wall, inner edge</span>
<span><span class="swatch" style="background:hsl(210,70%,46%)"></span>04 wall, outer edge</span>
<span><span class="swatch" style="background:hsl(90,65%,40%)"></span>02 other observed</span>
<span><span class="swatch" style="background:hsl(280,60%,45%)"></span>20/30/21/etc.</span>
</div>
<div class="toolbar" style="margin-top:14px;">
<button id="copyTileBtn" disabled>Copy 4×4 segment JSON</button>
<button id="pasteTileBtn" disabled>Paste 4×4 segment JSON</button>
</div>
<textarea id="jsonBox" placeholder="Copied 4×4 segment JSON appears here. Hex strings are accepted when pasting." style="width:100%; min-height:110px; background:#11151e; color:var(--text); border:1px solid #3b4252; border-radius:8px; padding:8px;"></textarea>
</aside>
</main>
<script>
const BOARD_W = 64;
const BOARD_H = 64;
const SUBCELLS = 64;
const QUAD = 4;
const state = {
tileData: null,
angleData: null,
typeData: null,
selectedX: 0,
selectedY: 0,
selectedQuadX: 0,
selectedQuadY: 0,
loadedNames: {},
baseName: "",
dirty: { tile: false, angle: false, type: false }
};
const $ = id => document.getElementById(id);
const canvas = $("boardCanvas");
const boardWrap = $("boardWrap");
const ctx = canvas.getContext("2d");
window.addEventListener("error", ev => {
$("status").textContent = `Script error: ${ev.message}`;
$("status").className = "status warning";
});
function toHex(value, width) {
return (Number(value) >>> 0).toString(16).toUpperCase().padStart(width, "0");
}
function parseHexish(text, maxValue) {
const s = String(text).trim().replace(/^0x/i, "");
if (!/^[0-9a-fA-F]+$/.test(s)) throw new Error(`Invalid hex value: ${text}`);
const value = parseInt(s, 16);
if (value < 0 || value > maxValue) throw new Error(`Hex value ${text} exceeds ${toHex(maxValue, maxValue > 255 ? 4 : 2)}.`);
return value;
}
function readU16LE(buffer) {
const view = new DataView(buffer);
const out = new Uint16Array(buffer.byteLength / 2);
for (let i = 0; i < out.length; i++) out[i] = view.getUint16(i * 2, true);
return out;
}
function writeU16LE(array) {
const buffer = new ArrayBuffer(array.length * 2);
const view = new DataView(buffer);
for (let i = 0; i < array.length; i++) view.setUint16(i * 2, array[i] & 0xFFFF, true);
return new Blob([buffer], { type: "application/octet-stream" });
}
function saveBlob(blob, name) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function detectKindFromName(name) {
const lower = name.toLowerCase();
if (lower.includes("tiledata")) return "tile";
if (lower.includes("angledata")) return "angle";
if (lower.includes("typedata") || lower.includes("collisiontype")) return "type";
return null;
}
function detectBaseName(name) {
return name.replace(/(tiledata|angledata|typedata|collisiontype).*$/i, "");
}
async function loadFileSet(fileList) {
const files = Array.from(fileList);
const firstKindFile = files.find(f => detectKindFromName(f.name));
if (!firstKindFile) throw new Error("No tiledata/angledata/typedata file found.");
state.baseName = detectBaseName(firstKindFile.name);
for (const file of files) {
const kind = detectKindFromName(file.name);
if (kind && detectBaseName(file.name) === state.baseName) {
await loadFile(kind, file);
}
}
if (!state.tileData || !state.angleData || !state.typeData) {
throw new Error(`Could not find all 3 files for base "${state.baseName}". Select tiledata, angledata, and typedata together.`);
}
}
async function loadFile(kind, file) {
const buffer = await file.arrayBuffer();
state.loadedNames[kind] = file.name;
if (kind === "tile") {
if (buffer.byteLength % 2) throw new Error("tileData must have even byte length for u16 data.");
state.tileData = readU16LE(buffer);
} else if (kind === "angle") {
if (buffer.byteLength % 2) throw new Error("angleData must have even byte length for u16 data.");
state.angleData = readU16LE(buffer);
} else if (kind === "type") {
state.typeData = new Uint8Array(buffer.slice(0));
}
validateAndRefresh();
}
function collisionTileCount() {
if (!state.angleData || !state.typeData) return 0;
return Math.floor(Math.min(state.angleData.length, state.typeData.length) / SUBCELLS);
}
function validateAndRefresh() {
const messages = [];
let ok = true;
if (!state.tileData || !state.angleData || !state.typeData) {
ok = false;
messages.push("Load all three files to begin. tileData is used as a placement map/sanity check and is regenerated on save.");
} else {
if (state.tileData.length !== BOARD_W * BOARD_H) {
messages.push(`Warning: expected tileData to be ${BOARD_W * BOARD_H} u16 entries, got ${state.tileData.length}.`);
}
if (state.angleData.length % SUBCELLS !== 0) {
ok = false;
messages.push("angleData length is not divisible by 64 cells.");
}
if (state.typeData.length % SUBCELLS !== 0) {
ok = false;
messages.push("collisionType length is not divisible by 64 cells.");
}
if (state.angleData.length / SUBCELLS !== state.typeData.length / SUBCELLS) {
messages.push(`Warning: angle collision tile count (${state.angleData.length / SUBCELLS}) does not match type count (${state.typeData.length / SUBCELLS}).`);
}
if (ok) {
const count = collisionTileCount();
const refs = Array.from(state.tileData);
const maxTile = refs.reduce((m, v) => Math.max(m, v), 0);
const used = new Set(refs);
const nonzeroRefs = refs.filter(v => v !== 0).length;
const unused = [];
for (let i = 0; i < count; i++) if (!used.has(i)) unused.push(i);
messages.push(`Loaded. Board cells: ${state.tileData.length}. Nonzero placements: ${nonzeroRefs}. Collision tiles: ${count} / max ref 0x${toHex(maxTile, 4)}.`);
if (maxTile >= count) messages.push(`Warning: tileData references 0x${toHex(maxTile, 4)}, but only 0x${toHex(count - 1, 4)} is available.`);
if (unused.length) messages.push(`Sanity: ${unused.length} collision tile indices are not referenced by tileData. First: ${unused.slice(0, 8).map(v => "0x" + toHex(v, 4)).join(", ")}.`);
else messages.push("Sanity: every collision tile index is referenced at least once by tileData.");
}
}
$("status").textContent = messages.join("\n");
$("status").className = messages.some(m => m.startsWith("Warning")) ? "status warning" : "status";
const enabled = !!(state.tileData && state.angleData && state.typeData);
for (const id of ["saveSetBtn", "copyTileBtn", "pasteTileBtn", "applyTileIndexBtn", "tileIndexInput"]) {
$(id).disabled = !enabled;
}
renderBoard();
renderSelected();
}
function typeColor(value) {
switch (value) {
case 0x00: return "#11151e";
case 0x01: return "hsl(20,70%,46%)";
case 0x04: return "hsl(210,70%,46%)";
case 0x02: return "hsl(90,65%,40%)";
case 0x20: case 0x21: case 0x24: case 0x30: case 0x31: case 0x34:
return "hsl(280,60%,45%)";
default: return `hsl(${Math.floor((value * 41) % 360)}, 65%, 42%)`;
}
}
function selectedSegmentStart() {
return {
sx: state.selectedQuadX * QUAD,
sy: state.selectedQuadY * QUAD
};
}
function segmentIndices(tileIndex, qx = state.selectedQuadX, qy = state.selectedQuadY) {
const base = tileIndex * SUBCELLS;
const out = [];
for (let y = qy * QUAD; y < qy * QUAD + QUAD; y++) {
for (let x = qx * QUAD; x < qx * QUAD + QUAD; x++) {
out.push(base + y * 8 + x);
}
}
return out;
}
function dominantNonzeroType(tileIndex, qx, qy) {
if (!state.typeData || tileIndex < 0 || tileIndex >= collisionTileCount()) return 0;
const counts = new Map();
for (const i of segmentIndices(tileIndex, qx, qy)) {
const v = state.typeData[i] ?? 0;
if (v !== 0) counts.set(v, (counts.get(v) || 0) + 1);
}
let best = 0, bestCount = -1;
for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; }
return best;
}
function averageQuadAngle(tileIndex, qx, qy) {
if (!state.angleData || !state.typeData || tileIndex < 0 || tileIndex >= collisionTileCount()) return 0;
let sum = 0, n = 0;
for (const i of segmentIndices(tileIndex, qx, qy)) {
if ((state.typeData[i] ?? 0) !== 0) {
sum += state.angleData[i] ?? 0;
n++;
}
}
return n ? Math.round(sum / n) : 0;
}
function detailGranularity() {
return Number($("detailGranularity").value) || QUAD;
}
function selectedEditStart() {
const size = detailGranularity();
return {
sx: size === 8 ? 0 : state.selectedQuadX * QUAD,
sy: size === 8 ? 0 : state.selectedQuadY * QUAD,
size
};
}
function selectedEditIndices(tileIndex) {
const { sx, sy, size } = selectedEditStart();
const base = tileIndex * SUBCELLS;
const out = [];
for (let y = sy; y < sy + size; y++) {
for (let x = sx; x < sx + size; x++) {
out.push(base + y * 8 + x);
}
}
return out;
}
function renderBoard() {
try {
const zoom = Math.max(8, Math.min(48, Number($("zoomInput").value) || 16));
canvas.width = BOARD_W * zoom;
canvas.height = BOARD_H * zoom;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#11151e";
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (!state.tileData) return;
const mode = $("colorMode").value;
const count = collisionTileCount();
const maxAngle = state.angleData && state.angleData.length ? state.angleData.reduce((m, v) => Math.max(m, v), 0) : 65535;
const maxTile = state.tileData.length ? (state.tileData.reduce((m, v) => Math.max(m, v), 0) || 1) : 1;
for (let y = 0; y < BOARD_H; y++) {
for (let x = 0; x < BOARD_W; x++) {
const mapIndex = y * BOARD_W + x;
const tileIndex = state.tileData[mapIndex] || 0;
const tileOk = tileIndex >= 0 && tileIndex < count;
const subZoom = zoom / 8;
const base = tileIndex * SUBCELLS;
for (let sy = 0; sy < 8; sy++) {
for (let sx = 0; sx < 8; sx++) {
let fill = "#10141c";
const dataIndex = base + sy * 8 + sx;
if (mode === "tile") {
fill = `hsl(${Math.floor((tileIndex / maxTile) * 280)}, 55%, ${tileIndex ? 38 : 16}%)`;
} else if (!tileOk) {
fill = "#7a1f1f";
} else if (mode === "angle") {
const angle = state.angleData[dataIndex] ?? 0;
const type = state.typeData[dataIndex] ?? 0;
fill = (angle || type)
? `hsl(${Math.floor((angle / Math.max(maxAngle, 1)) * 260 + 40)}, 60%, 40%)`
: "#11151e";
} else if (mode === "wall") {
const t = state.typeData[dataIndex] ?? 0;
fill = (t === 0x01 || t === 0x04) ? typeColor(t) : (t ? "#303540" : "#11151e");
} else {
fill = typeColor(state.typeData[dataIndex] ?? 0);
}
ctx.fillStyle = fill;
ctx.fillRect(
x * zoom + sx * subZoom,
y * zoom + sy * subZoom,
Math.ceil(subZoom),
Math.ceil(subZoom)
);
}
}
}
}
ctx.strokeStyle = "rgba(255,255,255,0.09)";
ctx.lineWidth = 1;
for (let i = 0; i <= BOARD_W; i += 8) {
ctx.beginPath(); ctx.moveTo(i * zoom + 0.5, 0); ctx.lineTo(i * zoom + 0.5, canvas.height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i * zoom + 0.5); ctx.lineTo(canvas.width, i * zoom + 0.5); ctx.stroke();
}
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--selected");
ctx.lineWidth = 3;
ctx.strokeRect(state.selectedX * zoom + 1.5, state.selectedY * zoom + 1.5, zoom - 3, zoom - 3);
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--selectedQuad");
ctx.lineWidth = 2;
const g = detailGranularity();
const subZoom = zoom / 8;
const selectX = g === 8 ? 0 : state.selectedQuadX * QUAD;
const selectY = g === 8 ? 0 : state.selectedQuadY * QUAD;
ctx.strokeRect(
state.selectedX * zoom + selectX * subZoom + 2.5,
state.selectedY * zoom + selectY * subZoom + 2.5,
g * subZoom - 5,
g * subZoom - 5
);
} catch (err) {
$("status").textContent = `Render error: ${err.message}`;
$("status").className = "status warning";
}
}
function selectedTileIndex() {
if (!state.tileData) return 0;
return state.tileData[state.selectedY * BOARD_W + state.selectedX] || 0;
}
function renderSelected() {
const hasData = !!(state.tileData && state.angleData && state.typeData);
const tileIndex = selectedTileIndex();
const indices = hasData ? selectedEditIndices(tileIndex) : [];
const first = indices[0] ?? 0;
const { sx, sy, size } = selectedEditStart();
$("selectedLabel").textContent = hasData ? `tile ${state.selectedX}, ${state.selectedY}; quad ${state.selectedQuadX}, ${state.selectedQuadY}` : "none";
$("boardXY").textContent = hasData ? `${state.selectedX}, ${state.selectedY}` : "—";
$("quadXY").textContent = hasData ? `${state.selectedQuadX}, ${state.selectedQuadY}` : "—";
$("tileIndexLabel").textContent = hasData ? `0x${toHex(tileIndex, 4)}` : "—";
$("segmentRangeLabel").textContent = hasData ? `x ${sx}${sx + size - 1}, y ${sy}${sy + size - 1}` : "—";
$("angleOffsetLabel").textContent = hasData ? `0x${toHex(first * 2, 6)}` : "—";
$("typeOffsetLabel").textContent = hasData ? `0x${toHex(first, 6)}` : "—";
$("tileIndexInput").value = hasData ? toHex(tileIndex, 4) : "";
renderGrid("angleGrid", "angle", 0xFFFF, 4);
renderGrid("typeGrid", "type", 0xFF, 2);
}
function renderGrid(containerId, kind, maxValue, width) {
const container = $(containerId);
container.innerHTML = "";
if (!state.tileData || !state.angleData || !state.typeData) return;
const tileIndex = selectedTileIndex();
const source = kind === "angle" ? state.angleData : state.typeData;
const indices = selectedEditIndices(tileIndex);
const cols = detailGranularity();
container.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
for (let local = 0; local < indices.length; local++) {
const i = indices[local];
const input = document.createElement("input");
input.className = "subcell";
input.type = "text";
input.value = toHex(source[i] ?? 0, width);
const cellX = state.selectedQuadX * QUAD + (local % QUAD);
const cellY = state.selectedQuadY * QUAD + Math.floor(local / QUAD);
input.title = `subcell x=${cellX}, y=${cellY}, file index=0x${toHex(i, 6)}`;
input.addEventListener("change", () => {
try {
const value = parseHexish(input.value, maxValue);
source[i] = value;
input.value = toHex(value, width);
state.dirty[kind] = true;
renderBoard();
updateDirtyStatus();
} catch (err) {
alert(err.message);
input.value = toHex(source[i] ?? 0, width);
}
});
container.appendChild(input);
}
}
function updateDirtyStatus() {
const dirty = Object.entries(state.dirty).filter(([, v]) => v).map(([k]) => k).join(", ");
if (dirty) $("status").textContent = `Unsaved changes in: ${dirty}.`;
else validateAndRefresh();
}
function regenerateTileData() {
const oldToNew = new Map([[0, 0]]);
let next = 1;
const generated = new Uint16Array(BOARD_W * BOARD_H);
const count = collisionTileCount();
function tileHasCollision(tileIndex) {
if (tileIndex <= 0 || tileIndex >= count) return false;
const base = tileIndex * SUBCELLS;
for (let i = 0; i < SUBCELLS; i++) if ((state.typeData[base + i] ?? 0) !== 0) return true;
return false;
}
for (let i = 0; i < generated.length; i++) {
const old = state.tileData[i] || 0;
if (!tileHasCollision(old)) {
generated[i] = 0;
continue;
}
if (!oldToNew.has(old)) oldToNew.set(old, next++);
generated[i] = oldToNew.get(old);
}
return { generated, oldToNew };
}
canvas.addEventListener("click", ev => {
boardWrap.focus();
const rect = canvas.getBoundingClientRect();
const zoom = canvas.width / BOARD_W;
const localX = Math.max(0, Math.min(canvas.width - 1, ev.clientX - rect.left));
const localY = Math.max(0, Math.min(canvas.height - 1, ev.clientY - rect.top));
state.selectedX = Math.max(0, Math.min(BOARD_W - 1, Math.floor(localX / zoom)));
state.selectedY = Math.max(0, Math.min(BOARD_H - 1, Math.floor(localY / zoom)));
const insideX = localX - state.selectedX * zoom;
const insideY = localY - state.selectedY * zoom;
state.selectedQuadX = insideX >= zoom / 2 ? 1 : 0;
state.selectedQuadY = insideY >= zoom / 2 ? 1 : 0;
state.selectedCellX = Math.max(0, Math.min(7, Math.floor(insideX / (zoom / 8))));
state.selectedCellY = Math.max(0, Math.min(7, Math.floor(insideY / (zoom / 8))));
renderBoard();
renderSelected();
});
boardWrap.addEventListener("keydown", ev => {
if (!state.tileData) return;
const dx =
ev.key === "ArrowLeft" ? -1 :
ev.key === "ArrowRight" ? 1 :
0;
const dy =
ev.key === "ArrowUp" ? -1 :
ev.key === "ArrowDown" ? 1 :
0;
if (!dx && !dy) return;
ev.preventDefault();
const g = detailGranularity();
if (g === 8) {
state.selectedX = Math.max(0, Math.min(BOARD_W - 1, state.selectedX + dx));
state.selectedY = Math.max(0, Math.min(BOARD_H - 1, state.selectedY + dy));
state.selectedQuadX = 0;
state.selectedQuadY = 0;
} else {
const absBlockX = state.selectedX * 2 + state.selectedQuadX + dx;
const absBlockY = state.selectedY * 2 + state.selectedQuadY + dy;
const clampedBlockX = Math.max(0, Math.min(BOARD_W * 2 - 1, absBlockX));
const clampedBlockY = Math.max(0, Math.min(BOARD_H * 2 - 1, absBlockY));
state.selectedX = Math.floor(clampedBlockX / 2);
state.selectedY = Math.floor(clampedBlockY / 2);
state.selectedQuadX = clampedBlockX % 2;
state.selectedQuadY = clampedBlockY % 2;
}
renderBoard();
renderSelected();
});
$("setFiles").addEventListener("change", e => {
if (e.target.files.length) loadFileSet(e.target.files).catch(err => alert(err.message));
});
$("zoomInput").addEventListener("change", renderBoard);
$("colorMode").addEventListener("change", renderBoard);
$("detailGranularity").addEventListener("change", () => {
renderBoard();
renderSelected();
});
$("applyTileIndexBtn").addEventListener("click", () => {
if (!state.tileData) return;
try {
const maxCollisionTile = collisionTileCount() - 1;
const value = parseHexish($("tileIndexInput").value, maxCollisionTile);
state.tileData[state.selectedY * BOARD_W + state.selectedX] = value;
state.dirty.tile = true;
renderBoard();
renderSelected();
updateDirtyStatus();
} catch (err) {
alert(err.message);
$("tileIndexInput").value = toHex(selectedTileIndex(), 4);
}
});
$("saveSetBtn").addEventListener("click", () => {
const base = state.baseName || "edited_";
const { generated } = regenerateTileData();
saveBlob(writeU16LE(generated), `${base}tiledata.4bpp`);
saveBlob(writeU16LE(state.angleData), `${base}angledata.4bpp`);
saveBlob(new Blob([state.typeData], { type: "application/octet-stream" }), `${base}typedata.4bpp`);
state.tileData = generated;
state.dirty.tile = false;
state.dirty.angle = false;
state.dirty.type = false;
renderBoard();
renderSelected();
$("status").textContent = `Saved edited set using base "${base}".`;
});
$("copyTileBtn").addEventListener("click", () => {
const tileIndex = selectedTileIndex();
const indices = selectedEditIndices(tileIndex);
const { sx, sy } = selectedSegmentStart();
const data = {
collisionTileIndex: `0x${toHex(tileIndex, 4)}`,
board: { x: state.selectedX, y: state.selectedY },
quad: { x: state.selectedQuadX, y: state.selectedQuadY, cellRange: { x0: sx, y0: sy, x1: sx + 3, y1: sy + 3 } },
angle: indices.map(i => `0x${toHex(state.angleData[i] ?? 0, 4)}`),
type: indices.map(i => `0x${toHex(state.typeData[i] ?? 0, 2)}`)
};
$("jsonBox").value = JSON.stringify(data, null, 2);
});
$("pasteTileBtn").addEventListener("click", () => {
try {
const data = JSON.parse($("jsonBox").value);
const expected = detailGranularity() * detailGranularity();
if (!Array.isArray(data.angle) || !Array.isArray(data.type) || data.angle.length !== expected || data.type.length !== expected) {
throw new Error(`JSON must contain angle[${expected}] and type[${expected}] for the selected edit region.`);
}
const indices = selectedEditIndices(tileIndex);
for (let i = 0; i < indices.length; i++) {
state.angleData[indices[i]] = typeof data.angle[i] === "string" ? parseHexish(data.angle[i], 0xFFFF) : Math.max(0, Math.min(0xFFFF, Number(data.angle[i]) || 0));
state.typeData[indices[i]] = typeof data.type[i] === "string" ? parseHexish(data.type[i], 0xFF) : Math.max(0, Math.min(0xFF, Number(data.type[i]) || 0));
}
state.dirty.angle = true;
state.dirty.type = true;
renderBoard();
renderSelected();
updateDirtyStatus();
} catch (err) {
alert(err.message);
}
});
$("loadSampleBtn").addEventListener("click", () => {
state.tileData = new Uint16Array(BOARD_W * BOARD_H);
state.angleData = new Uint16Array(367 * SUBCELLS);
state.typeData = new Uint8Array(367 * SUBCELLS);
let idx = 1;
for (let y = 10; y < 54; y++) {
const left = Math.max(6, 25 - Math.floor(y / 3));
const right = Math.min(57, 38 + Math.floor(y / 3));
for (let x = 0; x < BOARD_W; x++) {
if ((x === left || x === right) && idx < 367) state.tileData[y * BOARD_W + x] = idx++;
}
}
for (let t = 1; t < 367; t++) {
for (let i = 0; i < SUBCELLS; i++) {
const x = i % 8;
state.angleData[t * SUBCELLS + i] = (t * 0x10 + i) & 0xFFFF;
state.typeData[t * SUBCELLS + i] = x < 2 ? 0x01 : (x > 5 ? 0x04 : 0x00);
}
}
validateAndRefresh();
});
renderBoard();
</script>
</body>
</html>