guts/web/js/world/arena.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

126 lines
6.1 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 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;
}
// 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.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(); },
};
}