docs/tracks.html joins the roster page: all 18 tracks with a plan view, what the console calls them, which scenarios offer them, and how big they are. There are no track maps in the game's files. The console had pictures of them and those pictures did not survive - RPConfig.xml still points at "images/red planet maps/Wiseguy's Wake.bmp" and the folder is gone. So the plans are drawn from the tracks themselves. A map's instance stream places its scenery: 76-byte records carrying a position at +48 and a unit quaternion at +60, a few of them longer, so the reader resyncs on an unexpected class id rather than trusting the stride. The quaternion doubles as a checksum - a mis-read almost never yields a unit one - and 17 of the 18 decode every instance the header promises. Trough gives up 631 of 633 and the card says so. Seen this way the tracks have obvious shapes: Brewer's Bane turns two corners, Tour De Mars is one 23,000-unit run, and both arenas are a regular lattice of obstacles rather than a route at all. The eras come from the resource-file archaeology rather than a guess: 9 tracks shipped in the 4.10 cabinets, headoff and headmf arrived with 4.11, and 7 were built by the community afterwards. Scenario legality is read out of the front end's own kMaps and kFootballMaps, so the page cannot claim a track is offered when the menu does not offer it. tools/pages carries the generators for both reference pages, with a README covering the two formats they read and the id-alignment the listing is needed for. They were scratch scripts until now, which made a committed page harder to regenerate than to rebuild by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
476 lines
18 KiB
Python
476 lines
18 KiB
Python
"""Render the VTV preset reference page from the decoded JSON."""
|
||
import base64, html, io, json, os, sys
|
||
from PIL import Image
|
||
|
||
data = json.load(open(sys.argv[1]))
|
||
OUT = sys.argv[2]
|
||
GAUGE = 'c:/VWE/RP412/assets/RP411/GAUGE'
|
||
SCALE = 4 # pre-scale nearest-neighbour; masks get smoothed otherwise
|
||
|
||
|
||
def hull_mask(pcc):
|
||
"""The plan view as an alpha mask, so it takes the page's own colour.
|
||
Index 0 is the black ground; the two greys become two alpha levels,
|
||
which keeps the interior panel detail readable."""
|
||
im = Image.open(os.path.join(GAUGE, pcc.upper())).convert('P')
|
||
pal = im.getpalette()
|
||
lut = {}
|
||
for i in set(im.tobytes()):
|
||
r, g, b = pal[i * 3:i * 3 + 3]
|
||
lut[i] = 0 if (r, g, b) == (0, 0, 0) else int(max(r, g, b))
|
||
top = max(lut.values()) or 1
|
||
alpha = Image.frombytes('L', im.size,
|
||
bytes(round(lut[p] * 255 / top) for p in im.tobytes()))
|
||
out = Image.merge('RGBA', (Image.new('L', im.size, 255),) * 3 + (alpha,))
|
||
out = out.resize((im.width * SCALE, im.height * SCALE), Image.NEAREST)
|
||
buf = io.BytesIO()
|
||
out.save(buf, 'PNG', optimize=True)
|
||
return ('data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
|
||
im.size)
|
||
|
||
|
||
masks, sizes = {}, {}
|
||
for v in data.values():
|
||
art = v.get('hullArt')
|
||
if art and art not in masks:
|
||
masks[art], sizes[art] = hull_mask(art)
|
||
|
||
BUTTONS = ['trigger', 'thumb high', 'thumb low', 'pinky'] # index to little finger
|
||
ORDER = sorted(data, key=lambda v: (-len(data[v]['weapons']), v))
|
||
|
||
|
||
def kind(fn):
|
||
if fn is None:
|
||
return 'empty'
|
||
if fn.startswith('Booster'):
|
||
return 'booster'
|
||
if fn.startswith('Chute'):
|
||
return 'chute'
|
||
if fn in ('LIFT CUT', 'SIDESLIP', 'reverse thrust'):
|
||
return 'handling'
|
||
if fn in ('HORN', 'reticle'):
|
||
return 'comms'
|
||
return 'weapon'
|
||
|
||
|
||
def pretty(fn):
|
||
if fn is None:
|
||
return '—'
|
||
if fn.startswith('Booster'):
|
||
return 'BOOST ' + fn[7:] if fn[7:] else 'BOOST'
|
||
if fn.startswith('Chute'):
|
||
return 'CHUTE'
|
||
return {'RivetGun': 'RIVET', 'RivetGun1': 'RIVET 1', 'RivetGun2': 'RIVET 2',
|
||
'LaserGun': 'LASER', 'LaserGun1': 'LASER 1', 'LaserGun2': 'LASER 2',
|
||
'DemopackDropper': 'DEMO PACK', 'DemoPackDropper': 'DEMO PACK',
|
||
'Slaver3': 'SLAVER'}.get(fn, fn.upper())
|
||
|
||
|
||
def loadout_chips(v):
|
||
chips = []
|
||
if v['boosters']:
|
||
chips.append(('booster', '%d× BOOST' % v['boosters']))
|
||
if v['chute']:
|
||
chips.append(('chute', 'CHUTE'))
|
||
for w in v['weapons']:
|
||
chips.append(('weapon', pretty(w)))
|
||
if not v['weapons']:
|
||
chips.append(('empty', 'UNARMED'))
|
||
return ''.join('<span class="chip chip--%s">%s</span>' % (k, html.escape(t))
|
||
for k, t in chips)
|
||
|
||
|
||
def table(stream):
|
||
rows = []
|
||
for i, row in enumerate(stream['presets'], 1):
|
||
cells = []
|
||
for b in BUTTONS:
|
||
fn = row.get(b)
|
||
cells.append('<td class="fn fn--%s">%s</td>' % (kind(fn), html.escape(pretty(fn))))
|
||
rows.append('<tr><th scope="row"><span class="pnum">%d</span></th>%s</tr>'
|
||
% (i, ''.join(cells)))
|
||
return (
|
||
'<div class="tablewrap"><table>'
|
||
'<caption class="sr-only">Preset assignments</caption><thead><tr>'
|
||
'<th scope="col"><span class="sr-only">Preset</span></th>'
|
||
+ ''.join('<th scope="col">%s</th>' % b.upper() for b in BUTTONS)
|
||
+ '</tr></thead><tbody>' + ''.join(rows) + '</tbody></table></div>')
|
||
|
||
|
||
family = {}
|
||
for n, v in data.items():
|
||
family.setdefault(v.get('silhouette'), []).append(n)
|
||
|
||
cards = []
|
||
for name in ORDER:
|
||
v = data[name]
|
||
tags = ' '.join(['armed' if v['weapons'] else 'unarmed']
|
||
+ [kind(w) for w in v['weapons']])
|
||
code = v.get('silhouette') or ''
|
||
display = v.get('display') or name
|
||
# Hulls are named by the console, not by their two-letter art code.
|
||
hull_class = v.get('hullClass') or (code.upper() if code else 'unknown')
|
||
kin = sorted((data[x].get('display') or x)
|
||
for x in family.get(code, []) if x != name)
|
||
search = ' '.join([name, display, hull_class, code] + v['subsystems']).lower()
|
||
|
||
art = v.get('hullArt')
|
||
if art:
|
||
w, h = sizes[art]
|
||
note = ''
|
||
if v.get('hullMismatch'):
|
||
note = (' <span class="warn" title="The console showed a %s '
|
||
'picture for this vehicle; the game draws the %s hull">'
|
||
'(console says %s)</span>'
|
||
% (html.escape(v['consoleClass']), html.escape(hull_class),
|
||
html.escape(v['consoleClass'])))
|
||
hull = ('<figure class="hull"><span class="hull__img" role="img" '
|
||
'aria-label="{display} plan view" style="--art:url({uri});'
|
||
'--ar:{w}/{h}"></span>'
|
||
'<figcaption>{cls} hull{kin}{note}</figcaption></figure>').format(
|
||
display=html.escape(display), uri=masks[art], w=w, h=h,
|
||
cls=html.escape(hull_class), note=note,
|
||
kin=(' · shared with ' + html.escape(', '.join(kin))) if kin else '')
|
||
else:
|
||
hull = ''
|
||
|
||
cards.append("""
|
||
<article class="vtv" data-tags="{tags}" data-search="{search}">
|
||
<header class="vtv__id">
|
||
{hull}
|
||
<h3>{display}</h3>
|
||
<p class="vtv__key">{key}</p>
|
||
<p class="vtv__load">{chips}</p>
|
||
<p class="vtv__meta"><span>{nsub} subsystems</span><span>{nrec} mappings</span></p>
|
||
</header>
|
||
<div class="vtv__tables">{l4}</div>
|
||
</article>""".format(
|
||
tags=tags, search=html.escape(search), display=html.escape(display.upper()),
|
||
key=html.escape(name), chips=loadout_chips(v), nsub=len(v['subsystems']),
|
||
hull=hull, nrec=v['L4']['records'], l4=table(v['L4'])))
|
||
|
||
armed = sum(1 for v in data.values() if v['weapons'])
|
||
CSS = """
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
|
||
:root {
|
||
/* The pod's own lamp fills - MUNGA_L4/L4MFDVIEW.cpp:112 */
|
||
--amber: #ffd230;
|
||
--red: #ff482c;
|
||
--phosphor: #6fdc8c;
|
||
|
||
--ground: #0d0b07;
|
||
--panel: #16130d;
|
||
--rule: #2f2819;
|
||
--ink: #f3ead6;
|
||
--ink-mid: #b3a68a;
|
||
--ink-low: #7d7360;
|
||
|
||
--sys: var(--amber);
|
||
--wep: var(--red);
|
||
--pilot: var(--phosphor);
|
||
--on-accent: #0d0b07; /* text that sits ON --sys */
|
||
|
||
--step--1: clamp(.72rem, .70rem + .1vw, .78rem);
|
||
--step-0: clamp(.88rem, .85rem + .15vw, .95rem);
|
||
--step-1: clamp(1.05rem, 1rem + .3vw, 1.2rem);
|
||
--step-2: clamp(1.4rem, 1.2rem + .9vw, 2rem);
|
||
--step-3: clamp(1.9rem, 1.5rem + 2vw, 3.1rem);
|
||
|
||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
|
||
--sans: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||
}
|
||
|
||
@media (prefers-color-scheme: light) {
|
||
:root {
|
||
--ground: #f7f3e8;
|
||
--panel: #fffdf7;
|
||
--rule: #ddd2b8;
|
||
--ink: #17140d;
|
||
--ink-mid: #5c5340;
|
||
--ink-low: #857a63;
|
||
--sys: #8a6400;
|
||
--wep: #b3260f;
|
||
--pilot: #1c7a3f;
|
||
--on-accent: #fffdf7;
|
||
}
|
||
}
|
||
:root[data-theme="light"] {
|
||
--ground: #f7f3e8;
|
||
--panel: #fffdf7;
|
||
--rule: #ddd2b8;
|
||
--ink: #17140d;
|
||
--ink-mid: #5c5340;
|
||
--ink-low: #857a63;
|
||
--sys: #8a6400;
|
||
--wep: #b3260f;
|
||
--pilot: #1c7a3f;
|
||
--on-accent: #fffdf7;
|
||
}
|
||
:root[data-theme="dark"] {
|
||
--ground: #0d0b07;
|
||
--panel: #16130d;
|
||
--rule: #2f2819;
|
||
--ink: #f3ead6;
|
||
--ink-mid: #b3a68a;
|
||
--ink-low: #7d7360;
|
||
--sys: #ffd230;
|
||
--wep: #ff482c;
|
||
--pilot: #6fdc8c;
|
||
--on-accent: #0d0b07;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
background: var(--ground);
|
||
color: var(--ink);
|
||
font: 400 var(--step-0)/1.6 var(--sans);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
.wrap { max-width: 78rem; margin: 0 auto; padding: 0 clamp(1rem, 4vw, 3rem); }
|
||
.sr-only {
|
||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||
}
|
||
:focus-visible { outline: 2px solid var(--sys); outline-offset: 2px; }
|
||
|
||
/* ---------------------------------------------------------- masthead */
|
||
.top { border-bottom: 1px solid var(--rule); padding: clamp(2.5rem,6vw,4.5rem) 0 2rem; }
|
||
.eyebrow {
|
||
font: 500 var(--step--1)/1 var(--mono);
|
||
letter-spacing: .22em; text-transform: uppercase; color: var(--ink-low);
|
||
margin: 0 0 1.1rem;
|
||
}
|
||
h1 {
|
||
font: 700 var(--step-3)/1.02 var(--mono);
|
||
letter-spacing: -.02em; text-transform: uppercase; text-wrap: balance;
|
||
margin: 0 0 1rem;
|
||
}
|
||
h1 em { font-style: normal; color: var(--sys); }
|
||
.lede { max-width: 62ch; color: var(--ink-mid); margin: 0 0 1.6rem; font-size: var(--step-1); }
|
||
.lede strong { color: var(--ink); font-weight: 600; }
|
||
|
||
.rules { display: flex; flex-wrap: wrap; gap: .6rem 2rem; margin: 0; padding: 0; list-style: none; }
|
||
.rules li {
|
||
font: 500 var(--step--1)/1.5 var(--mono);
|
||
color: var(--ink-mid); padding-left: 1.1rem; position: relative;
|
||
}
|
||
.rules li::before {
|
||
content: ""; position: absolute; left: 0; top: .5em;
|
||
width: .45rem; height: .45rem; background: var(--sys);
|
||
}
|
||
.rules b { color: var(--ink); font-weight: 700; }
|
||
|
||
/* ----------------------------------------------------------- controls */
|
||
.bar {
|
||
position: sticky; top: 0; z-index: 5;
|
||
background: var(--ground);
|
||
background: color-mix(in srgb, var(--ground) 92%, transparent);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
backdrop-filter: blur(8px);
|
||
border-bottom: 1px solid var(--rule);
|
||
padding: .75rem 0;
|
||
}
|
||
.bar__in { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; align-items: center; }
|
||
.seg { display: inline-flex; border: 1px solid var(--rule); }
|
||
.seg button {
|
||
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
|
||
background: none; border: 0; color: var(--ink-mid);
|
||
padding: .55rem .9rem; cursor: pointer;
|
||
}
|
||
.seg button[aria-pressed="true"] { background: var(--sys); color: var(--on-accent); }
|
||
.search {
|
||
flex: 1 1 12rem; min-width: 9rem;
|
||
font: 400 var(--step--1) var(--mono);
|
||
background: var(--panel); color: var(--ink);
|
||
border: 1px solid var(--rule); padding: .55rem .7rem;
|
||
}
|
||
.search::placeholder { color: var(--ink-low); }
|
||
.count { font: 500 var(--step--1) var(--mono); color: var(--ink-low); letter-spacing: .1em; }
|
||
|
||
/* -------------------------------------------------------------- key */
|
||
.key { display: flex; flex-wrap: wrap; gap: .4rem .9rem; padding: 1.25rem 0 0; }
|
||
.key span { display: inline-flex; align-items: center; gap: .4rem;
|
||
font: 500 var(--step--1) var(--mono); letter-spacing: .08em; color: var(--ink-low); }
|
||
.key i { width: .7rem; height: .7rem; display: inline-block; }
|
||
|
||
/* ------------------------------------------------------------- roster */
|
||
.roster { display: grid; gap: 1px; background: var(--rule); border: 1px solid var(--rule);
|
||
margin: 1.75rem 0 4rem; }
|
||
.vtv { background: var(--panel); display: grid; gap: 0;
|
||
grid-template-columns: minmax(13rem, 17rem) 1fr; }
|
||
@media (max-width: 46rem) { .vtv { grid-template-columns: 1fr; } }
|
||
.vtv[hidden] { display: none; }
|
||
|
||
.vtv__id { padding: 1.1rem 1.25rem; border-right: 1px solid var(--rule); }
|
||
@media (max-width: 46rem) { .vtv__id { border-right: 0; border-bottom: 1px solid var(--rule); } }
|
||
|
||
/* plan view, straight off the damage gauge, as a mask so it takes the
|
||
page colour in either theme */
|
||
.hull { margin: 0 0 .9rem; display: flex; flex-direction: column; gap: .45rem;
|
||
align-items: flex-start; }
|
||
.hull__img {
|
||
display: block; width: 100%; max-width: 6.5rem; aspect-ratio: var(--ar);
|
||
background-color: var(--ink-mid);
|
||
-webkit-mask-image: var(--art); mask-image: var(--art);
|
||
-webkit-mask-size: contain; mask-size: contain;
|
||
-webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
|
||
-webkit-mask-position: left center; mask-position: left center;
|
||
}
|
||
.hull figcaption {
|
||
font: 500 var(--step--1)/1.35 var(--mono); letter-spacing: .1em;
|
||
text-transform: uppercase; color: var(--ink-low);
|
||
}
|
||
.vtv__id h3 {
|
||
font: 700 var(--step-2)/1 var(--mono); letter-spacing: .01em;
|
||
margin: 0 0 .7rem; color: var(--ink);
|
||
}
|
||
.vtv__key {
|
||
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .12em;
|
||
color: var(--ink-low); margin: -.45rem 0 .7rem;
|
||
}
|
||
.warn { color: var(--wep); }
|
||
.vtv__load { display: flex; flex-wrap: wrap; gap: .3rem; margin: 0 0 .7rem; }
|
||
.chip {
|
||
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .08em;
|
||
padding: .3rem .45rem; border: 1px solid currentColor;
|
||
}
|
||
.chip--booster, .chip--chute { color: var(--sys); }
|
||
.chip--weapon { color: var(--wep); }
|
||
.chip--empty { color: var(--ink-low); }
|
||
.vtv__meta { display: flex; gap: .8rem; margin: 0; font: 400 var(--step--1) var(--mono);
|
||
color: var(--ink-low); font-variant-numeric: tabular-nums; }
|
||
|
||
.vtv__tables { min-width: 0; }
|
||
.tablewrap { overflow-x: auto; }
|
||
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
|
||
thead th {
|
||
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .14em;
|
||
color: var(--ink-low); text-align: left; font-weight: 500;
|
||
padding: .8rem .7rem .5rem; border-bottom: 1px solid var(--rule); white-space: nowrap;
|
||
}
|
||
tbody th {
|
||
width: 3.4rem; padding: .42rem .7rem; text-align: left; font-weight: 500;
|
||
border-bottom: 1px solid var(--rule); white-space: nowrap;
|
||
}
|
||
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
|
||
.pnum { font: 500 var(--step--1) var(--mono); color: var(--ink-mid); }
|
||
td.fn {
|
||
padding: .42rem .7rem; border-bottom: 1px solid var(--rule);
|
||
font: 500 var(--step--1)/1.3 var(--mono); letter-spacing: .06em; white-space: nowrap;
|
||
}
|
||
.fn--booster, .fn--chute { color: var(--sys); }
|
||
.fn--weapon { color: var(--wep); }
|
||
.fn--handling, .fn--comms { color: var(--pilot); }
|
||
.fn--empty { color: var(--ink-low); }
|
||
.fn--chute, .fn--comms { opacity: .82; }
|
||
.foot { border-top: 1px solid var(--rule); padding: 1.5rem 0 4rem;
|
||
color: var(--ink-low); font-size: var(--step--1); }
|
||
.foot code { font-family: var(--mono); color: var(--ink-mid); }
|
||
.empty-state { padding: 2rem; background: var(--panel); color: var(--ink-low);
|
||
font: 400 var(--step-0) var(--mono); }
|
||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||
"""
|
||
|
||
JS = """
|
||
const root = document.documentElement;
|
||
const cards = [...document.querySelectorAll('.vtv')];
|
||
const count = document.getElementById('count');
|
||
const search = document.getElementById('search');
|
||
let filter = 'all';
|
||
|
||
function apply() {
|
||
const q = search.value.trim().toLowerCase();
|
||
let shown = 0;
|
||
for (const c of cards) {
|
||
const tags = c.dataset.tags;
|
||
const okTag = filter === 'all'
|
||
|| (filter === 'armed' && tags.includes('armed'))
|
||
|| (filter === 'unarmed' && tags.includes('unarmed'))
|
||
|| tags.split(' ').includes(filter);
|
||
const okQ = !q || c.dataset.search.includes(q);
|
||
const on = okTag && okQ;
|
||
c.hidden = !on;
|
||
if (on) shown++;
|
||
}
|
||
count.textContent = shown + ' / ' + cards.length + ' VTV';
|
||
document.getElementById('nomatch').hidden = shown > 0;
|
||
}
|
||
|
||
for (const b of document.querySelectorAll('[data-filter]')) {
|
||
b.addEventListener('click', () => {
|
||
filter = b.dataset.filter;
|
||
for (const o of document.querySelectorAll('[data-filter]'))
|
||
o.setAttribute('aria-pressed', String(o === b));
|
||
apply();
|
||
});
|
||
}
|
||
search.addEventListener('input', apply);
|
||
apply();
|
||
"""
|
||
|
||
PAGE = """<!doctype html>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Red Planet - VTV control presets</title>
|
||
<style>{css}</style>
|
||
|
||
<header class="top">
|
||
<div class="wrap">
|
||
<p class="eyebrow">Red Planet 4.12 · decoded from RPL4.RES</p>
|
||
<h1>Every VTV and its<br><em>six control presets</em></h1>
|
||
<p class="lede">The six amber switches down the right flank of the map screen are not a
|
||
display option. <strong>Each is a complete factory layout for the four mappable
|
||
joystick buttons</strong>, authored per vehicle and shipped in the game's resource
|
||
file. Pressing one swaps the whole stick over, live, mid-race.</p>
|
||
<ul class="rules">
|
||
<li><b>Preset 2</b> always puts LIFT CUT on the pinky</li>
|
||
<li><b>Preset 6</b> always puts LIFT CUT on the trigger</li>
|
||
<li><b>Presets 1–5</b> keep the primary weapon on the trigger</li>
|
||
</ul>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="bar">
|
||
<div class="wrap bar__in">
|
||
<div class="seg" role="group" aria-label="Filter by armament">
|
||
<button type="button" data-filter="all" aria-pressed="true">All</button>
|
||
<button type="button" data-filter="armed" aria-pressed="false">Armed</button>
|
||
<button type="button" data-filter="unarmed" aria-pressed="false">Unarmed</button>
|
||
</div>
|
||
<input id="search" class="search" type="search" placeholder="Search vehicle or system…"
|
||
aria-label="Search vehicle or system">
|
||
<span class="count" id="count">{n} / {n} VTV</span>
|
||
</div>
|
||
</div>
|
||
|
||
<main class="wrap">
|
||
<div class="key">
|
||
<span><i style="background:var(--sys)"></i>Vehicle systems</span>
|
||
<span><i style="background:var(--wep)"></i>Weapons</span>
|
||
<span><i style="background:var(--pilot)"></i>Pilot controls</span>
|
||
</div>
|
||
|
||
<div class="roster">{cards}</div>
|
||
<p class="empty-state" id="nomatch" hidden>No VTV matches that.</p>
|
||
|
||
<p class="foot">{n} VTVs carry a preset table; {armed} are armed. Decoded from the pod
|
||
RIO <code>ControlMappings</code> stream in <code>assets/RP411/RPL4.RES</code> —
|
||
subsystem names from each vehicle's own subsystem list, functions from
|
||
<code>ModePreset1..6</code> mask bits. Boosters, chute and weapons are the vehicle's
|
||
activatable systems; LIFT CUT and HORN are pilot controls that also live on the
|
||
panel. Empty cells are unbound — nothing on the pod fires them.</p>
|
||
<p class="foot">The plan views are the vehicle's own damage-gauge silhouette
|
||
(<code>GAUGE/s<hull>.pcc</code>), redrawn from the 3-colour original. The game has
|
||
no per-vehicle art: vehicles are grouped into hull families and share both the
|
||
silhouette and the 3D mesh in <code>VIDEO/</code>, which is keyed by the same two-letter
|
||
code. What distinguishes vehicles inside a family is the loadout, not the shape.</p>
|
||
</main>
|
||
|
||
<script>{js}</script>
|
||
"""
|
||
|
||
open(OUT, 'w', encoding='utf-8').write(PAGE.format(
|
||
css=CSS, js=JS, cards=''.join(cards), n=len(data), armed=armed))
|
||
print('wrote %s (%d VTVs)' % (OUT, len(data)))
|