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>
133 lines
6.6 KiB
JavaScript
133 lines
6.6 KiB
JavaScript
// world/arena.js (Lane A) — arena shells, v0. A displaced icosphere wearing the same wall
|
|
// shader as the tube, so the mouth/stomach/boss lairs are the same material world as the
|
|
// corridors (ART_BIBLE) and Lane B's 6DOF clamp has a real `arenaAt` to clamp against.
|
|
//
|
|
// v0 scope, stated plainly: the shell + its bounds. The stomach's animated acid plane
|
|
// (GDD §3, emissive #c8ff3a, height driven by C's events) is round 2 — see LANE_A_NOTES.
|
|
|
|
import * as THREE from 'three';
|
|
|
|
/** Seeded 3D value noise. Same lattice trick as spline.js: a table, not a hash function. */
|
|
function makeNoise3(rng) {
|
|
const N = 64, tab = new Float32Array(N * N * N);
|
|
const r = rng('world.arena');
|
|
for (let i = 0; i < tab.length; i++) tab[i] = r() * 2 - 1;
|
|
const at = (x, y, z) => tab[(((x & 63) * N + (y & 63)) * N + (z & 63))];
|
|
const fade = (t) => t * t * (3 - 2 * t);
|
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
function vnoise(x, y, z) {
|
|
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
|
const xf = fade(x - xi), yf = fade(y - yi), zf = fade(z - zi);
|
|
const c00 = lerp(at(xi, yi, zi), at(xi + 1, yi, zi), xf);
|
|
const c10 = lerp(at(xi, yi + 1, zi), at(xi + 1, yi + 1, zi), xf);
|
|
const c01 = lerp(at(xi, yi, zi + 1), at(xi + 1, yi, zi + 1), xf);
|
|
const c11 = lerp(at(xi, yi + 1, zi + 1), at(xi + 1, yi + 1, zi + 1), xf);
|
|
return lerp(lerp(c00, c10, yf), lerp(c01, c11, yf), zf);
|
|
}
|
|
return (x, y, z) => {
|
|
let sum = 0, amp = 1, f = 1, n = 0;
|
|
for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * f, y * f, z * f); n += amp; amp *= 0.5; f *= 2.07; }
|
|
return sum / n;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {object} spec level `arenas[]` entry: { at, radius, biome }
|
|
* @param {object} spline
|
|
* @param {THREE.Material} material a wall material built for this arena's biome
|
|
* @param {function} rng
|
|
*/
|
|
export function createArena({ spec, spline, material, rng, quality = 'high', waveAmpDefault = 0.7 }) {
|
|
// three's polyhedron `detail` splits each edge into (detail+1) segments, so face count is
|
|
// 20*(detail+1)^2 — NOT 20*4^detail. detail:5 is 720 tris, which on a 55-unit room is a
|
|
// 10-unit facet and the fbm displacement has nothing to displace. Solve for ~3u spacing
|
|
// instead (icosahedron edge ~ 1.05r), so arena cost tracks arena size.
|
|
const spacing = quality === 'low' ? 6 : 3;
|
|
const detail = Math.max(3, Math.min(24, Math.round((1.05 * spec.radius) / spacing) - 1));
|
|
const noise = makeNoise3(rng);
|
|
const f = spline.frameAt(spec.at);
|
|
const center = new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z);
|
|
|
|
const geo = new THREE.IcosahedronGeometry(spec.radius, detail); // non-indexed
|
|
const pos = geo.attributes.position;
|
|
const n = pos.count;
|
|
const position = new Float32Array(n * 3);
|
|
const aInward = new Float32Array(n * 3);
|
|
const aTangent = new Float32Array(n * 3);
|
|
const uv = new Float32Array(n * 2);
|
|
const aPhase = new Float32Array(n);
|
|
const aK = new Float32Array(n);
|
|
const aWaveA = new Float32Array(n);
|
|
// The shell wears the same wall material as the tube, so it owes the same attributes. Its
|
|
// uv is spherical, so "radius around theta" is the radius of the ring this vertex sits on
|
|
// (shrinking to 0 at the poles), not the sphere's radius — that is what keeps the texel
|
|
// density matched to the tube's and the pole from smearing worse than it already does.
|
|
const aRadius = new Float32Array(n);
|
|
|
|
// The churn wave crosses the room along the canal's own axis, slowly enough to read as a
|
|
// room breathing rather than a corridor's transit wave.
|
|
const k = 3.08 / Math.max(4, spec.radius / 5);
|
|
const axis = new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z);
|
|
const ref = new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z);
|
|
const bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z);
|
|
const amp = spec.radius * 0.09;
|
|
// A room churns, it doesn't transit: the shell's wave amplitude comes from the arena's own
|
|
// biome (or C's per-arena override), never from whatever segment happens to span it.
|
|
const waveAmp = typeof spec.wave?.amp === 'number' ? spec.wave.amp : waveAmpDefault;
|
|
const v = new THREE.Vector3();
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
v.fromBufferAttribute(pos, i);
|
|
const dir = v.clone().normalize();
|
|
const r = spec.radius + amp * noise(dir.x * 2.3 + 11, dir.y * 2.3 + 5, dir.z * 2.3 + 3);
|
|
const p = dir.clone().multiplyScalar(r);
|
|
position[i * 3] = p.x; position[i * 3 + 1] = p.y; position[i * 3 + 2] = p.z;
|
|
aInward[i * 3] = -dir.x; aInward[i * 3 + 1] = -dir.y; aInward[i * 3 + 2] = -dir.z;
|
|
aTangent[i * 3] = axis.x; aTangent[i * 3 + 1] = axis.y; aTangent[i * 3 + 2] = axis.z;
|
|
const along = p.dot(axis);
|
|
uv[i * 2] = (Math.atan2(p.dot(bin), p.dot(ref)) / (Math.PI * 2)) + 0.5;
|
|
uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube
|
|
aPhase[i] = k * (spec.at + along);
|
|
aK[i] = k;
|
|
aWaveA[i] = waveAmp;
|
|
aRadius[i] = Math.hypot(p.dot(ref), p.dot(bin)); // distance from the room's own axis
|
|
}
|
|
|
|
// Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it
|
|
// from ~1 back to ~0 and the wall shader's fold pattern crams a full cycle into that one
|
|
// triangle — a zigzag scar down the room. The geometry is non-indexed, so each triangle owns
|
|
// its three vertices and we can just push the low ones past the wrap.
|
|
for (let t = 0; t < n; t += 3) {
|
|
let lo = Infinity, hi = -Infinity;
|
|
for (let j = 0; j < 3; j++) { const x = uv[(t + j) * 2]; lo = Math.min(lo, x); hi = Math.max(hi, x); }
|
|
if (hi - lo > 0.5) for (let j = 0; j < 3; j++) if (uv[(t + j) * 2] < 0.5) uv[(t + j) * 2] += 1;
|
|
}
|
|
|
|
const g = new THREE.BufferGeometry();
|
|
g.setAttribute('position', new THREE.BufferAttribute(position, 3));
|
|
g.setAttribute('aInward', new THREE.BufferAttribute(aInward, 3));
|
|
g.setAttribute('aTangent', new THREE.BufferAttribute(aTangent, 3));
|
|
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
|
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
|
g.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1));
|
|
g.setAttribute('aRadius', new THREE.BufferAttribute(aRadius, 1));
|
|
g.computeBoundingSphere();
|
|
geo.dispose(); // the source icosphere was scaffolding
|
|
|
|
const mesh = new THREE.Mesh(g, material); // material built with side: BackSide
|
|
mesh.position.copy(center);
|
|
mesh.name = `arena ${spec.biome} @${spec.at}`;
|
|
|
|
return {
|
|
spec,
|
|
mesh,
|
|
center,
|
|
radius: spec.radius,
|
|
/** Conservative inner surface: shell minus displacement peak minus the shader's wave. */
|
|
innerRadius: spec.radius - amp - waveAmp - 0.6,
|
|
covers: (s) => Math.abs(s - spec.at) <= spec.radius,
|
|
dispose() { g.dispose(); },
|
|
};
|
|
}
|