// 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'; import { createAcid } from './acid.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__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' }; // NOT temporary, and not the same thing as the slug map above: this is the ART_BIBLE's // "one texture can serve two biomes at different tints" law, used deliberately. The appendix // is anatomically an outpouching of the colon — it is the same tissue — so it wears the colon // pack under its own gold tint and costs zero bytes. Keep this when the slug map dies. const TEXTURE_SHARE = { appendix: 'large_intestine' }; const slug = (biomeId) => { const b = TEXTURE_SHARE[biomeId] ?? biomeId; return TEXTURE_SLUG[b] ?? b; }; /** 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; }); // The acid sea (round-2 prototype). Optional and absent by default: no `level.acid`, no // plane, no cost — same law as assets. Fog/void come from the room's own biome so the far // shore dissolves exactly like the far wall. const acidSpec = levelData.acid || null; let acid = null; if (acidSpec) { // NB: spline.segmentAt, not the biomeIdAt below — that one is declared later and this // would be reading it from its temporal dead zone. const b = biomeOf(acidSpec.biome ?? spline.segmentAt(acidSpec.from).biome); acid = createAcid({ spec: acidSpec, spline, quality, fog: Math.min(b.fog, arenaFog(spline.radiusAt((acidSpec.from + acidSpec.to) / 2))), voidColor: b.palette.void, }); group.add(acid.mesh); } tube.prime(0); // --- contract ------------------------------------------------------------------------- let time = 0; const biomeIdAt = (s) => spline.segmentAt(s).biome; // --- open spans: the swept room (round-2 PROTOTYPE, Lane A -> Lane C) ------------------- // The proposal in LANE_A_NOTES §-> Lane C: a room is not a different primitive, it is the // canal at room radii with the disc clamp taken off. `segments[].mode: "open"` is the whole // schema ask. Everything else already works: the tube extrudes any radius, project()/wallRho // already collide against a varying one, and the uv stays (theta, s) so D's pack tiles with // no pole pinch. Deliberately additive — a level without `mode` behaves exactly as before, // so C can ignore this entirely and nothing changes. const isOpenAt = (s) => spline.segmentAt(s).mode === 'open'; // 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 }; }, /** 'arena' means "6DOF, no disc clamp" to Lane B — an authored room OR an open span. */ modeAt: (s) => (arenas.some((a) => a.covers(s)) || isOpenAt(s) ? 'arena' : 'tube'), /** * B: unchanged for authored rooms (a static sphere). For an **open span** there is no one * sphere — the room is swept — so this returns the LOCAL sphere at s: centred on the * centreline, radius = the playable wall right there. It slides with you. `swept: true` * says which you got. Clamping to it is not required and not advised: `collide()` is exact * in an open span (it is the same tube path you already trust), and this is here for * cheap "am I near the middle / how much room is there" questions. */ arenaAt(s) { const a = arenas.find((x) => x.covers(s)); if (a) return { center: a.center, radius: a.radius, swept: false }; if (!isOpenAt(s)) return null; const f = spline.frameAt(s); return { center: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z), radius: world.wallRho(s), swept: true }; }, /** 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, /** The acid sea, or null on a level without one. See world/acid.js for the C/B hooks. */ acid, update(dt, playerS = 0) { time += dt; for (const m of owned) m.uniforms.uTime.value = time; tube.update(playerS); if (acid) acid.update(dt); }, hash: () => spline.hash(), stats: () => ({ ...spline.stats(), chunks: tube.stats.live, built: tube.stats.built, disposed: tube.stats.disposed }), dispose() { tube.dispose(); if (acid) { group.remove(acid.mesh); acid.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; }