Wired B's player/combat + event pump, D's assets, C's level pick into boot. Fixed qa ESM gate (node --check no-op, found by B) + added spline selfcheck. TECH: world contract FROZEN v1.1, bus events ratified, colorspace + crest-speed laws (CREST_FACTOR 1.6 — B proved surfing lost to throttle at 1.0). Stub complies. Verified integrated build via 60s stepped sim: 9 draws, 0 errors, 0 asset misses; one known gap (fiction-id spawn resolve) confirmed and assigned as B's top item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
8.7 KiB
JavaScript
192 lines
8.7 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';
|
||
|
||
// Sample level — kept in sync with LEVEL SCHEMA v1 by Lane C (round 1, authorised by
|
||
// ROUND1_INSTRUCTIONS §Lane C.1). Schema of record: docs/TECH.md §Level data schema v1.
|
||
// This is the schema's shop window: if you are implementing against level data, this is the
|
||
// smallest complete example of it.
|
||
//
|
||
// Two deliberate restraints, both so this stays a stub and not a level:
|
||
// - It keeps the BARE archetype ids ('floater'/'seeker'/'turret') rather than the fiction
|
||
// ids that real levels use ('bolus_chunk'...). Lane B's pools are keyed by archetype and
|
||
// B's dev harness (flight/dev.js) reads STUB_LEVEL directly, so renaming here would break
|
||
// a live lane for nothing. levels/enemies.js resolves both (passthrough entries).
|
||
// - Turrets are authored one-per-event with a scalar `theta`. Real levels may use a
|
||
// `theta: [a, b]` array for count > 1; that form needs one line in B's spawn loop first
|
||
// (snippet in LANE_C_NOTES.md §→ Lane B), so it is not demonstrated here yet.
|
||
export const STUB_LEVEL = {
|
||
schema: 1,
|
||
id: 'STUB', name: 'Stub Esophagus', seed: 20260716,
|
||
par: { time: 30, score: 900, samples: 0 },
|
||
segments: [{
|
||
biome: 'esophagus', name: 'Stub Esophagus',
|
||
length: 400, radius: { base: 10, wobble: 0.15 }, curviness: 0.3, flow: 14,
|
||
}],
|
||
arenas: [],
|
||
events: [
|
||
{ s: 60, type: 'spawn', enemy: 'floater', count: 4, spread: 60 },
|
||
{ s: 140, type: 'spawn', enemy: 'seeker', count: 3, spread: 40 },
|
||
{ s: 200, type: 'checkpoint', name: 'Stub Checkpoint' },
|
||
{ s: 260, type: 'spawn', enemy: 'turret', count: 1, theta: 0 },
|
||
{ s: 280, type: 'spawn', enemy: 'turret', count: 1, theta: 3.1416 },
|
||
{ s: 320, type: 'pickup', kind: 'nutrient_orb', count: 3, spread: 40 },
|
||
],
|
||
};
|
||
|
||
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 CREST = 1.6; // crest phase speed = CREST × flow. Round-2 ruling: must beat
|
||
// throttleMax (1.4) or surfing loses to throttle-mashing (Lane B,
|
||
// round 1). ω = k · CREST · flow, so at flow 14: 22.4 u/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 waveW = WAVE_K * CREST * seg.flow; // crest phase speed ω/k = CREST × flow
|
||
const flowPulse = (s, t) => Math.pow(Math.max(0, Math.sin(WAVE_K * s - waveW * 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 - ${waveW.toFixed(3)} * 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);
|
||
#include <colorspace_fragment>
|
||
}`,
|
||
});
|
||
|
||
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),
|
||
crestSpeed: (_s) => CREST * seg.flow, // u/s the crest travels at s (contract v1.1)
|
||
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;
|
||
}
|