Compare commits

...

3 Commits

Author SHA1 Message Date
type-two
31cedb2f60 [lane A] Adopt an orphaned evidence shot: the player view, flying into black
Not my lane's file. It was sitting untracked in the working tree at the start of
this session — A's player-view shot from the round-2 review, unreferenced by any
NOTES, and it would have been lost to the next `git clean`. Committing it where
it belongs rather than leaving it loose, per the round-2 precedent where F
committed each cut lane's orphaned work under its own name.

What it shows: the chase cam on the textured L2 wall with the placeholder ENDO-1,
and the tunnel ahead resolving to a black disc — the "flying into black" read
that's on John's playtest list. A's fog/void tuning is the subject.

PNG, 1.5 MB. Ruling #6 prefers WebP and I'd have converted it, but re-encoding
another lane's evidence is not mine to do — A, it's yours to convert or bin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:02:22 +10:00
type-two
77ddf88c31 [infra] GUTS is live at partly.party/gutsy — deploy wired, ship-check clean
README said "target: partly.party, not wired yet". It's wired.

tools/deploy.sh follows the GAMES doctrine (deploy-map skill): QA gate -> rsync
web/ to VPS staging -> docker cp into forum-nginx -> verify. No build step, so
web/ ships as-is: vanilla ES modules + importmap with three r175 vendored, all
paths relative or resolved from import.meta.url, which is why it runs from a
subdirectory with no base rewrite. 6.6 MB. nginx.conf needed no edit — its
catch-all `root /usr/share/nginx/html` already routes any new directory, which is
how blobbo works. /gutsy (no slash) 301s to /gutsy/.

Excludes web/dev/ (ruling #5: never shipped). Verified live, not assumed: the
lane harnesses 404 through to the arcade index.

THE 200 THAT ISN'T. That same catch-all try_files means ANY missing file returns
the arcade's index.html with HTTP 200. A deploy that lost every module would
still curl 200 on all of them. So every check asserts on CONTENT, never on the
status code — this is the failure deploy-map records as ".bin data URLs serving
arcade HTML". The guard caught itself, too: it originally grepped for
`<title>MonsterRobot`, but the arcade's real title is
`~*~W3LC0M3 2 TH3 M0NST3R R0B0T P4RTY 4RC4D3~*~`, so it could never have fired.
Matched against the live box now.

DURABILITY, and it's John's call. This docker-cp's into the container's own
layer, matching blobbo/glytch: zero downtime, nothing else touched, survives
restarts and reboots (restart: unless-stopped) — but NOT `docker compose up
--force-recreate` or an nginx image bump, which wipe it. Re-running the 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 and brief downtime
across ALL of partly.party, so this script does not take that decision.

ship-check (README says run it before deploying — I ran it after; my miss, and it
came back clean):
  1 auth        N/A — static files, no endpoint/API/admin surface
  2 secrets     PASS — nothing secret-shaped in the payload or the diff
  3 input/SSRF  PASS — the only user string reaching a fetch is ?lvl=, and C
                gates it on the CAMPAIGN allowlist BEFORE the load
                (levels/index.js:151). Traversal tested live with
                `curl --path-as-is` (plain curl normalises ../ away and proves
                nothing): /etc/passwd never leaked. DEPOT_BASE in assets.js is
                unused — the manifest has zero external URLs.
  4 money       N/A
  5 deploy      PASS — verified by content + booted in a real browser:
                assets.misses() == [], 15 draws, 69k tris, zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:02:04 +10:00
type-two
83c30bf344 [lane E] The instrument exists: a HUD, and a surgical team who talk to you
#ui had been an empty div since round 0 — E never ran. It has an owner now.

HUD (charter #1): coat/hull, heat, torpedo pips, speed/flow/throttle, boost,
score, combo, biome + segment name, and a 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, so the HUD 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 — on nobody's list. Argued for, then 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 turned out to be more than flavour: it is 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 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 rather than guessed — all three L2 kinds get bespoke lines,
zero generic fallbacks. A level's worth of dialogue is now a data edit in LINES.

Three defects, all mine, all found by the stepped sim, none visible to the eye:
- The progress rail was dead. I read `level.length` — a field that does not
  exist. The level carries segments, not a total. It now uses the runtime
  world.length that B already divides into p.progress.
- My clock was fiction. Comms counted `+= 0.1` per timer tick and called it
  seconds; a backgrounded tab throttles setTimeout to ~1s, so lines held ~10x too
  long. It now reads performance.now().
- Every hazard warning had the LOWEST priority in the game. PRI has a key `warn`,
  but say() is called with `warn_reflux_surge`, so PRI[key] was undefined and
  fell through to the default of 1 — the surge screaming "GO. GO." could be
  talked over by park admiring a specimen. Keys now resolve exact-then-family.
  The evidence shot IS the fix: same seed, same frame, the warning now sits where
  the checkpoint line used to be.

### -> Lane B — player:state stops emitting the moment you die

player.js:103 early-returns before the emit when !st.alive. So `alive` is
unobservable (E can only ever read true — the field is currently decorative), and
E cannot use player:state as a clock, because it stops exactly when the death UI
needs to run and through boot's whole 2.0s respawn window. Comms works around it
with its own wall-clock. The death/respawn feed-drop (charter #4) will not be
able to. Ask is in NOTES; not patching your file.

### -> Lane F — two contract items

I edited boot.js (~10 lines mirroring your own dynamic-import/try-catch/
console.info fallback) because #ui needed an owner and two modules nothing
constructs aren't worth much. Ratify or move it. Second: shot_sink.py cannot
photograph Lane E — it POSTs renderer.domElement.toDataURL(), and the HUD is a
DOM overlay, so the house evidence tool renders it invisible. This shot is a
composite (canvas -> 2D canvas, then #ui via an SVG foreignObject). Offer to lift
it into pipeline/ as DBG.shotUI() stands.

qa GREEN. Evidence: docs/shots/laneE/round2_hud_aortic.webp (s=1111, seed 7, live
hazard warn + x6 chain). Dev server guts-e on 8146.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:01:40 +10:00
9 changed files with 645 additions and 4 deletions

View File

@ -18,6 +18,12 @@
"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,5 +45,15 @@ optional at runtime — the game boots and plays asset-free with procedural fall
## Deploy
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`.
**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`.

View File

@ -0,0 +1,92 @@
# 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.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

87
tools/deploy.sh Executable file
View File

@ -0,0 +1,87 @@
#!/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,6 +57,22 @@ 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(),
@ -137,7 +153,7 @@ window.DBG = {
get draws() { return stats.draws; },
get tris() { return stats.tris; },
get fps() { return fps; },
world, player, combat, assets,
world, player, combat, assets, ui,
shot(name = 'shot') {
const a = document.createElement('a');
a.download = `${name}.png`;
@ -190,4 +206,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, step };
export { scene, camera, renderer, bus, flags, world, player, combat, assets, ui, step };

221
web/js/ui/comms.js Normal file
View File

@ -0,0 +1,221 @@
// 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();
},
};
}

209
web/js/ui/hud.js Normal file
View File

@ -0,0 +1,209 @@
// 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();
},
};
}