diff --git a/.claude/launch.json b/.claude/launch.json index 5b9b0be..6140848 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "python3", "runtimeArgs": ["-m", "http.server", "8140", "--directory", "web"], "port": 8140 + }, + { + "name": "guts-f", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "8141", "--directory", "web"], + "port": 8141 } ] } diff --git a/docs/LANES/ROUND1_INSTRUCTIONS.md b/docs/LANES/ROUND1_INSTRUCTIONS.md index 6813f50..ae1fef9 100644 --- a/docs/LANES/ROUND1_INSTRUCTIONS.md +++ b/docs/LANES/ROUND1_INSTRUCTIONS.md @@ -1,5 +1,8 @@ # GUTS — Round 1 lane instructions (from Fable, integrator) +> **⚠ SUPERSEDED by [ROUND2_INSTRUCTIONS.md](ROUND2_INSTRUCTIONS.md) (2026-07-16).** Round 1 +> is complete (lanes A–D landed; E's scope carries into round 2). Historical reference only. + Date: 2026-07-16 · Repo state: F's round-0 scaffold is committed on `main` — docs complete (README/PROCESS/GDD/TECH/ART_BIBLE/PIPELINE + charters), shell boots the **stub world** (straight pulsing tube, flat shading) with a fly camera, `tools/qa.sh` GREEN. Everything diff --git a/docs/LANES/ROUND2_INSTRUCTIONS.md b/docs/LANES/ROUND2_INSTRUCTIONS.md new file mode 100644 index 0000000..b8ce3e3 --- /dev/null +++ b/docs/LANES/ROUND2_INSTRUCTIONS.md @@ -0,0 +1,172 @@ +# GUTS — Round 2 lane instructions (from Fable, integrator) + +Date: 2026-07-16 · Written after reviewing all four round-1 lanes (A/B/C/D — E did not run +and joins this round) and integrating their offered wiring. Every round-1 claim I checked +held up: A's hash `cefc4f83` and 8-draw sweep, B's `node --check` ESM no-op (reproduced on a +deliberately broken file), C's 3/3 selfcheck, D's manifest. Superb round, all of you. + +## Repo state (F integrated, commit this round) + +**The game is a game now.** Default boot = A's world on C's `L2_esophagus` with D's textures, +B's ENDO-1 + camera + combat, level-event pump live. Verified by stepped simulation (60 sim +seconds, deterministic dt): flow carried the player to s=777, checkpoints + pickups fired, +9 draws / 52–61k tris, zero console errors, zero asset misses. +Evidence: `docs/shots/laneF/round1_integrated_s777.png`. + +F landed since round 1: B/C/D snippets in `boot.js` (assets → world, player/combat wiring, +`?stub=1&lvl=` composing, event pump) · qa.sh **ESM gate fixed** (it had never checked +anything — thank you B) + A's spline selfcheck gate · TECH: world contract **frozen v1.1**, +bus events ratified, asset entry points ratified, colorspace + crest-speed laws · stub: +`crestSpeed()`, CREST factor, colorspace fix. Second dev server config `guts-f` on **8141** +(8140 was held by another session — lanes: if 8140 is busy, use your own port and say so). + +## The ONE integration gap found (and it was predicted) + +Stepped sim confirmed: **all 11 L2 spawn events no-op with `[combat] unknown enemy type +"bolus_chunk"/"eosinophil_swarm"/"candida_bloom"`.** C's fiction-id → archetype resolve +(LANE_C_NOTES → Lane B #1) is the single blocker between "it boots" and "it's a game with +enemies". B: it is your first task. + +## Integrator rulings (settled — argue in NOTES for round 3 if you must) + +1. **Surf: CREST_FACTOR = 1.6.** B proved crest speed == flow makes surfing strictly worse + than throttle (LANE_B_NOTES §surf). Ruling: `crestSpeed(s) = 1.6 × flow(s)` (beats + throttleMax 1.4), A implements `k(s) = OMEGA/(1.6·flow)`, B speed-locks to + `world.crestSpeed(s)` while riding, gameplay wave == visual wave stays law. Stub already + complies — B can build now. C re-runs pacing (pars will compress; the finale's escape + math changes deliberately — surfing becomes the stylish escape). +2. **Colorspace law** (TECH §Shader law): every custom shader ends `main()` with + `#include `. Stub done. A (wall), B (emissive) apply this round — + expect a visible shift; A re-eyeballs biome tints against D's textures after. +3. **Texture naming**: D renames keys to A's biome ids **early in the round** and pings in + NOTES; A deletes the slug map after pulling. That order, one round, done. +4. **`level:event` pump is boot's (F).** Landed. B: when you build respawn, tell F what + reset/replay API the pump needs (`### → Lane F`), don't work around it. +5. **`web/dev/` ratified** as the shared dev-harness dir, per-file lane ownership, never + shipped. D's texview stays. A: `world/dev.html` may move there or die, your call. + D's `pipeline/shot_sink.py` is the **house evidence tool** — all lanes: + `python3 pipeline/shot_sink.py --dir docs/shots/lane --port 8143` (NOT 8140/8141), + then POST the canvas. `DBG.shot()`'s download silently no-ops in the pane (A was right). +6. **WebP shots ratified** (B's 25 KB vs 1.9 MB argument). PNG also fine. Name the round. +7. **C's GDD refinements + boss specs: signed off.** C folds the three boss one-pagers into + GDD.md proper. Per-level pars stand. A/D treat boss specs as stable input. +8. **A's uv simplification** (one uv attr + uTile uniform, no uv2): ratified, contract-level. + **C's arena semantics + A's per-segment `wave` override request: approved** — C specs + `segments[].wave: { amp }` into schema v2, A implements `waveMaxAt` preferring it. + +## Round-2 goal + +**L2 esophagus, playable start to finish, textured, scored, survivable and losable** — every +spawn live, all three hazards real, checkpoints/death/respawn, gate → `level:complete`, +HUD + audio on top. Tag `v0.2`. (Round-3 preview: arenas — L1 tutorial + L3 acid sea + the +Pyloric Guardian.) + +--- + +## Lane A — contract v1.2 + the look, deepened + +1. **`crestSpeed(s)` + CREST_FACTOR 1.6** (ruling #1) — small, unblocks B, do first. +2. **Answer C's biggest dependency** (LANE_C_NOTES → A #4): segment radius **blending**. + C assumes a smooth ~40–60u taper at joins (12→9 at s 1100, 10→6.8 at 2600). Confirm or + implement; a hard step reads as a level seam and breaks the constrictions. +3. **Per-segment `wave` override** (ruling #8) — the hiatus should be tight AND calm. +4. Colorspace one-liner in the wall shader (ruling #2), then re-eyeball all six biome tints + with D's textures; adjust `biomes.js` freely (C's selfcheck re-checks live, as designed). +5. **TBN normal maps** — lift D's `perturb()` from `web/dev/laneD_texview.html` (their NOTES + has the exact GLSL and the trap: never add tangent-space normals to vertex normals). Wire + `_b` variants + matcap. This is the round's visual headline; D called it "the single + biggest visual win" and their shots agree. +6. `sample(s, out)` out-param variant (v1.2) — B adopts on landing. +7. Stomach-arena shape read for C (→ C #5): propose capsule/lobed/chained-spheres, one + paragraph + sketch, before C authors the acid sea in round 3. Triplanar arena shells if + time (the pole-pinch fix) — acid plane can wait for round 3 with the L3 work. +Defer: villi band (no L4 level yet). + +## Lane B — make L2 a game (priority order is the order) + +1. **C's three consumption fixes** (LANE_C_NOTES → B #1–3, exact snippets provided): + `getEnemy()` resolve, `theta` arrays, `spread`. Verify against the live L2: sim to s=800 + and the console warns must be gone, bolus/eosinophil/candida all spawning. +2. **The three L2 hazards** (params in `levels/enemies.js` HAZARDS): `reflux_surge` (rear + chaser; emit `hazard:proximity {kind,distance}` — C's most important UI dependency), + `aortic_squeeze` (**directional** — one-sided by design, keep contact non-lethal or tell + C), `ring_gate` (timeable point gate). Emit `hazard:warn {kind,s,eta}` from each event's + `warn` field. +3. **Checkpoint / death / respawn** (≤30 s law). Define the pump reset API with F (ruling + #4). Respawn never inside a hazard zone (C placed checkpoints accordingly — keep it true). +4. **Pickups**: nutrient, mucin, B12, antacid ammo, biopsy samples (`pickup` events are in + L2 already; economy numbers from C via NOTES if you need more than `levels/enemies.js`). +5. **Gate → `level:complete {stats}`** with score/time/samples for E's medal card. +6. **Surf speed-lock** on `world.crestSpeed(s)` (stub has it now; A lands theirs early). + Re-measure: riding a crest must beat throttle-mashing by a margin worth learning. +7. Colorspace in `emissive.js` (your own finding) — then re-shoot `round1_combat`'s hostile + amber and confirm it reads amber. +8. Stretch only: arena 6DOF controller (round 3 needs it for L1/L3). +Your tuning.js header still cites the stale `wallRho` numbers (C flagged) — fix the comment. + +## Lane C — validate with a real canal + +1. **Fly L2 on the integrated build** once B's #1 lands (mid-round — watch B's NOTES). Use + F's stepped-sim technique (f-progress documents it) or the browser; replace sim numbers + with observed ones. **The hiatus (s 2600–3000) is the thing to validate**; your open + question #4 (`MIN_CLEARANCE 2.5`) gets its answer here, alongside John's playtest. +2. **Re-run pacing under CREST 1.6 surf** (ruling #1): pars, the finale escape math, and + whether antacid-stalling is still the smart play. Adjust `par` values with rationale. +3. **Schema v2**: `segments[].wave` override (approved) — spec it, re-author the hiatus calm + (amp ~0.6), selfcheck it. +4. Fold boss specs into GDD.md (signed off, ruling #7). +5. **L1 encounter design on paper** (molar crush-cycles, saliva tides, epiglottis exam) — + full authoring waits for B's arena controller in round 3, but the design doc and the + skeleton→full upgrade plan land now. +6. Give B the pickup economy (counts/values) as data or NOTES — B's #4 consumes it. + +## Lane D — heroes and the full pack + +1. **Slug rename first** (ruling #3): `wall_small_intestine_a` etc., manifest + batch JSONs, + ping A in NOTES same session. +2. **Hero meshes, the round-2 headline**: ENDO-1 ship, `bolus_chunk`, `eosinophil_swarm` — + concepts via flux_local (ART_BIBLE stems + C's tints in `levels/enemies.js`) → + **concepts into `docs/shots/laneD/` + NOTES for F/C sign-off BEFORE any TRELLIS run** → + sf3d draft → trellis_mac → normalize (port `normalize.py` + `glb_stat.py` from PROCITY — + the GLB law is unenforced until they exist) → thumbs → manifest. `candida_bloom` as a + decal/sprite sheet (your call, ratified — C agreed). +3. **Colon + mouth walls** (2 each) + acid/decal sprites — completes the biome texture set. +4. **Full audio pack**: E must hand you their key list by mid-round (E's #5); if it hasn't + arrived, propose the keys yourself in NOTES and E adapts. Beds for all six biomes, sfx + (torpedo, gate, alarm, checkpoint, death/respawn, UI ticks), 3 boss stingers, ≤10 MB law. +5. Tapeworm stays a **round-3** fal.ai candidate — do NOT spend on it yet; if concepts are + ready early, put it on `FAL_LIST.md` with the concept attached for John's go. + +## Lane E — you have a full house waiting (round-1 charter + these specifics) + +Read your charter + the `→ Lane E` sections in B/C/D/A NOTES — all four wrote APIs for you. +1. **HUD** from `player:state` + `combat:state` (emitted every frame, live now): coat/hull, + heat, throttle/speed/flow, boost cd, score/combo. Bus-only, no imports from B. +2. **Gut-map v1** (the signature): `listLevels()`/`getLevel()` in `levels/index.js` give you + names/lengths/pars; L2 has 7 named segments + 10 checkpoints authored for exactly this. +3. **Rear-proximity indicator** for `hazard:proximity` + telegraph pings for `hazard:warn` — + C calls this the single most important UI request (the surge chases from behind). +4. Title (over `?fly=1`-style drift) · pause · **medal card** on `level:complete` (par data + in the level) · death/respawn feed-drop. Damage = feed corruption, never red vignette. +5. **Audio engine**: D's pack via `assets.audioUrl/audio` (keys + gain law in LANE_D_NOTES → + Lane E), procedural fallback beeps (`?localassets=0` must make noise), bed crossfade on + `biomeAt`, heartbeat tempo → danger scalar (define it from bus events, document mapping). + **Hand D your round-3 key wishlist by mid-round** (D's #4). +6. Respect `?shots=1`/`?mute=1`; every screen screenshotted into `docs/shots/laneE/`. + +## Lane F (me) — done this round: the integration above. On-call for contract questions; +end of round: boot matrix, v0.2 tag, ROUND3. + +--- + +## 🎮 John's playtest checklist (the things no agent can measure) + +Open `http://localhost:8140/?dbg=1` (or 8141) in a real browser and just play: +1. **The fps number** in the bottom-left overlay (we can only prove draws/tris; rAF is + paused in our tooling — every lane flagged this honestly). +2. **Feel** — B set `bank.gain`, `cam.discFollow`, `aim.lag` by reasoning, not play. Say + "banking feels heavy/floaty/right" in any form; B will translate. +3. **The hiatus** (s 2600–3000, once B's hazards land): thrilling or miserable? This decides + C's clearance floor. +4. **Dart readability** when a turret volley passes the camera (B suspects too dominant). +5. **Audio ear-check** of D's pack (spectrograms pass; ears are yours). diff --git a/docs/TECH.md b/docs/TECH.md index a5dddfa..a8c8b29 100644 --- a/docs/TECH.md +++ b/docs/TECH.md @@ -44,28 +44,49 @@ export function createX({ scene, world, bus, rng, flags, assets }) { Default-off behind flags until F wires them on. Dispose must actually free GPU resources (geometries, materials, textures) — enter/exit cycles are leak-tested. -## THE WORLD CONTRACT (A implements; stub implements it today; frozen after round-1 sign-off) +## THE WORLD CONTRACT — **FROZEN v1.1** (round-1 sign-off, 2026-07-16) -Spline-space: `s` = arclength along the canal centreline (units), `θ` = angle in the -cross-section (rad), `ρ` = radial distance from centreline. Units are metres in three.js -terms; fiction scale ≈ 1 unit : 1 mm. +Y up, right-handed. +Changes now go through NOTES + F referee only. A implements (`world/index.js`); the stub is +the executable reference. Spline-space: `s` = arclength along the canal centreline (units), +`θ` = angle in the cross-section (rad), `ρ` = radial distance from centreline. Units are +metres in three.js terms; fiction scale ≈ 1 unit : 1 mm (lengths story-dilated ~14×, see +LANE_C_NOTES). +Y up, right-handed. Frame convention (B builds against it): +`worldPos = pos + nor·y + bin·x`, `theta = atan2(x, y)`; camera `up` = `nor`, never world-up. ```js -const world = await createWorld(levelData, { rng, quality }); +const world = await createWorld(levelData, { rng, assets, quality }); // assets optional world.length // total arclength +world.level // the levelData it was built from world.group // THREE.Group (boot adds to scene) world.sample(s) // → { pos, tan, nor, bin, radius } (parallel-transport frame) -world.project(pos) // → { s, theta, rho } (world → spline space, player-local fast path) -world.wallRho(s, theta) // → max safe ρ (radius minus conservative displacement amplitude) +world.project(pos, hint?) // → { s, theta, rho }; pass previous s as hint (near-free path) +world.wallRho(s, theta) // → max safe ρ (radius − conservative displacement); time-independent world.collide(pos, r) // → null | { push:Vector3, kind:'wall'|'hazard', biome } -world.biomeAt(s) // → { id, palette, fog, flow, coatDrain } (see biomes.js registry) +world.biomeAt(s) // → { id, palette, fog, flow, coatDrain }; flow BLENDS across joins world.modeAt(s) // → 'tube' | 'arena' world.arenaAt(s) // → null | { center:Vector3, radius } (bounds for 6DOF clamp) +world.flowPulse(s, t?) // → 0..1 peristalsis phase (1 = crest); same fn the shader runs +world.crestSpeed(s) // → u/s the crest travels; == CREST_FACTOR × flow(s), see law below world.update(dt, playerS) // stream chunks, advance shader time +world.hash() // golden determinism hash (qa gate) +world.dispose() // frees geometries/materials it created (not D's textures) ``` -Determinism law: same `levelData.seed` ⇒ byte-identical geometry. A maintains a golden -hash (`world.hash()` over sampled frames) checked by qa. +Planned v1.2 (A, round 2; B adopts when it exists): `sample(s, out)` out-param variant — +`sample` currently allocates ~6 Vector3s/call and it's B's hot path. + +**Crest-speed law (round-2 ruling, from B's round-1 finding):** the wave must outrun the +player or surfing loses to throttle-mashing. `crestSpeed = CREST_FACTOR × flow` with +**CREST_FACTOR = 1.6** (> throttleMax 1.4), i.e. A's `k(s) = OMEGA / (1.6 · flow(s))`. +Gameplay wave == visual wave, always. B speed-locks to `crestSpeed(s)` while riding. + +Determinism law: same `levelData.seed` ⇒ byte-identical geometry, node == browser. + +**Shader law (round-1 finding, B):** every custom shader that writes to the default target +must end `main()` with `#include ` — three converts `THREE.Color` +inputs to linear but does NOT convert a raw shader's output back to sRGB, so un-converted +colours ship visibly wrong (measured: ART_BIBLE amber `#ff5a2a` displayed as pure red). +Stub complies; A's wall + B's emissive materials comply from round 2. ## Level data schema **v1** (C owns — ratified round 1; F drafted v0) @@ -142,10 +163,22 @@ re-tuned. Below 2.5 a constriction is not a challenge, it is a toll. ## Bus events (append here when adding; F referees name collisions) -`player:spawn {s}` · `player:damage {amount, kind}` · `player:death` · `player:boost` · -`enemy:spawn {id,type}` · `enemy:die {id,type,s}` · `pickup {kind}` · `level:event {…}` (raw -C events as player crosses their `s`) · `level:checkpoint {s}` · `level:complete {stats}` · -`boss:start {id}` / `boss:phase` / `boss:end` · `combo {n}` · `audio:cue {name}`. +`player:spawn {s}` · `player:damage {amount, kind}` (**discrete hits only** — continuous +drain rides `player:state`, never events, or E's damage flash pins on) · `player:death` · +`player:boost` · `enemy:spawn {id,type}` · `enemy:die {id,type,s,score,kind}` · +`pickup {kind}` · `level:event {…}` (raw C events as the player crosses their `s`; **the +pump is owned by boot.js/F**, wired round 2) · `level:checkpoint {s}` · +`level:complete {stats}` · `level:neutralize {s,radius,duration}` (B's antacid torpedo → +A/C acid zones) · `boss:start {id}` / `boss:phase` / `boss:end` · `combo {n}` · +`audio:cue {name}`. + +Ratified round 2 (round-1 requests): +- `player:state {coat,coatMax,hull,hullMax,speed,flow,throttle,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive}` — every frame, read-only, don't retain (B → E). +- `combat:state {heat,heatMax,overheated,ammo,ammoMax,score,kills,enemies}` — every frame (B → E). +- `player:surf {active, s}` — edge-triggered (B → E). +- `hazard:warn {kind, s, eta}` — fires `warn` seconds before a hazard (C events carry `warn`). +- `hazard:proximity {kind, distance}` — rear-chaser telemetry (reflux surge); drives E's + rear indicator, C's most important UI dependency. ## Asset manifest contract (D owns `assets.js` + `manifest.json`) @@ -163,6 +196,16 @@ synthesized beep). The game must be fully playable with an empty `assets/gen/`. House GLB law (from PROCITY): metres, +Y up, sensible origin, **≤5k tris**, WebP textures ≤1024, no Draco. `?localassets=0` simulates empty-manifest boot for fallback testing. +Ratified round 2: `get(category, name)` is the raw floor; the consumer entry points are +**`assets.texture(name)`** → `{map, normalMap, tile, repeat}` (repeat pre-computed +`[tile[0], 1/tile[1]]`; multiply into UVs yourself — `texture.repeat` is inert in a raw +ShaderMaterial), **`assets.audioUrl(group, key)`** (browser-correct ogg/m4a pick) and +**`assets.audio(group, key)`** → `{ogg, m4a, loop, gain, seconds}` (respect `gain`). +Misses are ledgered: first miss logs once, `assets.misses()` lists all (drifted names +announce themselves instead of falling back silently forever). +Texture naming law: keys use **A's biome ids** (`wall_small_intestine_a`), no abbreviations +— D renames + A deletes its slug map in round 2, in that order. + ## Flags (F owns this table; lanes request rows via NOTES) | flag | owner | effect | diff --git a/docs/progress/f-progress.md b/docs/progress/f-progress.md index 405dd86..8df8dfc 100644 --- a/docs/progress/f-progress.md +++ b/docs/progress/f-progress.md @@ -1,5 +1,29 @@ # Lane F (Fable) — progress +## 2026-07-16 — Round 1 review + integration (round 2 opened) + +Reviewed all four lanes; every checked claim held (A's hash cefc4f83 + 8-draw sweep, B's +node --check ESM no-op — reproduced, C's 3/3 selfcheck + sim, D's manifest + seam numbers). + +Integrated (this commit): B's player/combat wiring + level-event pump into boot.js · D's +assets into boot + world · C's ?stub=1&lvl= composition · qa.sh ESM-gate fix (was a no-op +on every module in the repo) + spline selfcheck gate · TECH world contract FROZEN v1.1 +(project hint, flowPulse, crestSpeed, hash, dispose, level) + bus ratifications +(player:state, combat:state, player:surf, hazard:warn, hazard:proximity, level:neutralize) ++ assets.texture/audioUrl/audio promotion + Shader-law (colorspace) + crest-speed law +(CREST_FACTOR 1.6, from B's surf finding) · stub: crestSpeed + CREST wave + colorspace. + +Verified integrated build (guts-f server, :8141): default boot = A world + C L2 + D pack + +B ship. 60 sim-seconds stepped (rAF is paused in the hidden pane — deterministic dt loop +via the boot module exports instead): s→777, checkpoints+pickups fired, 9 draws / 61k tris, +0 console errors, 0 asset misses. ONE gap: all 11 spawns no-op on fiction ids ("unknown +enemy type bolus_chunk") — exactly C's predicted → B item; top of B's round 2. +Evidence: docs/shots/laneF/round1_integrated_s777.png. fps/feel still needs John (checklist +in ROUND2 §John). + +shot_sink usage that works from the pane: `python3 pipeline/shot_sink.py --dir +docs/shots/laneF --port 8143` then POST canvas blob (DBG.shot download no-ops, as A warned). + ## 2026-07-16 — Round 0: scaffold Repo initialized from empty. Landed: diff --git a/docs/shots/laneF/round1_integrated_s777.png b/docs/shots/laneF/round1_integrated_s777.png new file mode 100644 index 0000000..b8981f2 Binary files /dev/null and b/docs/shots/laneF/round1_integrated_s777.png differ diff --git a/tools/qa.sh b/tools/qa.sh index 7a480ec..0adaf5e 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -7,12 +7,14 @@ FAIL=0 red() { printf '\033[31m✗ %s\033[0m\n' "$1"; FAIL=1; } green() { printf '\033[32m✓ %s\033[0m\n' "$1"; } -# 1. every JS module parses +# 1. every JS module parses. +# NB: plain `node --check` exits 0 on ANY file containing import/export (ESM detection +# no-op — found by Lane B round 1, verified on node v26). Must force module parsing. JSBAD=0 while IFS= read -r -d '' f; do - node --check "$f" 2>/dev/null || { node --check "$f"; JSBAD=1; } + node --check --input-type=module < "$f" 2>/dev/null || { echo "SYNTAX: $f"; node --check --input-type=module < "$f"; JSBAD=1; } done < <(find web/js -name '*.js' -print0) -[ "$JSBAD" = 0 ] && green "node --check: all web/js modules parse" || red "JS syntax errors" +[ "$JSBAD" = 0 ] && green "node --check (ESM): all web/js modules parse" || red "JS syntax errors" # 2. determinism law: Math.random only in core/rng.js RAND=$(grep -rn 'Math\.random' web/js --include='*.js' | grep -v 'core/rng.js' || true) @@ -35,6 +37,11 @@ if [ -f web/js/levels/index.js ]; then node web/js/levels/index.js --selfcheck && green "levels selfcheck" || red "levels selfcheck failed" fi +# 4b. Lane A world math (headless; no three.js needed) +if [ -f web/js/world/spline.js ]; then + node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed" +fi + # 5. Lane D manifest validation (when it exists) if [ -f pipeline/validate_manifest.py ]; then python3 pipeline/validate_manifest.py && green "manifest valid" || red "manifest errors" diff --git a/web/js/boot.js b/web/js/boot.js index 58a31dd..b8f65b6 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -1,11 +1,15 @@ // boot.js (Lane F) — the shell. Creates renderer/scene/loop, picks the world (real or -// stub), hosts the debug camera + DBG. Lanes: request wiring via NOTES, don't edit here. +// stub), constructs assets + player + combat, pumps level events, hosts DBG. +// Lanes: request wiring via NOTES, don't edit here. Round 2: A/B/C/D snippets integrated. import * as THREE from 'three'; import { parseFlags } from './core/flags.js'; import { createRng } from './core/rng.js'; import { createBus } from './core/bus.js'; +import { createAssets } from './core/assets.js'; import { createWorld as createStubWorld, STUB_LEVEL } from './stub/world_stub.js'; +import { createPlayer } from './flight/player.js'; +import { createCombat } from './combat/index.js'; const flags = parseFlags(); const bus = createBus(); @@ -15,31 +19,50 @@ renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight); document.getElementById('game').appendChild(renderer.domElement); -const scene = new THREE.Scene(); -const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600); +const assets = await createAssets({ flags, renderer }); + +async function loadLevel(id) { + try { + const mod = await import('./levels/index.js'); // Lane C's registry + return mod.getLevel(id); // default level lives there + } catch (e) { + console.info('[boot] Lane C registry not available —', e.message); + return null; + } +} async function pickWorld() { + const level = (await loadLevel(flags.lvl)) ?? STUB_LEVEL; + const rng = createRng(flags.seed ?? level.seed); if (!flags.stub) { try { - const mod = await import('./world/index.js'); // Lane A's world, when it lands - const level = await loadLevel(flags.lvl); - return mod.createWorld(level, { rng: createRng(flags.seed ?? level.seed) }); + const mod = await import('./world/index.js'); // Lane A's world + return mod.createWorld(level, { rng, assets }); } catch (e) { console.info('[boot] Lane A world not available, using stub —', e.message); } } - return createStubWorld(STUB_LEVEL, { rng: createRng(flags.seed ?? STUB_LEVEL.seed) }); -} -async function loadLevel(id) { - const mod = await import('./levels/index.js'); // Lane C's registry, when it lands - return mod.getLevel(id); + return createStubWorld(level, { rng, assets }); // ?stub=1&lvl= composes (beat 1 only — stub is single-segment) } const world = await pickWorld(); +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600); scene.add(world.group); scene.background = new THREE.Color(world.biomeAt(0).palette.void); -// --- debug drift/fly camera (placeholder until Lane B's player rig; ?fly=1 = manual) --- +// --- player + combat (Lane B). ?fly=1 keeps the noclip/drift camera instead. --- +const rng = createRng(flags.seed ?? world.level?.seed ?? 0); +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; + +// --- level-event pump (owned by boot per round-2 ruling): emits C's events as the +// player crosses their s. Checkpoint/respawn replay semantics: revisit with B in round 2. +let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0; +const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); }; + +// --- debug drift/fly camera (?fly=1, or fallback when B's player is absent) --- let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6; const keys = new Set(); addEventListener('keydown', (e) => keys.add(e.code)); @@ -47,7 +70,7 @@ addEventListener('keyup', (e) => keys.delete(e.code)); addEventListener('mousedown', () => (dragging = true)); addEventListener('mouseup', () => (dragging = false)); addEventListener('mousemove', (e) => { - if (!dragging) return; + if (!dragging || player) return; look.yaw -= e.movementX * 0.003; look.pitch = THREE.MathUtils.clamp(look.pitch - e.movementY * 0.003, -1.2, 1.2); }); @@ -78,7 +101,7 @@ window.DBG = { get draws() { return stats.draws; }, get tris() { return stats.tris; }, get fps() { return fps; }, - world, + world, player, combat, assets, shot(name = 'shot') { const a = document.createElement('a'); a.download = `${name}.png`; @@ -97,8 +120,15 @@ let last = performance.now(); function frame(now) { const dt = Math.min((now - last) / 1000, 0.05); last = now; - world.update(dt, sCam); - updateCamera(dt); + if (player) { + player.update(dt); // must run first: combat hit-tests this frame's ship position + pumpTo(player.state.s); + combat.update(dt); + world.update(dt, player.state.s); + } else { + updateCamera(dt); // ?fly=1 noclip / title drift + world.update(dt, sCam); + } renderer.render(scene, camera); stats.draws = renderer.info.render.calls; stats.tris = renderer.info.render.triangles; @@ -106,12 +136,14 @@ function frame(now) { frames++; fpsT += dt; if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; - if (flags.dbg && !flags.shots) - dbgEl.textContent = `${fps} fps · ${DBG.draws} draws · ${(DBG.tris / 1000) | 0}k tris · s=${sCam.toFixed(0)} · ${world.level?.id ?? '?'} ${world.hash?.() ?? ''}`; + if (flags.dbg && !flags.shots) { + const s = player?.state.s ?? sCam; + dbgEl.textContent = `${fps} fps · ${DBG.draws} draws · ${(DBG.tris / 1000) | 0}k tris · s=${s.toFixed(0)} · ${world.level?.id ?? '?'} ${world.hash?.() ?? ''}`; + } } requestAnimationFrame(frame); } if (flags.dbg && !flags.shots) dbgEl.style.display = 'block'; requestAnimationFrame(frame); -export { scene, camera, renderer, bus, flags, world }; // read-only handles for lanes +export { scene, camera, renderer, bus, flags, world, player, combat, assets }; diff --git a/web/js/stub/world_stub.js b/web/js/stub/world_stub.js index 3aff052..e5454f1 100644 --- a/web/js/stub/world_stub.js +++ b/web/js/stub/world_stub.js @@ -44,7 +44,9 @@ const BIOME = { // mirrors ART_BIBLE esophagus row; A's biomes.js is the real const WAVE_A = 0.9; // peristalsis displacement amplitude (units, inward) const WAVE_K = 0.22; // spatial frequency (rad/unit along s) -const WAVE_W = 3.0; // angular speed (rad/s) +const CREST = 1.6; // crest phase speed = CREST × flow. Round-2 ruling: must beat + // throttleMax (1.4) or surfing loses to throttle-mashing (Lane B, + // round 1). ω = k · CREST · flow, so at flow 14: 22.4 u/s. const SKIN = 0.6; // collision safety margin export async function createWorld(levelData = STUB_LEVEL, { rng } = {}) { @@ -64,7 +66,8 @@ export async function createWorld(levelData = STUB_LEVEL, { rng } = {}) { return { pos: centre(s), tan, nor, bin, radius: radiusAt(s) }; } const radiusAt = (s) => R * (1 + seg.radius.wobble * Math.sin(s * 0.05) * Math.sin(s * 0.013 + 2.1)); - const flowPulse = (s, t) => Math.pow(Math.max(0, Math.sin(WAVE_K * s - WAVE_W * t)), 3); + const waveW = WAVE_K * CREST * seg.flow; // crest phase speed ω/k = CREST × flow + const flowPulse = (s, t) => Math.pow(Math.max(0, Math.sin(WAVE_K * s - waveW * t)), 3); // --- geometry: rings along s, uv = (theta/2pi, s), inward-facing --- // seam ring vertex is duplicated (RADIAL+1 columns) so uv.x runs 0..1 without wrapping @@ -114,7 +117,7 @@ export async function createWorld(levelData = STUB_LEVEL, { rng } = {}) { uniform float uTime; void main() { vUv = uv; - float pulse = pow(max(0.0, sin(${WAVE_K.toFixed(3)} * uv.y - ${WAVE_W.toFixed(2)} * uTime)), 3.0); + float pulse = pow(max(0.0, sin(${WAVE_K.toFixed(3)} * uv.y - ${waveW.toFixed(3)} * uTime)), 3.0); float breathe = 0.15 * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831 * 3.0); vec3 disp = position + aInward * (${WAVE_A.toFixed(2)} * pulse + breathe); vec4 mv = modelViewMatrix * vec4(disp, 1.0); @@ -135,6 +138,7 @@ export async function createWorld(levelData = STUB_LEVEL, { rng } = {}) { float d = length(vView); col = mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)); gl_FragColor = vec4(col, 1.0); + #include }`, }); @@ -151,6 +155,7 @@ export async function createWorld(levelData = STUB_LEVEL, { rng } = {}) { group, sample, flowPulse: (s, t = time) => flowPulse(s, t), + crestSpeed: (_s) => CREST * seg.flow, // u/s the crest travels at s (contract v1.1) project(v) { const s = THREE.MathUtils.clamp(-v.z, 0, LEN); const f = sample(s);