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