guts/web/js/world/index.js
jing 689c478a7f [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>
2026-07-16 08:59:19 +10:00

230 lines
9.8 KiB
JavaScript

// 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;
}