// world/acid.js (Lane A) — the acid sea's surface. GDD §3: the stomach's animated `#c8ff3a` // level, the thing that makes the acid sea a SEA and not just a big room. // // PROTOTYPE, round 2. It renders and it animates; it does not yet drain your coat, because the // height it should sit at is Lane C's to drive (`level:event` → a height, per their L3 design) // and Lane B owns what touching it costs. Both hooks are here and unclaimed: // world.acid.height read it; it's the y of the surface in world space // world.acid.set(y, dt) drive it; C's event pump or DBG can call this // world.acid.depthAt(pos) >0 means submerged, in units. B: this is your drain test. // // Why a horizontal plane and not a shell: acid is a fluid, so it is level — it does NOT follow // the tube. That is the entire point of it as a mechanic. The canal dives and climbs through // its own bends (the greater curvature sinks, the lesser rises), so a single flat plane at a // fixed y makes some of the room lethal and some of it safe **for free, out of the geometry // C already authored** — no lanes, no volumes, no authoring. Fly low and you swim; hug the // ceiling of the sea and you don't. The mucus shallows are then just "where the wall rises // above the surface", which is a fact about the spline, not a thing anyone has to place. // // Colorspace: `#include ` at the end of main() is LAW (TECH §Shader law). import * as THREE from 'three'; const ACID = 0xc8ff3a; // ART_BIBLE §Colour: acid / corrosive zones /** * @param {object} spec `level.acid`: { from, to, height, biome } * @param {object} spline * @param {object} opts { fog, void: hex } — matched to the room's material by index.js */ export function createAcid({ spec, spline, fog = 0.02, voidColor = 0x120801, quality = 'high' }) { // Size the plane to the span it covers, plus the widest wall in it, so the surface always // reaches the rock. Sampling the radius rather than trusting `radius.base`: wobble is real // and a plane that stops short of the wall shows a seam of void at the waterline. let halfWidth = 0; const step = 4; for (let s = spec.from; s <= spec.to; s += step) halfWidth = Math.max(halfWidth, spline.radiusAt(s) * 1.35); const pts = []; for (let s = spec.from; s <= spec.to; s += step) { const f = spline.frameAt(s); pts.push(new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z)); } const box = new THREE.Box3().setFromPoints(pts).expandByScalar(halfWidth); // A plane in XZ. Segments are for the vertex ripple, not for shape: 1 unit per segment is // ample for a wave whose wavelength is ~14 units, and it keeps a 300x300u sea under 2k tris. const w = box.max.x - box.min.x, d = box.max.z - box.min.z; const seg = quality === 'low' ? 24 : Math.min(96, Math.max(16, Math.round(Math.max(w, d) / 6))); const geo = new THREE.PlaneGeometry(w, d, seg, seg); geo.rotateX(-Math.PI / 2); // PlaneGeometry is XY; we want a floor const mat = new THREE.ShaderMaterial({ transparent: true, side: THREE.DoubleSide, // you will be under it, and that must read depthWrite: false, // it's a fluid surface, not an occluder uniforms: { uTime: { value: 0 }, uAcid: { value: new THREE.Color(ACID) }, uVoid: { value: new THREE.Color(voidColor) }, uFog: { value: fog }, uOpacity: { value: 0.62 }, }, vertexShader: /* glsl */` uniform float uTime; varying vec2 vP; varying vec3 vView; varying float vRipple; void main() { vP = position.xz; // Two crossing swells + a fine chop. Cheap, and it never repeats visibly because the // three periods are mutually irrational-ish. float a = sin(position.x * 0.09 + uTime * 0.8); float b = sin(position.z * 0.13 - uTime * 0.6); float c = sin((position.x + position.z) * 0.31 + uTime * 1.7); vRipple = a * 0.5 + b * 0.4 + c * 0.18; vec3 p = position + vec3(0.0, vRipple * 0.55, 0.0); vec4 mv = modelViewMatrix * vec4(p, 1.0); vView = -mv.xyz; gl_Position = projectionMatrix * mv; }`, fragmentShader: /* glsl */` uniform vec3 uAcid, uVoid; uniform float uFog, uOpacity, uTime; varying vec2 vP; varying vec3 vView; varying float vRipple; void main() { // Caustic-ish banding: the surface is emissive (no lights in GUTS), so the "light" has // to be in the colour itself. Bright where the swells pinch, dark in the troughs. float band = 0.5 + 0.5 * sin(vRipple * 3.4 + uTime * 0.9); float foam = smoothstep(0.72, 1.0, abs(vRipple)); // crests catch a hot rim vec3 col = uAcid * (0.42 + band * 0.5) + vec3(0.85, 1.0, 0.55) * foam * 0.5; // Same exponential fog to the same void as the wall, or the sea would float free of // the room it is in — the far shore must dissolve exactly like the far wall does. float dist = length(vView); float f = 1.0 - exp(-uFog * dist * 0.55); col = mix(col, uVoid, f); // Grazing angles read as a slab of fluid; straight down you see through it. Without // this it looks like a sheet of paper laid in the room. float graze = 1.0 - abs(normalize(vView).y); float alpha = uOpacity * mix(0.55, 1.0, graze) * (1.0 - f * 0.85); gl_FragColor = vec4(col, alpha); #include }`, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.set((box.min.x + box.max.x) / 2, spec.height ?? 0, (box.min.z + box.max.z) / 2); mesh.renderOrder = 2; // after the walls: it's transparent mesh.name = `acid ${spec.from}..${spec.to}`; let time = 0; return { spec, mesh, get height() { return mesh.position.y; }, /** C's event pump (or DBG) drives this. dt > 0 eases; dt = 0 snaps. */ set(y, dt = 0) { mesh.position.y = dt > 0 ? THREE.MathUtils.damp(mesh.position.y, y, 1.6, dt) : y; }, /** B: >0 means submerged, in units. Outside the acid's s-span it is always <= 0. */ depthAt(pos) { const s = spline.project(pos).s; if (s < spec.from || s > spec.to) return -1; return mesh.position.y - pos.y; }, update(dt) { time += dt; mat.uniforms.uTime.value = time; }, dispose() { geo.dispose(); mat.dispose(); }, }; }