Files
pokepinballrs/tools/collision_viewer/entity_collision_view_editor.html
Retnuhytnuob 158ba46210 naming patch
2026-08-08 15:36:15 -05:00

574 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Entity Collision Map Viewer/Editor</title>
<style>
:root {
--bg: #12141a;
--panel: #1d2028;
--panel2: #272b35;
--text: #eef1f7;
--muted: #9da6b8;
--accent: #77a7ff;
--danger: #ff7b7b;
--grid: #3b4252;
--cell: #202532;
--selected: #ffd166;
}
* { 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) 420px;
gap: 14px;
padding: 14px;
}
section, .panel {
background: var(--panel);
border: 1px solid #2d3340;
border-radius: 12px;
padding: 14px;
}
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;
}
.toolbar label { display: inline-flex; align-items: center; gap: 6px; }
.toolbar input[type="number"] { width: 82px; }
.toolbar select { width: auto; }
.file-row {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.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;
}
.status {
color: var(--muted);
font-size: 13px;
min-height: 20px;
margin-top: 8px;
margin-bottom: 10px;
white-space: pre-wrap;
}
.warning { color: var(--danger); }
.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;
}
.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;
margin: 6px 0 10px;
}
.three-col {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin: 6px 0 10px;
}
.legend, .type-key {
display: flex;
gap: 8px 14px;
flex-wrap: wrap;
color: var(--muted);
font-size: 12px;
margin-top: 8px;
}
.type-key {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
vertical-align: -2px;
margin-right: 4px;
border: 1px solid #777;
}
.cell-preview {
min-height: 44px;
border: 1px solid #3b4252;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
text-shadow: 0 1px 2px black;
font-weight: 700;
letter-spacing: 0.04em;
}
.nav-help {
color: var(--muted);
font-size: 12px;
margin-top: 8px;
}
</style>
</head>
<body>
<header>
<h1>Entity Collision Map Viewer/Editor</h1>
<div class="subtle">Loads normal u16 collision maps, plus _WxH_typeless.bin maps where each cell is one byte: bit 7 = collision and bits 0–6 = angle index.</div>
</header>
<main>
<section>
<div class="toolbar">
<button id="saveBtn" disabled>Save edited map</button>
<label class="subtle">Preset
<select id="presetSelect">
<option value="filename" selected>From filename _WxH</option>
<option value="custom">Custom / manual</option>
</select>
</label>
<label class="subtle">W <input id="widthInput" type="number" value="96" min="1" max="512"></label>
<label class="subtle">H <input id="heightInput" type="number" value="88" min="1" max="512"></label>
<label class="subtle">Element <input id="elementInput" type="number" value="0" min="0" step="1"></label>
<label class="subtle">Zoom <input id="zoomInput" type="number" value="8" min="2" max="32"></label>
<label class="subtle">Color by
<select id="colorMode">
<option value="type">Type nibble</option>
<option value="angle" selected>Angle</option>
<option value="combined">Angle + type</option>
<option value="solid">Nonzero mask</option>
</select>
</label>
<label class="subtle"><input id="showGrid" type="checkbox"> grid</label>
</div>
<div class="file-row">
<label>Collision map</label>
<input id="fileInput" type="file" accept=".bin,.4bpp,*/*">
</div>
<div class="status" id="status">Load an entity collision .bin file. Normal files are u16. Files named like Name_48x48_typeless.bin are decoded as one byte per cell.</div>
<div class="board-wrap" id="boardWrap" tabindex="0">
<canvas id="mapCanvas" width="768" height="704"></canvas>
</div>
<div class="legend">
<span><span class="swatch" style="background:#ffd166"></span>selected cell</span>
<span><span class="swatch" style="background:#11151e"></span>empty / type 0</span>
<span>Arrow keys move the selected cell. Click any cell to inspect/edit it.</span>
</div>
</section>
<aside class="panel">
<div class="editor-title">
<h2>Selected map cell</h2>
<span class="subtle" id="selectedLabel">none</span>
</div>
<div class="meta">
<div>X/Y<br><strong id="xyLabel">—</strong></div>
<div>Index<br><strong id="indexLabel">—</strong></div>
<div>Byte offset<br><strong id="offsetLabel">—</strong></div>
<div><span id="rawMetaTitle">Raw u16</span><br><strong id="rawLabel">—</strong></div>
<div><span id="angleMetaTitle">Angle bits</span><br><strong id="angleLabel">—</strong></div>
<div><span id="typeMetaTitle">Type nibble</span><br><strong id="typeLabel">—</strong></div>
</div>
<div class="cell-preview" id="cellPreview">—</div>
<div class="editor-title">
<h2>Edit value</h2>
<span class="subtle" id="editEncodingHint">u16 = angle &amp; FFF0 + type &amp; F</span>
</div>
<label class="subtle" id="rawInputLabel">Raw u16 hex</label>
<div class="two-col">
<input id="rawInput" type="text" disabled placeholder="0000">
<button id="applyRawBtn" disabled>Apply raw</button>
</div>
<label class="subtle" id="partsLabel">Angle/type hex</label>
<div class="three-col">
<input id="angleInput" type="text" disabled placeholder="0000">
<input id="typeInput" type="text" disabled placeholder="0">
<label id="collisionToggleWrap" class="subtle" style="display:none; align-items:center; gap:8px; padding:6px 4px;">
<input id="collisionToggle" type="checkbox" disabled> collision
</label>
<button id="applyPartsBtn" disabled>Apply parts</button>
</div>
<div class="nav-help" id="partsHelp">Angle input is automatically masked to FFF0. Type input accepts 0–F.</div>
<div class="editor-title">
<h2>Collision type key</h2>
<span class="subtle" id="typeKeyHint">lower nibble</span>
</div>
<div id="typeKey" class="type-key"></div>
<div class="editor-title">
<h2>Notes</h2>
</div>
<div class="subtle">
Normal files use <code>angle = value &amp; 0xFFF0</code> and <code>type = value &amp; 0x000F</code> in little-endian u16 cells.
<br><br>
A filename with <code>_WxH_typeless</code> (for example <code>catch_target_collision_48x48_typeless.bin</code>) switches to one-byte cells: <code>collision = value &amp; 0x80</code>, <code>angle = (value &amp; 0x7F) &lt;&lt; 9</code>. Typeless collision is displayed as type 3, but that type is fixed and not stored or editable.
<br><br>
Filename dimensions are read from a pattern like <code>NAME_96x120.bin</code> or <code>NAME_48x48_typeless.bin</code>.
</div>
</aside>
</main>
<script>
const ANGLE_MASK = 0xFFF0;
const TYPE_MASK = 0x000F;
const TYPELESS_COLLISION_MASK = 0x80;
const TYPELESS_ANGLE_MASK = 0x7F;
const TYPELESS_DISPLAY_TYPE = 3;
const state = {
data: null,
fileName: "",
format: "u16",
selectedElement: 0,
elementCount: 0,
selectedX: 0,
selectedY: 0,
dirty: false
};
const $ = id => document.getElementById(id);
const canvas = $("mapCanvas");
const boardWrap = $("boardWrap");
const ctx = canvas.getContext("2d");
const typeHues = {0x0:220,0x1:24,0x2:92,0x3:0,0x4:210,0x5:48,0x6:160,0x7:280,0x8:330,0x9:188,0xA:120,0xB:300,0xC:16,0xD:250,0xE:62,0xF:350};
function isTypeless() { return state.format === "typeless-u8"; }
function bytesPerCell() { return isTypeless() ? 1 : 2; }
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 0x${toHex(maxValue, maxValue > 0xF ? 4 : 1)}.`);
return value;
}
function readU16LE(buffer) {
const view = new DataView(buffer);
const out = new Uint16Array(Math.floor(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 cellsPerElement() { return width() * height(); }
function width() { return Math.max(1, Number($("widthInput").value) || 1); }
function height() { return Math.max(1, Number($("heightInput").value) || 1); }
function selectedLocalIndex() { return state.selectedY * width() + state.selectedX; }
function selectedIndex() { return state.selectedElement * cellsPerElement() + selectedLocalIndex(); }
function getValueAt(index) { return state.data && index >= 0 && index < state.data.length ? state.data[index] : 0; }
function formatFromFileName(fileName) {
return /_\d{1,4}x\d{1,4}_typeless(?=[_.-]|$)/i.test(String(fileName)) ? "typeless-u8" : "u16";
}
function dimensionsFromFileName(fileName) {
const match = String(fileName).match(/(?:^|[_\-.])(\d{1,4})x(\d{1,4})(?=[_\-.]|$)/i);
if (!match) return null;
const w = Number(match[1]), h = Number(match[2]);
return Number.isInteger(w) && Number.isInteger(h) && w > 0 && h > 0 ? { w, h } : null;
}
function applyPresetFromFile(fileName, cellLength) {
if ($("presetSelect").value !== "filename") return;
const dims = dimensionsFromFileName(fileName);
if (!dims) return;
$("widthInput").value = String(dims.w); $("heightInput").value = String(dims.h);
const per = dims.w * dims.h;
state.elementCount = per > 0 ? Math.floor(cellLength / per) : 0;
state.selectedElement = Math.max(0, Math.min(Math.max(0, state.elementCount - 1), state.selectedElement));
}
function decodeCell(value) {
if (isTypeless()) {
const collision = !!(value & TYPELESS_COLLISION_MASK);
const angleIndex = value & TYPELESS_ANGLE_MASK;
return { raw:value, collision, angleIndex, angle:angleIndex << 9, type:collision ? TYPELESS_DISPLAY_TYPE : 0 };
}
return { raw:value, collision:value !== 0, angleIndex:null, angle:value & ANGLE_MASK, type:value & TYPE_MASK };
}
function encodeTypeless(collision, angle) {
const angleIndex = (angle >>> 9) & TYPELESS_ANGLE_MASK;
return (collision ? TYPELESS_COLLISION_MASK : 0) | angleIndex;
}
function typeColor(type, angle = 0) {
if ((type & TYPE_MASK) === 0) return angle ? "hsl(220,32%,24%)" : "#11151e";
const t = type & TYPE_MASK, hue = typeHues[t] ?? ((t * 31) % 360);
const sat = t === 3 ? 76 : 68, light = t === 1 ? 46 : t === 4 ? 50 : t === 3 ? 52 : 44;
return `hsl(${hue},${sat}%,${light}%)`;
}
function angleColor(angle, type) {
if (!angle && !type) return "#11151e";
const hue = Math.floor(((angle & ANGLE_MASK) / ANGLE_MASK) * 280 + 30) % 360;
return `hsl(${hue},60%,${type ? 42 : 28}%)`;
}
function combinedDecodedColor(cell) {
const { angle, type } = cell;
if (!angle && !type) return "#11151e";
const hue = type ? (typeHues[type] ?? type * 24) : Math.floor((angle / ANGLE_MASK) * 360);
const light = type ? Math.max(34, Math.min(62, 32 + ((angle >>> 4) % 8) * 4)) : 24;
return `hsl(${hue},${type ? 72 : 35}%,${light}%)`;
}
function colorForValue(value) {
const cell = decodeCell(value), mode = $("colorMode").value;
if (mode === "type") return typeColor(cell.type, cell.angle);
if (mode === "angle") return angleColor(cell.angle, cell.type);
if (mode === "combined") return combinedDecodedColor(cell);
if (mode === "solid") return cell.collision ? typeColor(cell.type || TYPELESS_DISPLAY_TYPE, cell.angle) : "#11151e";
return typeColor(cell.type, cell.angle);
}
async function loadFile(file) {
const buffer = await file.arrayBuffer();
state.fileName = file.name;
state.format = formatFromFileName(file.name);
if (isTypeless()) state.data = new Uint8Array(buffer);
else {
if (buffer.byteLength % 2) throw new Error("Normal collision maps must have even byte length for u16 little-endian data.");
state.data = readU16LE(buffer);
}
state.dirty = false;
applyPresetFromFile(file.name, state.data.length);
clampSelection(); validateAndRefresh();
}
function clampSelection() {
state.selectedX = Math.max(0, Math.min(width() - 1, state.selectedX));
state.selectedY = Math.max(0, Math.min(height() - 1, state.selectedY));
}
function updateFormatUI() {
const typeless = isTypeless();
$("rawMetaTitle").textContent = typeless ? "Raw u8" : "Raw u16";
$("angleMetaTitle").textContent = typeless ? "Collision angle" : "Angle bits";
$("typeMetaTitle").textContent = typeless ? "Display type" : "Type nibble";
$("editEncodingHint").textContent = typeless ? "u8 = collision bit 7 + 7-bit angle index" : "u16 = angle & FFF0 + type & F";
$("rawInputLabel").textContent = typeless ? "Raw u8 hex" : "Raw u16 hex";
$("partsLabel").textContent = typeless ? "Angle / collision" : "Angle/type hex";
$("typeInput").style.display = typeless ? "none" : "block";
$("collisionToggleWrap").style.display = typeless ? "flex" : "none";
$("typeKeyHint").textContent = typeless ? "type 3 display color is fixed" : "lower nibble";
$("partsHelp").textContent = typeless
? "Angle is stored as a 7-bit index in 0x0200 increments. Type 3 is display-only; collision is the editable bit 7 flag."
: "Angle input is automatically masked to FFF0. Type input accepts 0–F.";
}
function validateAndRefresh() {
const messages = []; let warning = false;
updateFormatUI();
const elementInput = $("elementInput");
elementInput.disabled = !state.data;
elementInput.max = String(Math.max(0, state.elementCount - 1));
elementInput.value = String(state.selectedElement);
if (!state.data) {
messages.push("Load an entity collision .bin file. Normal files are u16; filenames ending in _WxH_typeless.bin use one byte per cell.");
} else {
const elementSize = cellsPerElement(), completeElements = Math.floor(state.data.length / elementSize), remainder = state.data.length % elementSize;
state.elementCount = completeElements;
messages.push(`Loaded ${state.fileName}: ${state.data.length} cells / ${state.data.length * bytesPerCell()} bytes.`);
messages.push(`Format: ${isTypeless() ? "typeless u8 (bit 7 collision, bits 0–6 angle; display type fixed to 3)" : "u16 little-endian (angle + type nibble)"}.`);
messages.push(`Element dimensions: ${width()} × ${height()} = ${elementSize} cells.`);
messages.push(`Detected ${completeElements} complete element${completeElements === 1 ? "" : "s"}.`);
if (remainder) { warning = true; messages.push(`Warning: file contains ${remainder} extra cell${remainder === 1 ? "" : "s"} after the final complete element.`); }
if (state.dirty) messages.push("Unsaved changes present.");
}
$("status").textContent = messages.join("\n"); $("status").className = warning ? "status warning" : "status";
const enabled = !!state.data;
for (const id of ["saveBtn","rawInput","angleInput","applyRawBtn","applyPartsBtn","collisionToggle"]) $(id).disabled = !enabled;
$("typeInput").disabled = !enabled || isTypeless();
renderMap(); renderSelected();
}
function renderMap() {
try {
clampSelection();
const z = Math.max(2, Math.min(32, Number($("zoomInput").value) || 8)), w = width(), h = height();
canvas.width = w * z; canvas.height = h * z;
ctx.clearRect(0,0,canvas.width,canvas.height); ctx.fillStyle="#11151e"; ctx.fillRect(0,0,canvas.width,canvas.height);
if (state.data) {
const elementOffset = state.selectedElement * cellsPerElement();
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
const index = elementOffset + y*w+x; if (index >= state.data.length) continue;
ctx.fillStyle = colorForValue(state.data[index] ?? 0); ctx.fillRect(x*z,y*z,z,z);
}
}
if ($("showGrid").checked && z >= 6) {
ctx.strokeStyle="rgba(255,255,255,0.10)"; ctx.lineWidth=1;
for (let x=0;x<=w;x++){ctx.beginPath();ctx.moveTo(x*z+0.5,0);ctx.lineTo(x*z+0.5,canvas.height);ctx.stroke();}
for (let y=0;y<=h;y++){ctx.beginPath();ctx.moveTo(0,y*z+0.5);ctx.lineTo(canvas.width,y*z+0.5);ctx.stroke();}
}
ctx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue("--selected"); ctx.lineWidth=Math.max(2,Math.floor(z/3));
ctx.strokeRect(state.selectedX*z+0.5,state.selectedY*z+0.5,z-1,z-1);
} catch(err) { $("status").textContent=`Render error: ${err.message}`; $("status").className="status warning"; }
}
function renderSelected() {
const hasData=!!state.data, index=selectedIndex(), value=hasData?getValueAt(index):0, cell=decodeCell(value), localIndex=selectedLocalIndex();
$("selectedLabel").textContent=hasData?`${state.selectedX}, ${state.selectedY}`:"none";
$("xyLabel").textContent=hasData?`${state.selectedX}, ${state.selectedY}`:"—";
$("indexLabel").textContent=hasData?`Element ${state.selectedElement}: ${localIndex} / file index ${index}`:"—";
$("offsetLabel").textContent=hasData?`0x${toHex(index*bytesPerCell(),6)}`:"—";
$("rawLabel").textContent=hasData?`0x${toHex(value,isTypeless()?2:4)}`:"—";
$("angleLabel").textContent=hasData?`0x${toHex(cell.angle,4)}${isTypeless()?` (index 0x${toHex(cell.angleIndex,2)})`:""}`:"—";
$("typeLabel").textContent=hasData?(isTypeless()?(cell.collision?"0x3 (fixed)":"0x0 (no collision)"):`0x${toHex(cell.type,1)}`):"—";
$("rawInput").value=hasData?toHex(value,isTypeless()?2:4):"";
$("angleInput").value=hasData?toHex(cell.angle,4):"";
$("typeInput").value=hasData?toHex(cell.type,1):"";
$("collisionToggle").checked=hasData&&cell.collision;
const preview=$("cellPreview");
preview.textContent=hasData?(isTypeless()?`ANGLE ${toHex(cell.angle,4)} / ${cell.collision?"COLLISION / TYPE 3":"NO COLLISION"}`:`ANGLE ${toHex(cell.angle,4)} / TYPE ${toHex(cell.type,1)}`):"—";
preview.style.background=hasData?colorForValue(value):"#11151e";
}
function setSelectedValue(value) {
const index=selectedIndex();
if(!state.data||index<0||index>=state.data.length) throw new Error("Selected cell is outside the loaded file.");
state.data[index]=value&(isTypeless()?0xFF:0xFFFF); state.dirty=true; validateAndRefresh();
}
function buildTypeKey() {
const key=$("typeKey"); key.innerHTML="";
for(let t=0;t<=0xF;t++){const item=document.createElement("span"), swatch=document.createElement("span");swatch.className="swatch";swatch.style.background=typeColor(t,0);item.appendChild(swatch);item.append(`0x${toHex(t,1)}`);if(t===0)item.append(" empty");if(t===3)item.append(" typeless");key.appendChild(item);}
}
canvas.addEventListener("click",ev=>{if(!state.data)return;boardWrap.focus();const rect=canvas.getBoundingClientRect(),z=canvas.width/width();const localX=Math.max(0,Math.min(canvas.width-1,ev.clientX-rect.left)),localY=Math.max(0,Math.min(canvas.height-1,ev.clientY-rect.top));state.selectedX=Math.max(0,Math.min(width()-1,Math.floor(localX/z)));state.selectedY=Math.max(0,Math.min(height()-1,Math.floor(localY/z)));renderMap();renderSelected();});
boardWrap.addEventListener("keydown",ev=>{if(!state.data)return;const dx=ev.key==="ArrowLeft"?-1:ev.key==="ArrowRight"?1:0,dy=ev.key==="ArrowUp"?-1:ev.key==="ArrowDown"?1:0;if(!dx&&!dy)return;ev.preventDefault();state.selectedX=Math.max(0,Math.min(width()-1,state.selectedX+dx));state.selectedY=Math.max(0,Math.min(height()-1,state.selectedY+dy));renderMap();renderSelected();});
$("fileInput").addEventListener("change",e=>{if(e.target.files.length)loadFile(e.target.files[0]).catch(err=>alert(err.message));});
$("presetSelect").addEventListener("change",()=>{if(state.fileName)applyPresetFromFile(state.fileName,state.data?state.data.length:0);validateAndRefresh();});
for(const id of ["widthInput","heightInput","zoomInput","colorMode","showGrid"]) $(id).addEventListener("change",validateAndRefresh);
$("elementInput").addEventListener("change",()=>{const maxElement=Math.max(0,state.elementCount-1);state.selectedElement=Math.max(0,Math.min(maxElement,Number($("elementInput").value)||0));$("elementInput").value=String(state.selectedElement);validateAndRefresh();});
$("applyRawBtn").addEventListener("click",()=>{try{setSelectedValue(parseHexish($("rawInput").value,isTypeless()?0xFF:0xFFFF));}catch(err){alert(err.message);renderSelected();}});
$("applyPartsBtn").addEventListener("click",()=>{try{
if(isTypeless()){
const angle=parseHexish($("angleInput").value,0xFFFF);
if(angle&0x01FF) throw new Error("Typeless angles must be multiples of 0x0200 (512).");
setSelectedValue(encodeTypeless($("collisionToggle").checked,angle));
}else{
const angle=parseHexish($("angleInput").value,0xFFFF)&ANGLE_MASK, type=parseHexish($("typeInput").value,0xF)&TYPE_MASK;
setSelectedValue(angle|type);
}
}catch(err){alert(err.message);renderSelected();}});
$("saveBtn").addEventListener("click",()=>{if(!state.data)return;const name=state.fileName?state.fileName.replace(/(\.[^.]+)?$/,"_edited$1"):"entity_collision_edited.bin";const blob=isTypeless()?new Blob([state.data],{type:"application/octet-stream"}):writeU16LE(state.data);saveBlob(blob,name);state.dirty=false;validateAndRefresh();$("status").textContent=`Saved ${name}.`;});
window.addEventListener("error",ev=>{$("status").textContent=`Script error: ${ev.message}`;$("status").className="status warning";});
buildTypeKey(); validateAndRefresh();
</script>
</body>
</html>