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.
220 lines
7.7 KiB
JavaScript
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);
|