'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 => '
' + r[0] + '
' + r[1] + '
').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) => '').join(''); sel.addEventListener('change', () => pick(+sel.value)); addEventListener('resize', draw); pick(0);