The shape read in LANE_A_NOTES was written from reading code, so I built it to
find out if it was true. Flyable: web/dev/laneA_world.html?dbg=1&assets=1&lvl=swept
Nothing here is imposed on anyone: every addition is additive and a level that
opts out behaves exactly as today. C can bin the whole proposal and lose nothing.
WHAT THE PROTOTYPE DISPROVED (my own cost estimates, all four):
- "radial resolution must scale with radius" — wrong. 64 segments is a 0.05u
circle error at r=70. The arena needs it because it fbm-displaces; the tube's
wave is smooth in theta. No change made.
- "cap geometry at the span ends" — a thinko. The canal is continuous; a room is
a fat part of it. Only the fundus dome caps, and it stays an icosphere.
- cost — cheaper than feared: 9 draws / 98k tris worst-frame for a radius-70
cathedral vs 8 / 74k for the L2 corridor. No leak over a full sweep.
- "one schema flag" — held. `segments[].mode: "open"` is the entire ask.
THE TWO REAL COSTS, NEITHER PREDICTED:
1. D's `tile[0]` is a COUNT, not a density — only a texel size if radius never
changes. Measured 6.1:1 smear at r=70. The wall now derives the count from
each ring's own arc length (aRadius attr + uThetaSpan); D's authored numbers
keep their exact meaning at their reference radius, no re-authoring. That fix
then laid a seam down the canal (a fractional count can't wrap) -> rounded to
an integer, seam gone. Not a no-op on corridors: L2's hiatus takes 3 repeats
where it took 4, which is the texel size correctly holding still as the pipe
narrows. Eyeballed.
2. MY OWN PINCH GUARD WAS WRONG and would have blocked C. The fixture scored
1.30 and "failed" the >1.5 law; it was never bent. stats() divided the
tightest turn ANYWHERE by the widest radius ANYWHERE — 996 units apart, a
bend in the radius-13 pylorus against the radius of the sea. Locally its
worst point is 6.4. pinchRatio is now min over s of turnRadius(s)/radius(s).
Provably one-directional (global <= local by construction) so nothing that
passed can fail; real levels GAINED headroom: L2 3.32->4.16, L3 1.91->6.99,
L1 2.11->6.06. This mattered — the guard is what backs the round-1 promise
that C never has to think about curvature.
THE ACID SEA (world/acid.js, `level.acid {from,to,height,biome}`):
Flat emissive #c8ff3a, level and NOT tube-following — that's the mechanic. The
best find of the session: `height` is ONE NUMBER that tunes the level. Measured:
-18 -> 0% of the sea floor is dry shallow, -55 -> 14%, -62 -> 48%. So C's "position
IS the resource" falls out of the radius wobble they already author — the mucus
shallows are just "where the wall rises above the waterline", nothing to place —
and rising acid literally drowns them: free escalation, same scalar their event
pump drives. Also disproved my own claim: the centreline stays level (+/-1u over
700u), so shallows come from radius wobble, NOT the canal's bends. C must author
vertical bends deliberately if they want depth by anatomy.
Stated plainly, not papered over: the waterline is prototype quality (the plane
meets faceted rings and the shoreline steps). Acid does not drain the coat —
depthAt(pos) is the hook and the cost is Lane B's call. Nothing drives height but
DBG until C's events do.
qa GREEN incl. the new provenance gate; spline selfcheck green; L2 corridor
re-eyeballed for regression. Rulings requested from F in NOTES §-> Lane F.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
314 lines
15 KiB
JavaScript
314 lines
15 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';
|
||
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_<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' };
|
||
|
||
// 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;
|
||
}
|