Compare commits
3 Commits
a99f733452
...
31cedb2f60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31cedb2f60 | ||
|
|
77ddf88c31 | ||
|
|
83c30bf344 |
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
14
README.md
14
README.md
@ -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`.
|
||||
|
||||
92
docs/LANES/LANE_E_NOTES.md
Normal file
92
docs/LANES/LANE_E_NOTES.md
Normal 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.
|
||||
BIN
docs/shots/laneA/review_player_view.png
Normal file
BIN
docs/shots/laneA/review_player_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneE/round2_hud_aortic.webp
Normal file
BIN
docs/shots/laneE/round2_hud_aortic.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
87
tools/deploy.sh
Executable file
87
tools/deploy.sh
Executable 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."
|
||||
@ -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
221
web/js/ui/comms.js
Normal 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 somebody’s 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
209
web/js/ui/hud.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user