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>
135 lines
6.3 KiB
JavaScript
135 lines
6.3 KiB
JavaScript
// 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 <colorspace_fragment>` 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 <colorspace_fragment>
|
|
}`,
|
|
});
|
|
|
|
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(); },
|
|
};
|
|
}
|