mirror of
https://github.com/pret/pokepinballrs.git
synced 2026-09-28 21:51:07 -04:00
574 lines
24 KiB
HTML
574 lines
24 KiB
HTML
<!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 & FFF0 + type & 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 & 0xFFF0</code> and <code>type = value & 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 & 0x80</code>, <code>angle = (value & 0x7F) << 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> |