Files
RP412/tools/mapview/mapview.js
T
Cyd b1b82d5da1 A map viewer that draws tracks the way the map screen does
Pan with the arrows, zoom with plus and minus, [ and ] for the next
track. Self-contained HTML with the track data and palette embedded;
nothing here ships, and pack-dist.ps1 does not look at it.

It follows the engine rather than approximating it. NavDisplay derives
metersPerPixel from the zoom and sets LODIndex to it; L4GaugeImage::Draw
takes the first LOD whose scale is at least that value and draws nothing
once the value runs past the largest, so objects vanish rather than
simplify. Both map gauges are here because they disagree - nav is the
448x416 radar screen with LOD following zoom, gps the 125x203 panel whose
config pins LOD at 1.0. The HUD reports what is dropped, and is honest
that this content barely exercises it: every placement in every track is
cn3 with one LOD at scale 1000.

The map is not a phosphor screen. Primitives carry palette indices and
the palette is whichever the port was configured with - for the pod's
secondary port, configure(0,sec,270,0x00ff,clut0,rgb,secpal.pcc). PCC is
PCX, so the palette is the last 769 bytes. Walls are grey because index
51 is #4b4b4b; background is index 0, black; a primitive with colour 0
keeps the display's staticColor, 0x3C. Per-file palettes, not a global
one - 39 of 40 gauge PCCs differ - so the port's configured palette is
the one that counts.
2026-08-07 12:40:52 -05:00

220 lines
7.7 KiB
JavaScript

'use strict';
const DATA = JSON.parse(document.getElementById('data').textContent);
const cv = document.getElementById('view');
const ctx = cv.getContext('2d');
// ---------------------------------------------------------------- state
const S = {
track: 0,
cx: 0, cz: 0, // view centre, world units
mpp: 10, // meters per pixel - the engine's own scale term
gps: false, // false = NavDisplay (LOD follows zoom), true = GPS
stats: {},
};
// Seen from above with +Z up the page the engine's +X runs to the LEFT.
// The console's own picture of Brewer's Bane is what settles the
// handedness; drawn the other way every plan comes out mirrored.
function toScreen(x, z, w, h) {
return [w / 2 - (x - S.cx) / S.mpp, h / 2 - (z - S.cz) / S.mpp];
}
// Quaternion (x,y,z,w) applied to a point - MUNGA's own convention.
function rotate(q, p) {
const [qx, qy, qz, qw] = q, [x, y, z] = p;
const tx = 2 * (qy * z - qz * y);
const ty = 2 * (qz * x - qx * z);
const tz = 2 * (qx * y - qy * x);
return [x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
z + qw * tz + qx * ty - qy * tx];
}
// The map screen is not a phosphor display. Primitives carry palette
// indices and the palette is the one the port was configured with -
// secpal.pcc for the pod's secondary screen, where the nav display lives.
// Walls (index 51) are grey. L4GaugeImage::Draw only overrides the colour
// when it is non-zero, so 0 means "keep what is set", which is the
// display's own staticColor (0x3C).
function ink(c) {
return DATA.palette[c ? c : DATA.staticColor] || '#535353';
}
// ------------------------------------------------------------- bounds
function bounds(list) {
let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity;
for (const r of list) {
if (r[1] < x0) x0 = r[1];
if (r[1] > x1) x1 = r[1];
if (r[3] < z0) z0 = r[3];
if (r[3] > z1) z1 = r[3];
}
return list.length ? {x0, x1, z0, z1} : {x0: -1, x1: 1, z0: -1, z1: 1};
}
// Fourteen tracks park a single piece far off the course; fitting to
// everything squeezes the track into a sliver, so "fit" ignores loners
// and "fit all" (A) is there when you want the literal extent.
function course(t) {
if (t._course) return t._course;
const keep = t.i.filter(a =>
t.i.some(b => b !== a && (b[1] - a[1]) ** 2 + (b[3] - a[3]) ** 2 < 200 * 200));
return (t._course = keep.length ? keep : t.i);
}
function fit(all) {
const t = DATA.tracks[S.track];
const b = bounds(all ? t.i : course(t));
S.cx = (b.x0 + b.x1) / 2;
S.cz = (b.z0 + b.z1) / 2;
const w = cv.clientWidth || 800, h = cv.clientHeight || 600;
// The GPS gauge allows 1.2x "for large objects at edges of map"
// (RPL4GAUG.cpp:2060); same here so nothing clips at the border.
S.mpp = Math.max((b.x1 - b.x0) / w, (b.z1 - b.z0) / h, 1e-4) * 1.2;
draw();
}
// -------------------------------------------------------------- render
function draw() {
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const t = DATA.tracks[S.track];
// NavDisplay sets LODIndex = metersPerPixel every time it recalculates
// bounds. The GPS gauge takes its LOD from the config and never moves it.
const lodValue = S.gps ? 1.0 : S.mpp;
let drawn = 0, culled = 0, prims = 0;
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
for (const r of t.i) {
const img = DATA.images[r[0]];
if (!img) { culled++; continue; }
// L4GaugeImage::Draw - scales ascend, so index 0 is the FINEST, and
// running past the largest means the object is not drawn at all.
let k = 0;
while (k < img.l.length && lodValue > img.l[k].s) k++;
if (k >= img.l.length) { culled++; continue; }
drawn++;
const q = [r[4], r[5], r[6], r[7]];
for (const p of img.l[k].p) {
ctx.strokeStyle = ink(p.c);
ctx.beginPath();
for (let n = 0; n < p.i.length; n++) {
let v = img.v[p.i[n]];
if (!v) continue;
// attributeUnscaled: pre-multiply by the scale factor so the
// primitive keeps a constant size on screen (L4GAUIMA.cpp:1599).
if (p.a & 1) v = [v[0] * S.mpp, v[1] * S.mpp, v[2] * S.mpp];
const g = rotate(q, v);
const [sx, sy] = toScreen(g[0] + r[1], g[2] + r[3], w, h);
n ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
}
ctx.stroke();
prims++;
}
}
S.stats = {drawn, culled, prims, lodValue};
hud();
}
// ----------------------------------------------------------------- hud
function hud() {
const t = DATA.tracks[S.track];
const s = S.stats;
const px = 1 / S.mpp;
document.getElementById('name').textContent = t.name;
document.getElementById('key').textContent =
t.key + ' ' + (S.track + 1) + '/' + DATA.tracks.length;
const rows = [
['mode', S.gps ? 'GPS (LOD pinned 1.0)' : 'NavDisplay (LOD = m/px)'],
['metersPerPixel', S.mpp.toFixed(3)],
['pixelsPerMeter', px.toFixed(4)],
['LOD value', s.lodValue.toFixed(3)],
['centre X / Z', S.cx.toFixed(0) + ' / ' + S.cz.toFixed(0)],
['objects drawn', s.drawn],
['dropped by LOD', s.culled],
['primitives', s.prims],
['no map art', t.noart],
];
document.getElementById('stats').innerHTML = rows.map(
r => '<dt>' + r[0] + '</dt><dd>' + r[1] + '</dd>').join('');
// Scale bar: a round number of meters, whatever fits under 160px.
let m = Math.pow(10, Math.floor(Math.log10(160 * S.mpp)));
for (const f of [5, 2, 1]) if (m * f <= 160 * S.mpp) { m *= f; break; }
document.getElementById('barline').style.width = (m / S.mpp) + 'px';
document.getElementById('bartext').textContent = m + ' m';
}
// ------------------------------------------------------------ controls
function pan(dx, dz) {
const step = 0.1 * Math.min(cv.clientWidth, cv.clientHeight) * S.mpp;
S.cx += dx * step;
S.cz += dz * step;
draw();
}
function zoom(f) {
S.mpp = Math.max(0.02, Math.min(4000, S.mpp * f));
draw();
}
function pick(n) {
S.track = (n + DATA.tracks.length) % DATA.tracks.length;
document.getElementById('track').value = S.track;
fit(false);
}
addEventListener('keydown', e => {
if (e.target.tagName === 'SELECT') return;
const k = e.key;
// Right on screen is -X, up is +Z: north/south/east/west as drawn.
if (k === 'ArrowUp') pan(0, 1);
else if (k === 'ArrowDown') pan(0, -1);
else if (k === 'ArrowLeft') pan(1, 0);
else if (k === 'ArrowRight') pan(-1, 0);
else if (k === '+' || k === '=') zoom(1 / 1.25);
else if (k === '-' || k === '_') zoom(1.25);
else if (k === '[') pick(S.track - 1);
else if (k === ']') pick(S.track + 1);
else if (k === 'f' || k === 'F') fit(false);
else if (k === 'a' || k === 'A') fit(true);
else if (k === 'g' || k === 'G') { S.gps = !S.gps; draw(); }
else return;
e.preventDefault();
});
let drag = null;
cv.addEventListener('pointerdown', e => {
drag = {x: e.clientX, y: e.clientY};
cv.setPointerCapture(e.pointerId);
});
cv.addEventListener('pointermove', e => {
if (!drag) return;
S.cx -= (e.clientX - drag.x) * S.mpp;
S.cz += (e.clientY - drag.y) * S.mpp;
drag = {x: e.clientX, y: e.clientY};
draw();
});
cv.addEventListener('pointerup', () => { drag = null; });
cv.addEventListener('wheel', e => {
e.preventDefault();
zoom(e.deltaY > 0 ? 1.1 : 1 / 1.1);
}, {passive: false});
const sel = document.getElementById('track');
sel.innerHTML = DATA.tracks.map(
(t, i) => '<option value="' + i + '">' + t.name + '</option>').join('');
sel.addEventListener('change', () => pick(+sel.value));
addEventListener('resize', draw);
pick(0);