GDD v1 (flow-locked hybrid movement, 5 biomes + secret, boss roster), TECH contracts (world API, level schema v0, bus events, manifest law), ART_BIBLE (synthetic scanner look), PIPELINE (MODELBEAST-first, fal gated), PROCESS (PROCITY lane/round law), charters A-F + ROUND1 instructions. Seed code: shell + boot + core (rng/bus/flags) + stub world (peristalsis shader tube, 1 draw / 102k tris, contract-complete) + qa.sh (GREEN). Verified in-browser; evidence docs/shots/laneF/round0_stub_tube.png. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
7.0 KiB
JavaScript
167 lines
7.0 KiB
JavaScript
// stub/world_stub.js (Lane F) — executable spec of THE WORLD CONTRACT (docs/TECH.md).
|
|
// A straight-ish pulsing esophagus tube, analytic everywhere. Lane A replaces this with
|
|
// js/world/index.js; until then every lane builds against this. Keep contract-identical.
|
|
|
|
import * as THREE from 'three';
|
|
|
|
export const STUB_LEVEL = {
|
|
id: 'STUB', name: 'Stub Esophagus', seed: 20260716,
|
|
segments: [{ biome: 'esophagus', length: 400, radius: { base: 10, wobble: 0.15 }, curviness: 0.3, flow: 14 }],
|
|
arenas: [],
|
|
events: [
|
|
{ s: 60, type: 'spawn', enemy: 'floater', count: 4 },
|
|
{ s: 140, type: 'spawn', enemy: 'seeker', count: 3 },
|
|
{ s: 200, type: 'checkpoint' },
|
|
{ s: 260, type: 'spawn', enemy: 'turret', count: 2 },
|
|
],
|
|
};
|
|
|
|
const BIOME = { // mirrors ART_BIBLE esophagus row; A's biomes.js is the real registry
|
|
id: 'esophagus', flow: 14, coatDrain: 0.5,
|
|
palette: { tint: 0x1e6e64, rim: 0x5affd2, void: 0x02100d },
|
|
fog: 0.028,
|
|
};
|
|
|
|
const WAVE_A = 0.9; // peristalsis displacement amplitude (units, inward)
|
|
const WAVE_K = 0.22; // spatial frequency (rad/unit along s)
|
|
const WAVE_W = 3.0; // angular speed (rad/s)
|
|
const SKIN = 0.6; // collision safety margin
|
|
|
|
export async function createWorld(levelData = STUB_LEVEL, { rng } = {}) {
|
|
const seg = levelData.segments[0];
|
|
const LEN = seg.length, R = seg.radius.base;
|
|
const AX = seg.curviness * 8, AY = seg.curviness * 5; // gentle analytic centreline
|
|
const KX = 0.020, KY = 0.031;
|
|
|
|
// --- centreline & frames (analytic; s ≈ arclength for these gentle amplitudes) ---
|
|
const centre = (s) => new THREE.Vector3(AX * Math.sin(s * KX), AY * Math.sin(s * KY), -s);
|
|
const tangent = (s) => new THREE.Vector3(AX * KX * Math.cos(s * KX), AY * KY * Math.cos(s * KY), -1).normalize();
|
|
function sample(s) {
|
|
const tan = tangent(s);
|
|
const up = new THREE.Vector3(0, 1, 0);
|
|
const nor = up.clone().addScaledVector(tan, -up.dot(tan)).normalize(); // Gram-Schmidt (stable: tube never goes vertical)
|
|
const bin = new THREE.Vector3().crossVectors(tan, nor);
|
|
return { pos: centre(s), tan, nor, bin, radius: radiusAt(s) };
|
|
}
|
|
const radiusAt = (s) => R * (1 + seg.radius.wobble * Math.sin(s * 0.05) * Math.sin(s * 0.013 + 2.1));
|
|
const flowPulse = (s, t) => Math.pow(Math.max(0, Math.sin(WAVE_K * s - WAVE_W * t)), 3);
|
|
|
|
// --- geometry: rings along s, uv = (theta/2pi, s), inward-facing ---
|
|
// seam ring vertex is duplicated (RADIAL+1 columns) so uv.x runs 0..1 without wrapping
|
|
// backwards across the last quad (visible streak otherwise).
|
|
const RADIAL = 64, COLS = RADIAL + 1, STEP = 0.5;
|
|
const rings = Math.floor(LEN / STEP) + 1;
|
|
const pos = new Float32Array(rings * COLS * 3);
|
|
const inward = new Float32Array(rings * COLS * 3);
|
|
const uv = new Float32Array(rings * COLS * 2);
|
|
let p = 0, q = 0;
|
|
for (let i = 0; i < rings; i++) {
|
|
const s = i * STEP, f = sample(s);
|
|
for (let j = 0; j < COLS; j++) {
|
|
const th = (j / RADIAL) * Math.PI * 2;
|
|
const dir = f.nor.clone().multiplyScalar(Math.cos(th)).addScaledVector(f.bin, Math.sin(th));
|
|
const v = f.pos.clone().addScaledVector(dir, f.radius);
|
|
pos[p] = v.x; pos[p + 1] = v.y; pos[p + 2] = v.z;
|
|
inward[p] = -dir.x; inward[p + 1] = -dir.y; inward[p + 2] = -dir.z;
|
|
p += 3;
|
|
uv[q++] = j / RADIAL; uv[q++] = s;
|
|
}
|
|
}
|
|
const idx = [];
|
|
for (let i = 0; i < rings - 1; i++)
|
|
for (let j = 0; j < RADIAL; j++) {
|
|
const a = i * COLS + j, b = i * COLS + j + 1, c = a + COLS, d = b + COLS;
|
|
idx.push(a, c, b, b, c, d); // wound to face inward
|
|
}
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
geo.setAttribute('aInward', new THREE.BufferAttribute(inward, 3));
|
|
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
geo.setIndex(idx);
|
|
|
|
const mat = new THREE.ShaderMaterial({
|
|
side: THREE.FrontSide,
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uTint: { value: new THREE.Color(BIOME.palette.tint) },
|
|
uRim: { value: new THREE.Color(BIOME.palette.rim) },
|
|
uVoid: { value: new THREE.Color(BIOME.palette.void) },
|
|
uFog: { value: BIOME.fog },
|
|
},
|
|
vertexShader: /* glsl */`
|
|
attribute vec3 aInward;
|
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView;
|
|
uniform float uTime;
|
|
void main() {
|
|
vUv = uv;
|
|
float pulse = pow(max(0.0, sin(${WAVE_K.toFixed(3)} * uv.y - ${WAVE_W.toFixed(2)} * uTime)), 3.0);
|
|
float breathe = 0.15 * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831 * 3.0);
|
|
vec3 disp = position + aInward * (${WAVE_A.toFixed(2)} * pulse + breathe);
|
|
vec4 mv = modelViewMatrix * vec4(disp, 1.0);
|
|
vN = normalize(normalMatrix * aInward);
|
|
vView = -mv.xyz;
|
|
gl_Position = projectionMatrix * mv;
|
|
}`,
|
|
fragmentShader: /* glsl */`
|
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView;
|
|
uniform vec3 uTint; uniform vec3 uRim; uniform vec3 uVoid; uniform float uFog; uniform float uTime;
|
|
void main() {
|
|
// fake SEM detail until Lane D textures land: ridged folds along theta + s striation
|
|
float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831 * 9.0 + sin(vUv.y * 0.9) * 2.0);
|
|
float stria = 0.85 + 0.15 * sin(vUv.y * 2.2);
|
|
vec3 base = uTint * folds * stria * 0.8;
|
|
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), 2.2);
|
|
vec3 col = base + uRim * fres * 0.9;
|
|
float d = length(vView);
|
|
col = mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55));
|
|
gl_FragColor = vec4(col, 1.0);
|
|
}`,
|
|
});
|
|
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.frustumCulled = false; // one chunk; A's real world streams + culls properly
|
|
const group = new THREE.Group();
|
|
group.name = 'world';
|
|
group.add(mesh);
|
|
|
|
let time = 0;
|
|
const world = {
|
|
level: levelData,
|
|
length: LEN,
|
|
group,
|
|
sample,
|
|
flowPulse: (s, t = time) => flowPulse(s, t),
|
|
project(v) {
|
|
const s = THREE.MathUtils.clamp(-v.z, 0, LEN);
|
|
const f = sample(s);
|
|
const rel = v.clone().sub(f.pos);
|
|
return { s, theta: Math.atan2(rel.dot(f.bin), rel.dot(f.nor)), rho: rel.length() };
|
|
},
|
|
wallRho: (s, _theta) => radiusAt(s) - WAVE_A - SKIN,
|
|
collide(v, r = 0) {
|
|
const { s, theta, rho } = world.project(v);
|
|
const max = world.wallRho(s, theta) - r;
|
|
if (rho <= max) return null;
|
|
const f = sample(s);
|
|
const dir = f.nor.clone().multiplyScalar(Math.cos(theta)).addScaledVector(f.bin, Math.sin(theta));
|
|
return { push: dir.multiplyScalar(max - rho), kind: 'wall', biome: BIOME.id };
|
|
},
|
|
biomeAt: (_s) => BIOME,
|
|
modeAt: (_s) => 'tube',
|
|
arenaAt: (_s) => null,
|
|
update(dt, _playerS) { time += dt; mat.uniforms.uTime.value = time; },
|
|
hash() {
|
|
let h = 2166136261 >>> 0;
|
|
for (let s = 0; s <= LEN; s += 10) {
|
|
const v = sample(s).pos;
|
|
for (const x of [v.x, v.y, v.z, radiusAt(s)]) {
|
|
h ^= Math.round(x * 1000) & 0xffff; h = Math.imul(h, 16777619);
|
|
}
|
|
}
|
|
return (h >>> 0).toString(16);
|
|
},
|
|
dispose() { geo.dispose(); mat.dispose(); },
|
|
};
|
|
return world;
|
|
}
|