'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 => '