Compare commits
4 Commits
4ccf5edcc0
...
29cca53675
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29cca53675 | ||
|
|
2e85ec0622 | ||
|
|
4ed3433e55 | ||
|
|
f0982e70c3 |
@ -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:08–15: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
|
||||
|
||||
@ -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:08–15: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
|
||||
|
||||
BIN
docs/shots/laneA/round2_L2_hiatus.png
Normal file
BIN
docs/shots/laneA/round2_L2_hiatus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
docs/shots/laneA/round2_L2_tbn_wall.png
Normal file
BIN
docs/shots/laneA/round2_L2_tbn_wall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/shots/laneF/round2_L2_integrated_full_run.png
Normal file
BIN
docs/shots/laneF/round2_L2_integrated_full_run.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 MiB |
@ -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 };
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
272
web/js/combat/hazards.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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
114
web/js/combat/pickups.js
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"id": "L1_mouth",
|
||||
"name": "Oral Cavity",
|
||||
"tagline": "Learn to swim",
|
||||
|
||||
@ -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." },
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"id": "L3_stomach",
|
||||
"name": "Stomach",
|
||||
"tagline": "The acid sea",
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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(); },
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ~40–60 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`);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user