guts/web/js/stub/world_stub.js
jing 4ccf5edcc0 [lane F] Round 1 integration + ROUND2 instructions: the game is a game
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>
2026-07-16 14:39:02 +10:00

192 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}