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>
260 lines
12 KiB
JavaScript
260 lines
12 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, 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;
|
||
}
|