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.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# mapview
|
||||
|
||||
A dev tool: every track drawn the way the pod's map screen draws it, with pan
|
||||
and zoom, so you can ask "what does the map actually show here?" without booting
|
||||
the game. Nothing here ships — [pack-dist.ps1](../../pack-dist.ps1) does not
|
||||
look at it.
|
||||
|
||||
```powershell
|
||||
python tools\mapview\build_mapview.py <rpl4tool-listing.txt> tools\mapview\mapview.html
|
||||
```
|
||||
|
||||
Then open `mapview.html`. It is self-contained: track data, palette and all.
|
||||
|
||||
| key | |
|
||||
|---|---|
|
||||
| <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> | pan north / south / west / east |
|
||||
| <kbd>+</kbd> <kbd>-</kbd> | zoom in / out |
|
||||
| <kbd>[</kbd> <kbd>]</kbd> | previous / next track |
|
||||
| <kbd>F</kbd> / <kbd>A</kbd> | fit the course / fit everything |
|
||||
| <kbd>G</kbd> | GPS or NavDisplay LOD behaviour |
|
||||
|
||||
Dragging pans and the wheel zooms.
|
||||
|
||||
## What it reproduces
|
||||
|
||||
`NavDisplay::CalculateBounds` ([RPL4GAUG.cpp:1587](../../RP_L4/RPL4GAUG.cpp#L1587))
|
||||
derives `metersPerPixel` from the zoom and sets `LODIndex` to it. `L4GaugeImage::Draw`
|
||||
([L4GAUIMA.cpp:908](../../MUNGA_L4/L4GAUIMA.cpp#L908)) then walks the LOD scales:
|
||||
|
||||
```c
|
||||
for (lod_index = 0; lod_index < LODCount; ++lod_index)
|
||||
if (LOD_value <= LODScales[lod_index]) break;
|
||||
if (lod_index < LODCount) { ...Draw... } // else nothing is drawn
|
||||
```
|
||||
|
||||
Scales ascend, so index 0 is the finest and running past the largest drops the
|
||||
object entirely rather than simplifying it. The viewer does the same and reports
|
||||
the count, though this content barely exercises it: every placement in every
|
||||
track is `cn3`, which carries a single LOD at scale 1000, so nothing is dropped
|
||||
until 1000 m/px and then all of it goes at once.
|
||||
|
||||
Two display modes, because the game has two map gauges and they disagree:
|
||||
|
||||
* **NavDisplay** — `nav(A,ModeAlwaysActive,(448,416),0x00,0x3C,...)`. The
|
||||
448×416 radar screen, LOD following the zoom.
|
||||
* **GPS** — `gps(R,ModeAlwaysActive,(125,203),1.0)`. The 125×203 panel,
|
||||
LOD pinned at 1.0 by the config however far out it is scaled.
|
||||
|
||||
## Colour
|
||||
|
||||
Not a phosphor screen. Primitives carry palette indices and the palette is
|
||||
whichever the port was configured with; for the pod's secondary port, where the
|
||||
nav display lives, `L4GAUGE.CFG` says
|
||||
`configure(0, sec, 270, 0x00ff, clut0, rgb, secpal.pcc)`. PCC is PCX, so the last
|
||||
769 bytes are `0x0C` and 256 RGB triples. The walls come out grey because index
|
||||
51 is `#4b4b4b`; the background is index 0, black. A primitive whose own colour
|
||||
is 0 keeps the colour already set, which is the display's `staticColor`, `0x3C`.
|
||||
|
||||
Palettes are per-file, not global — 39 of 40 gauge PCCs differ from each
|
||||
other — so the port's configured palette is the one that matters, not any
|
||||
convenient nearby image.
|
||||
|
||||
## Orientation
|
||||
|
||||
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 it: long leg up the right
|
||||
side to Score Zone 1, the corner, then the run out to Score Zone 2. Drawn the
|
||||
other way round every plan comes out mirrored, which is the bug this shares a
|
||||
fix with in [../pages/](../pages/).
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Build the map viewer: every track, drawn the way the pod's map draws it.
|
||||
|
||||
This is a dev tool, not something that ships. It exists to answer "what
|
||||
does the map screen actually show here?" without booting the game, which
|
||||
matters because the answer is not obvious: the engine drops whole objects
|
||||
at low zoom rather than simplifying them, and you cannot see that happen
|
||||
from a static picture.
|
||||
|
||||
What it reproduces, from the engine:
|
||||
|
||||
NavDisplay::CalculateBounds (RP_L4/RPL4GAUG.cpp:1587)
|
||||
pixelsPerMeter = (halfWidth<<1) / currentScale
|
||||
metersPerPixel = 1 / pixelsPerMeter
|
||||
LODIndex = metersPerPixel // N = 1.0 pixels/meter
|
||||
|
||||
L4GaugeImage::Draw (MUNGA_L4/L4GAUIMA.cpp:908)
|
||||
for (i = 0; i < LODCount; ++i)
|
||||
if (LOD_value <= LODScales[i]) break;
|
||||
if (i < LODCount) draw LODList[i]; // else NOT DRAWN AT ALL
|
||||
|
||||
That last line is the whole point. LODScales ascend, so index 0 is the
|
||||
finest, and when the LOD value runs past the largest scale the object is
|
||||
skipped entirely. Zooming out in this viewer makes objects vanish exactly
|
||||
where the pod makes them vanish.
|
||||
|
||||
The GPS gauge is the other case: L4GAUGE.CFG has gps(R,ModeAlwaysActive,
|
||||
(125,203),1.0), so its LOD value is pinned at 1.0 by the config and only
|
||||
its scale follows the track. Both modes are here - G switches.
|
||||
|
||||
Usage: build_mapview.py <rpl4tool-listing.txt> <out.html>
|
||||
"""
|
||||
import json, os, re, struct, sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, '..', 'pages'))
|
||||
import navmap # noqa: E402 (path first)
|
||||
|
||||
ROOT = 'c:/VWE/RP412'
|
||||
RES = open(ROOT + '/assets/RP411/RPL4.RES', 'rb').read()
|
||||
LISTING, OUT = sys.argv[1], sys.argv[2]
|
||||
TABLE = navmap.resource_table(RES, LISTING)
|
||||
|
||||
|
||||
def gauge_image(addr):
|
||||
"""The whole stream, every LOD - not just the finest, which is what
|
||||
the page builder wants and exactly what a viewer must not assume."""
|
||||
o = addr
|
||||
|
||||
def i32():
|
||||
nonlocal o
|
||||
v = struct.unpack_from('<i', RES, o)[0]; o += 4; return v
|
||||
|
||||
def f32():
|
||||
nonlocal o
|
||||
v = struct.unpack_from('<f', RES, o)[0]; o += 4; return v
|
||||
|
||||
vcount = i32()
|
||||
if not (0 < vcount < 100000):
|
||||
return None
|
||||
verts = [[round(f32(), 2) for _ in range(3)] for _ in range(vcount)]
|
||||
lodcount = i32()
|
||||
if not (0 < lodcount < 64):
|
||||
return None
|
||||
scales = [f32() for _ in range(lodcount)]
|
||||
lods = []
|
||||
for lod in range(lodcount):
|
||||
pcount = i32()
|
||||
if not (0 < pcount < 100000):
|
||||
return None
|
||||
prims = []
|
||||
for _ in range(pcount):
|
||||
i32() # type: only primitiveVector
|
||||
colour = i32()
|
||||
attrs = i32()
|
||||
n = i32()
|
||||
if not (0 < n <= vcount * 4):
|
||||
return None
|
||||
prims.append({'c': colour, 'a': attrs,
|
||||
'i': [i32() for _ in range(n)]})
|
||||
lods.append({'s': scales[lod], 'p': prims})
|
||||
return {'v': verts, 'l': lods}
|
||||
|
||||
|
||||
images, bad = {}, 0
|
||||
for rid, r in TABLE.items():
|
||||
if r['desc'].endswith(': GaugeImage'):
|
||||
img = gauge_image(r['addr'])
|
||||
if img is None:
|
||||
bad += 1
|
||||
else:
|
||||
img['n'] = r['desc'].split(':')[0]
|
||||
images[rid] = img
|
||||
print('gauge images: %d (%d unreadable)' % (len(images), bad), file=sys.stderr)
|
||||
|
||||
CONSOLE = {}
|
||||
xml = open(ROOT + '/tools/console-config/RPConfig.xml', encoding='utf-8-sig').read()
|
||||
for m in re.finditer(r'<map\s+key="([^"]+)"\s+name="([^"]+)"', xml):
|
||||
CONSOLE[m.group(1)] = m.group(2)
|
||||
|
||||
tracks = []
|
||||
for rid, r in sorted(TABLE.items()):
|
||||
m = re.match(r'^(\w+): Map stream of (\d+) instances$', r['desc'])
|
||||
if not m:
|
||||
continue
|
||||
key, count = m.group(1), int(m.group(2))
|
||||
placed, noart = [], 0
|
||||
for gid, pos, q in navmap.instances(RES, r['addr'], r['size'], count,
|
||||
set(images)):
|
||||
if gid is None:
|
||||
noart += 1 # the map screen skips these
|
||||
continue
|
||||
placed.append([gid,
|
||||
round(pos[0], 2), round(pos[1], 2), round(pos[2], 2),
|
||||
round(q[0], 5), round(q[1], 5), round(q[2], 5),
|
||||
round(q[3], 5)])
|
||||
tracks.append({'key': key, 'name': CONSOLE.get(key, key),
|
||||
'noart': noart, 'i': placed})
|
||||
print(' %-14s %4d placed, %2d without map art'
|
||||
% (key, len(placed), noart), file=sys.stderr)
|
||||
|
||||
# The map is not a phosphor screen. Primitives carry palette indices, and
|
||||
# the palette is whatever the port was configured with - for the pod's
|
||||
# secondary screen, where the nav display lives, L4GAUGE.CFG says
|
||||
# configure(0, sec, 270, 0x00ff, clut0, rgb, secpal.pcc). So the walls
|
||||
# (index 51) come out grey, not green. PCC is PCX: 0x0C then 256 RGB
|
||||
# triples in the last 769 bytes.
|
||||
PALETTE_FILE = ROOT + '/assets/RP411/GAUGE/secpal.pcc'
|
||||
raw = open(PALETTE_FILE, 'rb').read()
|
||||
if len(raw) < 769 or raw[-769] != 0x0C:
|
||||
sys.exit('%s has no VGA palette where PCX keeps one' % PALETTE_FILE)
|
||||
base = len(raw) - 768 # absolute: -768+255*3 wraps round to 0
|
||||
palette = ['#%02x%02x%02x' % tuple(raw[base + i * 3:base + i * 3 + 3])
|
||||
for i in range(256)]
|
||||
|
||||
# nav(A, ModeAlwaysActive, (448,416), 0x00, 0x3C, ...): the screen is
|
||||
# 448x416, the background is colour 0 and static objects default to 0x3C.
|
||||
# A primitive whose own colour is 0 keeps the colour already set, which is
|
||||
# that default - see L4GaugeImage::Draw.
|
||||
data = {'images': images, 'tracks': tracks, 'palette': palette,
|
||||
'staticColor': 0x3C, 'background': 0x00, 'screen': [448, 416]}
|
||||
blob = json.dumps(data, separators=(',', ':'))
|
||||
|
||||
read = lambda n: open(os.path.join(HERE, n), encoding='utf-8').read()
|
||||
open(OUT, 'w', encoding='utf-8').write(
|
||||
read('mapview.tpl').format(css=read('mapview.css'), js=read('mapview.js'),
|
||||
data=blob))
|
||||
print('wrote %s (%.0f KB, %d tracks)'
|
||||
% (OUT, len(blob) / 1024, len(tracks)), file=sys.stderr)
|
||||
@@ -0,0 +1,80 @@
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
/* Chrome only. Everything inside the screen is painted from the game's
|
||||
own palette (secpal.pcc) and must not be tinted by any of this. */
|
||||
--chrome: #17181a;
|
||||
--panel: #1e2023;
|
||||
--rule: #2f3237;
|
||||
--ink: #e6e4e0;
|
||||
--ink-mid: #9aa0a6;
|
||||
--ink-low: #6d737a;
|
||||
--accent: #bf9300; /* palette 56, the map's own amber */
|
||||
--mono: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--chrome); color: var(--ink);
|
||||
font: 400 13px/1.5 var(--mono); overflow: hidden;
|
||||
display: grid; grid-template-columns: 1fr 17rem;
|
||||
}
|
||||
|
||||
/* The screen. Background is palette index 0 - black - so the chrome must
|
||||
not bleed into it: this is what the gauge actually draws onto. */
|
||||
#screen { position: relative; background: #000; overflow: hidden; }
|
||||
#view { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
|
||||
#view:active { cursor: grabbing; }
|
||||
|
||||
#scale {
|
||||
position: absolute; left: 1rem; bottom: 1rem;
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
color: var(--ink-mid); font-size: 11px; letter-spacing: .08em;
|
||||
text-shadow: 0 0 4px #000, 0 0 4px #000;
|
||||
}
|
||||
#barline { height: 0; border-top: 2px solid var(--ink-mid); }
|
||||
|
||||
aside {
|
||||
background: var(--panel); border-left: 1px solid var(--rule);
|
||||
padding: 1rem; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
|
||||
h1 { font: 700 15px/1.2 var(--mono); margin: 0; letter-spacing: .02em; }
|
||||
#key { color: var(--ink-low); font-size: 11px; letter-spacing: .14em; margin: .2rem 0 0; }
|
||||
|
||||
select {
|
||||
width: 100%; font: 400 12px var(--mono); padding: .45rem .5rem;
|
||||
background: var(--chrome); color: var(--ink); border: 1px solid var(--rule);
|
||||
}
|
||||
select:focus-visible, #view:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
dl#stats {
|
||||
margin: 0; display: grid; grid-template-columns: 1fr auto;
|
||||
gap: .3rem .75rem; font-size: 12px;
|
||||
}
|
||||
dl#stats dt { color: var(--ink-low); }
|
||||
dl#stats dd {
|
||||
margin: 0; text-align: right; color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.note {
|
||||
border-top: 1px solid var(--rule); padding-top: .8rem;
|
||||
color: var(--ink-low); font-size: 11px; line-height: 1.6;
|
||||
}
|
||||
.note b { color: var(--ink-mid); font-weight: 500; }
|
||||
|
||||
kbd {
|
||||
display: inline-block; min-width: 1.5em; text-align: center;
|
||||
border: 1px solid var(--rule); border-bottom-width: 2px;
|
||||
padding: .1em .35em; margin-right: .15em; border-radius: 3px;
|
||||
background: var(--chrome); color: var(--ink-mid); font: 400 11px var(--mono);
|
||||
}
|
||||
.keys { display: grid; grid-template-columns: auto 1fr; gap: .35rem .6rem; font-size: 11px; }
|
||||
.keys span:nth-child(even) { color: var(--ink-low); align-self: center; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
body { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
|
||||
aside { border-left: 0; border-top: 1px solid var(--rule); max-height: 45vh; }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,219 @@
|
||||
'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);
|
||||
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Red Planet map viewer</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="screen">
|
||||
<canvas id="view" tabindex="0" aria-label="Track map"></canvas>
|
||||
<div id="scale"><span id="barline"></span><span id="bartext"></span></div>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div>
|
||||
<h1 id="name">—</h1>
|
||||
<p id="key"></p>
|
||||
</div>
|
||||
|
||||
<select id="track" aria-label="Track"></select>
|
||||
|
||||
<dl id="stats"></dl>
|
||||
|
||||
<div class="note">
|
||||
<div class="keys">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd></span><span>pan N/S/E/W</span>
|
||||
<span><kbd>+</kbd><kbd>-</kbd></span><span>zoom</span>
|
||||
<span><kbd>[</kbd><kbd>]</kbd></span><span>previous / next track</span>
|
||||
<span><kbd>F</kbd></span><span>fit the course</span>
|
||||
<span><kbd>A</kbd></span><span>fit everything, strays included</span>
|
||||
<span><kbd>G</kbd></span><span>GPS / NavDisplay LOD</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note"><b>Dropped by LOD</b> counts what the map screen does not draw
|
||||
at all. <code>L4GaugeImage::Draw</code> takes the first LOD whose scale is at
|
||||
least the LOD value and draws <em>nothing</em> once the value runs past the
|
||||
largest, so objects vanish rather than simplify. Worth knowing what the data
|
||||
does with that: every placement in every track is the same model,
|
||||
<code>cn3</code>, and it carries one LOD at scale 1000. So the count stays at
|
||||
zero until you pass 1000 m/px, when the whole track disappears at once. The
|
||||
rule is the engine's; this content just never leans on it.</p>
|
||||
|
||||
<p class="note"><b>NavDisplay</b> sets its LOD from the zoom
|
||||
(<code>LODIndex = metersPerPixel</code>), which is the pannable radar screen,
|
||||
448×416 in <code>L4GAUGE.CFG</code>. <b>GPS</b> is the little 125×203
|
||||
panel whose LOD the config pins at 1.0 however far out it is scaled.</p>
|
||||
|
||||
<p class="note">Colours are the game's own: palette indices resolved through
|
||||
<code>secpal.pcc</code>, the palette the pod's secondary port is configured
|
||||
with. The walls are grey because index 51 is grey.</p>
|
||||
</aside>
|
||||
|
||||
<script id="data" type="application/json">{data}</script>
|
||||
<script>{js}</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user