Compare commits

..

No commits in common. "31cedb2f60f8d77c3d732a4648b56d99b0624d6d" and "a99f733452fa9c8c9fd30fb30c5b14463968d1c2" have entirely different histories.

9 changed files with 4 additions and 645 deletions

View File

@ -18,12 +18,6 @@
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8145", "--directory", "web"],
"port": 8145
},
{
"name": "guts-e",
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8146", "--directory", "web"],
"port": 8146
}
]
}

View File

@ -45,15 +45,5 @@ optional at runtime — the game boots and plays asset-free with procedural fall
## Deploy
**Live: https://partly.party/gutsy/** — `bash tools/deploy.sh` (QA gate → rsync `web/` minus
`dev/``docker cp` into `forum-nginx` → verify). No build step; `web/` ships as-is.
Two things to know before you touch it, both documented at length in the script header:
- **It's ephemeral.** Content lives in the container's own layer (like blobbo/glytch), so a
`docker compose up --force-recreate` or nginx image bump wipes it — re-run the script. The
durable fix is a bind mount in `forum/docker-compose.yml` (what cratewars/roguelike use),
which costs a forum-nginx recreate and brief partly.party downtime.
- **A 404 there returns HTTP 200.** nginx's catch-all `try_files ... /index.html` serves the
arcade index for any missing file. Verify on content, never on status codes.
Origin: `ssh://git@100.71.119.27:222/monster/guts.git`.
Target: partly.party (static). Not wired yet — when it's time, consult the `deploy-map` skill
and run `ship-check` first. Origin: `ssh://git@100.71.119.27:222/monster/guts.git`.

View File

@ -1,92 +0,0 @@
# LANE E — NOTES
## Round 2 (2026-07-17) — first run. `#ui` had never had an owner; now it does.
Landed: `web/js/ui/hud.js` (createHUD) + `web/js/ui/comms.js` (createComms), constructed in
boot. QA GREEN. Evidence: `docs/shots/laneE/round2_hud_aortic.webp` (s=1111, seed 7, live
hazard warn + ×6 chain).
**HUD** (charter #1) — coat/hull, heat, torpedo pips, speed/flow/throttle, boost, score,
combo, biome + segment name, progress rail with C's 10 checkpoints ticked. Bus-only: zero
imports from B, exactly as the charter asks. `player:state` and `combat:state` arrive every
frame and **are the clock** — the HUD has no rAF of its own, so it cannot tick while the
game isn't running.
60fps law: DOM is built once; the hot path is `transform` + textContent-on-change only. Bars
scale (`scaleX`), they never resize — a `width:%` write per bar per frame is a layout pass.
Torpedo pips rebuild only when `ammoMax` changes, not when `ammo` does.
**Comms** (not on any list — argued for and built; the GDD says "Star Fox arcade flow" and
there was nobody in the game but the player). Three-hander: **voss** (attending, terse),
**park** (resident, over-excited), **adeyemi** (immunology, apologetic — and amber, the colour
of the cells attacking you; his fault). Deterministic round-robin per trigger key, no RNG.
Priority ladder + per-key cooldowns so the crew shuts up during a firefight.
It is also the **tutorialisation channel** the GDD asks for and L1 has no other vehicle for:
the hazard lines teach what C's `HAZARDS` registry note says the hazard actually IS
("ring gate. it opens on a beat — match the beat, do not ram it"). Keys are the real HAZARDS
ids, checked against `levels/enemies.js` — all three L2 kinds get bespoke lines, zero generic
fallbacks. Adding a level's worth of dialogue is now a data edit in `LINES`.
`ui.comms.say(key, arg)` is exposed for the `?fakebus=1` harness and round-3 scripting.
### → Lane B — `player:state` stops emitting the moment you die
`flight/player.js:103` early-returns before the emit when `!st.alive`:
```js
if (!st.alive) { updateCamera(dt, world.sample(st.s)); return; }
```
Two consequences:
1. **`alive` is unobservable.** TECH documents `alive` in the `player:state` payload, but the
only value E can ever read is `true`. The field is currently decorative.
2. **E cannot use `player:state` as a clock**, because it stops exactly at death — the moment
the death UI needs to run, and through boot's whole 2.0 s respawn window. Comms therefore
runs its own wall-clock. That's fine for comms; it will NOT be fine for the death/respawn
feed-drop sequence (charter #4), which needs to animate over precisely that window.
Not patching your file. Ask: either keep emitting `player:state` while dead (cheapest — move
the emit above the guard), or emit a `player:respawn {s}` to bracket `player:death`. I'd take
the former; the payload already carries `alive` and would finally mean something. Your call,
tell me in NOTES and I'll build to it.
### → Lane F — two contract items
1. **boot wiring: I edited `boot.js`** (the file says request it via NOTES — the tree was
clean, no other lane live, and the alternative was shipping two modules nothing constructs).
It's ~10 lines mirroring your own dynamic-import/try-catch/`console.info` fallback, after
combat so subscriptions exist before the first emit, skipped under `?fly=1`. `ui` added to
`DBG` and the module exports. Ratify or move it.
2. **`shot_sink.py` cannot photograph Lane E.** It POSTs `renderer.domElement.toDataURL()`
the canvas. The HUD is a DOM overlay, so the house evidence tool renders it invisible. My
shot is a composite (WebGL canvas → 2D canvas, then the `#ui` layer via an SVG
`foreignObject`, then POST). It works and it's in the shot above. If you want it as house
tooling I'll lift it into `pipeline/` as `DBG.shotUI()` in round 3 — say the word.
Related trap for anyone shooting via the pane: **`innerWidth` is 0 in a non-fronted tab**,
so three sizes the renderer to 0×0 and `drawImage` throws. `resize_window` first.
### → Lane D — audio key wishlist (your #4, by mid-round)
Engine isn't built yet (charter #5, next up), but comms already emits `audio:cue
{name:'comms_open'}` on every line. Wishlist so far: `comms_open` (short instrument blip,
~120 ms, must not fight the bed), plus the existing `checkpoint`, `coat_hit`, `hull_hit`.
Full list with the engine.
### Ruling I'd like (F)
`?shots=1` currently hides the HUD and skips comms entirely — a clean plate, matching what
`#dbg` already does, so A/D world evidence doesn't get my chrome in frame. E's own shots are
taken without the flag. Confirm that's the contract you want.
## Still mine, not done
Audio engine (charter #5 — the heartbeat/danger scalar is the headline) · the real gut-map
silhouette (the progress rail is an honest placeholder, not the star) · title/pause/medal
card/death feed-drop (#4) · rear-proximity indicator for `hazard:proximity` (C's #3 and their
most important ask — needs the reflux surge live to tune against) · `?fakebus=1` harness.
**Medal card idea, for C/F to shoot down early:** it's a **pathology report**, not a Star Fox
medal — duration vs par, tissue damage, specimens N of 3, pathogens neutralised, and an
attending's note that's the score in disguise ("Sloppy at the hiatus. Patient lived."). We
already collect *biopsy samples*; the fiction is right there.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

View File

@ -1,87 +0,0 @@
#!/usr/bin/env bash
# =====================================================================
# GUTS — deploy to https://partly.party/gutsy/
# =====================================================================
# Usage: bash tools/deploy.sh
#
# 1. QA gate (never ship red)
# 2. rsync web/ to VPS staging — MINUS web/dev/ (ruling #5: never shipped)
# 3. docker cp into forum-nginx web root
# 4. verify index AND data endpoints (see "the 200 that isn't" below)
#
# No build step: GUTS is vanilla ES modules + an importmap with three r175 vendored, so
# web/ ships as-is. All paths are relative (index.html) or resolved from import.meta.url
# (core/assets.js), which is why it runs from a subdirectory with no base rewrite.
#
# THE 200 THAT ISN'T. nginx.conf serves `root /usr/share/nginx/html` with a catch-all
# `try_files $uri $uri/ /index.html`. That's what routes /gutsy/ with no location block of
# its own — but it also means ANY missing file returns the forum's index.html with HTTP 200.
# A deploy that lost every module would still curl 200 on all of them. So each check below
# asserts on CONTENT, never on the status code alone. (This is the failure the deploy-map
# skill records as ".bin data URLs serving arcade HTML".)
#
# DURABILITY. This docker-cp's into the container's own layer, matching blobbo/glytch. It
# survives restarts and reboots (restart: unless-stopped) but NOT `docker compose up
# --force-recreate` or an nginx image bump — those wipe it, and re-running this script is
# the fix. The durable alternative is a bind mount in forum/docker-compose.yml like
# cratewars/roguelike have, which costs a forum-nginx recreate (brief partly.party downtime)
# and so is John's call to schedule, not this script's to take.
#
# Requirements: ssh humanjing@100.71.119.27 (tailscale), container forum-nginx.
# =====================================================================
set -euo pipefail
VPS="humanjing@100.71.119.27"
CONTAINER="forum-nginx"
REMOTE_WEB_ROOT="/usr/share/nginx/html/gutsy"
STAGING="/tmp/gutsy_deploy"
LIVE="https://partly.party/gutsy"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
echo "[1/4] QA gate ..."
bash "$ROOT/tools/qa.sh" > /tmp/gutsy_qa.log 2>&1 || { echo " ✗ QA RED — not shipping. See /tmp/gutsy_qa.log"; exit 1; }
echo " ✓ QA green"
echo "[2/4] Syncing web/ to VPS staging (excluding dev harnesses) ..."
ssh "$VPS" "rm -rf '$STAGING' && mkdir -p '$STAGING'"
rsync -az --delete --exclude='.DS_Store' --exclude='dev/' "$ROOT/web/" "$VPS:$STAGING/"
echo " $(ssh "$VPS" "du -sh $STAGING | cut -f1") staged"
echo "[3/4] Replacing container content ..."
ssh "$VPS" "docker exec $CONTAINER sh -c 'rm -rf ${REMOTE_WEB_ROOT:?} && mkdir -p ${REMOTE_WEB_ROOT:?}' && docker cp $STAGING/. $CONTAINER:$REMOTE_WEB_ROOT"
echo "[4/4] Verifying live endpoints (content, not status) ..."
BUST="$(date +%s)"
fail=0
check() { # check <label> <url-suffix> <grep-pattern>
local label="$1" url="$LIVE$2?v=$BUST" pat="$3" body
body="$(curl -fsS --max-time 20 "$url" 2>/dev/null || true)"
if [ -z "$body" ]; then
echo "$label — empty/failed response"; fail=1; return
fi
# The arcade index is what try_files falls through TO. Match its real title — checked
# against the live box, not guessed; the guard is worthless if the marker is wrong.
if grep -q 'M0NST3R R0B0T P4RTY 4RC4D3' <<<"$body" 2>/dev/null; then
echo "$label — served the ARCADE index (file is missing; try_files fell through)"; fail=1; return
fi
if ! grep -q "$pat" <<<"$body" 2>/dev/null; then
echo "$label — 200 but content unrecognised"; fail=1; return
fi
echo "$label"
}
check "index.html" "/" 'id="game"'
check "js/boot.js" "/js/boot.js" 'Lane F'
check "ui/hud.js" "/js/ui/hud.js" 'createHUD'
check "vendor/three r175" "/vendor/three.module.js" 'REVISION'
check "assets/manifest" "/assets/manifest.json" 'textures'
# the no-trailing-slash form John will actually type
REDIR="$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$LIVE?v=$BUST")"
echo " · $LIVE (no slash) -> HTTP $REDIR (301/302 to /gutsy/ expected)"
[ "$fail" = "0" ] || { echo " ✗ DEPLOY UNVERIFIED"; exit 1; }
echo " ✓ deployed: $LIVE/"
echo
echo "Cloudflare fronts partly.party — purge the cache if you redeploy over existing files."

View File

@ -57,22 +57,6 @@ const player = flags.fly ? null
: createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null;
// --- UI (Lane E). Constructed here because #ui needs an owner and E is bus-only: it never
// sees player/combat, just the events they emit. After combat so every subscription exists
// before the first state emit; skipped under ?fly=1, which has no player to instrument.
let ui = null;
if (player) {
try {
const [hudMod, commsMod] = await Promise.all([import('./ui/hud.js'), import('./ui/comms.js')]);
ui = {
hud: hudMod.createHUD({ bus, flags, level: world.level }),
comms: commsMod.createComms({ bus, flags }),
};
} catch (e) {
console.info('[boot] Lane E UI not available —', e.message);
}
}
// --- level-event pump (owned by boot per round-2 ruling): emits C's events as the
// player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump
// (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(),
@ -153,7 +137,7 @@ window.DBG = {
get draws() { return stats.draws; },
get tris() { return stats.tris; },
get fps() { return fps; },
world, player, combat, assets, ui,
world, player, combat, assets,
shot(name = 'shot') {
const a = document.createElement('a');
a.download = `${name}.png`;
@ -206,4 +190,4 @@ function frame(now) {
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
requestAnimationFrame(frame);
export { scene, camera, renderer, bus, flags, world, player, combat, assets, ui, step };
export { scene, camera, renderer, bus, flags, world, player, combat, assets, step };

View File

@ -1,221 +0,0 @@
// ui/comms.js (Lane E) — the surgical team on the other end of the feed.
//
// GDD calls the tone "Star Fox arcade flow" and "playful-gross medical sci-fi" but the game
// had nobody in it except you. This is the comms window: a voice that tutorialises L1, calls
// your position so the tube stops being anonymous black, and carries the humour the docs
// describe but never sited anywhere.
//
// No portrait art (D has a hero-mesh queue and doesn't need faces on it) — a signal panel
// with a line-work waveform reads more "instrument feed" than a face would anyway.
//
// Clock: its own timer, NOT player:state. player.js:103 early-returns before the emit while
// dead, so player:state stops exactly when the death line needs to show and dismiss.
// Round-3 note filed to B in NOTES.
const WHO = {
voss: { name: 'voss', role: 'attending', color: '#39e6ff' }, // terse; the calm one
park: { name: 'park', role: 'resident', color: '#7dffb0' }, // too excited, always
adeyemi: { name: 'adeyemi', role: 'immunology', color: '#ffb13a' }, // apologetic — and amber,
}; // the colour of the cells
// attacking you. His fault.
// Lines are grouped by trigger key. Delivery rotates through each group deterministically —
// no RNG at all: this repo hashes and seeds everything, and round-robin has the nicer
// property anyway that it cannot repeat a line back-to-back.
const LINES = {
start: [
['voss', 'endo-1, you are in. mind the walls — that is a patient.'],
],
checkpoint: [
['voss', (n) => `mark. ${n}.`],
['park', (n) => `${n}! i have only ever seen that in the atlas.`],
['voss', (n) => `${n}. on schedule.`],
],
warn_aortic_squeeze: [
['voss', 'aortic arch — the wall squeezes on the beat. thread it.'],
],
warn_reflux_surge: [
['voss', 'reflux. it is coming up behind you. go. GO.'],
],
// keys are the real HAZARDS ids (levels/enemies.js) — checked, not guessed. L2 authors
// exactly three. The lines teach what C's registry note says the hazard IS; this is the
// tutorialisation channel the GDD asks for and L1 has no other vehicle for.
warn_ring_gate: [
['voss', 'ring gate. it opens on a beat — match the beat, do not ram it.'],
['park', 'peristaltic ring! ease the throttle, arrive when it opens!'],
],
warn: [
['voss', (k) => `hazard ahead — ${String(k).replace(/_/g, ' ')}.`],
],
hull_hit: [
['park', 'that was hull! that was the actual ship!'],
['voss', 'you are bleeding structure. do not trade hits.'],
],
coat_hit: [
['adeyemi', 'coat is taking it. that is what it is for.'],
],
coat_low: [
['adeyemi', 'your coat is nearly gone. find mucin, please.'],
['voss', 'coat critical. next hit is hull.'],
],
sample: [
['park', 'specimen! oh, that is going straight in my thesis.'],
['park', 'got it! that is three years of somebodys grant.'],
],
surf: [
['park', 'he is riding the peristalsis. he is RIDING it.'],
],
combo: [
['park', (n) => `${n} in a row! did everyone see that?`],
['voss', 'do not showboat in a live patient.'],
],
death: [
['voss', 'we lost the feed. re-acquiring.'],
['adeyemi', 'that was the immune system. i am sorry. they think you are a parasite.'],
],
boss: [
['voss', 'that is the pylorus. it does not open for you.'],
],
complete: [
['voss', 'clear. next segment.'],
],
};
// seconds a line holds before it yields; higher pri interrupts a lower one mid-line
const HOLD = 3.0;
const PRI = { death: 9, boss: 8, warn: 7, complete: 6, coat_low: 5, hull_hit: 4, checkpoint: 3 };
// per-key silence so the crew doesn't chatter over a firefight
const COOLDOWN = { coat_hit: 12, hull_hit: 9, combo: 8, surf: 20, checkpoint: 0, sample: 0 };
// Keys are `family` or `family_variant` (`warn` / `warn_reflux_surge`). Look up the exact key
// first, then fall back to its family — otherwise every bespoke hazard line inherits the
// default priority of 1 and the surge warning, the single most urgent line in the level, can
// be talked over by a resident admiring a specimen. Exact keys still win: `hull_hit` is 4, not
// whatever `hull` would be.
const family = (key) => key.slice(0, key.indexOf('_') + 1 || key.length).replace(/_$/, '');
const priOf = (key) => PRI[key] ?? PRI[family(key)] ?? 1;
const cdOf = (key) => COOLDOWN[key] ?? COOLDOWN[family(key)] ?? 0;
const CSS = `
.comms { position:absolute; left:18px; bottom:70px; width:330px;
font:11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing:.1em; text-transform:uppercase;
border-left:1px solid currentColor; padding:6px 0 6px 9px;
opacity:0; transition:opacity .18s ease-out; text-shadow:0 0 6px rgba(0,0,0,.95); }
.comms.on { opacity:.95; }
.comms .who { display:flex; align-items:center; gap:7px; font-size:9px; letter-spacing:.22em; }
.comms .role { opacity:.45; }
.comms .txt { color:#dff2ff; margin-top:3px; letter-spacing:.08em; opacity:.9;
text-transform:none; font-size:12px; }
/* the "carrier" — line-work waveform, CSS-animated so a talking head costs zero JS/frame */
.comms .wave { display:flex; align-items:flex-end; gap:2px; height:9px; }
.comms .wave i { width:2px; height:100%; background:currentColor; transform-origin:bottom;
animation:commsWave .52s ease-in-out infinite; }
.comms .wave i:nth-child(2){ animation-delay:.09s } .comms .wave i:nth-child(3){ animation-delay:.18s }
.comms .wave i:nth-child(4){ animation-delay:.27s } .comms .wave i:nth-child(5){ animation-delay:.36s }
@keyframes commsWave { 0%,100%{ transform:scaleY(.25) } 50%{ transform:scaleY(1) } }
@media (prefers-reduced-motion:reduce){ .comms .wave i{ animation:none; transform:scaleY(.6) } }
`;
export function createComms({ bus, flags = {}, mount = null } = {}) {
const host = mount ?? document.getElementById('ui');
if (!host || flags.shots) return { dispose() {} }; // ?shots=1 = clean plate, same as the HUD
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = document.createElement('div');
root.className = 'comms';
root.innerHTML = `<div class="who"><span class="wave"><i></i><i></i><i></i><i></i><i></i></span>` +
`<span class="name"></span><span class="role"></span></div><div class="txt"></div>`;
host.appendChild(root);
const nameEl = root.querySelector('.name');
const roleEl = root.querySelector('.role');
const txtEl = root.querySelector('.txt');
const rot = new Map(); // key -> next index (deterministic rotation)
const last = new Map(); // key -> clock reading at last delivery
// Wall-clock, read — never accumulated. Counting `+= 0.1` per timer tick assumes the timer
// is punctual; a backgrounded tab throttles setTimeout to ~1s and the count silently becomes
// fiction (lines then hold ~10x too long). It must be wall time and not sim time because the
// one moment this box MUST work — death — is the moment player:state stops emitting.
const now = () => performance.now() / 1000;
const t0 = now();
let clock = 0, current = null, timer = null, disposed = false;
const tick = () => {
if (disposed) return;
clock = now() - t0;
if (current && clock >= current.until) hide();
timer = setTimeout(tick, 100);
};
timer = setTimeout(tick, 100);
function hide() {
current = null;
root.classList.remove('on');
}
function say(key, arg) {
const group = LINES[key];
if (!group?.length) return;
const cd = cdOf(key);
if (cd && clock - (last.get(key) ?? -1e9) < cd) return;
const pri = priOf(key);
if (current && pri < current.pri) return; // never talk over something worse
const i = rot.get(key) ?? 0;
rot.set(key, (i + 1) % group.length);
const [whoId, line] = group[i];
const who = WHO[whoId];
nameEl.textContent = who.name;
roleEl.textContent = who.role;
root.style.color = who.color;
txtEl.textContent = typeof line === 'function' ? line(arg) : line;
root.classList.add('on');
last.set(key, clock);
current = { pri, until: clock + HOLD };
bus.emit('audio:cue', { name: 'comms_open' }); // E's own audio engine picks this up
}
const offs = [];
offs.push(bus.on('level:event', (ev) => {
if (ev.type === 'checkpoint' && ev.name) say('checkpoint', ev.name);
else if (ev.type === 'boss') say('boss');
}));
offs.push(bus.on('hazard:warn', (e) => {
const k = `warn_${e?.kind}`;
say(LINES[k] ? k : 'warn', e?.kind);
}));
offs.push(bus.on('player:damage', (e) => say(e?.kind === 'hull_hit' ? 'hull_hit' : 'coat_hit')));
offs.push(bus.on('player:death', () => say('death')));
offs.push(bus.on('level:complete', () => say('complete')));
offs.push(bus.on('pickup', (e) => { if (e?.kind === 'biopsy_sample') say('sample'); }));
offs.push(bus.on('combo', (e) => { if ((e?.n ?? 0) >= 5) say('combo', e.n); }));
offs.push(bus.on('player:surf', (e) => { if (e?.active) say('surf'); }));
// coat_low is the one continuous signal worth a line — edge-triggered here so it fires on
// the crossing, not every frame we're under the threshold.
let wasLow = false;
offs.push(bus.on('player:state', (p) => {
const low = p.coatMax ? p.coat / p.coatMax < 0.25 : false;
if (low && !wasLow) say('coat_low');
wasLow = low;
}));
say('start');
return {
say, // exposed for the ?fakebus harness + round-3 scripting
dispose() {
disposed = true;
clearTimeout(timer);
for (const off of offs) off();
root.remove();
style.remove();
},
};
}

View File

@ -1,209 +0,0 @@
// ui/hud.js (Lane E) — the instrument overlay. ART_BIBLE §Screens: thin cyan line-work,
// small caps, data-noise ticks. An instrument reading a live feed, not a game HUD.
//
// Bus-driven only (LANE_E charter): player:state + combat:state arrive every frame and ARE
// the clock — no rAF of our own, so the HUD cannot tick while the game is paused or absent.
// Level data comes from C's registry (sanctioned: ROUND2 §Lane E #2), never from B.
//
// 60fps law: build the DOM once, then only transform + textContent-on-change. Bars scale,
// they never resize — a width:% write per bar per frame is a layout pass we can't afford.
const C = {
line: '#39e6ff', // cyan line-work — ART_BIBLE emissive code, neutral/interactive
good: '#7dffb0',
warn: '#ffb13a',
bad: '#ff5a2a',
surf: '#b06aff',
};
const RAIL_W = 190; // px; shared by the CSS and the blip transform below
const CSS = `
.hud { position:absolute; inset:0; color:${C.line};
font:11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing:.14em; text-transform:uppercase;
text-shadow:0 0 6px rgba(0,0,0,.95); opacity:.92; }
.hud .q { position:absolute; display:flex; flex-direction:column; gap:5px; }
.hud .tl { top:16px; left:18px; }
.hud .tr { top:16px; right:18px; align-items:flex-end; }
.hud .bl { bottom:18px; left:18px; }
.hud .br { bottom:18px; right:18px; align-items:flex-end; }
.hud .bc { bottom:18px; left:50%; transform:translateX(-50%); align-items:center; }
.hud .dim { opacity:.5; }
.hud .row { display:flex; align-items:center; gap:8px; white-space:nowrap; }
.hud .lbl { font-size:9px; opacity:.65; letter-spacing:.2em; }
/* bars: a hairline rail + a fill that only ever scales */
.hud .rail { position:relative; width:132px; height:5px;
border:1px solid currentColor; opacity:.85; }
.hud .fill { position:absolute; inset:1px; background:currentColor;
transform-origin:left center; transform:scaleX(1); }
.hud .rail.thin { height:3px; width:96px; }
/* progress rail — the gut-map's honest placeholder until the silhouette lands */
.hud .prog { position:relative; width:${RAIL_W}px; height:2px; background:currentColor;
opacity:.35; margin-top:3px; }
.hud .blip { position:absolute; top:-3px; left:0; width:2px; height:8px;
background:${C.line}; box-shadow:0 0 5px ${C.line}; transform:translateX(0); }
.hud .tick { position:absolute; top:-2px; width:1px; height:6px; background:${C.surf}; opacity:.6; }
.hud .big { font-size:15px; letter-spacing:.16em; }
.hud .chain { color:${C.warn}; font-size:12px; }
.hud .noise { font-size:9px; opacity:.28; letter-spacing:.3em; }
.hud .pips { display:flex; gap:3px; }
.hud .pip { width:6px; height:6px; border:1px solid currentColor; }
.hud .pip.on { background:currentColor; }
`;
const el = (tag, cls, parent) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (parent) parent.appendChild(n);
return n;
};
// A label + rail whose fill scales. set() is the hot path: one transform write, and a color
// write only when the danger band actually changes.
function makeBar(parent, label, thin = false) {
const row = el('div', 'row', parent);
const lbl = el('span', 'lbl', row);
lbl.textContent = label;
const rail = el('div', `rail${thin ? ' thin' : ''}`, row);
const fill = el('div', 'fill', rail);
let lastV = -1, lastC = '';
return {
set(v, color) {
v = v > 0 ? (v < 1 ? v : 1) : 0;
if (v !== lastV) { fill.style.transform = `scaleX(${v})`; lastV = v; }
if (color !== lastC) { rail.style.color = color; lastC = color; }
},
};
}
function makeText(parent, cls) {
const n = el('span', cls, parent);
let last = null;
return { set(s) { if (s !== last) { n.textContent = s; last = s; } }, node: n };
}
export function createHUD({ bus, flags = {}, level = null, mount = null } = {}) {
const host = mount ?? document.getElementById('ui');
if (!host) return { dispose() {} };
const style = el('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = el('div', 'hud', host);
// ?shots=1 = clean plate for other lanes' world evidence (same contract boot's #dbg keeps).
// E's own HUD shots are taken without the flag.
if (flags.shots) root.style.display = 'none';
const tl = el('div', 'q tl', root);
const tr = el('div', 'q tr', root);
const bl = el('div', 'q bl', root);
const br = el('div', 'q br', root);
const bc = el('div', 'q bc', root);
// --- top-left: where we are -----------------------------------------------------------
const where = makeText(tl, 'big');
where.set(level?.name ?? '—');
const depth = makeText(tl, 'dim');
const progWrap = el('div', null, tl);
const prog = el('div', 'prog', progWrap);
const blip = el('div', 'blip', prog);
// Checkpoint ticks, drawn once from C's authored events. Deferred to the first player:state
// because the canal's length is a RUNTIME number (world.length, which B already divides into
// p.progress) — the level object carries segments, not a total, and asking it for one is how
// the rail silently rendered empty the first time.
const checkpoints = (level?.events ?? []).filter((ev) => ev.type === 'checkpoint');
let ticksPlaced = false;
// --- top-right: the scoreboard --------------------------------------------------------
const score = makeText(tr, 'big');
score.set('000000');
const chain = makeText(tr, 'chain');
const noise = makeText(tr, 'noise');
// --- bottom-left: what's keeping you alive --------------------------------------------
const coat = makeBar(bl, 'coat');
const hull = makeBar(bl, 'hull');
// --- bottom-right: what you're shooting with ------------------------------------------
const heat = makeBar(br, 'heat', true);
const ammoRow = el('div', 'row', br);
const ammoLbl = el('span', 'lbl', ammoRow);
ammoLbl.textContent = 'torpedo';
const pips = el('div', 'pips', ammoRow);
let pipEls = [];
// --- bottom-centre: the flight strip --------------------------------------------------
const strip = el('div', 'row', bc);
const spd = makeText(strip, 'big');
const flowT = makeText(strip, 'dim');
const boostT = makeText(strip, null);
// --- bus ------------------------------------------------------------------------------
const offs = [];
let frame = 0;
offs.push(bus.on('player:state', (p) => {
const coatF = p.coatMax ? p.coat / p.coatMax : 0;
const hullF = p.hullMax ? p.hull / p.hullMax : 0;
coat.set(coatF, coatF < 0.25 ? C.warn : C.line);
hull.set(hullF, hullF < 0.34 ? C.bad : hullF < 0.67 ? C.warn : C.good);
depth.set(`s ${(p.s | 0).toString().padStart(4, '0')} / ${p.length | 0} · ${p.biome ?? ''}`);
blip.style.transform = `translateX(${(p.progress > 1 ? 1 : p.progress) * RAIL_W}px)`;
if (!ticksPlaced && p.length > 0) {
for (const ev of checkpoints) {
el('div', 'tick', prog).style.left = `${(ev.s / p.length) * 100}%`;
}
ticksPlaced = true;
}
spd.set(`${p.speed.toFixed(1)}`);
spd.node.style.color = p.surfing ? C.surf : C.line;
flowT.set(`u/s · flow ${p.flow.toFixed(0)} · thr ${(p.throttle * 100) | 0}%`);
if (p.boostReady) { boostT.set('· boost rdy'); boostT.node.style.color = C.good; }
else {
boostT.set(`· boost ${(p.boostCd ?? 0).toFixed(1)}`);
boostT.node.style.color = C.line;
boostT.node.style.opacity = '.45';
}
// data-noise ticks — the feed is alive even when nothing is happening. 4 Hz-ish, and
// derived from s so it reads as telemetry rather than a random number generator.
if ((frame++ & 15) === 0) {
noise.set(`0x${(((p.s * 4099) ^ 0x5f3a) & 0xffff).toString(16).padStart(4, '0')}`);
}
}));
offs.push(bus.on('combat:state', (c) => {
const heatF = c.heatMax ? c.heat / c.heatMax : 0;
heat.set(heatF, c.overheated ? C.bad : heatF > 0.7 ? C.warn : C.line);
score.set(((c.score | 0) % 1000000).toString().padStart(6, '0'));
if (pipEls.length !== (c.ammoMax | 0)) { // rebuild only when the cap changes
pips.textContent = '';
pipEls = Array.from({ length: c.ammoMax | 0 }, () => el('div', 'pip', pips));
}
for (let i = 0; i < pipEls.length; i++) pipEls[i].classList.toggle('on', i < c.ammo);
}));
offs.push(bus.on('combo', (e) => chain.set(e.n > 1 ? `×${e.n} chain` : '')));
offs.push(bus.on('level:event', (ev) => {
if (ev.type === 'checkpoint' && ev.name) where.set(ev.name);
}));
return {
dispose() {
for (const off of offs) off();
root.remove();
style.remove();
},
};
}