Files
RP412/tools/mapview/mapview.js
T
Cyd 4979193528 The viewer uses the engine's projection, not the console's
It was drawing the map mirrored. The X flip came from the setup console's
picture of Brewer's Bane, which is an illustration and not a screenshot -
the game does not draw it that way round.

What the game does: L4GaugeImagePrimitive::Draw plots
MoveToAbsolute(dest->x, dest->z), so the screen axes are view-space X and
Z, and the graphics view's origin is bottom left with Y increasing upward
- BackgroundLine draws endpoints.bottomLeft to endpoints.topRight, and
the port's zero-degree blit is documented with the origin in the bottom
left corner. So +X runs right and +Z runs up.

Also added the heading the real display has. NavDisplay centres on the
vehicle and turns with it, inverting the viewer's transform and taking
yaw only unless rockAndRoll is set; the viewer defaults to heading 0,
which is the north-up case, and Q/E/R turn it. Panning and dragging now
work in what you see rather than in world axes, so up stays up when the
map is turned.

TRACKS.html is left following the console pictures on purpose: nine of
those cards are the console pictures, so the reconstructed nine have to
sit beside them consistently. The two disagree by a mirror and each is
right for what it is, which both READMEs now say.
2026-08-07 13:12:57 -05:00

242 lines
9.0 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
yaw: 0, // map heading, radians. NavDisplay takes the
// vehicle's; 0 is the map with +Z straight up.
stats: {},
};
// The engine's own projection, and it is not the obvious one.
// L4GaugeImagePrimitive::Draw plots MoveToAbsolute(dest->x, dest->z) - so
// the screen axes are view-space X and Z - and the graphics view puts its
// origin at the bottom left with Y increasing UP (BackgroundLine draws
// bottomLeft -> topRight, and the port's zero-degree blit is documented
// with the origin at the bottom left corner). So on the map +X runs RIGHT
// and +Z runs UP. Canvas y grows downward, hence the single negation.
//
// NavDisplay also inverts the viewer's transform before scaling, taking
// yaw only unless rockAndRoll is set - the map turns with the vehicle and
// is centred on it. Free panning with yaw 0 is the north-up case.
function toScreen(x, z, w, h) {
const dx = x - S.cx, dz = z - S.cz;
const c = Math.cos(S.yaw), s = Math.sin(S.yaw);
return [w / 2 + (dx * c - dz * s) / S.mpp,
h / 2 - (dx * s + dz * c) / 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)],
['heading', (((S.yaw * 180 / Math.PI) % 360 + 360) % 360).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
// Pan in what you see, not in world axes: with the map turned, "up" has
// to stay up. Screen delta rotated back out through the heading.
function pan(sx, sy) {
const step = 0.1 * Math.min(cv.clientWidth, cv.clientHeight) * S.mpp;
const c = Math.cos(S.yaw), s = Math.sin(S.yaw);
S.cx += (sx * c + sy * s) * step;
S.cz += (-sx * s + sy * c) * 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;
// Screen directions: right is +X and up is +Z when the heading is zero.
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 === 'q' || k === 'Q') { S.yaw -= Math.PI / 12; draw(); }
else if (k === 'e' || k === 'E') { S.yaw += Math.PI / 12; draw(); }
else if (k === 'r' || k === 'R') { S.yaw = 0; draw(); }
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;
const dx = (e.clientX - drag.x) * S.mpp, dy = (e.clientY - drag.y) * S.mpp;
const c = Math.cos(S.yaw), s = Math.sin(S.yaw);
S.cx -= dx * c - dy * s;
S.cz -= -dx * s - dy * c;
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);