// 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(); }, }; }