guts/web/js/world/index.js
jing f0982e70c3 [lane A] Round 2 WIP (session cut off): crest-speed law, taper blending, schema-v2 wave, TBN walls
Landed before the cut: crestSpeed(s) + CREST_FACTOR 1.6 (ruling #1) with selfcheck
asserts; radius blend widened +/-12 -> +/-25 (C's #4 dependency) + no-cliff selfcheck;
per-segment wave.amp override as per-vertex aWaveA (ruling #8); colorspace law in the
wall shader (ruling #2); TBN normal maps + matcap + dual detail layers (D's perturb(),
trap documented in-shader); sample(s, out) v1.2; slug map shrunk to the two real
mismatches. Evidence: docs/shots/laneA/round2_L2_*.png.

Cut off before: NOTES/progress, stomach-arena shape read for C (task #7).
Committed by F to protect the shared tree; spline + qa selfchecks GREEN at commit time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:26:01 +10:00

260 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, CREST_FACTOR } 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
/**
* Fill each segment's `wave` from its biome so the spline only ever sees numbers. C's schema v2
* `segments[].wave: { amp }` overrides the biome (ruling #8) — the diaphragmatic hiatus is a
* fixed muscular ring: tight AND calm. Returns a shallow copy; `world.level` stays C's object.
*/
function normalizeLevel(levelData) {
return {
...levelData,
segments: levelData.segments.map((seg) => {
const b = biomeOf(seg.biome);
return { ...seg, wave: { amp: seg.wave?.amp ?? b.wave.amp, breathe: seg.wave?.breathe ?? b.wave.breathe } };
}),
};
}
export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) {
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
if (!levelData || !Array.isArray(levelData.segments)) throw new Error('[world] level.segments is required');
const spline = buildSpline(normalizeLevel(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).
// TEMPORARY (ruling #3): D's round-1 keys aren't the biome ids — `small_intestine` ships as
// `wall_smallint_a`. D renames to biome ids early this round and pings in NOTES; **delete
// this map and the ?? fallback the moment they do.** D's assets.js now has a miss ledger
// (`assets.misses()`), so a drifted slug announces itself instead of silently falling back
// forever — which is what made this dangerous in round 1.
const TEXTURE_SLUG = { small_intestine: 'smallint', large_intestine: 'colon' };
const slug = (biomeId) => TEXTURE_SLUG[biomeId] ?? biomeId;
/** One wall's full texture set, all optional and independently so. */
function texturesFor(biomeId) {
const out = { detail: null, detailB: null, normalMap: null, matcap: null, repeat: null, repeatB: null };
if (!assets || typeof assets.texture !== 'function') return out;
try {
const a = assets.texture(`wall_${slug(biomeId)}_a`);
if (a?.map) { out.detail = a.map; out.repeat = a.repeat ?? null; out.normalMap = a.normalMap ?? null; }
const b = assets.texture(`wall_${slug(biomeId)}_b`);
if (b?.map) { out.detailB = b.map; out.repeatB = b.repeat ?? null; }
if (typeof assets.matcap === 'function') out.matcap = assets.matcap('tissue_wet') ?? null;
} catch (err) {
console.warn(`[world] assets lookup failed for ${biomeId}, using procedural wall —`, err.message);
}
return out;
}
// --- 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 m = createWallMaterial({
biome: biomeOf(biomeId), omega: OMEGA, side, fog,
radiusHint: radiusHintFor(biomeId),
...texturesFor(biomeId),
});
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
waveAmpDefault: biomeOf(spec.biome).wave.amp, // the room's biome, not the segment's
});
group.add(a.mesh);
return a;
});
tube.prime(0);
// --- contract -------------------------------------------------------------------------
let time = 0;
const biomeIdAt = (s) => spline.segmentAt(s).biome;
// Prefers the per-segment override (schema v2) over the biome default, via the blended
// schedule — so wallRho tracks C's calm hiatus instead of the biome's loudest wave.
const waveMaxAt = (s) => spline.waveAmpAt(s) + biomeOf(biomeIdAt(s)).wave.breathe;
const arenaSpatial = (v) => arenas.find((a) => v.distanceTo(a.center) <= a.radius) || null;
/**
* Contract v1.2: `sample(s)` allocates ~6 Vector3s and it's B's hot path (~60 calls/frame at
* 55 enemies = ~350 allocations/frame of GC churn — LANE_B_NOTES → A #3). Pass a caller-owned
* frame as `out` and nothing allocates. The allocating form stays as sugar.
*/
function sample(s, out) {
const f = spline.frameAt(s);
const o = out || { pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3() };
o.pos.set(f.pos.x, f.pos.y, f.pos.z);
o.tan.set(f.tan.x, f.tan.y, f.tan.z);
o.nor.set(f.nor.x, f.nor.y, f.nor.z);
o.bin.set(f.bin.x, f.bin.y, f.bin.z);
o.radius = f.radius;
return o;
}
/** A reusable frame, for callers who want the fast path without owning the boilerplate. */
sample.frame = () => ({ pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3(), radius: 0 });
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 (0 = trough, 1 = crest). E: pulse the mix. */
flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3),
/** Crest-speed law (TECH v1.1): u/s the crest travels == CREST_FACTOR × flow(s). B
* speed-locks to this while surfing; it outruns throttleMax by design (1.6 > 1.4). */
crestSpeed: (s) => spline.crestSpeedAt(s),
crestFactor: CREST_FACTOR,
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;
}