Compare commits

...

4 Commits

Author SHA1 Message Date
jing
29cca53675 [lane F] Mid-round-2 recovery: the glue that makes L2 a game
All round-2 lane sessions were cut off 15:08-15:16 before NOTES/commits; the three prior
commits preserve their work verbatim. This commit is the wiring B was cut off inside of,
plus the run-state boot owes per ruling #4:

- combat/index.js: fiction-id -> archetype resolve via C's getEnemy() (the round's declared
  ONE blocker), theta-array + spread fan-out for group spawns, hazards + pickups constructed
  and updated (order: enemies -> weapons -> hazards -> pickups, so a torpedo can neutralize
  a surge the frame it detonates), pickup score/samples onto the combat:state scoreboard,
  combat.reset(fromS) for respawn.
- flight/player.js: kill/shove/refill — the API B's new modules call; kill ignores iframes
  by design (boost-dodging through C's lethal acid wall is not a read). Deleted the
  crestSpeed fallback guard per its own comment — A's law landed.
- boot.js: run state. checkpoint crossed -> remembered; death -> 2s -> respawn at it +
  combat.reset() re-arms hazard timelines (pump deliberately does NOT rewind: no
  double-spawns, collected stays collected); gate -> level:complete {stats, par} for E's
  medal card. Exported step(dt) so the stepped-sim harness drives the REAL loop.
- ROUND2_INSTRUCTIONS: MID-ROUND STATUS box for the resuming lanes; f-progress entry.

Verified (deterministic stepped sim via boot.step, :8140): full L2 run fires all 47 events
with ZERO console warns (was: 11 spawn no-ops), all 5 hazard telegraphs, 2400 proximity
ticks, 14 pickups collected, finish 157s vs par 180 while surfing 94s of it. Death path:
throttle 0.7 into the finale -> killed by acid s=3386 -> respawn at Cardia Approach 2980 ->
surge re-arms -> second attempt escapes -> level:complete {deaths: 1}. Survivable AND
losable. Evidence: docs/shots/laneF/round2_L2_integrated_full_run.png. qa.sh GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:39:44 +10:00
jing
2e85ec0622 [lane C] Round 2 WIP (session cut off): schema v2, surf-aware pacing, chase analysis, --probe
Landed before the cut: schema v2 (segments[].wave.amp override, validated — breathe stays
biome-owned); hiatus re-authored tight AND calm (radius back to 6.5, amp 0.6, 3.3u steady
clearance); par 210 -> 180 with full rationale (below throttle-only par-pace, above a surf
run — the medal teaches the mechanic); finale surge 21 -> 24 so surfing is the stylish
escape (required 1.20x); surf profile reads A's live CREST_FACTOR; chaseAnalysis() +
escapability laws in validate; NEW --probe builds A's real canal and measures clearance
against the model (all L2 segments clear the 2.5 floor, hiatus 3.75); isMain guard +
importSpline shim — fixes A's selfcheck process.exit killing C's mid-run (qa.sh had been
green on one level out of three).

Cut off before: fly-through of the integrated build (blocked on B), pickup economy handover
(B proposed numbers in balance.js meanwhile — reconcile), L1 encounter design doc, GDD boss
folding, NOTES. Committed by F to protect the shared tree; levels selfcheck 3/3 GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:26:42 +10:00
jing
4ed3433e55 [lane B] Round 2 WIP (session cut off): surf speed-lock, hazards + pickups modules, rho-fraction spawns
Landed before the cut: surf as a speed-lock onto world.crestSpeed(s) with surf.authority
(ruling #1) — riding beats throttle and holding a crest is throttle discipline; tuning.js
header corrected to the real wallRho frame (C's flag); enemies.js reads rho as a FRACTION
of safe radius (C's placement law); balance.js hazard + pickup constants; NEW hazards.js
(reflux_surge / aortic_squeeze / ring_gate, self-scheduling telegraphs, reset(fromS) built
for respawn) and NEW pickups.js (all five kinds, shape-coded).

Cut off before: wiring — nothing imports hazards/pickups yet; the fiction-id -> archetype
resolve (round-2 task #1) does NOT exist despite the enemies.js comment saying index.js
does it; player.kill/shove/refill are called but were never added; checkpoint/respawn and
gate -> level:complete not started; NOTES unwritten. F lands the glue next commit.
Committed by F to protect the shared tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:26:15 +10:00
jing
f0982e70c3 [lane A] Round 2 WIP (session cut off): crest-speed law, taper blending, schema-v2 wave, TBN walls
Landed before the cut: crestSpeed(s) + CREST_FACTOR 1.6 (ruling #1) with selfcheck
asserts; radius blend widened +/-12 -> +/-25 (C's #4 dependency) + no-cliff selfcheck;
per-segment wave.amp override as per-vertex aWaveA (ruling #8); colorspace law in the
wall shader (ruling #2); TBN normal maps + matcap + dual detail layers (D's perturb(),
trap documented in-shader); sample(s, out) v1.2; slug map shrunk to the two real
mismatches. Evidence: docs/shots/laneA/round2_L2_*.png.

Cut off before: NOTES/progress, stomach-arena shape read for C (task #7).
Committed by F to protect the shared tree; spline + qa selfchecks GREEN at commit time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:26:01 +10:00
22 changed files with 1203 additions and 164 deletions

View File

@ -1,5 +1,35 @@
# GUTS — Round 2 lane instructions (from Fable, integrator)
> ## ⚡ MID-ROUND STATUS (F, 2026-07-16 evening) — read this first if you are resuming
>
> All round-2 sessions were **cut off 15:0815:16** before NOTES or commits. F triaged,
> verified and committed the orphaned work per lane (`f0982e7` A · `4ed3433` B · `2e85ec0` C —
> read your commit message, it is the NOTES you never got to write), then landed the wiring
> B was cut off in the middle of. **The tree is GREEN and L2 now plays start-to-finish:
> every spawn resolves, all three hazards run, pickups collect, checkpoint→death→respawn
> works, gate emits `level:complete {stats, par}`.** Proof: f-progress + laneF shots.
>
> What F landed that touches your lane (F commit, post-`2e85ec0`):
> - **B**: your round-2 #1#5 are DONE in skeleton — resolve + theta-arrays/spread
> (combat/index.js), hazards+pickups wired, `player.kill/shove/refill` added,
> respawn/`level:complete` glue in boot. Your crestSpeed fallback guard is deleted (A
> landed the law). STILL YOURS: re-measure surf economy in-engine (#6), hazard feel +
> your proposed pickup values vs C's economy, arena 6DOF stretch, NOTES.
> - **A**: nothing of yours changed. STILL YOURS: stomach-arena shape read for C (#7),
> biome-tint re-eyeball vs D's textures (#4 second half), NOTES.
> - **C**: pump semantics settled: events fire ONCE per run, respawn does NOT rewind the
> pump, hazards re-arm via `combat.reset(fromS)`. `boot.step(dt)` now exists — your
> fly-through validation (#1) is unblocked and can run deterministically. STILL YOURS:
> fly it, pickup economy → B (B proposed placeholder values in balance.js §pickups),
> L1 design doc (#5), GDD boss folding (#4), NOTES.
> - **D/E**: no round-2 files reached the tree before the cut. Your lists below stand
> unchanged. E: the `level:complete` payload for your medal card is live and verified.
> - Ports: the cut sessions left sinks running — 8143 (→laneF shots) and 8144 (→laneA).
> Reuse them or pick a fresh port.
>
> Everything below is the original round-2 brief and still stands except where this box
> says F already did it.
Date: 2026-07-16 · Written after reviewing all four round-1 lanes (A/B/C/D — E did not run
and joins this round) and integrating their offered wiring. Every round-1 claim I checked
held up: A's hash `cefc4f83` and 8-draw sweep, B's `node --check` ESM no-op (reproduced on a

View File

@ -1,5 +1,34 @@
# Lane F (Fable) — progress
## 2026-07-16 (evening) — Mid-round-2 recovery: cut-off triage + the glue that makes L2 a game
All round-2 lane sessions (A/B/C + one more that left no files) were cut off 15:0815:16,
mid-work, before NOTES/progress/commits. Reviewed the orphaned tree file by file, verified it
(qa GREEN, both selfchecks, 60s stepped sim clean), then committed each lane's work path-scoped
on their behalf: A `f0982e7` (crest law, taper, schema-v2 wave, TBN walls — essentially done),
B `4ed3433` (surf speed-lock + hazards/pickups modules — written but UNWIRED), C `2e85ec0`
(schema v2, par retune, chase analysis, --probe — done). Details in each commit message.
Then landed the integration glue as F (B's unfinished wiring was the round's critical path):
fiction-id → archetype resolve via C's `getEnemy()` + theta-array/spread fan-out
(combat/index.js), hazards + pickups constructed and updated there too, `player.kill/shove/
refill` (the API B's new modules call — it didn't exist), boot run-state (checkpoint → death →
2s → respawn at last checkpoint + `combat.reset()` re-arms hazard timelines; gate →
`level:complete {stats, par}`), deleted B's crestSpeed fallback guard (A landed the real one),
and exported `boot.step(dt)` so the stepped-sim harness drives the REAL loop, pump included.
Verified on :8140, deterministic stepped sim through boot.step:
- Full run at throttle 1.38: **all 47 L2 events fire, 0 console warns** (the round-2 blocker
— 11 spawn no-ops — is gone), all 5 hazard telegraphs, 2400 hazard:proximity ticks,
14 pickups collected, gate → level:complete {time 157s vs par 180, score 2650, kills 15}.
- Death path: dawdle at 0.7 into the finale → killed by acid at s=3386 → respawns at
checkpoint 2980 (Cardia Approach, exactly as C authored) → surge re-arms → second attempt
escapes → level:complete {deaths: 1}. **L2 is survivable AND losable.**
Evidence: docs/shots/laneF/round2_L2_integrated_full_run.png (posted via the 8143 sink).
Still open for the resumed lanes: see ROUND2_INSTRUCTIONS §MID-ROUND STATUS. Port note:
8143 (laneF sink) and 8144 (laneA sink) are still held by the cut-off sessions' processes.
## 2026-07-16 — Round 1 review + integration (round 2 opened)
Reviewed all four lanes; every checked claim held (A's hash cefc4f83 + 8-draw sweep, B's

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

View File

@ -58,10 +58,46 @@ const player = flags.fly ? null
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null;
// --- level-event pump (owned by boot per round-2 ruling): emits C's events as the
// player crosses their s. Checkpoint/respawn replay semantics: revisit with B in round 2.
// player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump
// (no double-spawns, collected pickups stay collected); hazards re-arm via combat.reset(),
// which is the one replay a death actually needs (the thing that killed you works again).
let _evts = [...(world.level?.events ?? [])].sort((a, b) => a.s - b.s), _ei = 0;
const pumpTo = (s) => { while (_ei < _evts.length && s >= _evts[_ei].s) bus.emit('level:event', _evts[_ei++]); };
// --- run state: checkpoint -> death -> respawn, gate -> level:complete ------------------
// C's authoring laws carry this: checkpoints sit outside hazard zones and <=30s apart, so
// "respawn at the last checkpoint crossed" is safe by construction. Stats accumulate across
// deaths (a run's story includes its deaths); E renders the medal card off level:complete.
const run = { t: 0, deaths: 0, checkpointS: player?.state.s ?? 0, respawnT: 0, done: false };
bus.on('level:event', (ev) => {
if (ev.type === 'checkpoint') {
run.checkpointS = ev.s;
bus.emit('audio:cue', { name: 'checkpoint' });
} else if (ev.type === 'gate' && !run.done) {
run.done = true;
bus.emit('level:complete', {
id: world.level?.id ?? null, to: ev.to ?? null, gate: ev.id ?? null,
stats: {
time: run.t, deaths: run.deaths,
score: combat?.score.value ?? 0, kills: combat?.score.kills ?? 0,
samples: combat?.score.samples ?? 0,
},
par: world.level?.par ?? null,
});
}
});
bus.on('player:death', () => { run.deaths++; run.respawnT = 2.0; }); // 2s of feed-drop, then back
function updateRun(dt) {
if (!run.done) run.t += dt;
if (run.respawnT > 0 && player) {
run.respawnT -= dt;
if (run.respawnT <= 0) {
combat?.reset(run.checkpointS); // re-arm hazards at/ahead of the checkpoint
player.respawn(run.checkpointS);
}
}
}
// --- debug drift/fly camera (?fly=1, or fallback when B's player is absent) ---
let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6;
const keys = new Set();
@ -116,19 +152,27 @@ addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
});
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
// One deterministic simulation step — the whole game minus rendering. Exported so the
// stepped-sim harness (f-progress documents it; rAF never fires in a hidden pane) exercises
// the REAL loop — pump, run state and all — instead of a hand-rolled copy that drifts.
function step(dt) {
if (player) {
player.update(dt); // must run first: combat hit-tests this frame's ship position
pumpTo(player.state.s);
combat.update(dt);
updateRun(dt);
world.update(dt, player.state.s);
} else {
updateCamera(dt); // ?fly=1 noclip / title drift
world.update(dt, sCam);
}
}
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
step(dt);
renderer.render(scene, camera);
stats.draws = renderer.info.render.calls;
stats.tris = renderer.info.render.triangles;
@ -146,4 +190,4 @@ function frame(now) {
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
requestAnimationFrame(frame);
export { scene, camera, renderer, bus, flags, world, player, combat, assets };
export { scene, camera, renderer, bus, flags, world, player, combat, assets, step };

View File

@ -53,6 +53,56 @@ export const BALANCE = {
darts: { pool: 48, radius: 0.45 },
// --- hazards (round 2) -------------------------------------------------------------
// C authors WHERE and HOW MUCH in the level JSON (speed/period/amplitude/span/warn); these
// are the mechanical constants those params drive. C's params always win where they overlap.
hazards: {
// reflux_surge — the rear chaser. C's finale: speed 21 vs flow 20, spawning 40 u behind
// and chasing 600 u. Lethal on contact by C's authoring (`lethal: true`); the s=1700
// teaching burp is `lethal: false` and only grazes. Antacid stalls it (`neutralizable`).
surge: {
graze: 26, // coat damage/tick for the NON-lethal teaching version
grazeTick: 0.35, // s between graze ticks while inside it
stallFactor: 0.0, // speed multiplier while neutralised — 0 = fully parked.
// C's design leans on this: "fired backward it stalls the surge
// ~10 s" (their finale maths assumes a real stop, not a slow).
proximityRange: 220, // u within which we emit hazard:proximity (E's rear indicator)
catchRadius: 3.0, // u of s-overlap that counts as being caught
},
// aortic_squeeze — DIRECTIONAL by design (C): the arch presses from `theta` only and the
// opposite arc is the answer. Non-lethal, and C's teach-then-test ledger depends on that
// staying true (LANE_C_NOTES: "if B makes squeeze contact lethal, this breaks — tell me").
squeeze: {
graze: 14, // coat damage per contact tick
grazeTick: 0.3,
shove: 9, // u/s pushed off the bulging wall — the hazard MOVES you
arc: 1.9, // rad of the cross-section the bulge occupies (~110°, one side)
},
// ring_gate — a POINT, therefore timeable: the player modulates throttle to arrive on an
// open beat. Closed contact is expensive but never lethal (C: it is a skill check, and
// a flow-locked player cannot stop to wait).
gate: {
hit: 30, // coat damage for arriving on a closed beat
shove: 6, // u/s shove toward the centreline (the iris squeezes you through)
irisMin: 0.12, // fraction of the lumen still open when fully "sealed" — never a
// true wall: a flow-locked player physically cannot stop, so a
// real seal would be an unavoidable toll rather than a skill check
},
},
// --- pickups (round 2) -------------------------------------------------------------
// C owns placement + counts (level JSON); B owns what each one is WORTH. C's round-2 task
// is to hand over an economy — until it lands these are B's proposal, documented in NOTES.
pickups: {
radius: 1.5, // generous collect radius: pickups are a reward, not a dexterity test
spin: 1.4, // rad/s idle rotation
nutrient_orb: { score: 50, boost: 0.6, note: 'score + boost recharge (shaves cooldown)' },
mucin_glob: { coat: 35 },
b12_cell: { hull: 30 },
antacid_ammo: { ammo: 1 },
biopsy_sample: { score: 500, sample: 1 },
},
// Chained kills feed the combo meter (GDD: score × style multiplier; E's music layer
// listens). Window is generous enough to cross a fold, tight enough to mean something.
combo: { window: 2.5 },

View File

@ -78,19 +78,28 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
// --- spawn ------------------------------------------------------------------------
// Charter API: spawn(type, {s, theta, rho}). Level events give us only an `s` and a
// count, so theta/rho are filled from the seeded stream — same seed, same encounter.
// Charter API: spawn(type, {s, theta, rho}). `type` is an ARCHETYPE — combat/index.js
// resolves C's fiction ids (`bolus_chunk`) to archetypes before calling us.
//
// `rho` is a FRACTION of the safe radius (0..1), never an absolute distance — TECH §Event
// fields. That is C's placement law and it is the only thing that survives a tube whose
// radius varies with s: rho 0.92 means "pinned to the wall" everywhere, at any radius.
// Round 1 read it as absolute, which silently parked C's wall-pinned biopsy samples and
// candida blooms ~1 unit off the centreline, i.e. in the middle of the racing line.
function spawn(type, { s = 0, theta = null, rho = null } = {}) {
const pool = pools[type];
if (!pool) { console.warn(`[combat] unknown enemy type "${type}"`); return null; }
if (!pool) { console.warn(`[combat] unknown enemy archetype "${type}"`); return null; }
if (pool.slots.length >= pool.cfg.pool) return null; // pool is the population cap
const cfg = pool.cfg;
s = THREE.MathUtils.clamp(s, 0, world.length);
const th = theta ?? rand() * Math.PI * 2;
const wall = world.wallRho(s, th) - cfg.radius;
// turrets bolt to the wall; everything else floats somewhere in the lumen
const r = rho ?? (type === 'turret' ? wall : wall * (0.25 + rand() * 0.6));
// free room for this body's centre: the safe radius less its own half-width, so rho 1.0
// is flush against the wall rather than half-buried in it
const free = Math.max(0, world.wallRho(s, th) - cfg.radius);
const r = rho != null
? THREE.MathUtils.clamp(rho, 0, 1) * free // C's fraction
: (type === 'turret' ? free : free * (0.25 + rand() * 0.6)); // unauthored: seeded
const e = {
id: nextId++, type, cfg,

272
web/js/combat/hazards.js Normal file
View File

@ -0,0 +1,272 @@
// combat/hazards.js (Lane B) — the three L2 hazards: reflux_surge, aortic_squeeze, ring_gate.
//
// C authors WHERE and HOW MUCH (level JSON); balance.js holds the mechanical constants; this
// file is the behaviour. C's params always win where they overlap.
//
// WHY THESE SCHEDULE THEMSELVES INSTEAD OF USING BOOT'S PUMP (→ Lane F in NOTES):
// every hazard carries a `warn` — seconds of telegraph before it bites — and TECH makes it a
// law (≥2 s for anything lethal). But the pump emits `level:event` when the player *crosses*
// `ev.s`, which is the moment the hazard arrives: zero lead time. A telegraph needs lookahead,
// so hazards read `world.level.events` directly and run their own timeline (idle → warned →
// active → done). We deliberately ignore `level:event` for `type:'hazard'` so nothing arms
// twice. This also makes respawn clean: the timeline is ours to rewind (see `reset`), which is
// the one piece of respawn that does NOT need F's pump.
//
// ART_BIBLE: acid/corrosive = sickly yellow-green; hostile = amber. The ring gate is the
// interesting case — it lerps violet (a gate you pass) → amber (a thing that will hurt you) as
// it closes, so the colour encodes what it currently MEANS, which is the actual law.
import * as THREE from 'three';
import { BALANCE as B } from './balance.js';
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
const _v = new THREE.Vector3(), _up = new THREE.Vector3(0, 0, 1);
const TAU = Math.PI * 2;
// shortest signed angular distance a → b
const angDelta = (a, b) => { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; };
export function createHazards({ scene, world, bus, rng, player }) {
const H = B.hazards;
let time = 0;
const discToWorld = (out, frame, x, y) =>
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
// --- visuals: one InstancedMesh per hazard family = 3 draws for the whole system --------
const surgeGeo = new THREE.CircleGeometry(1, 24); // a wall of acid across the lumen
const surgeMat = emissiveMat(EMISSIVE.acid, { additive: true, opacity: 0.55 });
surgeMat.side = THREE.DoubleSide;
const surgeMesh = new THREE.InstancedMesh(surgeGeo, surgeMat, 4);
const ridgeGeo = new THREE.SphereGeometry(1, 8, 6); // the aortic bulge
const ridgeMat = emissiveMat(EMISSIVE.hostile);
const ridgeMesh = new THREE.InstancedMesh(ridgeGeo, ridgeMat, 160);
const irisGeo = new THREE.SphereGeometry(1, 8, 6); // the peristaltic ring
const irisMat = emissiveMat(EMISSIVE.gate);
const irisMesh = new THREE.InstancedMesh(irisGeo, irisMat, 96);
const meshes = [surgeMesh, ridgeMesh, irisMesh];
for (const m of meshes) {
m.frustumCulled = false;
m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
m.count = 0;
scene.add(m);
}
// --- timeline ---------------------------------------------------------------------------
const RIDGE_EVERY = 26; // u between bulge rings along a squeeze zone
const RIDGE_ARC = 5; // spheres per ring
const IRIS_BEADS = 16;
let specs = [];
function build() {
specs = (world.level?.events ?? [])
.filter((e) => e.type === 'hazard' && H && e.kind)
.map((e) => ({ ev: e, kind: e.kind, s: e.s ?? 0, warn: e.warn ?? 2.5, state: 'idle', surge: null }));
}
build();
// Rewind our own timeline (respawn). Anything at or ahead of `fromS` is armed again;
// anything behind stays done, so flying back over a spent zone doesn't re-telegraph it.
function reset(fromS = 0) {
for (const h of specs) {
h.surge = null;
h.state = (h.s + (h.ev.span ?? 0)) >= fromS ? 'idle' : 'done';
}
}
// --- antacid: B's own torpedo stalls a neutralizable surge (GDD dual-use) ---------------
// C's finale maths depends on this being a real stop, not a slow: "fired backward it stalls
// the surge ~10 s". We honour the torpedo's own duration from the bus, not a local number.
const offNeutralize = bus.on('level:neutralize', ({ s, radius, duration }) => {
for (const h of specs) {
if (!h.surge || h.kind !== 'reflux_surge' || !h.ev.neutralizable) continue;
if (Math.abs(h.surge.s - s) <= radius) {
h.surge.stall = Math.max(h.surge.stall, duration);
bus.emit('audio:cue', { name: 'surge_stall' });
}
}
});
function update(dt) {
time += dt;
const ps = player.state;
const speed = Math.max(1, ps.speed);
for (const h of specs) {
if (h.state === 'done') continue;
const ev = h.ev;
// --- telegraph: the one thing the pump structurally cannot do ---
if (h.state === 'idle') {
const eta = (h.s - ps.s) / speed;
if (eta <= h.warn && h.s >= ps.s) {
h.state = 'warned';
bus.emit('hazard:warn', { kind: h.kind, s: h.s, eta: Math.max(0, eta) });
bus.emit('audio:cue', { name: `warn_${h.kind}` });
} else if (ps.s >= h.s) {
h.state = 'warned'; // arrived without lead (teleport/respawn)
}
}
if (h.state === 'warned' && ps.s >= h.s) {
h.state = 'active';
if (h.kind === 'reflux_surge') {
// `from` is the spawn offset relative to the trigger s; negative = behind you
h.surge = { s: h.s + (ev.from ?? -40), stall: 0, endS: h.s + (ev.span ?? 600), grazeT: 0 };
bus.emit('audio:cue', { name: 'surge_start' });
}
}
if (h.state !== 'active') continue;
if (h.kind === 'reflux_surge') updateSurge(h, dt, ps);
else if (h.kind === 'aortic_squeeze') updateSqueeze(h, dt, ps);
else if (h.kind === 'ring_gate') updateGate(h, dt, ps);
}
render();
}
// --- reflux surge: the rear chaser ------------------------------------------------------
function updateSurge(h, dt, ps) {
const g = h.surge;
if (!g) { h.state = 'done'; return; }
if (g.stall > 0) g.stall = Math.max(0, g.stall - dt);
const factor = g.stall > 0 ? H.surge.stallFactor : 1;
g.s += (h.ev.speed ?? 20) * factor * dt;
const gap = ps.s - g.s; // >0 = you're ahead. This is the whole game.
if (Math.abs(gap) < H.surge.proximityRange) {
// C's most important UI dependency: the surge chases from behind in a forward-facing
// game, so the player can only feel the margin shrink if we say so every frame.
bus.emit('hazard:proximity', { kind: h.kind, distance: gap, stalled: g.stall > 0 });
}
if (gap <= H.surge.catchRadius && ps.alive) {
if (h.ev.lethal) {
player.kill('acid'); // C's finale: dawdling is fatal
} else {
g.grazeT -= dt; // the s=1700 "burp": teaches the verb, never kills
if (g.grazeT <= 0) { player.damage(H.surge.graze, 'acid'); g.grazeT = H.surge.grazeTick; }
}
}
if (g.s > g.endS || g.s > world.length) { h.surge = null; h.state = 'done'; bus.emit('audio:cue', { name: 'surge_end' }); }
}
// --- aortic squeeze: DIRECTIONAL, non-lethal ---------------------------------------------
// The arch presses from ONE side; the opposite arc is the answer. C: a symmetric squeeze
// would be an untimeable toll because a flow-locked player cannot stop and wait.
function bulgeAt(h, t) {
const period = h.ev.period ?? 2.4;
return 0.5 * (1 + Math.sin((t / period) * TAU)); // 0..1
}
function updateSqueeze(h, dt, ps) {
const span = h.ev.span ?? 400;
if (ps.s > h.s + span) { h.state = 'done'; return; }
if (ps.s < h.s || !ps.alive) return;
const theta = h.ev.theta ?? 0;
const amp = h.ev.amplitude ?? 0.45;
const pulse = bulgeAt(h, time);
const rho = Math.hypot(ps.x, ps.y);
if (rho < 1e-4) return;
const pTheta = Math.atan2(ps.x, ps.y);
// only the pressing arc bites; the far side stays clear and is the verb
if (Math.abs(angDelta(theta, pTheta)) > H.squeeze.arc * 0.5) return;
const safe = world.wallRho(ps.s, pTheta);
const limit = safe * (1 - amp * pulse);
if (rho <= limit) return;
h.grazeT = (h.grazeT ?? 0) - dt;
if (h.grazeT <= 0) { player.damage(H.squeeze.graze, 'squeeze'); h.grazeT = H.squeeze.grazeTick; }
player.shove(-(ps.x / rho) * H.squeeze.shove, -(ps.y / rho) * H.squeeze.shove);
}
// --- ring gate: a POINT, therefore timeable ----------------------------------------------
const gateOpen = (h, t) => ((t % (h.ev.period ?? 3.0)) < (h.ev.open ?? 1.5));
function updateGate(h, dt, ps) {
if (ps.s > h.s + 6) { h.state = 'done'; return; }
if (h.passed || !ps.alive) return;
// the beat you arrive on is the beat that counts
if (ps.s >= h.s - 0.5) {
h.passed = true;
if (!gateOpen(h, time)) {
player.damage(H.gate.hit, 'gate');
const rho = Math.hypot(ps.x, ps.y) || 1e-4;
player.shove(-(ps.x / rho) * H.gate.shove, -(ps.y / rho) * H.gate.shove);
bus.emit('audio:cue', { name: 'gate_hit' });
} else {
bus.emit('audio:cue', { name: 'gate_pass' });
}
}
}
// --- render -----------------------------------------------------------------------------
function render() {
let sn = 0, rn = 0, gn = 0;
let hotGate = 0;
for (const h of specs) {
if (h.state !== 'active') continue;
if (h.kind === 'reflux_surge' && h.surge && sn < 4) {
const f = world.sample(THREE.MathUtils.clamp(h.surge.s, 0, world.length));
const r = world.wallRho(h.surge.s, 0) + 1.2;
_q.setFromUnitVectors(_up, f.tan);
surgeMesh.setMatrixAt(sn++, _m.compose(f.pos, _q, _scale.set(r, r, r)));
} else if (h.kind === 'aortic_squeeze') {
const span = h.ev.span ?? 400, theta = h.ev.theta ?? 0, amp = h.ev.amplitude ?? 0.45;
const pulse = bulgeAt(h, time);
for (let s = h.s; s <= h.s + span && rn + RIDGE_ARC <= 160; s += RIDGE_EVERY) {
const f = world.sample(s);
const safe = world.wallRho(s, theta);
for (let j = 0; j < RIDGE_ARC; j++) {
const a = theta + (j / (RIDGE_ARC - 1) - 0.5) * H.squeeze.arc;
const rr = safe * (1 - amp * pulse * 0.85);
discToWorld(_v, f, Math.sin(a) * rr, Math.cos(a) * rr);
ridgeMesh.setMatrixAt(rn++, _m.compose(_v, _q.identity(), _scale.setScalar(1.5 + pulse * 0.8)));
}
}
} else if (h.kind === 'ring_gate') {
const open = gateOpen(h, time);
if (!open) hotGate = 1;
const f = world.sample(h.s);
const safe = world.wallRho(h.s, 0);
const r = open ? safe : safe * H.gate.irisMin;
for (let j = 0; j < IRIS_BEADS && gn < 96; j++) {
const a = (j / IRIS_BEADS) * TAU;
discToWorld(_v, f, Math.sin(a) * r, Math.cos(a) * r);
irisMesh.setMatrixAt(gn++, _m.compose(_v, _q.identity(), _scale.setScalar(open ? 0.9 : 1.5)));
}
}
}
surgeMesh.count = sn; ridgeMesh.count = rn; irisMesh.count = gn;
for (const m of meshes) m.instanceMatrix.needsUpdate = true;
// violet (a gate you pass) → amber (a thing that will hurt you). The colour IS the tell.
irisMat.uniforms.uFlash.value = hotGate;
}
return {
update, reset,
rebuild() { build(); },
get specs() { return specs; },
get activeSurge() { return specs.find((h) => h.surge)?.surge ?? null; },
dispose() {
offNeutralize();
for (const m of meshes) { scene.remove(m); m.dispose(); }
surgeGeo.dispose(); surgeMat.dispose();
ridgeGeo.dispose(); ridgeMat.dispose();
irisGeo.dispose(); irisMat.dispose();
},
};
}

View File

@ -1,33 +1,61 @@
// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons and
// feeds them Lane C's level events. All the wiring, none of the behaviour.
// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons + hazards
// + pickups and feeds them Lane C's level events. All the wiring, none of the behaviour.
//
// Update order inside a frame is load-bearing:
// boot: player.update(dt) -> combat.update(dt)
// here: enemies.update(dt) -> weapons.update(dt)
// here: enemies.update(dt) -> weapons.update(dt) -> hazards.update(dt) -> pickups.update(dt)
// Enemies move first so weapons hit-test against this frame's positions, not last frame's.
// Hazards after weapons so a torpedo fired THIS frame can neutralize a surge THIS frame
// (weapons emits level:neutralize on detonation; hazards listens).
import { createEnemies } from './enemies.js';
import { createWeapons } from './weapons.js';
import { createHazards } from './hazards.js';
import { createPickups } from './pickups.js';
import { getEnemy } from '../levels/enemies.js';
export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) {
const enemies = createEnemies({ scene, world, bus, rng, assets });
const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies });
const hazards = createHazards({ scene, world, bus, rng, player });
const pickups = createPickups({ scene, world, bus, rng, player, weapons });
enemies.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur));
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
const score = { value: 0, kills: 0 };
const score = { value: 0, kills: 0, samples: 0 };
// Lane C's events arrive on the bus as the player crosses their `s`. We only care about
// `spawn`; checkpoints/hazards/bosses belong to C and E this round. Unknown types are
// ignored on purpose — C may add event kinds without breaking combat.
const offEvent = bus.on('level:event', (ev) => {
if (!ev || ev.type !== 'spawn') return;
// Fan a group of `count` out along s. C's `spread` is the authored span of the group
// ("count 4 spread 120" = a field, not a wall); without one, a tight default fan keeps
// a count from stacking at a single arclength.
const fan = (ev, i) => {
const n = ev.count ?? 1;
for (let i = 0; i < n; i++) {
// fan the group out over a short stretch of s so a count of 6 is an encounter,
// not a wall of enemies at one arclength
enemies.spawn(ev.enemy, { s: (ev.s ?? 0) + i * 3.5, theta: ev.theta ?? null, rho: ev.rho ?? null });
if (n <= 1) return ev.s ?? 0;
const span = ev.spread ?? (n - 1) * 3.5;
return (ev.s ?? 0) + (i / (n - 1)) * span;
};
// C authors `theta` as a scalar (whole group) or an array (per-member — "two blooms
// covering opposite arcs"). Placement is composition, so C's word is law here.
const thetaFor = (ev, i) =>
(Array.isArray(ev.theta) ? ev.theta[i % ev.theta.length] : (ev.theta ?? null));
// Lane C's events arrive on the bus as the player crosses their `s`. Spawns and pickups are
// consumed here; hazards run their own timeline off world.level.events (they need lookahead
// for the warn telegraph — see hazards.js header); checkpoints/gates are boot's (the pump
// owns respawn/complete semantics); unknown types are ignored on purpose so C can add event
// kinds without breaking combat.
const offEvent = bus.on('level:event', (ev) => {
if (!ev) return;
if (ev.type === 'spawn') {
// Levels name enemies in fiction ("bolus_chunk"); pools are keyed by archetype
// ("floater"). levels/enemies.js is the join table (C's catalogue, round-2 task #1).
const spec = getEnemy(ev.enemy);
if (!spec) { console.warn(`[combat] spawn "${ev.enemy}" is not in levels/enemies.js — skipped`); return; }
for (let i = 0; i < (ev.count ?? 1); i++)
enemies.spawn(spec.archetype, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
} else if (ev.type === 'pickup') {
for (let i = 0; i < (ev.count ?? 1); i++)
pickups.spawn(ev.kind, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
}
});
@ -36,23 +64,36 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
score.kills++;
});
// pickups.js applies the effect (refill/ammo) and announces; the score lives here so
// combat:state stays the one scoreboard E reads.
const offPickup = bus.on('pickup', (e) => {
score.value += e.score ?? 0;
score.samples += e.sample ?? 0;
});
function update(dt) {
enemies.update(dt, player);
weapons.update(dt);
hazards.update(dt);
pickups.update(dt);
// combat's slice of the HUD state (E reads player:state for the flight half)
bus.emit('combat:state', {
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
ammo: weapons.ammo, ammoMax: weapons.ammoMax,
score: score.value, kills: score.kills, enemies: enemies.count,
score: score.value, kills: score.kills, samples: score.samples, enemies: enemies.count,
});
}
return {
update,
enemies, weapons, score,
enemies, weapons, hazards, pickups, score,
// Respawn support (boot calls this from its death->checkpoint glue): re-arm hazard
// timelines at/ahead of the respawn s. Enemies and uncollected pickups persist — the
// pump never re-fires past events, so nothing double-spawns.
reset(fromS = 0) { hazards.reset(fromS); },
dispose() {
offEvent(); offDie();
enemies.dispose(); weapons.dispose();
offEvent(); offDie(); offPickup();
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
},
};
}

114
web/js/combat/pickups.js Normal file
View File

@ -0,0 +1,114 @@
// combat/pickups.js (Lane B) — the five pickup kinds C places in L2.
//
// Split of concerns (settled with C): C owns WHERE and HOW MANY (level JSON, and the design
// reasons are worth reading — "orb lines teach the racing line before they score anything",
// "the game pays you right before it charges you"). B owns what each one is WORTH
// (balance.js §pickups) and what touching it does. Placement law: `rho` is a FRACTION of the
// safe radius, exactly as for enemies — C pins sample 1 at rho 0.92 meaning "against the
// wall", and that has to mean the same thing in a 9-unit tube and a 6.8-unit one.
//
// ART_BIBLE: pickups are soft green, all five of them — the emissive code is a readability
// LAW keyed to meaning ("this is good for you"), not a palette to decorate with. So identity
// comes from SHAPE instead: orb = sphere, mucin = blob, B12 = octahedron, antacid = capsule,
// biopsy = the big slow diamond you can spot from 200 units away. One InstancedMesh per kind:
// 5 draws for the whole economy.
import * as THREE from 'three';
import { BALANCE as B } from './balance.js';
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
const _v = new THREE.Vector3(), _axis = new THREE.Vector3(0.3, 1, 0.2).normalize();
const KINDS = {
nutrient_orb: { geo: () => new THREE.SphereGeometry(0.8, 10, 8), pool: 48, scale: 1 },
mucin_glob: { geo: () => new THREE.IcosahedronGeometry(1.0, 0), pool: 16, scale: 1 },
b12_cell: { geo: () => new THREE.OctahedronGeometry(1.0, 0), pool: 8, scale: 1 },
antacid_ammo: { geo: () => new THREE.CapsuleGeometry(0.45, 0.9, 3, 8), pool: 12, scale: 1 },
biopsy_sample: { geo: () => new THREE.OctahedronGeometry(1.6, 0), pool: 6, scale: 1.15 },
};
export function createPickups({ scene, world, bus, rng, player, weapons }) {
const pools = {};
for (const [kind, def] of Object.entries(KINDS)) {
const geo = def.geo();
const mat = emissiveMat(EMISSIVE.pickup, { additive: kind === 'biopsy_sample' });
const mesh = new THREE.InstancedMesh(geo, mat, def.pool);
mesh.frustumCulled = false;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.count = 0;
scene.add(mesh);
pools[kind] = { mesh, geo, mat, def, items: [] };
}
let time = 0;
const discToWorld = (out, frame, x, y) =>
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
function spawn(kind, { s = 0, theta = null, rho = null } = {}) {
const pool = pools[kind];
if (!pool) { console.warn(`[combat] unknown pickup kind "${kind}"`); return null; }
if (pool.items.length >= pool.def.pool) return null;
s = THREE.MathUtils.clamp(s, 0, world.length);
const th = theta ?? rng('pickups')() * Math.PI * 2;
const free = Math.max(0, world.wallRho(s, th) - B.pickups.radius);
// rho is C's fraction of the safe radius. Unauthored => near the racing line, because an
// unplaced pickup is a reward, not a puzzle.
const r = rho != null ? THREE.MathUtils.clamp(rho, 0, 1) * free : free * 0.3;
const p = { kind, s, x: Math.sin(th) * r, y: Math.cos(th) * r, pos: new THREE.Vector3(), alive: true };
pool.items.push(p);
return p;
}
// Applies the effect, then announces it. E renders the feedback off `pickup {kind}`.
function collect(p) {
p.alive = false;
const v = B.pickups[p.kind] ?? {};
if (v.coat || v.hull || v.boost) player.refill({ coat: v.coat ?? 0, hull: v.hull ?? 0, boost: v.boost ?? 0 });
if (v.ammo) weapons?.addAmmo(v.ammo);
bus.emit('pickup', { kind: p.kind, s: p.s, score: v.score ?? 0, sample: v.sample ?? 0 });
bus.emit('audio:cue', { name: p.kind === 'biopsy_sample' ? 'pickup_sample' : 'pickup' });
}
function update(dt) {
time += dt;
const ps = player.state;
const reach = B.pickups.radius + player.radius;
const reach2 = reach * reach;
for (const pool of Object.values(pools)) {
let n = 0;
for (let i = pool.items.length - 1; i >= 0; i--) {
const p = pool.items[i];
if (!p.alive) { pool.items.splice(i, 1); continue; }
discToWorld(p.pos, world.sample(p.s), p.x, p.y);
// cheap reject along s before the distance test — pickups outlive the player's window
if (ps.alive && Math.abs(p.s - ps.s) < reach + 2) {
const dx = p.x - ps.x, dy = p.y - ps.y, ds = p.s - ps.s;
if (dx * dx + dy * dy + ds * ds <= reach2) { collect(p); pool.items.splice(i, 1); continue; }
}
}
for (const p of pool.items) {
if (n >= pool.def.pool) break;
_q.setFromAxisAngle(_axis, time * B.pickups.spin);
pool.mesh.setMatrixAt(n++, _m.compose(p.pos, _q, _scale.setScalar(pool.def.scale)));
}
pool.mesh.count = n;
pool.mesh.instanceMatrix.needsUpdate = true;
}
}
function clear() { for (const pool of Object.values(pools)) pool.items.length = 0; }
return {
spawn, update, clear,
get count() { return Object.values(pools).reduce((a, p) => a + p.items.length, 0); },
dispose() {
for (const pool of Object.values(pools)) {
scene.remove(pool.mesh); pool.mesh.dispose(); pool.geo.dispose(); pool.mat.dispose();
}
},
};
}

View File

@ -70,6 +70,30 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
st.coat = Math.max(0, st.coat - amount);
}
// Unconditional death — lethal hazards (C's reflux finale) call this. Deliberately ignores
// iframes: boost-dodging THROUGH an acid wall isn't a read, it's a bug C's design forbids.
function kill(kind = 'hazard') {
if (!st.alive) return;
st.coat = 0; st.hull = 0; st.alive = false;
bus.emit('player:death', { s: st.s, kind });
bus.emit('audio:cue', { name: 'death' });
}
// Disc-velocity impulse (u/s) — hazards push the ship around with this (squeeze/gate).
function shove(dx, dy) {
if (!st.alive) return;
st.vx += dx; st.vy += dy;
}
// Pickup economy (balance.js §pickups): coat/hull points, boost = seconds shaved off the
// cooldown (0.6 off a 2.4s cd — a recharge nudge, not a free boost).
function refill({ coat = 0, hull = 0, boost = 0 } = {}) {
if (!st.alive) return;
st.coat = Math.min(T.coat.max, st.coat + coat);
st.hull = Math.min(T.hull.max, st.hull + hull);
st.boostCd = Math.max(0, st.boostCd - boost);
}
// --- the loop ---------------------------------------------------------------------
function update(dt) {
const intent = input.sample();
@ -109,8 +133,19 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
if (was !== st.surfBlend > 0.5) bus.emit('player:surf', { active: st.surfBlend > 0.5, s: st.s });
}
// forward motion — the flow carries you; you only ever scale it
st.speed = biome.flow * (st.throttle + st.surfBlend * T.surf.gain + st.boostBlend * T.boost.gain);
// --- forward motion: the flow carries you; you only ever scale it ---
// Surf is a SPEED-LOCK onto the crest, not a bonus on top of throttle (round-2 ruling #1;
// round 1 proved a flat bonus is self-cancelling). The wave now outruns you
// (crestSpeed = 1.6 × flow > throttleMax 1.4), so riding means being *carried at the
// wave's own speed* — which is also why you stay on it instead of shooting off the front.
const flowSpeed = biome.flow * st.throttle;
const crest = world.crestSpeed(st.s); // contract v1.1 law: CREST_FACTOR × flow(s)
// Only ever accelerates. If you're already faster than the crest (boosting, or a fast
// segment), the wave must not brake you — it just stops being relevant.
const carried = crest > flowSpeed
? flowSpeed + (crest - flowSpeed) * st.surfBlend * T.surf.authority
: flowSpeed;
st.speed = carried + biome.flow * T.boost.gain * st.boostBlend; // boost punches out of a crest
st.s = clampS(st.s + st.speed * dt);
// disc movement
@ -264,6 +299,9 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
get forward() { return world.sample(st.s).tan; },
damage,
drain,
kill,
shove,
refill,
respawn(s = 0) {
Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true });
cam.init = false; aimLagInit = false;

View File

@ -1,28 +1,30 @@
// flight/tuning.js (Lane B) — THE FEEL. Every number that decides how GUTS handles lives
// here and nowhere else. Tuned by hand on the stub esophagus; re-tune per biome later.
//
// Stub reference frame (js/stub/world_stub.js), which all of these are sized against:
// biome.flow = 14 u/s · radius 10 ±15% wobble => 8.5..11.5
// wallRho = radius waveAmp(0.9) skin(0.6) => a ~7.0..10.0 playable disc radius
// peristalsis crest travels at WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
// Reference frame these are sized against — CORRECTED round 2 (C caught the stale numbers,
// LANE_C_NOTES §→ Lane B #4). The round-1 header quoted the round-0 stub; A's real world has
// since measured the esophagus wave at amp 1.4 (not 0.9) + breathe 0.15, and `wallRho`
// subtracts BOTH plus SKIN:
// biome.flow = 14 u/s · radius 10 ±15% wobble => 8.50 .. 11.50
// wallRho = radius (amp 1.4 + breathe 0.15) SKIN 0.6 => 6.35 .. 9.35 playable disc
// minus ship.radius 0.9 => 5.45 .. 8.45 free disc
// The envelope shrank ~0.65 u under us. disc.maxSpeed/accel and the graze numbers were sized
// against the old 7.0..10.0 and still read fine at 6.35..9.35, but that is luck, not design —
// if A retunes the wave again, re-check `disc` and `wall` here, not just C's clearances.
//
// SURF IS A SHOVE, NOT YET A RIDE — and that is a world-side problem, not a tuning one.
// Measured in-engine this round (numbers in LANE_B_NOTES §surf):
// crest phase speed = WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
// player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
// A wave travelling 13.64 u/s cannot carry anything faster than 13.64 u/s, so "ride the
// crest" can never be the fast line while the wave is slower than the player. Worse, the
// bonus is self-cancelling: the crest window is fixed in SPACE, so a bigger surf.gain just
// ejects you out the front sooner. Measured distance gained per crest is ~3.0 units at
// gain 0.4, 0.75 AND 1.2 — identical. Tuning this number cannot fix it.
// SURF: FIXED THIS ROUND — it is a ride now, not a shove.
// Round 1 measured the mechanic as strictly dominated: a crest travelling at flow (13.64 u/s)
// cannot carry anything faster than itself, while throttle-mashing gave 19.6 u/s, so the
// correct play was to ignore the level's signature mechanic. Tuning could not fix it (the
// crest window is fixed in SPACE, so a bigger bonus just ejected you sooner — measured ~3.0
// units gained per crest at gain 0.4, 0.75 AND 1.2, identical).
//
// So surf currently reads as what the wave physically is: a rhythmic peristaltic SHOVE as
// each crest sweeps over you (~0.3-0.6 s at 24.5 u/s), which is honest and feels good, but
// it is not the level-2 speed line the GDD asks for. The fix belongs to whoever owns the
// wave constants (stub = F, world = A): crest speed must exceed 19.6 u/s. Escalated in
// LANE_B_NOTES §"-> Lane A / F" with options. Do not paper over it here.
//
// gain below is therefore chosen for FEEL (punch of the shove), not for speed economy.
// F's round-2 ruling #1 fixed it at the source: `crestSpeed = CREST_FACTOR(1.6) × flow`, which
// beats throttleMax 1.4. So the wave now outruns the player and B **speed-locks to
// world.crestSpeed(s)** while riding instead of adding a flat bonus. Riding at flow 14 =
// 22.4 u/s vs 19.6 for mashing: +14%, and because you match the crest's own speed you STAY on
// it instead of being thrown off the front. Throttle still decides how long you hold it —
// see surf.authority. Read the crest, don't hold W.
export const TUNING = {
ship: {
@ -46,11 +48,21 @@ export const TUNING = {
},
surf: {
threshold: 0.45, // world.flowPulse(s,t) above this = on the crest.
// pulse=pow(sin,3) so 0.45 => sin>0.766 => the peak 22% of
// each wave: a ~6.3 u window in a 28.6 u wavelength.
gain: 0.75, // +75% flow while on a crest => a 24.5 u/s shove for ~0.3-0.6 s.
// Feel-tuned only; see the surf note in the header before
// touching it expecting a speed change.
// pulse=pow(sin,3) so 0.45 => sin>0.766 => the peak 22% of each
// wave. Under CREST 1.6 the wavelength grew to ~45.7 u at flow
// 14, so that window is now ~10 u long (was ~6.3) — the crest is
// a wider, faster lane than it was in round 1.
authority: 0.85, // how completely the crest owns your speed while riding, 0..1.
// THE knob. At 1.0 the wave takes you at exactly crestSpeed and
// throttle stops mattering while surfing — you'd ride forever
// and the mechanic would have no skill floor. At 0.85 you sit
// just under the crest and drift off the back at a rate YOU set:
// throttle 1.4 -> 21.98 vs crest 22.4 => drift 0.42 u/s, ~12 s ride
// throttle 1.0 -> 21.14 => drift 1.26 u/s, ~4 s ride
// throttle 0.6 -> 20.30 => drift 2.10 u/s, ~2 s ride
// So catching a crest is positioning; HOLDING one is throttle
// discipline. Never applied when it would slow you (boosting
// past the crest keeps your speed — see player.js).
ramp: 3.0, // /s blend in/out so cresting doesn't pop
},

View File

@ -1,5 +1,5 @@
{
"schema": 1,
"schema": 2,
"id": "L1_mouth",
"name": "Oral Cavity",
"tagline": "Learn to swim",

View File

@ -1,10 +1,10 @@
{
"schema": 1,
"schema": 2,
"id": "L2_esophagus",
"name": "Esophagus",
"tagline": "Ride the swallow",
"seed": 20260716,
"par": { "time": 210, "score": 9000, "samples": 3 },
"par": { "time": 180, "score": 9000, "samples": 3 },
"next": "L3_stomach",
"design": {
"owner": "Lane C",
@ -12,6 +12,7 @@
"anatomy": "Real esophagus, real order: upper esophageal sphincter -> cervical -> the broncho-aortic constriction -> thoracic -> diaphragmatic hiatus -> abdominal esophagus -> cardiac sphincter (LES) into the stomach. The three narrowings are the three real anatomical constrictions and they are this level's three setpieces.",
"scale": "Cross-sections are ANATOMICALLY TRUE (radius 10-12 units = ~2cm lumen at 1 unit : 1 mm). Lengths are DILATED ~14x (real esophagus is 25cm; this level is 3600 units). The canal is proportioned, not measured. See LANE_C_NOTES.md.",
"curve": "breather -> pressure -> SPIKE -> breather -> pressure -> SPIKE -> lethal finale. Pressure indices measured in LANE_C_NOTES.md §L2 pacing.",
"par_rationale": "180s (3:00), retuned in round 2 for the CREST 1.6 surf ruling. Measured: a perfect crest ride is 2:26, throttle-mashing is 2:47, par-pace throttle (1.15x) is 3:19, timid is 4:22. So par 3:00 sits BELOW throttle-only competent play and ABOVE a surf run: you cannot reach it by holding the stick forward, and you can reach it comfortably by learning to surf. That is deliberate - L2's signature mechanic is riding peristalsis crests, and the time medal is the thing that teaches it. Par is a medal threshold, not a pass condition: missing it costs a medal, not the level.",
"teach_then_test": "flow (b1) / bolus (b1 solo -> b3 in the pinch) / candida (b2 solo, clear sightline -> b3 in the squeeze) / eosinophil (b2 open stretch -> b6 in the hiatus) / ring_gate (b5 open 2.4s -> b6 open 1.2s) / reflux (b4 non-lethal burp -> b7 lethal finale)."
},
"segments": [
@ -64,10 +65,11 @@
"biome": "esophagus",
"name": "Diaphragmatic Hiatus",
"length": 400,
"radius": { "base": 6.8, "wobble": 0.15 },
"radius": { "base": 6.5, "wobble": 0.15 },
"curviness": 0.3,
"flow": 18,
"note": "BEAT 6 - SPIKE. The tightest hole in the level: the esophagus threads the diaphragm. Free radius ~2.7 units - the narrowest thing Lane C will author, and only 0.2 above the MIN_CLEARANCE law. Base was 6.5 until Lane A measured the esophagus peristalsis at amp 1.4 (up from the stub's 0.9); the wave eats the difference, so the hole had to widen to stay a challenge instead of a toll. Straightened out on purpose - the challenge is the ring gate's timing, and a writhing tube would make that unreadable."
"wave": { "amp": 0.6 },
"note": "BEAT 6 - SPIKE, and the level's precision test. The tightest hole in the game: the esophagus threads the diaphragm. TIGHT AND CALM is the whole design - schema v2's wave override (amp 0.6 vs the esophagus's 1.4) exists for this segment. Anatomically the hiatus is a fixed muscular ring held by the diaphragm crura and does NOT have big peristaltic waves; mechanically, a big wave in the smallest gap turned a precision setpiece into a lottery you cannot stop to time. Calm waves let the tube go back to base 6.5 (it was widened to 6.8 in round 1 purely to survive the biome-wide amp) and still leave 3.3 units of steady clearance. Steady-tight is a skill; pulsing-tight is a toll. Straightened out (curviness 0.3) for the same reason: the challenge is the ring gate's timing, and a writhing tube would make that unreadable."
},
{
"biome": "esophagus",
@ -127,7 +129,7 @@
{ "s": 2960, "type": "pickup", "kind": "mucin_glob", "count": 2, "spread": 20, "note": "Last top-up in the level. Take it or regret it for 600 units." },
{ "s": 2980, "type": "checkpoint", "name": "Cardia Approach", "note": "The finale's checkpoint. Sits 20 units before the surge trigger: a death in the chase costs you the chase, not the hiatus." },
{ "s": 3000, "type": "hazard", "kind": "reflux_surge", "speed": 21, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true, "note": "THE FINALE. Acid wall, 21 u/s, spawning 40 units behind you and chasing for 600 units. Flow here is 20, so throttle 1.05+ outruns it and throttle 0.6 (12 u/s) kills you: the level's last instruction is 'do not dawdle'. Par pace (~26 u/s) finishes ~5s clear. Antacid fired backward buys ~10s." },
{ "s": 3000, "type": "hazard", "kind": "reflux_surge", "speed": 24, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true, "note": "THE FINALE. Acid wall, 24 u/s, spawning 40 units behind you and chasing for 600 units. Retuned in round 2 for the CREST 1.6 surf ruling: at the old 21 u/s, throttle-max (28) escaped by 7 u/s and surfing (32) was merely a bigger cushion, so the level's signature mechanic was decoration in its own finale. At 24 against flow 20 the required throttle is 1.20x - so the finale reads: dawdling at 12 u/s dies, pushing hard (28) escapes by a white-knuckle ~4.5s, and SURFING (32) escapes clean by ~6s. Surf is now the stylish answer and the level's last exam is its signature verb. Hard mode (surgeSpeed x1.1 = 26.4) needs throttle 1.32+ or a crest; easy (x0.92 = 22.1) forgives at 1.11+. Antacid fired backward still buys ~10s - now the safety net for players who cannot surf rather than the clever trick, which is a better job for it." },
{ "s": 3080, "type": "pickup", "kind": "antacid_ammo", "count": 1, "note": "On the racing line, mid-chase: the one pickup in the level you get for free at speed." },
{ "s": 3120, "type": "spawn", "enemy": "bolus_chunk", "count": 5, "spread": 200, "note": "Traffic, not enemies. They drift 3.5 u/s slower than the current, so at chase pace you close on them fast." },
{ "s": 3200, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 4.7124, "rho": 0.85, "note": "SAMPLE 3/3. Cost = the chase itself. Off the racing line by ~1.5s with an acid wall 5s behind you. The cruellest of the three and the only one that is a real decision at full speed." },

View File

@ -1,5 +1,5 @@
{
"schema": 1,
"schema": 2,
"id": "L3_stomach",
"name": "Stomach",
"tagline": "The acid sea",

View File

@ -19,8 +19,42 @@ export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
/** Schema version this module speaks. Levels declare `"schema": N`. */
export const SCHEMA = 1;
/**
* True only when THIS file is what node was asked to run not when someone imports it.
* `process.argv.includes('--selfcheck')` is not enough: a module that merely gets imported
* by a process carrying that flag would fire its own CLI (and its own `process.exit`) inside
* its importer. That is not hypothetical see importSpline().
*/
const isMain = isNode && !!process.argv[1] && import.meta.filename === process.argv[1];
/**
* Import a Lane A world module without letting its CLI guard fire.
*
* Lane A's `spline.js` ends with `if (process.argv.includes('--selfcheck')) selfcheck()
* .then(f => process.exit(...))` — evaluated at import time. So `node levels/index.js
* --selfcheck` importing it for CREST_FACTOR made A's selfcheck run *inside* C's, and A's
* `process.exit(0)` killed C's process after only L1 had been checked: qa.sh went green on a
* gate that had silently validated one level out of three.
*
* Hiding our flags for the duration of the import is the fix available from inside C's own
* territory. The real fix is an entry-point guard in A's file (the `isMain` above) asked
* for in LANE_C_NOTES.md § Lane A. Remove this shim once A lands it.
*/
async function importSpline() {
const saved = process.argv;
try {
if (isNode) process.argv = [saved[0], saved[1]];
return await import('../world/spline.js');
} finally {
if (isNode) process.argv = saved;
}
}
/** Schema version new levels are authored to. Levels declare `"schema": N`. */
export const SCHEMA = 2;
/** v2 is additive over v1 (only `segments[].wave` was added), so v1 levels still load. */
export const SUPPORTED_SCHEMAS = new Set([1, 2]);
/** Campaign order. L4/L5/LS land in later rounds; the registry is the source of truth. */
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach'];
@ -59,12 +93,38 @@ export const BIOMES_EXPECTED = [
'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix',
];
/** Throttle profiles for the pacing sim. GDD: throttle gives +/-40% of biome flow. */
export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4 });
/**
* Speed profiles for the pacing sim, as multiples of the segment's `flow`.
* GDD: throttle gives ±40% of biome flow, so 0.6..1.4 is the whole throttle range.
*
* `surf` is not a throttle it is riding a peristalsis crest, speed-locked to
* `world.crestSpeed(s) = CREST_FACTOR × flow(s)` (round-2 ruling #1). It is read live from
* A's `spline.js` so C's pars can never drift from A's crest law; 1.6 is only the fallback.
* At 1.6 it beats throttleMax 1.4 by 14%, which is the whole point of the ruling: surfing
* had to stop being strictly dominated by mashing the throttle.
*/
export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4, surf: 1.6 });
/** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */
const CHASE_THROTTLE = 1.35;
/** A's CREST_FACTOR, refreshed by crestFactor(). Fallback = PROFILES.surf. */
let CREST = PROFILES.surf;
async function crestFactor() {
try {
const m = await importSpline(); // Lane A's, node-safe by design
if (typeof m.CREST_FACTOR === 'number') CREST = m.CREST_FACTOR;
} catch { /* Lane A not present — keep the fallback */ }
return CREST;
}
/** Resolve a profile name to a flow multiplier. `surf` tracks A's live crest law. */
function profileK(name) {
if (name === 'surf') return CREST;
return PROFILES[name] ?? PROFILES.par;
}
// ---------------------------------------------------------------------------------------
// loading
// ---------------------------------------------------------------------------------------
@ -122,17 +182,18 @@ async function biomeRegistry() {
if (m.BIOMES && Object.keys(m.BIOMES).length) {
REG = {
ids: new Set(Object.keys(m.BIOMES)),
// Mirror A's waveMaxAt() exactly: amp + breathe, not amp alone.
marginFor: (id) => {
const w = m.BIOMES[id]?.wave;
return w ? w.amp + (w.breathe ?? 0) : PLAY.PERISTALSIS_AMP;
},
ampFor: (id) => m.BIOMES[id]?.wave?.amp ?? PLAY.PERISTALSIS_AMP,
breatheFor: (id) => m.BIOMES[id]?.wave?.breathe ?? 0,
authoritative: true,
};
return REG;
}
} catch { /* Lane A hasn't landed yet — fall back to C's expectation */ }
REG = { ids: new Set(BIOMES_EXPECTED), marginFor: () => PLAY.PERISTALSIS_AMP, authoritative: false };
REG = {
ids: new Set(BIOMES_EXPECTED),
ampFor: () => PLAY.PERISTALSIS_AMP, breatheFor: () => 0,
authoritative: false,
};
return REG;
}
@ -194,9 +255,25 @@ export function scoreBudget(level) {
return total;
}
/** What the wall eats into the lumen — Lane A's per-biome (amp + breathe) when available. */
/**
* What the wall eats into the lumen at its worst, mirroring A's `waveMaxAt` = amp + breathe.
*
* **Schema v2:** `segments[].wave.amp` overrides the biome's amplitude for this segment
* (round-2 ruling #8, A implements `waveAmpAt` preferring it `spline.js` GET.waveAmp).
* `breathe` is NOT overridable: it is idle tissue motion, a property of the tissue, and A
* reads it from the biome registry regardless.
*
* Why the override exists: the diaphragmatic hiatus is a fixed muscular ring held by the
* diaphragm crura anatomically it does *not* have big peristaltic waves. But it is also
* L2's tightest hole, so a biome-wide amplitude put the game's biggest wave in its smallest
* gap, and the level's precision setpiece became a wave-luck lottery you cannot even stop to
* time. Tight AND calm is the design; v2 is how it gets said.
*/
function waveMargin(seg) {
return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP;
const amp = (typeof seg.wave?.amp === 'number')
? seg.wave.amp
: (REG?.ampFor ? REG.ampFor(seg.biome) : PLAY.PERISTALSIS_AMP);
return amp + (REG?.breatheFor ? REG.breatheFor(seg.biome) : 0);
}
/** Static free-flight radius of a segment at its narrowest wobble trough. */
@ -264,7 +341,7 @@ export function travelTime(level, a, b, k) {
* hazard = 1 + a per-kind term (see hazardWeight)
*/
export function pace(level, { profile = 'par' } = {}) {
const k = PROFILES[profile] ?? PROFILES.par;
const k = profileK(profile);
const total = totalLength(level);
const widest = Math.max(...level.segments.map((s) => s.radius.base));
@ -301,6 +378,38 @@ export function pace(level, { profile = 'par' } = {}) {
return { id: level.id, profile, total, beats, checkpoints: gaps, seconds: tAcc };
}
/**
* The escape math for every chase hazard. This is the finale's whole design in one number:
* `required` = the throttle multiple you must hold to merely hold the gap (surge / flow).
*
* Read it against the player's options: throttle tops out at 1.4, surfing a crest gives
* CREST_FACTOR (1.6, A's law). So required 1.2 means "push hard, or surf"; required > 1.4
* means "surf or use antacid, throttle alone cannot save you"; required > CREST means
* unwinnable. `clear` is units of daylight at the end of the span (head start + ground
* gained), i.e. how close it actually feels.
*/
export function chaseAnalysis(level) {
const out = [];
for (const e of level.events ?? []) {
if (e.type !== 'hazard' || e.kind !== 'reflux_surge') continue;
const { seg } = segmentAt(level, e.s);
const span = e.span ?? 0;
const head = Math.abs(e.from ?? 0);
const at = (k) => {
const v = seg.flow * k;
const t = v > 0 ? span / v : 0;
const clear = head + (v - e.speed) * t;
return { k, v, t, clear, escapes: clear > 0 };
};
out.push({
s: e.s, lethal: !!e.lethal, surge: e.speed, flow: seg.flow, span, head,
required: e.speed / seg.flow,
throttleMax: at(1.4), surfing: at(CREST), parPace: at(Math.max(PROFILES.par, CHASE_THROTTLE)),
});
}
return out;
}
function hazardWeight(h) {
switch (h.kind) {
case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
@ -330,7 +439,7 @@ export async function validate(level, { expectId = null } = {}) {
const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));
// --- structural -----------------------------------------------------------------
if (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${SCHEMA}`);
if (!SUPPORTED_SCHEMAS.has(level.schema)) E(`schema is ${level.schema}, this module speaks ${[...SUPPORTED_SCHEMAS].join('/')} (authoring v${SCHEMA})`);
if (typeof level.id !== 'string' || !level.id) E('missing id');
if (expectId && level.id !== expectId) E(`id "${level.id}" does not match filename "${expectId}"`);
if (!Number.isInteger(level.seed)) E(`seed must be an integer (got ${JSON.stringify(level.seed)})`);
@ -344,6 +453,7 @@ export async function validate(level, { expectId = null } = {}) {
if (!authoritative) W(`Lane A's world/biomes.js not present — biome ids checked against Lane C's expected list (${BIOMES_EXPECTED.length} ids)`);
const bal = await combatBalance();
if (!bal) W("Lane B's combat/balance.js not present — archetype rows and score budget unchecked");
await crestFactor(); // so par/chase checks use A's live CREST_FACTOR, not our fallback
// --- segments -------------------------------------------------------------------
let s0 = 0;
@ -357,6 +467,16 @@ export async function validate(level, { expectId = null } = {}) {
if (seg.curviness < 0 || seg.curviness > 1) E(`${at}: curviness must be in [0,1] (got ${seg.curviness})`);
if (!(seg.flow >= 0)) E(`${at}: flow must be >= 0`);
// schema v2: segments[].wave.amp override (A reads it as spline.js GET.waveAmp)
if (seg.wave !== undefined) {
if (level.schema < 2) E(`${at}: segments[].wave needs "schema": 2`);
if (typeof seg.wave.amp !== 'number' || !(seg.wave.amp > 0)) E(`${at}: wave.amp must be a number > 0`);
else if (seg.wave.amp > 3) W(`${at}: wave.amp ${seg.wave.amp} is huge — that is a wall that breathes at you, not peristalsis`);
if (seg.wave.breathe !== undefined) W(`${at}: wave.breathe is not overridable — breathe is a property of the tissue and A reads it from the biome registry. Ignored.`);
const biomeAmp = REG?.ampFor ? REG.ampFor(seg.biome) : PLAY.PERISTALSIS_AMP;
if (seg.wave.amp === biomeAmp) W(`${at}: wave.amp ${seg.wave.amp} equals the biome default — the override says nothing, drop it`);
}
// C's playability invariant — the check that catches "I made a cool tube you can't fly".
const clear = hazardClearance(level, seg, s0);
if (clear < PLAY.MIN_CLEARANCE) {
@ -435,15 +555,28 @@ export async function validate(level, { expectId = null } = {}) {
if (secs > 30) C(`checkpoint gap ${cp.s}..${next} ("${cp.name ?? ''}") = ${secs.toFixed(1)}s at par throttle — over the 30s death-cost law`);
}
// chase hazards must be escapable, and the escape must cost something (ruling #1)
for (const c of chaseAnalysis(level)) {
const where = `reflux_surge@${c.s} (surge ${c.surge} vs flow ${c.flow})`;
if (!c.lethal) continue;
if (c.required > CREST)
E(`${where}: requires throttle ${c.required.toFixed(2)}× — above even a crest ride (${CREST}×). Unwinnable.`);
else if (c.required > 1.4)
W(`${where}: requires ${c.required.toFixed(2)}× — throttle alone (max 1.4×) cannot escape; only surfing or antacid can. Deliberate?`);
else if (c.required <= 1.0)
C(`${where}: requires only ${c.required.toFixed(2)}× — the current alone outruns it, so the chase is theatre`);
if (!c.surfing.escapes) E(`${where}: not escapable even while surfing — check span/from`);
}
const samples = (level.events ?? []).filter((e) => e.type === 'pickup' && e.kind === 'biopsy_sample')
.reduce((a, e) => a + (e.count ?? 1), 0);
if (samples !== 3) C(`${samples} biopsy samples — the GDD says exactly 3 per level`);
// par sanity: an unbeatable par is a bug, a free par is a waste.
if (level.par?.time > 0) {
const fast = pace(level, { profile: 'speedrun' }).seconds;
const fast = pace(level, { profile: 'surf' }).seconds;
const slow = pace(level, { profile: 'cautious' }).seconds;
if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a full-throttle run (${fast.toFixed(0)}s) — unachievable`);
if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a perfect crest ride (${fast.toFixed(0)}s at ${CREST}× flow) — unachievable`);
else if (level.par.time > slow) C(`par.time ${level.par.time}s is slower than a timid run (${slow.toFixed(0)}s) — free medal`);
} else C('no par.time');
@ -495,12 +628,15 @@ async function cliSelfcheck() {
async function cliSim(only) {
const reg = await biomeRegistry(); // so clearance uses Lane A's real wave amplitudes
const bal = await combatBalance(); // so the score budget uses Lane B's real scores
const crest = await crestFactor(); // so surf pacing uses Lane A's real crest law
console.log(`biome registry: ${reg.authoritative ? "Lane A's world/biomes.js" : 'Lane C fallback list (Lane A not present)'}`);
console.log(`combat balance: ${bal ? "Lane B's combat/balance.js" : 'not present (score budget unavailable)'}`);
console.log(`crest law: CREST_FACTOR ${crest}× flow${crest === PROFILES.surf ? '' : " (Lane A's spline.js)"}`);
for (const id of CAMPAIGN) {
if (only && id !== only) continue;
const level = await loadJSON(`./${id}.json`);
const p = pace(level, { profile: 'par' });
const surf = pace(level, { profile: 'surf' }).seconds;
const fast = pace(level, { profile: 'speedrun' }).seconds;
const slow = pace(level, { profile: 'cautious' }).seconds;
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
@ -509,7 +645,12 @@ async function cliSim(only) {
const budget = scoreBudget(level);
console.log(`${p.total} units · par.time ${level.par?.time ?? '?'}s (${mmss(level.par?.time ?? 0)}) · par.score ${level.par?.score ?? '?'}` +
(budget != null ? ` of ${budget} kill budget (${((level.par.score / budget) * 100) | 0}%)` : ''));
console.log(`sim: speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`);
console.log(`sim: surf ${mmss(surf)} (${surf.toFixed(0)}s) · speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`);
const verdict = level.par?.time == null ? ''
: level.par.time < fast ? ' → par REQUIRES surfing (throttle alone misses it)'
: level.par.time < p.seconds ? ' → par requires pushing above par-pace'
: ' → par is reachable at par-pace throttle';
if (verdict) console.log(`par ${level.par.time}s vs surf ${surf.toFixed(0)} / throttle ${fast.toFixed(0)} / par-pace ${p.seconds.toFixed(0)}${verdict}`);
const maxP = Math.max(...p.beats.map((b) => b.pressure), 0.001);
console.log('\n beat s-range len flow en dens tight haz PRESSURE clear t cum');
for (const b of p.beats) {
@ -523,6 +664,23 @@ async function cliSim(only) {
const totEn = p.beats.reduce((a, b) => a + b.enemies, 0);
console.log(` ${'TOTAL'.padEnd(26)} ${String(0).padStart(4)}-${String(p.total).padStart(4)} ${String(p.total).padStart(5)} ${'-'.padStart(4)} ${String(totEn).padStart(3)}`);
const chases = chaseAnalysis(level);
if (chases.length) {
console.log('\n chase hazards — "required" = throttle multiple needed to hold the gap');
console.log(' (throttle tops out at 1.40; surfing a crest = ' + CREST.toFixed(2) + ')');
for (const c of chases) {
const tag = c.lethal ? 'LETHAL' : 'teach ';
const answer = c.required > CREST ? 'UNWINNABLE'
: c.required > 1.4 ? 'surf/antacid ONLY'
: c.required <= 1.0 ? 'theatre (flow outruns it)'
: 'push hard, or surf';
console.log(` ${tag} s=${String(c.s).padStart(4)} surge ${String(c.surge).padStart(4)} vs flow ${String(c.flow).padStart(2)} ` +
`→ required ${c.required.toFixed(2)}× ${answer}`);
console.log(` clearance at the gate: throttle-max ${c.throttleMax.clear.toFixed(0)}u (${(c.throttleMax.clear / c.throttleMax.v).toFixed(1)}s) · ` +
`surfing ${c.surfing.clear.toFixed(0)}u (${(c.surfing.clear / c.surfing.v).toFixed(1)}s)`);
}
}
if (p.checkpoints.length) {
console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)');
for (const g of p.checkpoints) {
@ -533,7 +691,90 @@ async function cliSim(only) {
}
}
if (isNode && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) {
/**
* `--probe` measure Lane A's REAL canal and check C's model against it.
*
* The pacing sim is a model of the world; this builds the actual thing (A's spline.js is
* node-safe by design) and reports measured radius/clearance along s. If C's model and A's
* world ever disagree, every par and every clearance in this module is fiction so the
* disagreement should be a number on a screen, not a surprise in round 4.
*/
async function cliProbe(only) {
let buildSpline, createRngFn;
try {
({ buildSpline } = await importSpline());
({ createRng: createRngFn } = await import('../core/rng.js'));
} catch (err) {
console.error(`probe: Lane A's world/spline.js not available — ${err.message}`);
return false;
}
await biomeRegistry();
await crestFactor();
for (const id of CAMPAIGN) {
if (only && id !== only) continue;
const level = await loadJSON(`./${id}.json`);
if (isSkeleton(level)) { console.log(`\n=== ${id}: skeleton, skipped ===`); continue; }
// A's index.js fills segments[].wave from the biome registry before spline sees them.
const fed = {
...level,
segments: level.segments.map((s) => ({
...s,
wave: { amp: s.wave?.amp ?? (REG.ampFor ? REG.ampFor(s.biome) : PLAY.PERISTALSIS_AMP) },
})),
};
const spline = buildSpline(fed, createRngFn(level.seed));
const st = spline.stats ? spline.stats() : null;
console.log(`\n=== ${id} — PROBE of Lane A's real canal ===`);
console.log(`spline length ${spline.length.toFixed(1)} (authored ${totalLength(level)}) · hash ${spline.hash?.() ?? '?'}`);
if (st) console.log(`minRadius ${st.minRadius.toFixed(2)} · maxRadius ${st.maxRadius.toFixed(2)} · pinchRatio ${st.pinchRatio.toFixed(2)} (A's guard: >1.5)`);
// measured clearance along s, vs C's model
console.log('\n segment s-range model MEASURED min@s verdict');
let s0 = 0;
for (const seg of level.segments) {
const s1 = s0 + seg.length;
const model = hazardClearance(level, seg, s0);
let worst = Infinity, worstS = s0;
for (let s = s0; s <= s1; s += 1) {
const clear = spline.radiusAt(s) - waveMargin(seg) - PLAY.SKIN - PLAY.SHIP_R;
if (clear < worst) { worst = clear; worstS = s; }
}
const bad = worst < PLAY.MIN_CLEARANCE;
console.log(
` ${(seg.name ?? seg.biome).padEnd(26).slice(0, 26)} ${String(s0).padStart(4)}-${String(s1).padStart(4)} ` +
`${model.toFixed(2).padStart(6)} ${worst.toFixed(2).padStart(9)} ${String(worstS).padStart(7)} ` +
`${bad ? 'UNDER FLOOR ' + PLAY.MIN_CLEARANCE : 'ok'}`);
s0 = s1;
}
// taper profile across the authored joins — C's constrictions must narrow, not step
const joins = [];
let acc = 0;
for (let i = 0; i < level.segments.length - 1; i++) {
acc += level.segments[i].length;
if (level.segments[i].radius.base !== level.segments[i + 1].radius.base) joins.push(acc);
}
if (joins.length) {
console.log('\n radius taper across joins (A blends; a step here would read as a level seam)');
for (const j of joins) {
const pts = [-40, -25, -12, 0, 12, 25, 40].map((d) => `${d >= 0 ? '+' : ''}${d}:${spline.radiusAt(j + d).toFixed(2)}`);
console.log(` s=${String(j).padStart(4)} ${pts.join(' ')}`);
}
}
}
return true;
}
if (isMain && process.argv.includes('--probe')) {
const i = process.argv.indexOf('--probe');
const ok = await cliProbe(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);
if (!process.argv.includes('--selfcheck') && !process.argv.includes('--sim')) process.exit(ok ? 0 : 1);
}
if (isMain && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) {
if (process.argv.includes('--sim')) {
const i = process.argv.indexOf('--sim');
await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);

View File

@ -37,7 +37,7 @@ function makeNoise3(rng) {
* @param {THREE.Material} material a wall material built for this arena's biome
* @param {function} rng
*/
export function createArena({ spec, spline, material, rng, quality = 'high' }) {
export function createArena({ spec, spline, material, rng, quality = 'high', waveAmpDefault = 0.7 }) {
// three's polyhedron `detail` splits each edge into (detail+1) segments, so face count is
// 20*(detail+1)^2 — NOT 20*4^detail. detail:5 is 720 tris, which on a 55-unit room is a
// 10-unit facet and the fbm displacement has nothing to displace. Solve for ~3u spacing
@ -57,6 +57,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) {
const uv = new Float32Array(n * 2);
const aPhase = new Float32Array(n);
const aK = new Float32Array(n);
const aWaveA = new Float32Array(n);
// The churn wave crosses the room along the canal's own axis, slowly enough to read as a
// room breathing rather than a corridor's transit wave.
@ -65,6 +66,9 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) {
const ref = new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z);
const bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z);
const amp = spec.radius * 0.09;
// A room churns, it doesn't transit: the shell's wave amplitude comes from the arena's own
// biome (or C's per-arena override), never from whatever segment happens to span it.
const waveAmp = typeof spec.wave?.amp === 'number' ? spec.wave.amp : waveAmpDefault;
const v = new THREE.Vector3();
for (let i = 0; i < n; i++) {
@ -80,6 +84,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) {
uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube
aPhase[i] = k * (spec.at + along);
aK[i] = k;
aWaveA[i] = waveAmp;
}
// Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it
@ -99,6 +104,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) {
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
g.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1));
g.computeBoundingSphere();
geo.dispose(); // the source icosphere was scaffolding
@ -112,7 +118,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) {
center,
radius: spec.radius,
/** Conservative inner surface: shell minus displacement peak minus the shader's wave. */
innerRadius: spec.radius - amp - (material.uniforms?.uWaveA?.value ?? 0) - 0.6,
innerRadius: spec.radius - amp - waveAmp - 0.6,
covers: (s) => Math.abs(s - spec.at) <= spec.radius,
dispose() { g.dispose(); },
};

View File

@ -12,7 +12,7 @@
import * as THREE from 'three';
import { createRng } from '../core/rng.js';
import { buildSpline, OMEGA } from './spline.js';
import { buildSpline, OMEGA, CREST_FACTOR } from './spline.js';
import { biome as biomeOf } from './biomes.js';
import { createWallMaterial } from './wall_material.js';
import { createTube } from './tube.js';
@ -20,9 +20,25 @@ import { createArena } from './arena.js';
const SKIN = 0.6; // collision safety margin (units) — matches the stub
/**
* Fill each segment's `wave` from its biome so the spline only ever sees numbers. C's schema v2
* `segments[].wave: { amp }` overrides the biome (ruling #8) the diaphragmatic hiatus is a
* fixed muscular ring: tight AND calm. Returns a shallow copy; `world.level` stays C's object.
*/
function normalizeLevel(levelData) {
return {
...levelData,
segments: levelData.segments.map((seg) => {
const b = biomeOf(seg.biome);
return { ...seg, wave: { amp: seg.wave?.amp ?? b.wave.amp, breathe: seg.wave?.breathe ?? b.wave.breathe } };
}),
};
}
export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) {
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
const spline = buildSpline(levelData, R);
if (!levelData || !Array.isArray(levelData.segments)) throw new Error('[world] level.segments is required');
const spline = buildSpline(normalizeLevel(levelData), R);
const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : [];
// --- assets: optional, always (TECH.md §Asset manifest contract) -----------------------
@ -44,29 +60,28 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
// wrong key doesn't throw — it silently falls back to the procedural wall forever, which is
// the worst kind of bug. Explicit map, so a mismatch is visible in one place.
// -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D).
const TEXTURE_SLUG = {
oral: 'oral', esophagus: 'esophagus', stomach: 'stomach',
small_intestine: 'smallint', large_intestine: 'colon', appendix: 'appendix',
};
const TEXTURE_FOR = (biomeId) => `wall_${TEXTURE_SLUG[biomeId] ?? biomeId}_a`;
function detailFor(biomeId) {
if (!assets) return { detail: null, tile: null };
const name = TEXTURE_FOR(biomeId);
// TEMPORARY (ruling #3): D's round-1 keys aren't the biome ids — `small_intestine` ships as
// `wall_smallint_a`. D renames to biome ids early this round and pings in NOTES; **delete
// this map and the ?? fallback the moment they do.** D's assets.js now has a miss ledger
// (`assets.misses()`), so a drifted slug announces itself instead of silently falling back
// forever — which is what made this dangerous in round 1.
const TEXTURE_SLUG = { small_intestine: 'smallint', large_intestine: 'colon' };
const slug = (biomeId) => TEXTURE_SLUG[biomeId] ?? biomeId;
/** One wall's full texture set, all optional and independently so. */
function texturesFor(biomeId) {
const out = { detail: null, detailB: null, normalMap: null, matcap: null, repeat: null, repeatB: null };
if (!assets || typeof assets.texture !== 'function') return out;
try {
if (typeof assets.texture === 'function') {
const t = assets.texture(name);
if (t && t.map) return { detail: t.map, tile: Array.isArray(t.tile) ? t.tile : null };
}
if (typeof assets.get === 'function') {
const e = assets.get('textures', name);
if (!e) return { detail: null, tile: null };
const tex = e.isTexture ? e : (e.texture ?? e.map ?? null);
return { detail: tex, tile: Array.isArray(e.tile) ? e.tile : null };
}
const a = assets.texture(`wall_${slug(biomeId)}_a`);
if (a?.map) { out.detail = a.map; out.repeat = a.repeat ?? null; out.normalMap = a.normalMap ?? null; }
const b = assets.texture(`wall_${slug(biomeId)}_b`);
if (b?.map) { out.detailB = b.map; out.repeatB = b.repeat ?? null; }
if (typeof assets.matcap === 'function') out.matcap = assets.matcap('tissue_wet') ?? null;
} catch (err) {
console.warn(`[world] assets lookup failed for ${name}, using procedural wall —`, err.message);
console.warn(`[world] assets lookup failed for ${biomeId}, using procedural wall —`, err.message);
}
return { detail: null, tile: null };
return out;
}
// --- materials -------------------------------------------------------------------------
@ -80,10 +95,10 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
};
function makeMaterial(biomeId, side, fog) {
const b = biomeOf(biomeId);
const { detail, tile } = detailFor(biomeId);
const m = createWallMaterial({
biome: b, omega: OMEGA, detail, tile, radiusHint: radiusHintFor(biomeId), side, fog,
biome: biomeOf(biomeId), omega: OMEGA, side, fog,
radiusHint: radiusHintFor(biomeId),
...texturesFor(biomeId),
});
owned.push(m);
return m;
@ -125,6 +140,7 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
const a = createArena({
spec, spline, rng: R, quality,
material: arenaMaterialFor(spec), // shell viewed from inside
waveAmpDefault: biomeOf(spec.biome).wave.amp, // the room's biome, not the segment's
});
group.add(a.mesh);
return a;
@ -136,20 +152,30 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
let time = 0;
const biomeIdAt = (s) => spline.segmentAt(s).biome;
const waveMaxAt = (s) => { const b = biomeOf(biomeIdAt(s)); return b.wave.amp + b.wave.breathe; };
// Prefers the per-segment override (schema v2) over the biome default, via the blended
// schedule — so wallRho tracks C's calm hiatus instead of the biome's loudest wave.
const waveMaxAt = (s) => spline.waveAmpAt(s) + biomeOf(biomeIdAt(s)).wave.breathe;
const arenaSpatial = (v) => arenas.find((a) => v.distanceTo(a.center) <= a.radius) || null;
function sample(s) {
/**
* Contract v1.2: `sample(s)` allocates ~6 Vector3s and it's B's hot path (~60 calls/frame at
* 55 enemies = ~350 allocations/frame of GC churn LANE_B_NOTES A #3). Pass a caller-owned
* frame as `out` and nothing allocates. The allocating form stays as sugar.
*/
function sample(s, out) {
const f = spline.frameAt(s);
return {
pos: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z),
tan: new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z),
nor: new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z),
bin: new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z),
radius: f.radius,
};
const o = out || { pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3() };
o.pos.set(f.pos.x, f.pos.y, f.pos.z);
o.tan.set(f.tan.x, f.tan.y, f.tan.z);
o.nor.set(f.nor.x, f.nor.y, f.nor.z);
o.bin.set(f.bin.x, f.bin.y, f.bin.z);
o.radius = f.radius;
return o;
}
/** A reusable frame, for callers who want the fast path without owning the boilerplate. */
sample.frame = () => ({ pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3(), radius: 0 });
const world = {
level: levelData,
length: spline.length,
@ -199,10 +225,14 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
return a ? { center: a.center, radius: a.radius } : null;
},
/** JS mirror of the vertex shader's wave, exact. B: crest speed == biomeAt(s).flow, so a
* ship riding a crest is riding the current. E: pulse the mix with it. */
/** JS mirror of the vertex shader's wave, exact (0 = trough, 1 = crest). E: pulse the mix. */
flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3),
/** Crest-speed law (TECH v1.1): u/s the crest travels == CREST_FACTOR × flow(s). B
* speed-locks to this while surfing; it outruns throttleMax by design (1.6 > 1.4). */
crestSpeed: (s) => spline.crestSpeedAt(s),
crestFactor: CREST_FACTOR,
update(dt, playerS = 0) {
time += dt;
for (const m of owned) m.uniforms.uTime.value = time;

View File

@ -40,26 +40,39 @@ const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1);
// --- tuning -----------------------------------------------------------------------------
// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in
// time — physiologically it's how often the muscle fires, which doesn't change because you
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / flow(s), so a
// wave crest travels at exactly the local flow speed and Lane B can surf one. Phase is the
// integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — plain
// `k*s - w*t` does not, and tears the wave at every segment join.
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's esophagus wave, preserved exactly.
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / crestSpeed(s), so
// phase is the integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes —
// plain `k*s - w*t` does not, and tears the wave at every segment join.
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's round-0 esophagus wave.
export const OMEGA = 3.08;
// Crest-speed law (TECH §FROZEN v1.1, round-2 ruling #1). Round 1 made a crest travel at
// exactly flow(s), which felt right and played wrong: B measured that surfing then loses to
// throttle-mashing (throttleMax 1.4 × flow > 1.0 × flow), so the level's signature mechanic
// was strictly dominated. The wave has to outrun the player. crestSpeed = 1.6 × flow beats
// 1.4 and stays "gameplay wave == visual wave" — the thing you see is the thing you ride.
// Consequence: crest spacing grows 1.6× (45.7u at flow 14, was 28.5u). Rings are further
// apart and move faster, which reads better on a speed level anyway.
export const CREST_FACTOR = 1.6;
const DU = 0.5; // march step in curve parameter
const DS = 0.5; // frame/phase LUT spacing in arclength
const NOISE_TAB = 1024;
const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle
const PINCH_SAFETY = 2.2; // min turn radius = this x base radius (>2 covers wobble peaks)
// Blend widths (units) for smoothstep-crossfading segment params across a join. Radius and
// flow are local scalars — a tight 12u transition reads as a sphincter. Curviness feeds
// centreline *amplitude*, which has a long lever arm: ramping it over a short span is itself a
// hard lateral swerve, i.e. curvature. It gets a wide, gentle ramp.
// `curvBase` is baseRadius again, but read through the wide ramp: it divides the curvature
// budget, so a 12u step in it would swerve the centreline just as hard as curviness would.
const BLEND = { curviness: 60, curvBase: 60, base: 12, wobble: 12, flow: 12 };
// Blend widths (units) for smoothstep-crossfading segment params across a join. Nothing ever
// steps: a hard radius change would read as a level seam, and C's constrictions are supposed
// to feel like the body narrowing.
//
// `base`/`wobble` at ±25 => a 50-unit taper, which is what C's L2 was authored against
// (LANE_C_NOTES → A #4: "assumes a smooth blend over ~4060 units", their biggest open
// dependency on me). Round 1 was ±12; widened here to match the design. At L2's 12→9 step
// (s 1100) that's ~3.5s of narrowing at flow 16 — you feel the body close in, not a wall.
// `curviness`/`curvBase` feed centreline *amplitude*, which has a long lever arm: ramping it
// over a short span is itself a hard lateral swerve, i.e. curvature. They get a wider ramp.
// `waveAmp` tracks radius: a segment that is calmer is calmer over the same taper it narrows.
const BLEND = { curviness: 60, curvBase: 60, base: 25, wobble: 25, flow: 12, waveAmp: 25 };
// [wavelength, share of the axis curvature budget]. Y is tamer than X on purpose: a canal that
// writhes vertically as hard as it does laterally would swing the parallel-transport frame
@ -82,6 +95,11 @@ const GET = {
base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10),
wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2),
flow: (g) => (typeof g.flow === 'number' ? g.flow : 10),
// schema v2 `segments[].wave: { amp }` (round-2 ruling #8, C's request): the diaphragmatic
// hiatus is a fixed muscular ring and does NOT have big peristaltic waves, but it's also the
// level's tightest hole — so biome-wide amplitude put the biggest wave in the smallest gap.
// index.js fills this from the biome registry before we ever see it, so it's always a number.
waveAmp: (g) => (g.wave && typeof g.wave.amp === 'number' ? g.wave.amp : 1.0),
};
GET.curvBase = GET.base;
@ -245,7 +263,9 @@ export function buildSpline(level, rng) {
const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]);
// --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ----------------------
const kAt = (s) => OMEGA / Math.max(1, paramAt(s, 'flow'));
const crestSpeedAt = (s) => CREST_FACTOR * Math.max(1, paramAt(s, 'flow'));
const kAt = (s) => OMEGA / crestSpeedAt(s);
const waveAmpAt = (s) => paramAt(s, 'waveAmp');
const phaseArr = new Float64Array(M);
for (let i = 1; i < M; i++)
phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
@ -326,6 +346,7 @@ export function buildSpline(level, rng) {
length: L, spans, uMax,
centre, sOfU, uOfS, ampAtU,
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
crestSpeedAt, waveAmpAt,
omega: OMEGA,
hash, stats,
segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
@ -407,15 +428,37 @@ async function selfcheck() {
ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`);
ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`);
// wave: phase monotone, and crest speed == local flow (the whole point of K(s))
// wave: phase monotone, and the crest-speed law (TECH FROZEN v1.1)
let phaseMono = true;
for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false;
ok('wave: phase K(s) strictly increasing', phaseMono);
const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
ok('wave: k == OMEGA/flow (esophagus flow 14 => k 0.22, matches stub)', Math.abs(kEso - 0.22) < 0.001, `k=${kEso.toFixed(4)}`);
ok('wave: crest speed == local flow in each biome',
Math.abs(OMEGA / kEso - 14) < 0.01 && Math.abs(OMEGA / kSto - 4) < 0.01,
`${(OMEGA / kEso).toFixed(2)} u/s eso, ${(OMEGA / kSto).toFixed(2)} u/s stomach`);
ok('wave: k == OMEGA/(CREST_FACTOR*flow)', Math.abs(kEso - OMEGA / (CREST_FACTOR * 14)) < 1e-6, `k=${kEso.toFixed(4)}`);
ok('wave: crestSpeed == 1.6 x local flow, in every biome',
Math.abs(a.crestSpeedAt(50) - 1.6 * 14) < 0.01 && Math.abs(a.crestSpeedAt(a.length - 20) - 1.6 * 4) < 0.01,
`${a.crestSpeedAt(50).toFixed(2)} u/s eso (flow 14), ${a.crestSpeedAt(a.length - 20).toFixed(2)} u/s stomach (flow 4)`);
// the law exists to make surfing the fast line — assert the thing B actually depends on
ok('wave: crest outruns a throttle-mashing player (crestSpeed > 1.4 x flow)',
a.crestSpeedAt(50) > 1.4 * 14, `${a.crestSpeedAt(50).toFixed(1)} > ${(1.4 * 14).toFixed(1)} u/s`);
ok('wave: OMEGA/kAt(s) == crestSpeedAt(s) (phase and speed agree)',
Math.abs(OMEGA / a.kAt(300) - a.crestSpeedAt(300)) < 1e-9);
// per-segment wave override (schema v2): fixture segment 3 declares wave.amp 0.55
const segWave = buildSpline({
...FIXTURE,
segments: FIXTURE.segments.map((s, i) => (i === 2 ? { ...s, wave: { amp: 0.55 } } : { ...s, wave: { amp: 1.4 } })),
});
ok('wave: per-segment amp override honoured', Math.abs(segWave.waveAmpAt(650) - 0.55) < 1e-6,
`amp at s=650 => ${segWave.waveAmpAt(650).toFixed(3)}`);
ok('wave: amp blends across the join (no step)',
Math.abs(segWave.waveAmpAt(540) - (1.4 + 0.55) / 2) < 0.02,
`amp at the join => ${segWave.waveAmpAt(540).toFixed(3)} (midpoint of 1.4 and 0.55)`);
// C's biggest dependency: radius must taper, not cliff (LANE_C_NOTES → A #4)
let maxRadiusStep = 0;
for (let s = 1; s <= a.length; s += 0.5) maxRadiusStep = Math.max(maxRadiusStep, Math.abs(a.radiusAt(s) - a.radiusAt(s - 0.5)));
ok('radius: no cliff at segment joins (max step < 0.15 u per 0.5 u)', maxRadiusStep < 0.15,
`max ${maxRadiusStep.toFixed(4)} u/step`);
console.log(` stats: L=${st.length} grid=${st.gridPoints} maxK=${st.maxCurvature.toFixed(4)} (s=${st.sAtMaxCurvature}) radius=${st.minRadius.toFixed(1)}..${st.maxRadius.toFixed(1)} hash=${a.hash()}`);
console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`);

View File

@ -52,6 +52,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans =
const uv = new Float32Array(n * 2);
const aPhase = new Float32Array(n);
const aK = new Float32Array(n);
const aWaveA = new Float32Array(n);
let p = 0, q = 0, w = 0;
for (let r = 0; r < rings; r++) {
@ -59,6 +60,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans =
const f = spline.frameAt(s);
const phase = spline.phaseAt(s);
const k = spline.kAt(s);
const waveA = spline.waveAmpAt(s);
for (let j = 0; j < cols; j++) {
const th = (j / Q.radial) * TAU;
const ct = Math.cos(th), st = Math.sin(th);
@ -72,7 +74,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans =
aTangent[p] = f.tan.x; aTangent[p + 1] = f.tan.y; aTangent[p + 2] = f.tan.z;
p += 3;
uv[q++] = j / Q.radial; uv[q++] = s;
aPhase[w] = phase; aK[w] = k; w++;
aPhase[w] = phase; aK[w] = k; aWaveA[w] = waveA; w++;
}
}
@ -93,6 +95,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans =
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
geo.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
geo.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1));
geo.setIndex(new THREE.BufferAttribute(idx, 1));
geo.computeBoundingSphere();
geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest

View File

@ -10,10 +10,13 @@
// axis and the tiling axis, and it must stay continuous across chunks
// aPhase float K(s) = ∫k ds, baked (see spline.js)
// aK float local wavenumber, for the analytic d(pulse)/ds
// aWaveA float local peristalsis amplitude. Per-VERTEX, not a uniform, because schema v2
// lets C set `wave.amp` per segment and segments share a biome material.
//
// Colorspace: like the stub, this writes its computed color straight out with no
// <colorspace_fragment> conversion. That's a whole-game decision (it moves every color at
// once) so it stays matched to F's round-0 look until F rules on it — see LANE_A_NOTES.
// Colorspace: `#include <colorspace_fragment>` at the end of main() is LAW (TECH §Shader law).
// three converts THREE.Color inputs to linear but does not convert a raw shader's output back,
// so without it every ART_BIBLE colour ships wrong (B measured amber #ff5a2a displaying as
// pure red). Round 1 shipped without it; this is the fix.
import * as THREE from 'three';
@ -21,43 +24,63 @@ export function createWallMaterial({
biome,
omega,
fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js)
waveAmp = biome.wave.amp,
breatheAmp = biome.wave.breathe,
detail = null, // THREE.Texture | null — Lane D's grayscale detail map
tile = null, // [repeatsAroundCircumference, unitsOfSPerRepeat]
detail = null, // THREE.Texture | null — Lane D's grayscale detail/AO map
detailB = null, // THREE.Texture | null — the _b variant, macro variation
normalMap = null, // THREE.Texture | null — tangent-space normals
matcap = null, // THREE.Texture | null — wet-tissue specular ball
repeat = null, // [repeats around theta, repeats per unit of s] (= D's .repeat)
repeatB = null,
normalScale = 0.6, // D: "0.6 looks right; 0 = off, 1 = full relief"
matcapGain = 0.22,
radiusHint = 10, // used only to pick a square-ish default tiling
side = THREE.FrontSide,
}) {
if (detail) { // defensive: we consume D's texture, so we set what we depend on
detail.wrapS = detail.wrapT = THREE.RepeatWrapping;
detail.needsUpdate = true;
for (const t of [detail, detailB, normalMap]) { // we consume D's textures; set what we rely on
if (t) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.needsUpdate = true; }
}
const tileAround = tile ? tile[0] : 3;
const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround);
// Default tiling if D has no `tile` hint: ~square texels for this biome's radius.
const fallbackRepeat = [3, 3 / Math.max(4, (2 * Math.PI * radiusHint) / 3)];
const rep = repeat ?? fallbackRepeat;
// The _b layer deliberately runs at a different, non-integer-multiple scale: sampling the
// same tiling twice would just reinforce the repeat it's meant to hide.
const repB = repeatB ?? [rep[0] / 2.7, rep[1] / 2.7];
return new THREE.ShaderMaterial({
const defines = {};
if (detail) defines.USE_DETAIL = '';
if (detail && detailB) defines.USE_DETAIL_B = '';
if (normalMap) defines.USE_NORMAL = '';
if (matcap) defines.USE_MATCAP = '';
const mat = new THREE.ShaderMaterial({
side,
defines: detail ? { USE_DETAIL: '' } : {},
defines,
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: fog },
uWaveA: { value: waveAmp },
uBreatheA: { value: breatheAmp },
uOmega: { value: omega },
uRimPow: { value: 2.2 },
uRimGain: { value: 0.9 },
uDetail: { value: detail },
uTile: { value: new THREE.Vector2(tileAround, tileAlong) },
uDetailB: { value: detailB },
uNormal: { value: normalMap },
uMatcap: { value: matcap },
uNormalScale: { value: normalScale },
uMatcapGain: { value: matcapGain },
uRepeat: { value: new THREE.Vector2(rep[0], rep[1]) },
uRepeatB: { value: new THREE.Vector2(repB[0], repB[1]) },
},
vertexShader: /* glsl */`
attribute vec3 aInward;
attribute vec3 aTangent;
attribute float aPhase;
attribute float aK;
uniform float uTime, uWaveA, uBreatheA, uOmega;
attribute float aWaveA;
uniform float uTime, uBreatheA, uOmega;
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
void main() {
@ -66,13 +89,13 @@ export function createWallMaterial({
float sn = max(0.0, sin(phi));
float pulse = sn * sn * sn; // sharp crest, long trough: a muscle, not a sine
float breathe = uBreatheA * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831853 * 3.0);
float disp = uWaveA * pulse + breathe;
float disp = aWaveA * pulse + breathe;
// Tilt the normal with the wave. Without this the crests are silhouette-only and the
// rim light slides over them as if the wall were flat — the effective wall radius is
// rho(s) = radius - disp, so the inward normal leans along the tangent by d(rho)/ds.
float dPulse_ds = 3.0 * sn * sn * cos(phi) * aK;
vec3 nIn = normalize(aInward - aTangent * (uWaveA * dPulse_ds));
vec3 nIn = normalize(aInward - aTangent * (aWaveA * dPulse_ds));
vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
vN = normalize(normalMatrix * nIn);
@ -82,33 +105,85 @@ export function createWallMaterial({
}`,
fragmentShader: /* glsl */`
uniform vec3 uTint, uRim, uVoid;
uniform float uFog, uRimPow, uRimGain;
uniform float uFog, uRimPow, uRimGain, uNormalScale, uMatcapGain;
uniform vec2 uRepeat, uRepeatB;
#ifdef USE_DETAIL
uniform sampler2D uDetail;
uniform vec2 uTile;
#endif
#ifdef USE_DETAIL_B
uniform sampler2D uDetailB;
#endif
#ifdef USE_NORMAL
uniform sampler2D uNormal;
#endif
#ifdef USE_MATCAP
uniform sampler2D uMatcap;
#endif
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
#ifdef USE_NORMAL
// three's perturbNormal2Arb, lifted from Lane D's web/dev/laneD_texview.html.
// The tube carries no tangent attribute, so rebuild the TBN per-pixel from screen
// derivatives. DO NOT "simplify" this to normalize(vN + tn * k): a tangent-space sample
// is ~(0,0,1), so adding it tilts every normal toward the camera, dot(n,view) -> 1, the
// fresnel rim dies and the tube renders near-black. The rim IS the biome's only light.
// That failure looks exactly like "Lane D's textures are too dark" and is not.
vec3 perturb(vec3 N, vec3 viewPos, vec2 st, vec3 mapN) {
vec3 q0 = dFdx(viewPos), q1 = dFdy(viewPos);
vec2 st0 = dFdx(st), st1 = dFdy(st);
vec3 S = normalize(q0 * st1.t - q1 * st0.t);
vec3 T = normalize(-q0 * st1.s + q1 * st0.s);
return normalize(mat3(S, T, N) * mapN);
}
#endif
void main() {
vec2 duv = vUv * uRepeat;
#ifdef USE_DETAIL
// Lane D authors grayscale; the biome tint is applied here, so one texture can serve
// two biomes at different tints (ART_BIBLE §FLUX prompt kit).
float detail = texture2D(uDetail, vec2(vUv.x * uTile.x, vUv.y / uTile.y)).r;
detail = mix(0.5, 1.2, detail);
// Lane D authors grayscale luminance/AO; the biome tint is applied here, so one
// texture serves two biomes at different tints (ART_BIBLE §FLUX prompt kit).
float detail = texture2D(uDetail, duv).r;
#ifdef USE_DETAIL_B
// macro variation: the _b wall at a coarser, non-multiple scale breaks _a's repeat
float db = texture2D(uDetailB, vUv * uRepeatB).r;
detail = mix(detail, detail * (0.55 + 0.9 * db), 0.6);
#endif
vec3 base = uTint * (0.35 + detail * 0.95) * 0.9; // D's measured curve
#else
// Assets-optional law: no texture is the *shipping* look until D lands, not an error
// state. Ridged folds around theta + striation along s = a passable SEM stand-in.
float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831853 * 9.0 + sin(vUv.y * 0.9) * 2.0);
float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2));
vec3 base = uTint * detail * 0.8;
#endif
vec3 base = uTint * detail * 0.8;
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), uRimPow);
vec3 N = normalize(vN);
#ifdef USE_NORMAL
vec3 tn = texture2D(uNormal, duv).xyz * 2.0 - 1.0;
tn.xy *= uNormalScale;
N = perturb(N, -vView, duv, normalize(tn));
#endif
vec3 V = normalize(vView);
float fres = pow(1.0 - abs(dot(N, V)), uRimPow);
// crest sheen: the wave is a gameplay tell (ride it for boost), so it gets a little
// help beyond what its own geometry earns from the rim term
vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10;
#ifdef USE_MATCAP
// wet specular. Keyed off the perturbed normal so the sheen follows the folds, and
// weighted toward grazing angles so it reads as a film of mucus, not a plastic gloss.
vec2 muv = N.xy * 0.5 + 0.5;
col += texture2D(uMatcap, muv).rgb * uMatcapGain * (0.25 + 0.75 * fres);
#endif
float d = length(vView);
gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0);
#include <colorspace_fragment>
}`,
});
// WebGL1 needs the derivatives extension for perturb(); harmless on WebGL2 where it's core.
mat.extensions = { derivatives: true };
return mat;
}