[lane A] Round 1: the canal v0 — spline, streaming tube, scanner wall, arenas
Implements THE WORLD CONTRACT (TECH.md) in web/js/world/; drop-in for the stub. boot.js now runs this on C's L2_esophagus (3600u, hash cefc4f83), zero console errors. - spline.js: centreline + arclength LUTs + parallel-transport frames + radius law + peristalsis phase + project() + hash(). No three.js import, so qa can run its 14-assertion selfcheck headless. - curviness is solved from a curvature budget, not authored as an amplitude: the pinch guard caught the tube folding through itself on a wide+curvy join (turn radius 9.9 vs tube radius 17.2). C can no longer author a pinch. - peristalsis: global OMEGA, k(s) = OMEGA/flow(s), phase K(s) = integral k ds. A crest therefore travels at exactly the local flow speed, so "surf the crest" == "ride the current". Esophagus wave amp 0.9 -> 1.4 (measured): moves wallRho for Lane B. - tube.js: chunked streaming, seams aligned to biome joins, arenas own their s-span. - arena.js: displaced icosphere shells v0 (three's polyhedron detail is 20*(detail+1)^2, not 20*4^detail — 720 tris was far too coarse); fog sized to the room, since biome fog tuned for 100u corridors renders C's 360u acid sea as a black rectangle. - index.js: prefers assets.texture() over get() (get returns raw JSON a shader can't eat), plus an explicit slug map — D's wall_smallint_a vs biome id small_intestine would miss silently forever under the assets-optional law. Measured (C's real L2 + D's real pack, 1920x1080, renderer.info): 8 draws worst frame (budget 150), 74k tris (500k), no geometry leak across a full sweep, <120ms load, pinch ratio 3.32. fps not claimed — the screenshot pane runs tabs hidden, pausing rAF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0a038582a7
commit
689c478a7f
193
docs/LANES/LANE_A_NOTES.md
Normal file
193
docs/LANES/LANE_A_NOTES.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# LANE A — NOTES (round 1)
|
||||||
|
|
||||||
|
World v0 is in and **the real game boots it**: `http://localhost:8140/?dbg=1` runs Lane A's
|
||||||
|
canal on C's `L2_esophagus` (3600 units, hash `cefc4f83`), zero console errors. No boot.js
|
||||||
|
change was needed — C's `levels/index.js` landing is what unblocked `pickWorld()`.
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
`web/js/world/` — nothing outside it was touched.
|
||||||
|
|
||||||
|
| file | what |
|
||||||
|
|---|---|
|
||||||
|
| `spline.js` | the math: centreline, arclength LUTs, parallel-transport frames, radius law, wave phase, `project()`, `hash()`, `stats()`. **Imports no three.js** → `node web/js/world/spline.js --selfcheck` runs headless (14 assertions). |
|
||||||
|
| `biomes.js` | palette/param registry (ART_BIBLE values). C ratified this as the biome-id source of truth. |
|
||||||
|
| `wall_material.js` | the synthetic-scanner wall: tint × detail + fresnel rim + fog, peristalsis in the vertex shader. |
|
||||||
|
| `tube.js` | chunked extrusion + streaming window. |
|
||||||
|
| `arena.js` | displaced-icosphere shells (the stretch goal), v0. |
|
||||||
|
| `flycam.js` | `?fly=1` noclip. **Not wired** — snippet for F below. |
|
||||||
|
| `index.js` | `createWorld()` — THE WORLD CONTRACT, drop-in for the stub. |
|
||||||
|
| `dev.html` | my harness. Dev-only; delete when boot covers it. |
|
||||||
|
| `_fixture.js` | dev fixture level. Delete when C's levels cover every case (arenas: L3 does now). |
|
||||||
|
|
||||||
|
Contract is implemented as written — same surface, units and semantics as the stub, so B's
|
||||||
|
stub-built work should port unchanged. Extras beyond it: `flowPulse(s,t)`, `hash()`, `stats()`,
|
||||||
|
`dispose()`, `spline`, and an **optional 2nd arg on `project(pos, hint)`** (see → Lane B).
|
||||||
|
|
||||||
|
## Measured (say how you measured it — so: how)
|
||||||
|
|
||||||
|
All from `dev.html` on C's real `L2_esophagus` with D's real pack, 1920×1080, via
|
||||||
|
`renderer.info` (`DBG.pose()` / the sweep in `DBG.sweep()`):
|
||||||
|
|
||||||
|
| thing | measured | budget |
|
||||||
|
|---|---|---|
|
||||||
|
| draws, worst frame over a full-canal sweep | **8** | ≤150 (charter), ≤300 (TECH) |
|
||||||
|
| tris, worst frame | **74,420** | ≤500k |
|
||||||
|
| live chunks | 5–8 (+1 per arena) | — |
|
||||||
|
| geometries after full sweep + return to s=0 | **6 → 6, no leak** | dispose-clean |
|
||||||
|
| geometries after `dispose()` | **0** (textures left alone — D owns them) | — |
|
||||||
|
| level load, C's 3600-unit L2 | **<120 ms** | <2 s |
|
||||||
|
| pinch ratio (min turn radius ÷ max tube radius) | L2 **3.32**, L3 **1.89**, fixture 2.37 | >1.5 |
|
||||||
|
|
||||||
|
**fps is NOT measured and I'm not claiming it.** Screenshot tooling drives the tab hidden,
|
||||||
|
which pauses rAF; `gl.finish()` didn't sync either (it reported 0.12 ms/frame = 8219 fps,
|
||||||
|
which is obviously junk). Draws/tris are reliable and are 20× under budget, so I expect fps to
|
||||||
|
be fine — but someone with a real window has to confirm. → Lane F, at round end.
|
||||||
|
|
||||||
|
Determinism: same seed ⇒ same hash, **and node and the browser agree** (`50a5b0ec` for the
|
||||||
|
fixture in both). Different seed ⇒ different hash. Both asserted in the selfcheck.
|
||||||
|
|
||||||
|
## Decisions (mine to make; flagging the ones that touch you)
|
||||||
|
|
||||||
|
1. **`curviness` = fraction of the tightest bend this pipe can survive**, not an amplitude.
|
||||||
|
Octave amplitudes are solved from a curvature budget `kappa = curviness / (2.2 × baseRadius)`.
|
||||||
|
Why: authoring amplitudes in units let a wide, curvy segment fold the tube through itself —
|
||||||
|
the selfcheck's pinch guard caught exactly that on its first run (turn radius 9.9 vs tube
|
||||||
|
radius 17.2). Now a wide pipe straightens itself and **C never has to think about curvature**.
|
||||||
|
C's 0.15–0.95 range works as documented; nothing to change.
|
||||||
|
2. **Peristalsis: global `OMEGA = 3.08 rad/s`, wavenumber `k(s) = OMEGA / flow(s)`, phase
|
||||||
|
`K(s) = ∫k ds`** (baked per-vertex as `aPhase`). Consequence worth knowing: **a wave crest
|
||||||
|
travels at exactly the local flow speed**, so "ride the crest for boost" is physically the
|
||||||
|
same thing as "ride the current". Plain `k·s − ω·t` tears the wave at every flow change;
|
||||||
|
`K(s)` doesn't. For a flow-14 esophagus this reproduces the stub's wave exactly (k = 0.22).
|
||||||
|
3. **Esophagus `wave.amp` 0.9 → 1.4** (`biomes.js`). The stub's 0.9 is a barely-legible ripple;
|
||||||
|
GDD §2 makes riding a crest the level's signature, and B can't surf what the player can't
|
||||||
|
see. 1.4 reads as distinct rings and still leaves ~78% of a radius-10 tube flyable. Measured
|
||||||
|
in the harness against 0.9 and 3.2. **This moves `wallRho`** → Lane B.
|
||||||
|
4. **Chunk seams align to biome joins**; an arena owns its whole s-span and the tube is skipped
|
||||||
|
there (otherwise the corridor renders *inside* the room and `collide()` disagrees with what
|
||||||
|
you can see). Seam is chunk-quantised ±20u — fine until sphincter geometry exists.
|
||||||
|
5. **Arena fog is sized to the room**, not the biome: `min(biome.fog, 1.09/radius)`. Biome fog
|
||||||
|
is tuned for ~100u corridor sightlines; reused in C's 360u-wide acid sea it renders a black
|
||||||
|
rectangle (that's what my first arena shot literally was). Small lairs keep the murk.
|
||||||
|
6. **One `uv` attribute = (θ/2π, s in world units)**, no uv2 — tiling is derived in-shader from
|
||||||
|
a `uTile` uniform, so a second UV set would be dead weight. Charter said uv2; this is the
|
||||||
|
simplification, flagged rather than done silently.
|
||||||
|
7. **Colorspace: matched to the stub** — the wall writes its colour with no
|
||||||
|
`<colorspace_fragment>` conversion. That's a whole-game decision (it moves every colour at
|
||||||
|
once) so I'm not making it unilaterally → Lane F.
|
||||||
|
|
||||||
|
## Known limitations (v0, stated plainly)
|
||||||
|
|
||||||
|
- **Arena pole pinch.** Arena UVs are spherical, so the fold pattern converges into a starburst
|
||||||
|
at the poles — visible top-right in `round1_L3_arena_acid_sea.png`. A real texture will pinch
|
||||||
|
there too. Round 2: triplanar projection for arena shells.
|
||||||
|
- **Arena shape.** A sphere is the wrong shape for a stomach and C says so in their own
|
||||||
|
`L3` note. Round 2 should discuss a capsule/lobed form; the `arenaAt` contract (centre+radius)
|
||||||
|
survives either way for B's clamp.
|
||||||
|
- **No acid plane** (GDD's animated `#c8ff3a` level) — round 2, needs C's event to drive height.
|
||||||
|
- **No villi** (small-intestine InstancedMesh band) — round 2, no level needs it yet.
|
||||||
|
- **No matcap / normal map.** D ships `normal` maps already; using them needs a TBN in the wall
|
||||||
|
shader. Round 2. Untested code paths are worse than absent ones.
|
||||||
|
- **`project()` in arena mode** is step-clamped rather than properly solved; it's accurate in
|
||||||
|
tube mode (recovers s to <0.25, θ to <0.02 rad — asserted). Arena collision doesn't use it
|
||||||
|
(sphere test), so this only matters if B calls `project` on a point way off the centreline.
|
||||||
|
- Segment param blending lags the march by one 0.5u step. Deterministic, sub-step, harmless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## → Lane F
|
||||||
|
|
||||||
|
1. **Pass `assets` into `createWorld`.** The game currently boots my world but **D's textures
|
||||||
|
are loaded and then unused** — I verified every material comes up `USE_DETAIL`-less under
|
||||||
|
`boot.js`. In `pickWorld()`:
|
||||||
|
```js
|
||||||
|
const mod = await import('./world/index.js');
|
||||||
|
const level = await loadLevel(flags.lvl);
|
||||||
|
const assets = await (await import('./core/assets.js')).createAssets({ flags, renderer });
|
||||||
|
return mod.createWorld(level, { rng: createRng(flags.seed ?? level.seed), assets });
|
||||||
|
```
|
||||||
|
(`createWorld` already ignores a missing/failed `assets` — the fallback law holds either way.)
|
||||||
|
2. **Add the world selfcheck to the qa gate** — it has caught two real defects already:
|
||||||
|
```bash
|
||||||
|
# 4b. Lane A world math (headless; no three.js needed)
|
||||||
|
node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed"
|
||||||
|
```
|
||||||
|
3. **Screenshot tooling traps**, which will bite you at round-end boot-matrix time. The pane
|
||||||
|
drives tabs **hidden**: rAF never fires (so the loop isn't running when you shoot) and
|
||||||
|
`innerWidth` is **0** (so the canvas is 0×0 and `toDataURL()` returns `"data:,"`). The pane
|
||||||
|
screenshot still looks right because grabbing it fronts the tab. `DBG.shot()`'s anchor-click
|
||||||
|
download also silently no-ops here. My harness works around all three in `DBG.size/pose/poseAt`
|
||||||
|
— worth lifting into `boot.js`'s DBG. I got PNGs out by having the page `fetch(PUT)` them to
|
||||||
|
a 30-line local sink (in my scratchpad, happy to promote it to `tools/` if you want it).
|
||||||
|
4. **Dev port**: 8140 was already held by another session's server, and `launch.json` is yours,
|
||||||
|
so I left it alone and reused the running one. If lanes are meant to run concurrently on one
|
||||||
|
box this needs a call.
|
||||||
|
5. Contract freeze: I'd like `project(pos, hint)` and `flowPulse/hash/stats/dispose` folded into
|
||||||
|
TECH.md's world contract, and `assets.texture()` added to the asset contract (see → Lane D).
|
||||||
|
|
||||||
|
## → Lane B
|
||||||
|
|
||||||
|
- **`wallRho` shrank**: it subtracts `wave.amp + wave.breathe + 0.6` and esophagus `wave.amp`
|
||||||
|
is now 1.4 (was 0.9). Playable radius in a base-10 tube ≈ **7.85**. Re-check your graze
|
||||||
|
distances. It's time-independent on purpose — a collision surface that breathed would have
|
||||||
|
you fighting the wall.
|
||||||
|
- **`project(pos, hint)`** — pass your previous `s` as `hint` and it's near-free and exact. With
|
||||||
|
no hint it seeds from `-pos.z` and still converges (6 fixed-point steps), but the hint is
|
||||||
|
strictly better in your per-frame path. Both are asserted in the selfcheck.
|
||||||
|
- **Crest speed == `biomeAt(s).flow`**, exactly, by construction. `world.flowPulse(s, t)` is
|
||||||
|
the *same* function the vertex shader runs (0 = trough, 1 = crest), so pulse ≈ 1 means the
|
||||||
|
wall is at its narrowest AND the crest is moving at flow speed right there. Boost window.
|
||||||
|
- `biomeAt(s).flow` is **blended across segment joins**, not stepped — safe to read per-frame.
|
||||||
|
- Arena mode: `modeAt(s)` / `arenaAt(s)` → `{center, radius}` for your 6DOF clamp. `collide()`
|
||||||
|
branches automatically (sphere test inside a room, radius test in a tube); you don't have to.
|
||||||
|
- `sample()` allocates fresh Vector3s per call, same as the stub. If that shows up in your
|
||||||
|
profile, ask and I'll add `sampleInto(s, out)`.
|
||||||
|
|
||||||
|
## → Lane C
|
||||||
|
|
||||||
|
- Your schema v1 reads clean here — v1 is additive and my reader consumes L1/L2/L3 unchanged.
|
||||||
|
Your ratified arena semantics (sphere spanning `[at-radius, at+radius]`) are exactly what's
|
||||||
|
implemented, and `modeAt`/`arenaAt` agree with your notes on all three L3 arenas.
|
||||||
|
- **Biome ids are the registry in `world/biomes.js`**: `oral · esophagus · stomach ·
|
||||||
|
small_intestine · large_intestine · appendix`. Unknown id ⇒ neutral fallback + one warning,
|
||||||
|
never a crash. Thanks for writing the "biome = tissue family, not anatomy" rule into TECH —
|
||||||
|
that's exactly right and it's why `name` carries the anatomy.
|
||||||
|
- `curviness` behaves as you documented (0 straight … 1 writhing). Under the hood 1.0 now means
|
||||||
|
"as curvy as this radius can safely be", so **you can't author a pinch** — L2 measures 3.32×
|
||||||
|
clear, L3 1.89×. Author freely.
|
||||||
|
- Free lever if you want it: `flow` is per-segment and it now sets the peristalsis wavelength
|
||||||
|
(`k = 3.08/flow`). Low flow ⇒ tighter, slower rings. A "sluggish" segment reads sluggish.
|
||||||
|
- Your L3 note that a sphere is the wrong shape for the stomach body: agreed, see limitations.
|
||||||
|
|
||||||
|
## → Lane D
|
||||||
|
|
||||||
|
- **Naming mismatch, silent by construction.** Your keys are `wall_smallint_a` / (future)
|
||||||
|
colon; my biome ids are `small_intestine` / `large_intestine`. `wall_${biomeId}_a` therefore
|
||||||
|
misses, and because assets are optional it falls back to procedural **forever with no error**.
|
||||||
|
I've added an explicit slug map in `index.js` so it works today. Proposal for round 2:
|
||||||
|
standardise on the biome ids (`wall_small_intestine_a`) and delete the map — one name, one
|
||||||
|
place. Your call, just let's not leave it implicit.
|
||||||
|
- **Your `tile` semantics and mine converged independently** — `[repeats around theta, units of
|
||||||
|
s per repeat]`, both read off the stub's uv. Ratified from my side; `[4,16]` looks right in
|
||||||
|
engine (`round1_L2_textured.png`).
|
||||||
|
- **`texture()` is the useful entry point, not `get()`.** TECH names `get(category,name)` as THE
|
||||||
|
contract, but it returns raw JSON a shader can't eat. I prefer `assets.texture(name)` and keep
|
||||||
|
`get` duck-typed as the documented floor. Suggest F promotes `texture()` into TECH.
|
||||||
|
- FYI: tiling is applied **in-shader** from `tile`. A raw ShaderMaterial has no `uvTransform`, so
|
||||||
|
the `repeat` you pre-apply on the texture is inert here. Not a problem — just don't count on it.
|
||||||
|
- `wall_esophagus_a` looks genuinely good in engine. The `_b` variants and matcap aren't wired
|
||||||
|
yet (round 2, with the TBN work).
|
||||||
|
|
||||||
|
## → Lane E
|
||||||
|
|
||||||
|
- `world.flowPulse(s, t)` (0..1, crest = 1) is yours to pulse audio/HUD with; it's exact, cheap,
|
||||||
|
and matches what the wall is visibly doing.
|
||||||
|
- `world.biomeAt(s)` → `{id, palette, fog, flow, coatDrain}`. `palette.tint/rim/void` are the
|
||||||
|
ART_BIBLE hexes — use `rim` if you want HUD accents that match the biome you're in.
|
||||||
|
|
||||||
|
## Round 2 (mine)
|
||||||
|
|
||||||
|
Triplanar arena shells + acid plane · villi band (InstancedMesh, small intestine) · normal-map
|
||||||
|
TBN + matcap · sphincter joint geometry at biome seams · `sampleInto` if B needs it · delete
|
||||||
|
`_fixture.js`/`dev.html` once boot + C's levels cover them.
|
||||||
46
docs/progress/a-progress.md
Normal file
46
docs/progress/a-progress.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Lane A — progress log
|
||||||
|
|
||||||
|
## 2026-07-16 · Round 1 — the canal, v0
|
||||||
|
|
||||||
|
Built `web/js/world/`: spline + parallel-transport frames + arclength LUTs, chunked streaming
|
||||||
|
tube, synthetic-scanner wall shader with vertex peristalsis, biome registry, arena shells v0,
|
||||||
|
noclip flycam, headless selfcheck, dev harness. Contract per TECH.md §THE WORLD CONTRACT,
|
||||||
|
drop-in for the stub.
|
||||||
|
|
||||||
|
**The game boots it.** `/?dbg=1` (no `?stub`) runs Lane A's world on C's `L2_esophagus`,
|
||||||
|
3600 units, hash `cefc4f83`, zero console errors. C's registry landing mid-round is what
|
||||||
|
unblocked `boot.js`'s `pickWorld()`; no shell change was needed.
|
||||||
|
|
||||||
|
Measured (dev.html, C's real L2 + D's real pack, 1920×1080, `renderer.info`):
|
||||||
|
draws **8** worst-frame over a full-canal sweep (budget ≤150) · tris **74,420** (≤500k) ·
|
||||||
|
geometries 6 → 6 across a full sweep and back, **no leak**, 0 after `dispose()` ·
|
||||||
|
load **<120 ms** (<2 s) · pinch ratio L2 3.32 / L3 1.89 (>1.5). fps deliberately **not**
|
||||||
|
claimed — the screenshot pane runs tabs hidden, which pauses rAF, and `gl.finish()` didn't
|
||||||
|
sync; needs a real window. Determinism: node and browser agree on the hash.
|
||||||
|
|
||||||
|
Three defects the checks caught that eyeballing would not have:
|
||||||
|
- **Tube folded through itself** on a wide+curvy join (turn radius 9.9 vs tube radius 17.2).
|
||||||
|
Root cause: centreline amplitude authored in units. Fixed by solving amplitudes from a
|
||||||
|
curvature budget, so `curviness` means "fraction of the pinch limit" and C can't author one.
|
||||||
|
- **Arena at 720 tris** — three's polyhedron `detail` is `20*(detail+1)^2`, not `20*4^detail`.
|
||||||
|
Now solved for ~3u spacing from the arena's own radius.
|
||||||
|
- **Acid sea rendered as a black rectangle** — biome fog is tuned for 100u corridors, C's arena
|
||||||
|
is 360u across. Arena fog is now sized to the room.
|
||||||
|
|
||||||
|
Also caught, and both would have rotted silently: the browser served a **stale cached
|
||||||
|
`biomes.js`**, so the first evidence set was shot at the old wave amplitude (re-shot); and D's
|
||||||
|
texture keys (`wall_smallint_a`) don't match my biome ids (`small_intestine`), which under the
|
||||||
|
assets-optional law fails to procedural forever with no error (slug map added, standardisation
|
||||||
|
proposed to D).
|
||||||
|
|
||||||
|
Decisions: peristalsis phase `K(s) = ∫k ds` with `k = OMEGA/flow(s)` ⇒ crest speed == local flow
|
||||||
|
(so "surf the crest" == "ride the current") · esophagus wave amp 0.9 → 1.4, measured, moves
|
||||||
|
`wallRho` for B · chunk seams align to biome joins, arenas own their span · one uv attribute,
|
||||||
|
tiling derived in-shader · colorspace left matching the stub, F to rule.
|
||||||
|
|
||||||
|
Evidence → `docs/shots/laneA/`: `round1_real_game_boot` (boot.js running Lane A's world),
|
||||||
|
`round1_L2_textured` / `round1_L2_procedural_fallback` (same pose, with and without D's pack —
|
||||||
|
the assets-optional law), `round1_L2_curve`, `round1_L2_wave_crest`, `round1_L3_arena_acid_sea`.
|
||||||
|
|
||||||
|
Open for round 2: triplanar arena shells + acid plane, villi band, normal/matcap TBN, sphincter
|
||||||
|
seam geometry. Full detail + per-lane asks in `LANE_A_NOTES.md`.
|
||||||
BIN
docs/shots/laneA/round1_L2_curve.png
Normal file
BIN
docs/shots/laneA/round1_L2_curve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/shots/laneA/round1_L2_procedural_fallback.png
Normal file
BIN
docs/shots/laneA/round1_L2_procedural_fallback.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/shots/laneA/round1_L2_textured.png
Normal file
BIN
docs/shots/laneA/round1_L2_textured.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneA/round1_L2_wave_crest.png
Normal file
BIN
docs/shots/laneA/round1_L2_wave_crest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneA/round1_L3_arena_acid_sea.png
Normal file
BIN
docs/shots/laneA/round1_L3_arena_acid_sea.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/shots/laneA/round1_real_game_boot.png
Normal file
BIN
docs/shots/laneA/round1_real_game_boot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
17
web/js/world/_fixture.js
Normal file
17
web/js/world/_fixture.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// world/_fixture.js (Lane A) — DEV FIXTURE, not shipping content.
|
||||||
|
// Lane C owns real levels (js/levels/*.json). This exists so spline.js's headless selfcheck
|
||||||
|
// and dev.html can exercise the world before/without C's data: multi-segment, curviness
|
||||||
|
// sweep, a biome change, and an arena. Delete the day C's L2 + registry land.
|
||||||
|
|
||||||
|
export const FIXTURE = {
|
||||||
|
id: 'A_FIXTURE',
|
||||||
|
name: 'Lane A fixture canal',
|
||||||
|
seed: 20260716,
|
||||||
|
segments: [
|
||||||
|
{ biome: 'esophagus', length: 300, radius: { base: 10, wobble: 0.25 }, curviness: 0.55, flow: 14 },
|
||||||
|
{ biome: 'esophagus', length: 240, radius: { base: 12, wobble: 0.35 }, curviness: 0.95, flow: 11 },
|
||||||
|
{ biome: 'stomach', length: 120, radius: { base: 16, wobble: 0.30 }, curviness: 0.20, flow: 4 },
|
||||||
|
],
|
||||||
|
arenas: [{ at: 620, radius: 55, biome: 'stomach' }],
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
119
web/js/world/arena.js
Normal file
119
web/js/world/arena.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// world/arena.js (Lane A) — arena shells, v0. A displaced icosphere wearing the same wall
|
||||||
|
// shader as the tube, so the mouth/stomach/boss lairs are the same material world as the
|
||||||
|
// corridors (ART_BIBLE) and Lane B's 6DOF clamp has a real `arenaAt` to clamp against.
|
||||||
|
//
|
||||||
|
// v0 scope, stated plainly: the shell + its bounds. The stomach's animated acid plane
|
||||||
|
// (GDD §3, emissive #c8ff3a, height driven by C's events) is round 2 — see LANE_A_NOTES.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/** Seeded 3D value noise. Same lattice trick as spline.js: a table, not a hash function. */
|
||||||
|
function makeNoise3(rng) {
|
||||||
|
const N = 64, tab = new Float32Array(N * N * N);
|
||||||
|
const r = rng('world.arena');
|
||||||
|
for (let i = 0; i < tab.length; i++) tab[i] = r() * 2 - 1;
|
||||||
|
const at = (x, y, z) => tab[(((x & 63) * N + (y & 63)) * N + (z & 63))];
|
||||||
|
const fade = (t) => t * t * (3 - 2 * t);
|
||||||
|
const lerp = (a, b, t) => a + (b - a) * t;
|
||||||
|
function vnoise(x, y, z) {
|
||||||
|
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
||||||
|
const xf = fade(x - xi), yf = fade(y - yi), zf = fade(z - zi);
|
||||||
|
const c00 = lerp(at(xi, yi, zi), at(xi + 1, yi, zi), xf);
|
||||||
|
const c10 = lerp(at(xi, yi + 1, zi), at(xi + 1, yi + 1, zi), xf);
|
||||||
|
const c01 = lerp(at(xi, yi, zi + 1), at(xi + 1, yi, zi + 1), xf);
|
||||||
|
const c11 = lerp(at(xi, yi + 1, zi + 1), at(xi + 1, yi + 1, zi + 1), xf);
|
||||||
|
return lerp(lerp(c00, c10, yf), lerp(c01, c11, yf), zf);
|
||||||
|
}
|
||||||
|
return (x, y, z) => {
|
||||||
|
let sum = 0, amp = 1, f = 1, n = 0;
|
||||||
|
for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * f, y * f, z * f); n += amp; amp *= 0.5; f *= 2.07; }
|
||||||
|
return sum / n;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} spec level `arenas[]` entry: { at, radius, biome }
|
||||||
|
* @param {object} spline
|
||||||
|
* @param {THREE.Material} material a wall material built for this arena's biome
|
||||||
|
* @param {function} rng
|
||||||
|
*/
|
||||||
|
export function createArena({ spec, spline, material, rng, quality = 'high' }) {
|
||||||
|
// three's polyhedron `detail` splits each edge into (detail+1) segments, so face count is
|
||||||
|
// 20*(detail+1)^2 — NOT 20*4^detail. detail:5 is 720 tris, which on a 55-unit room is a
|
||||||
|
// 10-unit facet and the fbm displacement has nothing to displace. Solve for ~3u spacing
|
||||||
|
// instead (icosahedron edge ~ 1.05r), so arena cost tracks arena size.
|
||||||
|
const spacing = quality === 'low' ? 6 : 3;
|
||||||
|
const detail = Math.max(3, Math.min(24, Math.round((1.05 * spec.radius) / spacing) - 1));
|
||||||
|
const noise = makeNoise3(rng);
|
||||||
|
const f = spline.frameAt(spec.at);
|
||||||
|
const center = new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z);
|
||||||
|
|
||||||
|
const geo = new THREE.IcosahedronGeometry(spec.radius, detail); // non-indexed
|
||||||
|
const pos = geo.attributes.position;
|
||||||
|
const n = pos.count;
|
||||||
|
const position = new Float32Array(n * 3);
|
||||||
|
const aInward = new Float32Array(n * 3);
|
||||||
|
const aTangent = new Float32Array(n * 3);
|
||||||
|
const uv = new Float32Array(n * 2);
|
||||||
|
const aPhase = new Float32Array(n);
|
||||||
|
const aK = new Float32Array(n);
|
||||||
|
|
||||||
|
// The churn wave crosses the room along the canal's own axis, slowly enough to read as a
|
||||||
|
// room breathing rather than a corridor's transit wave.
|
||||||
|
const k = 3.08 / Math.max(4, spec.radius / 5);
|
||||||
|
const axis = new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z);
|
||||||
|
const ref = new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z);
|
||||||
|
const bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z);
|
||||||
|
const amp = spec.radius * 0.09;
|
||||||
|
const v = new THREE.Vector3();
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
v.fromBufferAttribute(pos, i);
|
||||||
|
const dir = v.clone().normalize();
|
||||||
|
const r = spec.radius + amp * noise(dir.x * 2.3 + 11, dir.y * 2.3 + 5, dir.z * 2.3 + 3);
|
||||||
|
const p = dir.clone().multiplyScalar(r);
|
||||||
|
position[i * 3] = p.x; position[i * 3 + 1] = p.y; position[i * 3 + 2] = p.z;
|
||||||
|
aInward[i * 3] = -dir.x; aInward[i * 3 + 1] = -dir.y; aInward[i * 3 + 2] = -dir.z;
|
||||||
|
aTangent[i * 3] = axis.x; aTangent[i * 3 + 1] = axis.y; aTangent[i * 3 + 2] = axis.z;
|
||||||
|
const along = p.dot(axis);
|
||||||
|
uv[i * 2] = (Math.atan2(p.dot(bin), p.dot(ref)) / (Math.PI * 2)) + 0.5;
|
||||||
|
uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube
|
||||||
|
aPhase[i] = k * (spec.at + along);
|
||||||
|
aK[i] = k;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it
|
||||||
|
// from ~1 back to ~0 and the wall shader's fold pattern crams a full cycle into that one
|
||||||
|
// triangle — a zigzag scar down the room. The geometry is non-indexed, so each triangle owns
|
||||||
|
// its three vertices and we can just push the low ones past the wrap.
|
||||||
|
for (let t = 0; t < n; t += 3) {
|
||||||
|
let lo = Infinity, hi = -Infinity;
|
||||||
|
for (let j = 0; j < 3; j++) { const x = uv[(t + j) * 2]; lo = Math.min(lo, x); hi = Math.max(hi, x); }
|
||||||
|
if (hi - lo > 0.5) for (let j = 0; j < 3; j++) if (uv[(t + j) * 2] < 0.5) uv[(t + j) * 2] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute('position', new THREE.BufferAttribute(position, 3));
|
||||||
|
g.setAttribute('aInward', new THREE.BufferAttribute(aInward, 3));
|
||||||
|
g.setAttribute('aTangent', new THREE.BufferAttribute(aTangent, 3));
|
||||||
|
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||||
|
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||||
|
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
||||||
|
g.computeBoundingSphere();
|
||||||
|
geo.dispose(); // the source icosphere was scaffolding
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(g, material); // material built with side: BackSide
|
||||||
|
mesh.position.copy(center);
|
||||||
|
mesh.name = `arena ${spec.biome} @${spec.at}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
spec,
|
||||||
|
mesh,
|
||||||
|
center,
|
||||||
|
radius: spec.radius,
|
||||||
|
/** Conservative inner surface: shell minus displacement peak minus the shader's wave. */
|
||||||
|
innerRadius: spec.radius - amp - (material.uniforms?.uWaveA?.value ?? 0) - 0.6,
|
||||||
|
covers: (s) => Math.abs(s - spec.at) <= spec.radius,
|
||||||
|
dispose() { g.dispose(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
85
web/js/world/biomes.js
Normal file
85
web/js/world/biomes.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// world/biomes.js (Lane A) — the biome registry. Palette/fog values are transcribed from
|
||||||
|
// ART_BIBLE.md §Biome palettes; gameplay defaults (flow, coatDrain) are sane seeds that
|
||||||
|
// Lane C overrides per-segment in level JSON (`segments[].flow`). C owns the tuning, this
|
||||||
|
// owns the look.
|
||||||
|
//
|
||||||
|
// Canonical ids — level JSON `segments[].biome` must be one of these:
|
||||||
|
// oral · esophagus · stomach · small_intestine · large_intestine · appendix
|
||||||
|
// Unknown id => NEUTRAL fallback + one console warning, never a crash.
|
||||||
|
//
|
||||||
|
// `wave.amp` is displacement in units, inward, at the crest — and it is a GAMEPLAY number, not
|
||||||
|
// just a look: world.wallRho() subtracts (amp + breathe) as its conservative margin, so raising
|
||||||
|
// it narrows Lane B's flight envelope. Esophagus 1.4 was measured in the harness, not guessed:
|
||||||
|
// the stub's 0.9 renders as a barely-legible ripple, and GDD §2 makes riding a crest for boost
|
||||||
|
// the level's signature mechanic — B cannot surf a crest the player can't see. 1.4 reads as
|
||||||
|
// distinct rings and still leaves ~78% of a radius-10 tube flyable. The other biomes are scaled
|
||||||
|
// in proportion and are UNVERIFIED until each one is actually built and eyeballed.
|
||||||
|
|
||||||
|
export const BIOMES = {
|
||||||
|
oral: {
|
||||||
|
id: 'oral',
|
||||||
|
palette: { tint: 0xa8c4d8, rim: 0xdff2ff, void: 0x06090e },
|
||||||
|
fog: 0.016, // clinical, bright, tutorial-readable => you can see across the cavity
|
||||||
|
flow: 5,
|
||||||
|
coatDrain: 0.15,
|
||||||
|
wave: { amp: 0.5, breathe: 0.10 }, // saliva tides, not real peristalsis
|
||||||
|
},
|
||||||
|
esophagus: {
|
||||||
|
id: 'esophagus',
|
||||||
|
palette: { tint: 0x1e6e64, rim: 0x5affd2, void: 0x02100d },
|
||||||
|
fog: 0.028,
|
||||||
|
flow: 14,
|
||||||
|
coatDrain: 0.5,
|
||||||
|
wave: { amp: 1.4, breathe: 0.15 }, // the signature: ribbed rings rushing past (measured)
|
||||||
|
},
|
||||||
|
stomach: {
|
||||||
|
id: 'stomach',
|
||||||
|
palette: { tint: 0xb0571e, rim: 0xffb13a, void: 0x120801 },
|
||||||
|
fog: 0.020,
|
||||||
|
flow: 4,
|
||||||
|
coatDrain: 2.2, // the acid sea: brutal in open acid (GDD §Player systems)
|
||||||
|
wave: { amp: 0.7, breathe: 0.25 }, // churn, not transit
|
||||||
|
},
|
||||||
|
small_intestine: {
|
||||||
|
id: 'small_intestine',
|
||||||
|
palette: { tint: 0x7a2a6e, rim: 0xff6ad5, void: 0x0d0312 },
|
||||||
|
fog: 0.050, // dense: villi forest occludes sightlines
|
||||||
|
flow: 9,
|
||||||
|
coatDrain: 0.8,
|
||||||
|
wave: { amp: 1.5, breathe: 0.18 },
|
||||||
|
},
|
||||||
|
large_intestine: {
|
||||||
|
id: 'large_intestine',
|
||||||
|
palette: { tint: 0x3a4a26, rim: 0x9dff4a, void: 0x050702 },
|
||||||
|
fog: 0.075, // darkness is the mechanic here; scanner light is the safety bubble
|
||||||
|
flow: 5,
|
||||||
|
coatDrain: 0.6,
|
||||||
|
wave: { amp: 1.2, breathe: 0.2 },
|
||||||
|
},
|
||||||
|
appendix: {
|
||||||
|
id: 'appendix',
|
||||||
|
palette: { tint: 0x8a7020, rim: 0xffe066, void: 0x0a0800 },
|
||||||
|
fog: 0.030,
|
||||||
|
flow: 2,
|
||||||
|
coatDrain: 0.3,
|
||||||
|
wave: { amp: 0.5, breathe: 0.12 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const NEUTRAL = {
|
||||||
|
id: 'neutral',
|
||||||
|
palette: { tint: 0x557066, rim: 0x9fd8c8, void: 0x050a08 },
|
||||||
|
fog: 0.03, flow: 10, coatDrain: 0.4, wave: { amp: 0.9, breathe: 0.15 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const warned = new Set();
|
||||||
|
|
||||||
|
export function biome(id) {
|
||||||
|
const b = BIOMES[id];
|
||||||
|
if (b) return b;
|
||||||
|
if (!warned.has(id)) {
|
||||||
|
warned.add(id);
|
||||||
|
console.warn(`[world] unknown biome "${id}" — falling back to neutral. Valid: ${Object.keys(BIOMES).join(', ')}`);
|
||||||
|
}
|
||||||
|
return NEUTRAL;
|
||||||
|
}
|
||||||
226
web/js/world/dev.html
Normal file
226
web/js/world/dev.html
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>GUTS — Lane A world harness</title>
|
||||||
|
<!--
|
||||||
|
Lane A's own harness. NOT the game: js/boot.js is the game, F owns it.
|
||||||
|
|
||||||
|
This exists because boot.js can't reach js/world/index.js yet — pickWorld() imports Lane C's
|
||||||
|
js/levels/index.js in the same try block, and that module doesn't exist, so every non-stub
|
||||||
|
boot falls back to the stub regardless of my work landing. Patch offered in LANE_A_NOTES.md
|
||||||
|
§-> Lane F. Until it's applied, this is how the canal gets eyeballed. Delete it once boot can
|
||||||
|
show the real world.
|
||||||
|
|
||||||
|
http://localhost:8140/js/world/dev.html?dbg=1 rail cam down the fixture canal
|
||||||
|
...?dbg=1&fly=1 noclip (WASD/QE, drag to look)
|
||||||
|
...?dbg=1&s=430 park the rail cam at s=430
|
||||||
|
...?dbg=1&fakeassets=1 exercise the Lane D texture path
|
||||||
|
...?lvl=L2_esophagus C's level, when it lands
|
||||||
|
...?shots=1 hide the overlay
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #02100d; }
|
||||||
|
canvas { display: block; }
|
||||||
|
#dbg { position: fixed; left: 10px; bottom: 10px; color: #39e6ff;
|
||||||
|
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
|
||||||
|
pointer-events: none; white-space: pre; }
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "three": "../../vendor/three.module.js",
|
||||||
|
"three/addons/": "../../vendor/addons/" } }
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="dbg"></div>
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { createRng } from '../core/rng.js';
|
||||||
|
import { createWorld } from './index.js';
|
||||||
|
import { createFlyCam } from './flycam.js';
|
||||||
|
import { FIXTURE } from './_fixture.js';
|
||||||
|
|
||||||
|
const p = new URLSearchParams(location.search);
|
||||||
|
const on = (k) => p.has(k) && p.get(k) !== '0';
|
||||||
|
const num = (k, d) => (p.has(k) ? parseFloat(p.get(k)) : d);
|
||||||
|
|
||||||
|
// --- level: C's if it's landed, fixture otherwise ---------------------------------------
|
||||||
|
let level = FIXTURE;
|
||||||
|
if (p.has('lvl')) {
|
||||||
|
try {
|
||||||
|
const mod = await import('../levels/index.js');
|
||||||
|
level = await mod.getLevel(p.get('lvl'));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[devharness] no Lane C registry yet, using fixture —', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seed = p.has('seed') ? (parseInt(p.get('seed'), 10) >>> 0) : level.seed;
|
||||||
|
|
||||||
|
// --- fake assets (?fakeassets=1) ---------------------------------------------------------
|
||||||
|
// Stands in for Lane D so the USE_DETAIL branch of the wall shader is exercised before their
|
||||||
|
// pack exists: a seamless grayscale fold pattern, same duck-type index.js expects from
|
||||||
|
// assets.get('textures', 'wall_<biome>_a'). Proves the integration, not the art.
|
||||||
|
function fakeAssets() {
|
||||||
|
const S = 256, cv = document.createElement('canvas');
|
||||||
|
cv.width = cv.height = S;
|
||||||
|
const ctx = cv.getContext('2d'), img = ctx.createImageData(S, S), r = createRng(seed)('assets.fake');
|
||||||
|
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
|
||||||
|
const u = x / S, v = y / S; // periodic in both axes => tiles cleanly
|
||||||
|
let g = 0.5 + 0.22 * Math.sin(Math.PI * 2 * 3 * u + 2 * Math.sin(Math.PI * 2 * 2 * v))
|
||||||
|
+ 0.16 * Math.sin(Math.PI * 2 * 5 * v)
|
||||||
|
+ 0.10 * Math.sin(Math.PI * 2 * 11 * u + 1.7);
|
||||||
|
g = Math.max(0, Math.min(1, g + (r() - 0.5) * 0.10));
|
||||||
|
const i = (y * S + x) * 4, b = (g * 255) | 0;
|
||||||
|
img.data[i] = img.data[i + 1] = img.data[i + 2] = b; img.data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
const tex = new THREE.CanvasTexture(cv);
|
||||||
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
|
return { get: (kind, id) => (kind === 'textures' && /^wall_/.test(id) ? { isTexture: false, texture: tex, tile: [3, 14] } : null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shell -------------------------------------------------------------------------------
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
document.body.appendChild(renderer.domElement);
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600);
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
// ?assets=1 uses Lane D's real loader + pack; ?fakeassets=1 uses the synthetic stand-in above.
|
||||||
|
let assets = null;
|
||||||
|
if (on('fakeassets')) assets = fakeAssets();
|
||||||
|
else if (on('assets')) {
|
||||||
|
try {
|
||||||
|
const m = await import('../core/assets.js');
|
||||||
|
assets = await m.createAssets({ flags: { localassets: p.get('localassets') !== '0' } });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[devharness] Lane D assets.js unavailable, procedural walls —', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = await createWorld(level, { rng: createRng(seed), quality: p.get('quality') || 'high', assets });
|
||||||
|
const loadMs = performance.now() - t0;
|
||||||
|
scene.add(world.group);
|
||||||
|
scene.background = new THREE.Color(world.biomeAt(0).palette.void);
|
||||||
|
|
||||||
|
const fly = on('fly') ? createFlyCam({ camera, dom: renderer.domElement, world }) : null;
|
||||||
|
let sCam = num('s', 5);
|
||||||
|
const railSpeed = num('speed', 1) * (p.has('s') ? 0 : 1); // ?s= parks the cam for clean shots
|
||||||
|
|
||||||
|
function rail(dt) {
|
||||||
|
sCam = (sCam + world.biomeAt(sCam).flow * 0.6 * railSpeed * dt) % world.length;
|
||||||
|
const f = world.sample(sCam), ahead = world.sample(Math.min(sCam + 12, world.length));
|
||||||
|
camera.position.copy(f.pos).addScaledVector(f.nor, -f.radius * 0.25);
|
||||||
|
camera.up.copy(f.nor);
|
||||||
|
camera.lookAt(ahead.pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener('resize', () => {
|
||||||
|
camera.aspect = innerWidth / innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbgEl = document.getElementById('dbg');
|
||||||
|
if (!on('dbg') || on('shots')) dbgEl.style.display = 'none';
|
||||||
|
renderer.info.autoReset = false;
|
||||||
|
let frames = 0, fpsT = 0, fps = 0, draws = 0, tris = 0;
|
||||||
|
|
||||||
|
window.DBG = {
|
||||||
|
world, scene, camera, renderer,
|
||||||
|
get draws() { return draws; }, get tris() { return tris; }, get fps() { return fps; },
|
||||||
|
shot(name = 'shot') {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.download = `${name}.png`;
|
||||||
|
a.href = renderer.domElement.toDataURL('image/png');
|
||||||
|
a.click();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Fix the drawing buffer to an explicit size for evidence frames. Two traps, both learned
|
||||||
|
* the hard way, both of which will bite anyone shooting boot.js (see LANE_A_NOTES §-> Lane F):
|
||||||
|
* 1. Screenshot tooling drives the tab HIDDEN. A hidden tab gets no requestAnimationFrame,
|
||||||
|
* so the render loop simply is not running when you shoot.
|
||||||
|
* 2. A hidden tab also reports innerWidth/innerHeight 0 — so the canvas is 0x0 and
|
||||||
|
* toDataURL() returns the literal string "data:,". The pane screenshot still looks fine
|
||||||
|
* because grabbing it fronts the tab; JS-side capture does not.
|
||||||
|
* Sizing explicitly also makes every committed shot the same resolution regardless of window.
|
||||||
|
*/
|
||||||
|
size(w = 1920, h = 1080) {
|
||||||
|
renderer.setPixelRatio(1);
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
return [renderer.domElement.width, renderer.domElement.height];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deterministic evidence frame: park the rail cam at s, set the wave to exactly t, render. */
|
||||||
|
pose(s = 5, t = 0, w = 1920, h = 1080) {
|
||||||
|
this.size(w, h);
|
||||||
|
world.update(t - world.time, s); // land the wave phase exactly on t
|
||||||
|
for (let i = 0; i < 12; i++) world.update(0, s); // let the 2-chunks/update budget fill
|
||||||
|
sCam = s;
|
||||||
|
rail(0);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const r = { s, t, draws: renderer.info.render.calls, tris: renderer.info.render.triangles,
|
||||||
|
pulse: world.flowPulse(s, t), ...world.stats() };
|
||||||
|
renderer.info.reset();
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Park the noclip cam explicitly (arena interiors, wall close-ups), then render. */
|
||||||
|
poseAt(pos, lookAt, t = 0, w = 1920, h = 1080) {
|
||||||
|
this.size(w, h);
|
||||||
|
const p = new THREE.Vector3(...pos), l = new THREE.Vector3(...lookAt);
|
||||||
|
const s = world.project(p).s;
|
||||||
|
world.update(t - world.time, s);
|
||||||
|
for (let i = 0; i < 12; i++) world.update(0, s);
|
||||||
|
camera.position.copy(p);
|
||||||
|
camera.up.set(0, 1, 0);
|
||||||
|
camera.lookAt(l);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const r = { s, draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
|
||||||
|
renderer.info.reset();
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Streaming leak probe: fly the whole canal, report renderer.info deltas. */
|
||||||
|
sweep(step = 20) {
|
||||||
|
const g0 = renderer.info.memory.geometries;
|
||||||
|
for (let s = 0; s <= world.length; s += step) world.update(0.016, s);
|
||||||
|
world.update(0.016, 0);
|
||||||
|
const g1 = renderer.info.memory.geometries;
|
||||||
|
return { geometriesBefore: g0, geometriesAfter: g1, delta: g1 - g0, ...world.stats() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let last = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
const dt = Math.min((now - last) / 1000, 0.05);
|
||||||
|
last = now;
|
||||||
|
if (fly) fly.update(dt); else rail(dt);
|
||||||
|
const camS = fly ? world.project(camera.position).s : sCam;
|
||||||
|
world.update(dt, camS);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
draws = renderer.info.render.calls;
|
||||||
|
tris = renderer.info.render.triangles;
|
||||||
|
renderer.info.reset();
|
||||||
|
frames++; fpsT += dt;
|
||||||
|
if (fpsT >= 0.5) {
|
||||||
|
fps = Math.round(frames / fpsT); frames = 0; fpsT = 0;
|
||||||
|
if (on('dbg') && !on('shots')) {
|
||||||
|
const st = world.stats();
|
||||||
|
dbgEl.textContent =
|
||||||
|
`${fps} fps · ${draws} draws · ${(tris / 1000) | 0}k tris · load ${loadMs.toFixed(0)}ms\n` +
|
||||||
|
`${level.id} seed=${seed} hash=${world.hash()} L=${world.length}\n` +
|
||||||
|
`s=${camS.toFixed(0)} ${world.biomeAt(camS).id} ${world.modeAt(camS)} · flow ${world.biomeAt(camS).flow.toFixed(1)} · pulse ${world.flowPulse(camS).toFixed(2)}\n` +
|
||||||
|
`chunks ${st.chunks} live / ${st.built} built / ${st.disposed} disposed · turnR ${st.minTurnRadius.toFixed(0)} vs R ${st.maxRadius.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
web/js/world/flycam.js
Normal file
70
web/js/world/flycam.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// world/flycam.js (Lane A) — `?fly=1` noclip inspection camera.
|
||||||
|
// Owed to the other lanes per LANE_A_WORLD.md: a way to look at the canal without Lane B's
|
||||||
|
// player existing. True noclip (free 6DOF, ignores world.collide) — it is a debug tool, not
|
||||||
|
// a preview of arena-mode flight; Lane B owns how the ship actually feels.
|
||||||
|
//
|
||||||
|
// Not wired into boot.js yet: boot owns the camera and `?fly=1` currently drives a rail cam.
|
||||||
|
// The exact patch is offered in LANE_A_NOTES.md §-> Lane F.
|
||||||
|
//
|
||||||
|
// W/S forward/back · A/D strafe · Q/E down/up · Shift boost · drag to look · R reset to start
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export function createFlyCam({ camera, dom = window, world, speed = 35 }) {
|
||||||
|
const keys = new Set();
|
||||||
|
let yaw = 0, pitch = 0, dragging = false;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
const f = world.sample(4);
|
||||||
|
camera.position.copy(f.pos).addScaledVector(f.tan, -6);
|
||||||
|
yaw = Math.atan2(-f.tan.x, -f.tan.z);
|
||||||
|
pitch = Math.asin(THREE.MathUtils.clamp(f.tan.y, -1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDown = (e) => { keys.add(e.code); if (e.code === 'KeyR') reset(); };
|
||||||
|
const onUp = (e) => keys.delete(e.code);
|
||||||
|
const onBlur = () => keys.clear();
|
||||||
|
const onMouseDown = () => { dragging = true; };
|
||||||
|
const onMouseUp = () => { dragging = false; };
|
||||||
|
const onMouseMove = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
yaw -= e.movementX * 0.003;
|
||||||
|
pitch = THREE.MathUtils.clamp(pitch - e.movementY * 0.003, -1.5, 1.5);
|
||||||
|
};
|
||||||
|
|
||||||
|
addEventListener('keydown', onDown);
|
||||||
|
addEventListener('keyup', onUp);
|
||||||
|
addEventListener('blur', onBlur);
|
||||||
|
dom.addEventListener('mousedown', onMouseDown);
|
||||||
|
addEventListener('mouseup', onMouseUp);
|
||||||
|
addEventListener('mousemove', onMouseMove);
|
||||||
|
reset();
|
||||||
|
|
||||||
|
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get position() { return camera.position; },
|
||||||
|
reset,
|
||||||
|
update(dt) {
|
||||||
|
camera.up.copy(up); // noclip stays world-up: no frame roll to fight
|
||||||
|
camera.rotation.set(pitch, yaw, 0, 'YXZ');
|
||||||
|
fwd.set(0, 0, -1).applyEuler(camera.rotation);
|
||||||
|
right.set(1, 0, 0).applyEuler(camera.rotation);
|
||||||
|
const v = speed * (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 3 : 1) * dt;
|
||||||
|
if (keys.has('KeyW')) camera.position.addScaledVector(fwd, v);
|
||||||
|
if (keys.has('KeyS')) camera.position.addScaledVector(fwd, -v);
|
||||||
|
if (keys.has('KeyD')) camera.position.addScaledVector(right, v);
|
||||||
|
if (keys.has('KeyA')) camera.position.addScaledVector(right, -v);
|
||||||
|
if (keys.has('KeyE')) camera.position.addScaledVector(up, v);
|
||||||
|
if (keys.has('KeyQ')) camera.position.addScaledVector(up, -v);
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
removeEventListener('keydown', onDown);
|
||||||
|
removeEventListener('keyup', onUp);
|
||||||
|
removeEventListener('blur', onBlur);
|
||||||
|
dom.removeEventListener('mousedown', onMouseDown);
|
||||||
|
removeEventListener('mouseup', onMouseUp);
|
||||||
|
removeEventListener('mousemove', onMouseMove);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
229
web/js/world/index.js
Normal file
229
web/js/world/index.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
// world/index.js (Lane A) — createWorld(): THE WORLD CONTRACT (docs/TECH.md §THE WORLD
|
||||||
|
// CONTRACT), implemented for real. Drop-in replacement for js/stub/world_stub.js: same
|
||||||
|
// surface, same units, same semantics, so anything Lane B built against the stub keeps
|
||||||
|
// working. Structure:
|
||||||
|
//
|
||||||
|
// spline.js the math (no three.js — headless-testable, runs in qa.sh)
|
||||||
|
// biomes.js palette/param registry (ART_BIBLE values)
|
||||||
|
// wall_material.js the synthetic-scanner shader (tube AND arena wear it)
|
||||||
|
// tube.js chunked extrusion + streaming window
|
||||||
|
// arena.js displaced icosphere shells, v0
|
||||||
|
// flycam.js ?fly=1 noclip inspection camera (offered to F for boot wiring)
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { createRng } from '../core/rng.js';
|
||||||
|
import { buildSpline, OMEGA } from './spline.js';
|
||||||
|
import { biome as biomeOf } from './biomes.js';
|
||||||
|
import { createWallMaterial } from './wall_material.js';
|
||||||
|
import { createTube } from './tube.js';
|
||||||
|
import { createArena } from './arena.js';
|
||||||
|
|
||||||
|
const SKIN = 0.6; // collision safety margin (units) — matches the stub
|
||||||
|
|
||||||
|
export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) {
|
||||||
|
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
|
||||||
|
const spline = buildSpline(levelData, R);
|
||||||
|
const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : [];
|
||||||
|
|
||||||
|
// --- assets: optional, always (TECH.md §Asset manifest contract) -----------------------
|
||||||
|
// A miss is not an error: the wall shader's procedural SEM path is the fallback, and it's
|
||||||
|
// the look we ship until D's pack lands.
|
||||||
|
//
|
||||||
|
// Two entry points, deliberately. TECH.md names `get(category, name)` as THE contract, but
|
||||||
|
// it returns the raw manifest entry — JSON, no THREE.Texture — so a shader can't eat it
|
||||||
|
// without re-implementing D's loader. `assets.texture(name)` is the one that returns
|
||||||
|
// {map, normalMap, tile}. We prefer it and keep `get` duck-typed as the documented floor.
|
||||||
|
// (D and I independently landed on the same `tile` semantics — [repeats around theta, units
|
||||||
|
// of s per repeat] — both read off the stub's uv. Noted for F to fold into TECH.md.)
|
||||||
|
//
|
||||||
|
// NB: tiling is applied in-shader from `tile`, NOT via texture.repeat — a raw ShaderMaterial
|
||||||
|
// has no uvTransform, so D's pre-applied repeat is inert here. Don't "fix" it by removing
|
||||||
|
// the uTile math.
|
||||||
|
// Texture naming. D's round-1 manifest keys are wall_<slug>_a, and the slugs are NOT the
|
||||||
|
// biome ids: `small_intestine` ships as `wall_smallint_a`. Because assets are optional, a
|
||||||
|
// wrong key doesn't throw — it silently falls back to the procedural wall forever, which is
|
||||||
|
// the worst kind of bug. Explicit map, so a mismatch is visible in one place.
|
||||||
|
// -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D).
|
||||||
|
const TEXTURE_SLUG = {
|
||||||
|
oral: 'oral', esophagus: 'esophagus', stomach: 'stomach',
|
||||||
|
small_intestine: 'smallint', large_intestine: 'colon', appendix: 'appendix',
|
||||||
|
};
|
||||||
|
const TEXTURE_FOR = (biomeId) => `wall_${TEXTURE_SLUG[biomeId] ?? biomeId}_a`;
|
||||||
|
function detailFor(biomeId) {
|
||||||
|
if (!assets) return { detail: null, tile: null };
|
||||||
|
const name = TEXTURE_FOR(biomeId);
|
||||||
|
try {
|
||||||
|
if (typeof assets.texture === 'function') {
|
||||||
|
const t = assets.texture(name);
|
||||||
|
if (t && t.map) return { detail: t.map, tile: Array.isArray(t.tile) ? t.tile : null };
|
||||||
|
}
|
||||||
|
if (typeof assets.get === 'function') {
|
||||||
|
const e = assets.get('textures', name);
|
||||||
|
if (!e) return { detail: null, tile: null };
|
||||||
|
const tex = e.isTexture ? e : (e.texture ?? e.map ?? null);
|
||||||
|
return { detail: tex, tile: Array.isArray(e.tile) ? e.tile : null };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[world] assets lookup failed for ${name}, using procedural wall —`, err.message);
|
||||||
|
}
|
||||||
|
return { detail: null, tile: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- materials -------------------------------------------------------------------------
|
||||||
|
// Tube: one material per biome, cached. Chunks never straddle a biome join, so live chunks
|
||||||
|
// share a material and batch cleanly — this is why the draw count is ~7 and not ~700.
|
||||||
|
const owned = []; // every material we create, for update() + dispose()
|
||||||
|
const tubeMaterials = new Map();
|
||||||
|
const radiusHintFor = (biomeId) => {
|
||||||
|
const span = spline.spans.find((sp) => sp.seg.biome === biomeId);
|
||||||
|
return span ? spline.paramAt((span.s0 + span.s1) / 2, 'base') : 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeMaterial(biomeId, side, fog) {
|
||||||
|
const b = biomeOf(biomeId);
|
||||||
|
const { detail, tile } = detailFor(biomeId);
|
||||||
|
const m = createWallMaterial({
|
||||||
|
biome: b, omega: OMEGA, detail, tile, radiusHint: radiusHintFor(biomeId), side, fog,
|
||||||
|
});
|
||||||
|
owned.push(m);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tubeMaterialFor(biomeId) {
|
||||||
|
if (!tubeMaterials.has(biomeId)) tubeMaterials.set(biomeId, makeMaterial(biomeId, THREE.FrontSide));
|
||||||
|
return tubeMaterials.get(biomeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arenas get their OWN material per room, because fog has to be sized to the room.
|
||||||
|
* Biome fog is tuned for a corridor (legible to ~100 units, and it hides the streaming
|
||||||
|
* edge). Reuse it in C's 360-unit-wide acid sea and the far wall sits at 86% fog — the room
|
||||||
|
* renders as a black rectangle. Measured, not theorised: that's what the first arena shot
|
||||||
|
* was. So: fade the far wall (2*radius) to ~70% void, and never fog *more* than the biome
|
||||||
|
* would. Small lairs keep the biome's murk; big rooms open up.
|
||||||
|
*/
|
||||||
|
const arenaFog = (radius) => 1.09 / Math.max(1, radius); // ~70% void at the far wall
|
||||||
|
function arenaMaterialFor(spec) {
|
||||||
|
return makeMaterial(spec.biome, THREE.BackSide, Math.min(biomeOf(spec.biome).fog, arenaFog(spec.radius)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- geometry -------------------------------------------------------------------------
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'world';
|
||||||
|
|
||||||
|
const tube = createTube({
|
||||||
|
spline,
|
||||||
|
materialFor: tubeMaterialFor,
|
||||||
|
quality,
|
||||||
|
// An arena owns its whole s-span: skip the tube there or the corridor renders *inside* the
|
||||||
|
// room and collide() would disagree with what you can see.
|
||||||
|
skipSpans: arenaSpecs.map((a) => [a.at - a.radius, a.at + a.radius]),
|
||||||
|
});
|
||||||
|
group.add(tube.group);
|
||||||
|
|
||||||
|
const arenas = arenaSpecs.map((spec) => {
|
||||||
|
const a = createArena({
|
||||||
|
spec, spline, rng: R, quality,
|
||||||
|
material: arenaMaterialFor(spec), // shell viewed from inside
|
||||||
|
});
|
||||||
|
group.add(a.mesh);
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
|
||||||
|
tube.prime(0);
|
||||||
|
|
||||||
|
// --- contract -------------------------------------------------------------------------
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
const biomeIdAt = (s) => spline.segmentAt(s).biome;
|
||||||
|
const waveMaxAt = (s) => { const b = biomeOf(biomeIdAt(s)); return b.wave.amp + b.wave.breathe; };
|
||||||
|
const arenaSpatial = (v) => arenas.find((a) => v.distanceTo(a.center) <= a.radius) || null;
|
||||||
|
|
||||||
|
function sample(s) {
|
||||||
|
const f = spline.frameAt(s);
|
||||||
|
return {
|
||||||
|
pos: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z),
|
||||||
|
tan: new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z),
|
||||||
|
nor: new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z),
|
||||||
|
bin: new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z),
|
||||||
|
radius: f.radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = {
|
||||||
|
level: levelData,
|
||||||
|
length: spline.length,
|
||||||
|
group,
|
||||||
|
|
||||||
|
sample,
|
||||||
|
|
||||||
|
/** @param {THREE.Vector3} v @param {number} [hint] previous s — free accuracy for B */
|
||||||
|
project: (v, hint) => spline.project(v, hint),
|
||||||
|
|
||||||
|
/** Conservative: geometry radius minus the wave's full amplitude. Time-independent on
|
||||||
|
* purpose — a collision surface that breathed would have B's ship fighting the wall. */
|
||||||
|
wallRho: (s, _theta) => spline.radiusAt(s) - waveMaxAt(s) - SKIN,
|
||||||
|
|
||||||
|
collide(v, r = 0) {
|
||||||
|
const a = arenaSpatial(v);
|
||||||
|
if (a) {
|
||||||
|
const rel = v.clone().sub(a.center);
|
||||||
|
const d = rel.length();
|
||||||
|
const max = a.innerRadius - r;
|
||||||
|
if (d <= max) return null;
|
||||||
|
return { push: rel.normalize().multiplyScalar(max - d), kind: 'wall', biome: a.spec.biome };
|
||||||
|
}
|
||||||
|
const { s, theta, rho } = spline.project(v);
|
||||||
|
const max = world.wallRho(s, theta) - r;
|
||||||
|
if (rho <= max) return null;
|
||||||
|
const f = spline.frameAt(s);
|
||||||
|
const ct = Math.cos(theta), st = Math.sin(theta);
|
||||||
|
const dir = new THREE.Vector3(
|
||||||
|
f.nor.x * ct + f.bin.x * st,
|
||||||
|
f.nor.y * ct + f.bin.y * st,
|
||||||
|
f.nor.z * ct + f.bin.z * st,
|
||||||
|
);
|
||||||
|
return { push: dir.multiplyScalar(max - rho), kind: 'wall', biome: biomeIdAt(s) };
|
||||||
|
},
|
||||||
|
|
||||||
|
biomeAt(s) {
|
||||||
|
const b = biomeOf(biomeIdAt(s));
|
||||||
|
// flow is read through the blended segment schedule, not the biome default: C tunes it
|
||||||
|
// per segment and B's controller wants it continuous across joins.
|
||||||
|
return { id: b.id, palette: b.palette, fog: b.fog, flow: spline.paramAt(s, 'flow'), coatDrain: b.coatDrain };
|
||||||
|
},
|
||||||
|
|
||||||
|
modeAt: (s) => (arenas.some((a) => a.covers(s)) ? 'arena' : 'tube'),
|
||||||
|
arenaAt(s) {
|
||||||
|
const a = arenas.find((x) => x.covers(s));
|
||||||
|
return a ? { center: a.center, radius: a.radius } : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** JS mirror of the vertex shader's wave, exact. B: crest speed == biomeAt(s).flow, so a
|
||||||
|
* ship riding a crest is riding the current. E: pulse the mix with it. */
|
||||||
|
flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3),
|
||||||
|
|
||||||
|
update(dt, playerS = 0) {
|
||||||
|
time += dt;
|
||||||
|
for (const m of owned) m.uniforms.uTime.value = time;
|
||||||
|
tube.update(playerS);
|
||||||
|
},
|
||||||
|
|
||||||
|
hash: () => spline.hash(),
|
||||||
|
stats: () => ({ ...spline.stats(), chunks: tube.stats.live, built: tube.stats.built, disposed: tube.stats.disposed }),
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
tube.dispose();
|
||||||
|
for (const a of arenas) { group.remove(a.mesh); a.dispose(); }
|
||||||
|
for (const m of owned) m.dispose(); // D owns their textures; not ours to free
|
||||||
|
owned.length = 0;
|
||||||
|
tubeMaterials.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
// not contract, but handy and cheap to expose
|
||||||
|
spline,
|
||||||
|
get time() { return time; },
|
||||||
|
};
|
||||||
|
|
||||||
|
return world;
|
||||||
|
}
|
||||||
427
web/js/world/spline.js
Normal file
427
web/js/world/spline.js
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
// world/spline.js (Lane A) — the math core of the canal. Deliberately imports NOTHING but
|
||||||
|
// core/rng.js: no three.js, so `node web/js/world/spline.js --selfcheck` runs it headless in
|
||||||
|
// qa.sh. index.js wraps these plain {x,y,z} numbers into THREE objects at the contract
|
||||||
|
// boundary (docs/TECH.md §THE WORLD CONTRACT).
|
||||||
|
//
|
||||||
|
// Model
|
||||||
|
// -----
|
||||||
|
// The centreline is a graph over the -Z axis: P(u) = (X(u), Y(u), -u). Two consequences we
|
||||||
|
// rely on everywhere: it can never self-intersect, and `u ≈ -pos.z` is a free, close seed for
|
||||||
|
// project(). X/Y are sums of three seeded low-frequency sines, smoothstep-blended across
|
||||||
|
// segment joins so the tangent never kinks.
|
||||||
|
//
|
||||||
|
// Amplitudes are NOT authored in units — they're solved from a curvature budget. A tube bends
|
||||||
|
// through itself when the centreline's turn radius drops below the tube radius, so `curviness`
|
||||||
|
// means "fraction of the tightest bend this pipe's radius can survive":
|
||||||
|
// kappa_budget(s) = curviness(s) / (PINCH_SAFETY * baseRadius(s))
|
||||||
|
// split across axes and octaves by fixed weights, then inverted per octave (A = w*kappa/f^2).
|
||||||
|
// A wide pipe therefore straightens itself automatically and Lane C never has to think about
|
||||||
|
// curvature. selfcheck measures the result (`pinchRatio`) rather than trusting this argument —
|
||||||
|
// it caught the first version of this file, where amplitude was authored in units and a
|
||||||
|
// curviness-0.95 join into a radius-16 segment folded the tube shut.
|
||||||
|
//
|
||||||
|
// u is NOT arclength (|dP/du| >= 1). Everything public is in arclength `s`, mapped through
|
||||||
|
// LUTs built by one forward march.
|
||||||
|
|
||||||
|
import { createRng } from '../core/rng.js';
|
||||||
|
|
||||||
|
// --- tiny vec3 (plain objects; index.js converts) ---------------------------------------
|
||||||
|
const V = (x = 0, y = 0, z = 0) => ({ x, y, z });
|
||||||
|
const sub = (a, b) => V(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||||
|
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||||
|
const len = (a) => Math.sqrt(dot(a, a));
|
||||||
|
const norm = (a) => { const l = len(a) || 1; return V(a.x / l, a.y / l, a.z / l); };
|
||||||
|
const cross = (a, b) => V(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
|
||||||
|
const lerpV = (a, b, t) => V(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
|
||||||
|
const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
|
||||||
|
const lerp = (a, b, t) => a + (b - a) * t;
|
||||||
|
const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };
|
||||||
|
|
||||||
|
// --- tuning -----------------------------------------------------------------------------
|
||||||
|
// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in
|
||||||
|
// time — physiologically it's how often the muscle fires, which doesn't change because you
|
||||||
|
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / flow(s), so a
|
||||||
|
// wave crest travels at exactly the local flow speed and Lane B can surf one. Phase is the
|
||||||
|
// integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — plain
|
||||||
|
// `k*s - w*t` does not, and tears the wave at every segment join.
|
||||||
|
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's esophagus wave, preserved exactly.
|
||||||
|
export const OMEGA = 3.08;
|
||||||
|
|
||||||
|
const DU = 0.5; // march step in curve parameter
|
||||||
|
const DS = 0.5; // frame/phase LUT spacing in arclength
|
||||||
|
const NOISE_TAB = 1024;
|
||||||
|
const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle
|
||||||
|
const PINCH_SAFETY = 2.2; // min turn radius = this x base radius (>2 covers wobble peaks)
|
||||||
|
|
||||||
|
// Blend widths (units) for smoothstep-crossfading segment params across a join. Radius and
|
||||||
|
// flow are local scalars — a tight 12u transition reads as a sphincter. Curviness feeds
|
||||||
|
// centreline *amplitude*, which has a long lever arm: ramping it over a short span is itself a
|
||||||
|
// hard lateral swerve, i.e. curvature. It gets a wide, gentle ramp.
|
||||||
|
// `curvBase` is baseRadius again, but read through the wide ramp: it divides the curvature
|
||||||
|
// budget, so a 12u step in it would swerve the centreline just as hard as curviness would.
|
||||||
|
const BLEND = { curviness: 60, curvBase: 60, base: 12, wobble: 12, flow: 12 };
|
||||||
|
|
||||||
|
// [wavelength, share of the axis curvature budget]. Y is tamer than X on purpose: a canal that
|
||||||
|
// writhes vertically as hard as it does laterally would swing the parallel-transport frame
|
||||||
|
// around and make Lane B's chase cam seasick. Axis shares are set so that even with every
|
||||||
|
// octave peaking together, sqrt(X^2 + Y^2) lands exactly on the budget.
|
||||||
|
const OCT_X = [[240, 0.5], [110, 0.3], [60, 0.2]];
|
||||||
|
const OCT_Y = [[190, 0.5], [85, 0.3], [45, 0.2]];
|
||||||
|
const AXIS_X = 0.8, AXIS_Y = 0.6;
|
||||||
|
|
||||||
|
/** Solve each octave's amplitude from its share of a unit curvature budget: kappa ~ A*f^2. */
|
||||||
|
function octaveCoefs(octaves, axisShare) {
|
||||||
|
return octaves.map(([wavelength, weight]) => {
|
||||||
|
const f = (2 * Math.PI) / wavelength;
|
||||||
|
return { f, coef: (weight * axisShare) / (f * f) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const GET = {
|
||||||
|
curviness: (g) => (typeof g.curviness === 'number' ? g.curviness : 0.5),
|
||||||
|
base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10),
|
||||||
|
wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2),
|
||||||
|
flow: (g) => (typeof g.flow === 'number' ? g.flow : 10),
|
||||||
|
};
|
||||||
|
GET.curvBase = GET.base;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the canal math for a level. Deterministic: same `level.seed` (or injected rng) =>
|
||||||
|
* byte-identical output, forever. See world.hash().
|
||||||
|
* @param {object} level level JSON (TECH.md §Level data schema v0)
|
||||||
|
* @param {function} [rng] core/rng.js createRng() instance; defaults to level.seed
|
||||||
|
*/
|
||||||
|
export function buildSpline(level, rng) {
|
||||||
|
if (!level || !Array.isArray(level.segments) || level.segments.length === 0)
|
||||||
|
throw new Error('[world] level.segments is required and must be non-empty');
|
||||||
|
const R = rng || createRng((level.seed ?? 0) >>> 0);
|
||||||
|
|
||||||
|
// --- segment table (declared arclength boundaries; C's events index this same s) -------
|
||||||
|
const spans = [];
|
||||||
|
let acc = 0;
|
||||||
|
for (const seg of level.segments) {
|
||||||
|
const length = typeof seg.length === 'number' && seg.length > 0 ? seg.length : 100;
|
||||||
|
spans.push({ s0: acc, s1: acc + length, seg });
|
||||||
|
acc += length;
|
||||||
|
}
|
||||||
|
const L = acc;
|
||||||
|
|
||||||
|
function idxAt(s) {
|
||||||
|
// linear scan: levels have a handful of segments, and a branchy binary search here would
|
||||||
|
// cost more than it saves.
|
||||||
|
for (let i = 0; i < spans.length; i++) if (s < spans[i].s1) return i;
|
||||||
|
return spans.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segment param at s, smoothstep-blended across joins so nothing steps discontinuously. */
|
||||||
|
function paramAt(s, key) {
|
||||||
|
const get = GET[key], w = BLEND[key];
|
||||||
|
const i = idxAt(s);
|
||||||
|
const sp = spans[i];
|
||||||
|
const v = get(sp.seg);
|
||||||
|
if (i > 0 && s < sp.s0 + w)
|
||||||
|
return lerp(get(spans[i - 1].seg), v, smoothstep(sp.s0 - w, sp.s0 + w, s));
|
||||||
|
if (i < spans.length - 1 && s > sp.s1 - w)
|
||||||
|
return lerp(v, get(spans[i + 1].seg), smoothstep(sp.s1 - w, sp.s1 + w, s));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- seeded noise ---------------------------------------------------------------------
|
||||||
|
const tab = new Float32Array(NOISE_TAB);
|
||||||
|
{ const r = R('world.radius'); for (let i = 0; i < NOISE_TAB; i++) tab[i] = r() * 2 - 1; }
|
||||||
|
function vnoise(x) {
|
||||||
|
const i = Math.floor(x), f = x - i;
|
||||||
|
const a = tab[i & (NOISE_TAB - 1)], b = tab[(i + 1) & (NOISE_TAB - 1)];
|
||||||
|
return lerp(a, b, f * f * (3 - 2 * f));
|
||||||
|
}
|
||||||
|
function fbm1(x) {
|
||||||
|
let sum = 0, amp = 1, freq = 1, normSum = 0;
|
||||||
|
for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * freq + o * 17.3); normSum += amp; amp *= 0.5; freq *= 2.03; }
|
||||||
|
return sum / normSum; // [-1, 1]
|
||||||
|
}
|
||||||
|
const radiusAt = (s) => paramAt(s, 'base') * (1 + paramAt(s, 'wobble') * fbm1(s / RADIUS_WAVELENGTH));
|
||||||
|
|
||||||
|
// --- centreline -----------------------------------------------------------------------
|
||||||
|
const cx = octaveCoefs(OCT_X, AXIS_X), cy = octaveCoefs(OCT_Y, AXIS_Y);
|
||||||
|
const phX = cx.map(() => R('world.spline')() * Math.PI * 2);
|
||||||
|
const phY = cy.map(() => R('world.spline')() * Math.PI * 2);
|
||||||
|
|
||||||
|
/** How hard the canal is allowed to bend at s: curviness as a fraction of the pinch limit. */
|
||||||
|
const curvBudgetAt = (s) => paramAt(s, 'curviness') / (PINCH_SAFETY * Math.max(1, paramAt(s, 'curvBase')));
|
||||||
|
|
||||||
|
/** `budget` is kappa (1/units), not a length — octave coefs turn it into lateral offsets. */
|
||||||
|
function centreWith(u, budget) {
|
||||||
|
let x = 0, y = 0;
|
||||||
|
for (let i = 0; i < cx.length; i++) x += budget * cx[i].coef * Math.sin(u * cx[i].f + phX[i]);
|
||||||
|
for (let i = 0; i < cy.length; i++) y += budget * cy[i].coef * Math.sin(u * cy[i].f + phY[i]);
|
||||||
|
return V(x, y, -u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the march: u -> s, and the curvature budget sampled along it ----------------------
|
||||||
|
// The budget is a function of arclength (that's where segments are declared), but the curve
|
||||||
|
// is evaluated in u — so we resolve it during the march, where s is already known, and
|
||||||
|
// cache the schedule per u-step. One pass, no circularity.
|
||||||
|
const uToS = [0];
|
||||||
|
const ampArr = [curvBudgetAt(0)];
|
||||||
|
{
|
||||||
|
let s = 0, prev = centreWith(0, ampArr[0]), n = 0;
|
||||||
|
const MAXN = Math.ceil((L / DU) * 1.5) + 16;
|
||||||
|
while (s < L && n < MAXN) {
|
||||||
|
n++;
|
||||||
|
const amp = curvBudgetAt(s); // s lags one step: deterministic, sub-step error
|
||||||
|
const p = centreWith(n * DU, amp);
|
||||||
|
s += len(sub(p, prev));
|
||||||
|
uToS.push(s); ampArr.push(amp);
|
||||||
|
prev = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uMax = (uToS.length - 1) * DU;
|
||||||
|
const ampAtU = (u) => {
|
||||||
|
const t = clamp(u / DU, 0, ampArr.length - 1), i = Math.min(Math.floor(t), ampArr.length - 2);
|
||||||
|
return lerp(ampArr[i], ampArr[i + 1], t - i);
|
||||||
|
};
|
||||||
|
const centre = (u) => centreWith(u, ampAtU(u));
|
||||||
|
const sOfU = (u) => {
|
||||||
|
const t = clamp(u / DU, 0, uToS.length - 1), i = Math.min(Math.floor(t), uToS.length - 2);
|
||||||
|
return lerp(uToS[i], uToS[i + 1], t - i);
|
||||||
|
};
|
||||||
|
|
||||||
|
// uniform s -> u table, inverted by a monotone merge-walk (uToS is strictly increasing)
|
||||||
|
const M = Math.ceil(L / DS) + 1;
|
||||||
|
const sToU = new Float64Array(M);
|
||||||
|
{
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < M; i++) {
|
||||||
|
const target = Math.min(i * DS, L);
|
||||||
|
while (j < uToS.length - 2 && uToS[j + 1] < target) j++;
|
||||||
|
const s0 = uToS[j], s1 = uToS[j + 1];
|
||||||
|
const f = s1 > s0 ? (target - s0) / (s1 - s0) : 0;
|
||||||
|
sToU[i] = (j + f) * DU;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uOfS = (s) => {
|
||||||
|
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
|
||||||
|
return lerp(sToU[i], sToU[i + 1], t - i);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- frames: parallel transport, NOT Frenet -------------------------------------------
|
||||||
|
// Frenet's normal flips through inflection points; on a canal that writhes that shows up as
|
||||||
|
// the camera snapping upside-down mid-corridor (LANE_A_WORLD.md calls this out by name).
|
||||||
|
// Parallel transport carries the previous normal forward by the minimal rotation, so it's
|
||||||
|
// history-dependent => must be integrated once here and looked up, never recomputed ad hoc.
|
||||||
|
const posArr = new Float64Array(M * 3);
|
||||||
|
const tanArr = new Float64Array(M * 3);
|
||||||
|
const norArr = new Float64Array(M * 3);
|
||||||
|
{
|
||||||
|
const H = 0.25;
|
||||||
|
const tangentAtU = (u) => {
|
||||||
|
const a = centre(Math.max(0, u - H)), b = centre(Math.min(uMax, u + H));
|
||||||
|
return norm(sub(b, a));
|
||||||
|
};
|
||||||
|
const put = (arr, i, v) => { arr[i * 3] = v.x; arr[i * 3 + 1] = v.y; arr[i * 3 + 2] = v.z; };
|
||||||
|
|
||||||
|
let prevT = tangentAtU(uOfS(0));
|
||||||
|
// seed the frame from world up; the canal is a graph over -Z so it can never be vertical
|
||||||
|
let n = norm(sub(V(0, 1, 0), V(prevT.x * prevT.y, prevT.y * prevT.y, prevT.z * prevT.y)));
|
||||||
|
put(posArr, 0, centre(uOfS(0))); put(tanArr, 0, prevT); put(norArr, 0, n);
|
||||||
|
|
||||||
|
for (let i = 1; i < M; i++) {
|
||||||
|
const u = uOfS(Math.min(i * DS, L));
|
||||||
|
const p = centre(u), t = tangentAtU(u);
|
||||||
|
const axis = cross(prevT, t);
|
||||||
|
const sinA = len(axis), cosA = dot(prevT, t);
|
||||||
|
if (sinA > 1e-9) { // Rodrigues: rotate n by the frame's own turn
|
||||||
|
const k = norm(axis), angle = Math.atan2(sinA, cosA);
|
||||||
|
const c = Math.cos(angle), sN = Math.sin(angle), kv = cross(k, n), kd = dot(k, n);
|
||||||
|
n = V(n.x * c + kv.x * sN + k.x * kd * (1 - c),
|
||||||
|
n.y * c + kv.y * sN + k.y * kd * (1 - c),
|
||||||
|
n.z * c + kv.z * sN + k.z * kd * (1 - c));
|
||||||
|
}
|
||||||
|
n = norm(V(n.x - t.x * dot(n, t), n.y - t.y * dot(n, t), n.z - t.z * dot(n, t))); // re-orthogonalize
|
||||||
|
put(posArr, i, p); put(tanArr, i, t); put(norArr, i, n);
|
||||||
|
prevT = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]);
|
||||||
|
|
||||||
|
// --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ----------------------
|
||||||
|
const kAt = (s) => OMEGA / Math.max(1, paramAt(s, 'flow'));
|
||||||
|
const phaseArr = new Float64Array(M);
|
||||||
|
for (let i = 1; i < M; i++)
|
||||||
|
phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
|
||||||
|
const phaseAt = (s) => {
|
||||||
|
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
|
||||||
|
return lerp(phaseArr[i], phaseArr[i + 1], t - i);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Frame at arclength s. pos/tan/nor/bin + geometric radius. */
|
||||||
|
function frameAt(s) {
|
||||||
|
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2), f = t - i;
|
||||||
|
const pos = lerpV(at(posArr, i), at(posArr, i + 1), f);
|
||||||
|
const tan = norm(lerpV(at(tanArr, i), at(tanArr, i + 1), f));
|
||||||
|
let nor = lerpV(at(norArr, i), at(norArr, i + 1), f);
|
||||||
|
nor = norm(V(nor.x - tan.x * dot(nor, tan), nor.y - tan.y * dot(nor, tan), nor.z - tan.z * dot(nor, tan)));
|
||||||
|
return { pos, tan, nor, bin: cross(tan, nor), radius: radiusAt(s) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* World -> spline space. Seeded from u = -pos.z (exact for the centreline's own axis, so
|
||||||
|
* the seed error is only the lateral tilt), then fixed-point: s += (p - C(s))·T(s).
|
||||||
|
* Converges at rate ~ curvature*rho (<0.5 in tube mode). Step-clamped so a point far off
|
||||||
|
* the centreline (arena mode) walks instead of exploding.
|
||||||
|
*/
|
||||||
|
function project(p, hint) {
|
||||||
|
let s = typeof hint === 'number' ? clamp(hint, 0, L) : sOfU(clamp(-p.z, 0, uMax));
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const f = frameAt(s);
|
||||||
|
const d = dot(sub(p, f.pos), f.tan);
|
||||||
|
const next = clamp(s + clamp(d, -8, 8), 0, L);
|
||||||
|
if (Math.abs(next - s) < 1e-4) { s = next; break; }
|
||||||
|
s = next;
|
||||||
|
}
|
||||||
|
const f = frameAt(s);
|
||||||
|
const rel = sub(p, f.pos);
|
||||||
|
const along = dot(rel, f.tan);
|
||||||
|
return {
|
||||||
|
s,
|
||||||
|
theta: Math.atan2(dot(rel, f.bin), dot(rel, f.nor)),
|
||||||
|
rho: Math.sqrt(Math.max(0, dot(rel, rel) - along * along)), // true cross-section radius
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Golden determinism hash over sampled frames + radius + wave phase. qa.sh compares. */
|
||||||
|
function hash() {
|
||||||
|
let h = 2166136261 >>> 0;
|
||||||
|
const push = (x) => { h ^= Math.round(x * 1000) & 0xffff; h = Math.imul(h, 16777619); };
|
||||||
|
for (let s = 0; s <= L; s += 10) {
|
||||||
|
const f = frameAt(s);
|
||||||
|
push(f.pos.x); push(f.pos.y); push(f.pos.z); push(f.radius);
|
||||||
|
push(f.nor.x); push(f.nor.y); push(f.nor.z); push(phaseAt(s));
|
||||||
|
}
|
||||||
|
return (h >>> 0).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Measured geometry health — the pinch guard is the one that bites. */
|
||||||
|
function stats() {
|
||||||
|
let maxCurvature = 0, sAtMax = 0, maxRadius = 0, minRadius = Infinity;
|
||||||
|
for (let i = 1; i < M - 1; i++) {
|
||||||
|
const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS);
|
||||||
|
if (k > maxCurvature) { maxCurvature = k; sAtMax = i * DS; }
|
||||||
|
}
|
||||||
|
for (let s = 0; s <= L; s += 1) {
|
||||||
|
const r = radiusAt(s);
|
||||||
|
if (r > maxRadius) maxRadius = r;
|
||||||
|
if (r < minRadius) minRadius = r;
|
||||||
|
}
|
||||||
|
const minTurnRadius = maxCurvature > 0 ? 1 / maxCurvature : Infinity;
|
||||||
|
return {
|
||||||
|
length: L, gridPoints: M, uMax,
|
||||||
|
maxCurvature, sAtMaxCurvature: sAtMax, minTurnRadius,
|
||||||
|
minRadius, maxRadius,
|
||||||
|
pinchRatio: minTurnRadius / maxRadius, // <1 => tube folds through itself on a bend
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
length: L, spans, uMax,
|
||||||
|
centre, sOfU, uOfS, ampAtU,
|
||||||
|
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
|
||||||
|
omega: OMEGA,
|
||||||
|
hash, stats,
|
||||||
|
segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// headless selfcheck: `node web/js/world/spline.js --selfcheck` (qa.sh gate)
|
||||||
|
// Browsers never reach this (no `process`).
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
async function selfcheck() {
|
||||||
|
const { FIXTURE } = await import('./_fixture.js');
|
||||||
|
let fail = 0;
|
||||||
|
const ok = (name, cond, detail = '') => {
|
||||||
|
if (cond) console.log(` \x1b[32m✓\x1b[0m ${name}${detail ? ' — ' + detail : ''}`);
|
||||||
|
else { fail++; console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); }
|
||||||
|
};
|
||||||
|
console.log('world/spline selfcheck (fixture: %s)', FIXTURE.id);
|
||||||
|
|
||||||
|
const a = buildSpline(FIXTURE);
|
||||||
|
const b = buildSpline(FIXTURE);
|
||||||
|
const c = buildSpline({ ...FIXTURE, seed: FIXTURE.seed + 1 });
|
||||||
|
const st = a.stats();
|
||||||
|
|
||||||
|
ok('determinism: same seed => same hash', a.hash() === b.hash(), a.hash());
|
||||||
|
ok('determinism: different seed => different hash', a.hash() !== c.hash(), `${a.hash()} vs ${c.hash()}`);
|
||||||
|
|
||||||
|
const declared = FIXTURE.segments.reduce((n, s) => n + s.length, 0);
|
||||||
|
ok('arclength: length == declared total', Math.abs(a.length - declared) < 1e-6, `${a.length}`);
|
||||||
|
|
||||||
|
let mono = true, prev = -1;
|
||||||
|
for (let s = 0; s <= a.length; s += 1) { const u = a.uOfS(s); if (u < prev) mono = false; prev = u; }
|
||||||
|
ok('arclength: s -> u monotone', mono);
|
||||||
|
|
||||||
|
let maxRt = 0;
|
||||||
|
for (let s = 0; s <= a.length; s += 3) maxRt = Math.max(maxRt, Math.abs(a.sOfU(a.uOfS(s)) - s));
|
||||||
|
ok('arclength: s -> u -> s round-trip < 0.05', maxRt < 0.05, `max err ${maxRt.toExponential(2)}`);
|
||||||
|
|
||||||
|
let orth = 0, unit = 0;
|
||||||
|
for (let s = 0; s <= a.length; s += 2) {
|
||||||
|
const f = a.frameAt(s);
|
||||||
|
orth = Math.max(orth, Math.abs(f.tan.x * f.nor.x + f.tan.y * f.nor.y + f.tan.z * f.nor.z));
|
||||||
|
unit = Math.max(unit, Math.abs(Math.hypot(f.nor.x, f.nor.y, f.nor.z) - 1), Math.abs(Math.hypot(f.bin.x, f.bin.y, f.bin.z) - 1));
|
||||||
|
}
|
||||||
|
ok('frames: orthonormal (tan·nor ~ 0, |nor| = |bin| = 1)', orth < 1e-9 && unit < 1e-9,
|
||||||
|
`tan·nor ${orth.toExponential(1)}, unit err ${unit.toExponential(1)}`);
|
||||||
|
|
||||||
|
// parallel transport must not spin the frame up: total twist stays bounded & smooth
|
||||||
|
let maxTwist = 0;
|
||||||
|
for (let s = 1; s <= a.length; s += 1) {
|
||||||
|
const p = a.frameAt(s - 1).nor, q = a.frameAt(s).nor;
|
||||||
|
maxTwist = Math.max(maxTwist, Math.acos(Math.min(1, Math.abs(p.x * q.x + p.y * q.y + p.z * q.z))));
|
||||||
|
}
|
||||||
|
ok('frames: no roll snap (max per-unit normal turn < 0.2 rad)', maxTwist < 0.2, `${maxTwist.toFixed(4)} rad`);
|
||||||
|
|
||||||
|
ok('geometry: tube does not pinch (minTurnRadius / maxRadius > 1.5)', st.pinchRatio > 1.5,
|
||||||
|
`turn R ${st.minTurnRadius.toFixed(1)} vs tube R ${st.maxRadius.toFixed(1)} => ${st.pinchRatio.toFixed(2)}x`);
|
||||||
|
|
||||||
|
// project() round-trip: place a point at a known (s, theta, rho), recover it
|
||||||
|
let maxSerr = 0, maxTerr = 0, maxRerr = 0;
|
||||||
|
for (let s = 5; s < a.length - 5; s += 7) {
|
||||||
|
for (const theta of [0, 1.3, -2.4, 3.0]) {
|
||||||
|
const rho = a.radiusAt(s) * 0.6;
|
||||||
|
const f = a.frameAt(s);
|
||||||
|
const p = {
|
||||||
|
x: f.pos.x + (f.nor.x * Math.cos(theta) + f.bin.x * Math.sin(theta)) * rho,
|
||||||
|
y: f.pos.y + (f.nor.y * Math.cos(theta) + f.bin.y * Math.sin(theta)) * rho,
|
||||||
|
z: f.pos.z + (f.nor.z * Math.cos(theta) + f.bin.z * Math.sin(theta)) * rho,
|
||||||
|
};
|
||||||
|
const q = a.project(p);
|
||||||
|
maxSerr = Math.max(maxSerr, Math.abs(q.s - s));
|
||||||
|
let dt = Math.abs(q.theta - theta) % (Math.PI * 2);
|
||||||
|
if (dt > Math.PI) dt = Math.PI * 2 - dt;
|
||||||
|
maxTerr = Math.max(maxTerr, dt);
|
||||||
|
maxRerr = Math.max(maxRerr, Math.abs(q.rho - rho));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok('project: recovers s within 0.25', maxSerr < 0.25, `max ${maxSerr.toFixed(4)}`);
|
||||||
|
ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`);
|
||||||
|
ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`);
|
||||||
|
|
||||||
|
// wave: phase monotone, and crest speed == local flow (the whole point of K(s))
|
||||||
|
let phaseMono = true;
|
||||||
|
for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false;
|
||||||
|
ok('wave: phase K(s) strictly increasing', phaseMono);
|
||||||
|
const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
|
||||||
|
ok('wave: k == OMEGA/flow (esophagus flow 14 => k 0.22, matches stub)', Math.abs(kEso - 0.22) < 0.001, `k=${kEso.toFixed(4)}`);
|
||||||
|
ok('wave: crest speed == local flow in each biome',
|
||||||
|
Math.abs(OMEGA / kEso - 14) < 0.01 && Math.abs(OMEGA / kSto - 4) < 0.01,
|
||||||
|
`${(OMEGA / kEso).toFixed(2)} u/s eso, ${(OMEGA / kSto).toFixed(2)} u/s stomach`);
|
||||||
|
|
||||||
|
console.log(` stats: L=${st.length} grid=${st.gridPoints} maxK=${st.maxCurvature.toFixed(4)} (s=${st.sAtMaxCurvature}) radius=${st.minRadius.toFixed(1)}..${st.maxRadius.toFixed(1)} hash=${a.hash()}`);
|
||||||
|
console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`);
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof process !== 'undefined' && process.argv && process.argv.includes('--selfcheck')) {
|
||||||
|
selfcheck().then((f) => process.exit(f === 0 ? 0 : 1));
|
||||||
|
}
|
||||||
149
web/js/world/tube.js
Normal file
149
web/js/world/tube.js
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// world/tube.js (Lane A) — chunked tube geometry + the streaming window.
|
||||||
|
//
|
||||||
|
// The canal is extruded as rings along the parallel-transport frames and cut into chunks of
|
||||||
|
// ~CHUNK_LEN. Chunks are built/disposed around playerS so a 3000-unit level costs the same as
|
||||||
|
// a 300-unit one. Chunk seams are aligned to segment (biome) boundaries so a chunk never spans
|
||||||
|
// two biomes: each chunk gets exactly one material, and the tint changes exactly at the
|
||||||
|
// anatomical join — which is where a sphincter is anyway (GDD §structure).
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
export function createTube({ spline, materialFor, quality = 'high', skipSpans = [] }) {
|
||||||
|
const Q = quality === 'low'
|
||||||
|
? { radial: 48, step: 0.75, ahead: 120, behind: 45 }
|
||||||
|
: { radial: 64, step: 0.5, ahead: 180, behind: 60 };
|
||||||
|
const CHUNK_LEN = 40;
|
||||||
|
const BUILD_BUDGET = 2; // chunks per update: a boost must not stall the frame
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'tube';
|
||||||
|
|
||||||
|
// --- chunk plan: subdivide each segment span, never straddle a biome join --------------
|
||||||
|
// Chunks whose midpoint falls inside an arena are dropped: the arena shell is the wall
|
||||||
|
// there. The seam is therefore chunk-quantized (+/-CHUNK_LEN/2) — good enough for v0, and
|
||||||
|
// round 2 replaces it with real sphincter joint geometry anyway (LANE_A_NOTES).
|
||||||
|
const skipped = (s) => skipSpans.some(([a, b]) => s >= a && s <= b);
|
||||||
|
const plan = [];
|
||||||
|
for (const span of spline.spans) {
|
||||||
|
const len = span.s1 - span.s0;
|
||||||
|
const n = Math.max(1, Math.round(len / CHUNK_LEN));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const s0 = span.s0 + (len * i) / n;
|
||||||
|
const s1 = span.s0 + (len * (i + 1)) / n;
|
||||||
|
if (skipped((s0 + s1) / 2)) continue;
|
||||||
|
plan.push({ s0, s1, biomeId: span.seg.biome });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = new Map(); // plan index -> THREE.Mesh
|
||||||
|
const stats = { built: 0, disposed: 0, get live() { return live.size; } };
|
||||||
|
|
||||||
|
function buildChunk(i) {
|
||||||
|
const c = plan[i];
|
||||||
|
const rings = Math.max(2, Math.round((c.s1 - c.s0) / Q.step) + 1);
|
||||||
|
const cols = Q.radial + 1; // duplicate the seam column so uv.x runs 0..1 without
|
||||||
|
// wrapping backwards across the last quad (visible streak)
|
||||||
|
const n = rings * cols;
|
||||||
|
const position = new Float32Array(n * 3);
|
||||||
|
const aInward = new Float32Array(n * 3);
|
||||||
|
const aTangent = new Float32Array(n * 3);
|
||||||
|
const uv = new Float32Array(n * 2);
|
||||||
|
const aPhase = new Float32Array(n);
|
||||||
|
const aK = new Float32Array(n);
|
||||||
|
|
||||||
|
let p = 0, q = 0, w = 0;
|
||||||
|
for (let r = 0; r < rings; r++) {
|
||||||
|
const s = c.s0 + ((c.s1 - c.s0) * r) / (rings - 1);
|
||||||
|
const f = spline.frameAt(s);
|
||||||
|
const phase = spline.phaseAt(s);
|
||||||
|
const k = spline.kAt(s);
|
||||||
|
for (let j = 0; j < cols; j++) {
|
||||||
|
const th = (j / Q.radial) * TAU;
|
||||||
|
const ct = Math.cos(th), st = Math.sin(th);
|
||||||
|
const dx = f.nor.x * ct + f.bin.x * st;
|
||||||
|
const dy = f.nor.y * ct + f.bin.y * st;
|
||||||
|
const dz = f.nor.z * ct + f.bin.z * st;
|
||||||
|
position[p] = f.pos.x + dx * f.radius;
|
||||||
|
position[p + 1] = f.pos.y + dy * f.radius;
|
||||||
|
position[p + 2] = f.pos.z + dz * f.radius;
|
||||||
|
aInward[p] = -dx; aInward[p + 1] = -dy; aInward[p + 2] = -dz;
|
||||||
|
aTangent[p] = f.tan.x; aTangent[p + 1] = f.tan.y; aTangent[p + 2] = f.tan.z;
|
||||||
|
p += 3;
|
||||||
|
uv[q++] = j / Q.radial; uv[q++] = s;
|
||||||
|
aPhase[w] = phase; aK[w] = k; w++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = new (n > 65535 ? Uint32Array : Uint16Array)((rings - 1) * Q.radial * 6);
|
||||||
|
let t = 0;
|
||||||
|
for (let r = 0; r < rings - 1; r++) {
|
||||||
|
for (let j = 0; j < Q.radial; j++) {
|
||||||
|
const a = r * cols + j, b = a + 1, cc = a + cols, d = b + cols;
|
||||||
|
idx[t++] = a; idx[t++] = cc; idx[t++] = b; // wound to face inward
|
||||||
|
idx[t++] = b; idx[t++] = cc; idx[t++] = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(position, 3));
|
||||||
|
geo.setAttribute('aInward', new THREE.BufferAttribute(aInward, 3));
|
||||||
|
geo.setAttribute('aTangent', new THREE.BufferAttribute(aTangent, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||||
|
geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||||
|
geo.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(idx, 1));
|
||||||
|
geo.computeBoundingSphere();
|
||||||
|
geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geo, materialFor(c.biomeId));
|
||||||
|
mesh.name = `tube[${i}] ${c.biomeId} ${c.s0.toFixed(0)}..${c.s1.toFixed(0)}`;
|
||||||
|
group.add(mesh);
|
||||||
|
live.set(i, mesh);
|
||||||
|
stats.built++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeChunk(i) {
|
||||||
|
const mesh = live.get(i);
|
||||||
|
if (!mesh) return;
|
||||||
|
group.remove(mesh);
|
||||||
|
mesh.geometry.dispose(); // material is shared per biome; index.js owns it
|
||||||
|
live.delete(i);
|
||||||
|
stats.disposed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wanted(playerS) {
|
||||||
|
const lo = playerS - Q.behind, hi = playerS + Q.ahead;
|
||||||
|
const set = new Set();
|
||||||
|
for (let i = 0; i < plan.length; i++) if (plan[i].s1 >= lo && plan[i].s0 <= hi) set.add(i);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
stats,
|
||||||
|
chunkCount: plan.length,
|
||||||
|
quality: Q,
|
||||||
|
|
||||||
|
/** Synchronous full fill of the window — used once at load, inside the <2s budget. */
|
||||||
|
prime(playerS = 0) {
|
||||||
|
for (const i of wanted(playerS)) if (!live.has(i)) buildChunk(i);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(playerS) {
|
||||||
|
const want = wanted(playerS);
|
||||||
|
for (const i of live.keys()) if (!want.has(i)) disposeChunk(i);
|
||||||
|
let budget = BUILD_BUDGET;
|
||||||
|
for (const i of want) {
|
||||||
|
if (live.has(i)) continue;
|
||||||
|
buildChunk(i);
|
||||||
|
if (--budget <= 0) break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const i of [...live.keys()]) disposeChunk(i);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
114
web/js/world/wall_material.js
Normal file
114
web/js/world/wall_material.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// world/wall_material.js (Lane A) — the synthetic-scanner wall (ART_BIBLE.md §Rendering recipe).
|
||||||
|
// No real-time lights anywhere in GUTS: the wall is biome tint x detail luminance + a fresnel
|
||||||
|
// rim (the SEM edge glow) + exponential fog to the biome void. Peristalsis is vertex work.
|
||||||
|
//
|
||||||
|
// Attributes the geometry must supply (tube.js and arena.js both do):
|
||||||
|
// position vec3 baked ring/shell vertex
|
||||||
|
// aInward vec3 unit normal pointing into the playable space
|
||||||
|
// aTangent vec3 unit centreline tangent at this vertex (for the wave's normal tilt)
|
||||||
|
// uv vec2 (theta/2pi, s) — s in world units, NOT normalized: it's the wave's phase
|
||||||
|
// axis and the tiling axis, and it must stay continuous across chunks
|
||||||
|
// aPhase float K(s) = ∫k ds, baked (see spline.js)
|
||||||
|
// aK float local wavenumber, for the analytic d(pulse)/ds
|
||||||
|
//
|
||||||
|
// Colorspace: like the stub, this writes its computed color straight out with no
|
||||||
|
// <colorspace_fragment> conversion. That's a whole-game decision (it moves every color at
|
||||||
|
// once) so it stays matched to F's round-0 look until F rules on it — see LANE_A_NOTES.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export function createWallMaterial({
|
||||||
|
biome,
|
||||||
|
omega,
|
||||||
|
fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js)
|
||||||
|
waveAmp = biome.wave.amp,
|
||||||
|
breatheAmp = biome.wave.breathe,
|
||||||
|
detail = null, // THREE.Texture | null — Lane D's grayscale detail map
|
||||||
|
tile = null, // [repeatsAroundCircumference, unitsOfSPerRepeat]
|
||||||
|
radiusHint = 10, // used only to pick a square-ish default tiling
|
||||||
|
side = THREE.FrontSide,
|
||||||
|
}) {
|
||||||
|
if (detail) { // defensive: we consume D's texture, so we set what we depend on
|
||||||
|
detail.wrapS = detail.wrapT = THREE.RepeatWrapping;
|
||||||
|
detail.needsUpdate = true;
|
||||||
|
}
|
||||||
|
const tileAround = tile ? tile[0] : 3;
|
||||||
|
const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround);
|
||||||
|
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
side,
|
||||||
|
defines: detail ? { USE_DETAIL: '' } : {},
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uTint: { value: new THREE.Color(biome.palette.tint) },
|
||||||
|
uRim: { value: new THREE.Color(biome.palette.rim) },
|
||||||
|
uVoid: { value: new THREE.Color(biome.palette.void) },
|
||||||
|
uFog: { value: fog },
|
||||||
|
uWaveA: { value: waveAmp },
|
||||||
|
uBreatheA: { value: breatheAmp },
|
||||||
|
uOmega: { value: omega },
|
||||||
|
uRimPow: { value: 2.2 },
|
||||||
|
uRimGain: { value: 0.9 },
|
||||||
|
uDetail: { value: detail },
|
||||||
|
uTile: { value: new THREE.Vector2(tileAround, tileAlong) },
|
||||||
|
},
|
||||||
|
vertexShader: /* glsl */`
|
||||||
|
attribute vec3 aInward;
|
||||||
|
attribute vec3 aTangent;
|
||||||
|
attribute float aPhase;
|
||||||
|
attribute float aK;
|
||||||
|
uniform float uTime, uWaveA, uBreatheA, uOmega;
|
||||||
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
float phi = aPhase - uOmega * uTime;
|
||||||
|
float sn = max(0.0, sin(phi));
|
||||||
|
float pulse = sn * sn * sn; // sharp crest, long trough: a muscle, not a sine
|
||||||
|
float breathe = uBreatheA * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831853 * 3.0);
|
||||||
|
float disp = uWaveA * pulse + breathe;
|
||||||
|
|
||||||
|
// Tilt the normal with the wave. Without this the crests are silhouette-only and the
|
||||||
|
// rim light slides over them as if the wall were flat — the effective wall radius is
|
||||||
|
// rho(s) = radius - disp, so the inward normal leans along the tangent by d(rho)/ds.
|
||||||
|
float dPulse_ds = 3.0 * sn * sn * cos(phi) * aK;
|
||||||
|
vec3 nIn = normalize(aInward - aTangent * (uWaveA * dPulse_ds));
|
||||||
|
|
||||||
|
vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
|
||||||
|
vN = normalize(normalMatrix * nIn);
|
||||||
|
vView = -mv.xyz;
|
||||||
|
vPulse = pulse;
|
||||||
|
gl_Position = projectionMatrix * mv;
|
||||||
|
}`,
|
||||||
|
fragmentShader: /* glsl */`
|
||||||
|
uniform vec3 uTint, uRim, uVoid;
|
||||||
|
uniform float uFog, uRimPow, uRimGain;
|
||||||
|
#ifdef USE_DETAIL
|
||||||
|
uniform sampler2D uDetail;
|
||||||
|
uniform vec2 uTile;
|
||||||
|
#endif
|
||||||
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
#ifdef USE_DETAIL
|
||||||
|
// Lane D authors grayscale; the biome tint is applied here, so one texture can serve
|
||||||
|
// two biomes at different tints (ART_BIBLE §FLUX prompt kit).
|
||||||
|
float detail = texture2D(uDetail, vec2(vUv.x * uTile.x, vUv.y / uTile.y)).r;
|
||||||
|
detail = mix(0.5, 1.2, detail);
|
||||||
|
#else
|
||||||
|
// Assets-optional law: no texture is the *shipping* look until D lands, not an error
|
||||||
|
// state. Ridged folds around theta + striation along s = a passable SEM stand-in.
|
||||||
|
float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831853 * 9.0 + sin(vUv.y * 0.9) * 2.0);
|
||||||
|
float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2));
|
||||||
|
#endif
|
||||||
|
|
||||||
|
vec3 base = uTint * detail * 0.8;
|
||||||
|
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), uRimPow);
|
||||||
|
// crest sheen: the wave is a gameplay tell (ride it for boost), so it gets a little
|
||||||
|
// help beyond what its own geometry earns from the rim term
|
||||||
|
vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10;
|
||||||
|
float d = length(vView);
|
||||||
|
gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0);
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user