mirror of
https://github.com/pret/pokepinballrs.git
synced 2026-09-09 03:46:33 -05:00
Add collision view/edit tool
This commit is contained in:
2
Makefile
2
Makefile
@@ -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))))
|
||||
|
||||
|
||||
BIN
data/board_data/collision/dusclops/angledata.bin
Normal file
BIN
data/board_data/collision/dusclops/angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/dusclops/tiledata.bin
Normal file
BIN
data/board_data/collision/dusclops/tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/dusclops/typedata.bin
Normal file
BIN
data/board_data/collision/dusclops/typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/kecleon/angledata.bin
Normal file
BIN
data/board_data/collision/kecleon/angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/kecleon/tiledata.bin
Normal file
BIN
data/board_data/collision/kecleon/tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/kecleon/typedata.bin
Normal file
BIN
data/board_data/collision/kecleon/typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/kyogre/angledata.bin
Normal file
BIN
data/board_data/collision/kyogre/angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/kyogre/tiledata.bin
Normal file
BIN
data/board_data/collision/kyogre/tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/kyogre/typedata.bin
Normal file
BIN
data/board_data/collision/kyogre/typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/rayquaza/angledata.bin
Normal file
BIN
data/board_data/collision/rayquaza/angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/rayquaza/tiledata.bin
Normal file
BIN
data/board_data/collision/rayquaza/tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/rayquaza/typedata.bin
Normal file
BIN
data/board_data/collision/rayquaza/typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardbottom_angledata.bin
Normal file
BIN
data/board_data/collision/ruby/boardbottom_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardbottom_tiledata.bin
Normal file
BIN
data/board_data/collision/ruby/boardbottom_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardbottom_typedata.bin
Normal file
BIN
data/board_data/collision/ruby/boardbottom_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardtop_launch_angledata.bin
Normal file
BIN
data/board_data/collision/ruby/boardtop_launch_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardtop_launch_tiledata.bin
Normal file
BIN
data/board_data/collision/ruby/boardtop_launch_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardtop_launch_typedata.bin
Normal file
BIN
data/board_data/collision/ruby/boardtop_launch_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardtop_normal_angledata.bin
Normal file
BIN
data/board_data/collision/ruby/boardtop_normal_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardtop_normal_tiledata.bin
Normal file
BIN
data/board_data/collision/ruby/boardtop_normal_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/boardtop_normal_typedata.bin
Normal file
BIN
data/board_data/collision/ruby/boardtop_normal_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/topramps_angledata.bin
Normal file
BIN
data/board_data/collision/ruby/topramps_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/topramps_tiledata.bin
Normal file
BIN
data/board_data/collision/ruby/topramps_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/ruby/topramps_typedata.bin
Normal file
BIN
data/board_data/collision/ruby/topramps_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/boardbottom_angledata.bin
Normal file
BIN
data/board_data/collision/sapphire/boardbottom_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/boardbottom_tiledata.bin
Normal file
BIN
data/board_data/collision/sapphire/boardbottom_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/boardbottom_typedata.bin
Normal file
BIN
data/board_data/collision/sapphire/boardbottom_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/boardtop_angledata.bin
Normal file
BIN
data/board_data/collision/sapphire/boardtop_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/boardtop_tiledata.bin
Normal file
BIN
data/board_data/collision/sapphire/boardtop_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/boardtop_typedata.bin
Normal file
BIN
data/board_data/collision/sapphire/boardtop_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/hatchexitramp_angledata.bin
Normal file
BIN
data/board_data/collision/sapphire/hatchexitramp_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/hatchexitramp_tiledata.bin
Normal file
BIN
data/board_data/collision/sapphire/hatchexitramp_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/hatchexitramp_typedata.bin
Normal file
BIN
data/board_data/collision/sapphire/hatchexitramp_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/topramp_angledata.bin
Normal file
BIN
data/board_data/collision/sapphire/topramp_angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/topramp_tiledata.bin
Normal file
BIN
data/board_data/collision/sapphire/topramp_tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/sapphire/topramp_typedata.bin
Normal file
BIN
data/board_data/collision/sapphire/topramp_typedata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/spheal/angledata.bin
Normal file
BIN
data/board_data/collision/spheal/angledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/spheal/tiledata.bin
Normal file
BIN
data/board_data/collision/spheal/tiledata.bin
Normal file
Binary file not shown.
BIN
data/board_data/collision/spheal/typedata.bin
Normal file
BIN
data/board_data/collision/spheal/typedata.bin
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
900
tools/collision_viewer/collision_viewer_editor.html
Normal file
900
tools/collision_viewer/collision_viewer_editor.html
Normal 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">0000–FFFF</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">00–FF</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>
|
||||
Reference in New Issue
Block a user