Compare commits
No commits in common. "29cca53675cf2e03524876f75674f349f0783c37" and "4ccf5edcc031719dcc1b8479d2168cbd61e29211" have entirely different histories.
29cca53675
...
4ccf5edcc0
@ -1,35 +1,5 @@
|
|||||||
# GUTS — Round 2 lane instructions (from Fable, integrator)
|
# 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
|
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
|
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
|
held up: A's hash `cefc4f83` and 8-draw sweep, B's `node --check` ESM no-op (reproduced on a
|
||||||
|
|||||||
@ -1,34 +1,5 @@
|
|||||||
# Lane F (Fable) — progress
|
# 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)
|
## 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
|
Reviewed all four lanes; every checked claim held (A's hash cefc4f83 + 8-draw sweep, B's
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 MiB |
@ -58,46 +58,10 @@ const player = flags.fly ? null
|
|||||||
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : 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
|
// --- level-event pump (owned by boot per round-2 ruling): emits C's events as the
|
||||||
// player crosses their s. Each event fires ONCE per run — respawn does not rewind the pump
|
// player crosses their s. Checkpoint/respawn replay semantics: revisit with B in round 2.
|
||||||
// (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;
|
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++]); };
|
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) ---
|
// --- 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;
|
let sCam = 5, look = { yaw: 0, pitch: 0 }, dragging = false, speed = world.biomeAt(0).flow * 0.6;
|
||||||
const keys = new Set();
|
const keys = new Set();
|
||||||
@ -152,27 +116,19 @@ addEventListener('resize', () => {
|
|||||||
renderer.setSize(innerWidth, innerHeight);
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
});
|
});
|
||||||
|
|
||||||
// One deterministic simulation step — the whole game minus rendering. Exported so the
|
let last = performance.now();
|
||||||
// stepped-sim harness (f-progress documents it; rAF never fires in a hidden pane) exercises
|
function frame(now) {
|
||||||
// the REAL loop — pump, run state and all — instead of a hand-rolled copy that drifts.
|
const dt = Math.min((now - last) / 1000, 0.05);
|
||||||
function step(dt) {
|
last = now;
|
||||||
if (player) {
|
if (player) {
|
||||||
player.update(dt); // must run first: combat hit-tests this frame's ship position
|
player.update(dt); // must run first: combat hit-tests this frame's ship position
|
||||||
pumpTo(player.state.s);
|
pumpTo(player.state.s);
|
||||||
combat.update(dt);
|
combat.update(dt);
|
||||||
updateRun(dt);
|
|
||||||
world.update(dt, player.state.s);
|
world.update(dt, player.state.s);
|
||||||
} else {
|
} else {
|
||||||
updateCamera(dt); // ?fly=1 noclip / title drift
|
updateCamera(dt); // ?fly=1 noclip / title drift
|
||||||
world.update(dt, sCam);
|
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);
|
renderer.render(scene, camera);
|
||||||
stats.draws = renderer.info.render.calls;
|
stats.draws = renderer.info.render.calls;
|
||||||
stats.tris = renderer.info.render.triangles;
|
stats.tris = renderer.info.render.triangles;
|
||||||
@ -190,4 +146,4 @@ function frame(now) {
|
|||||||
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
|
if (flags.dbg && !flags.shots) dbgEl.style.display = 'block';
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
export { scene, camera, renderer, bus, flags, world, player, combat, assets, step };
|
export { scene, camera, renderer, bus, flags, world, player, combat, assets };
|
||||||
|
|||||||
@ -53,56 +53,6 @@ export const BALANCE = {
|
|||||||
|
|
||||||
darts: { pool: 48, radius: 0.45 },
|
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
|
// 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.
|
// listens). Window is generous enough to cross a fold, tight enough to mean something.
|
||||||
combo: { window: 2.5 },
|
combo: { window: 2.5 },
|
||||||
|
|||||||
@ -78,28 +78,19 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
|||||||
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
||||||
|
|
||||||
// --- spawn ------------------------------------------------------------------------
|
// --- spawn ------------------------------------------------------------------------
|
||||||
// Charter API: spawn(type, {s, theta, rho}). `type` is an ARCHETYPE — combat/index.js
|
// Charter API: spawn(type, {s, theta, rho}). Level events give us only an `s` and a
|
||||||
// resolves C's fiction ids (`bolus_chunk`) to archetypes before calling us.
|
// count, so theta/rho are filled from the seeded stream — same seed, same encounter.
|
||||||
//
|
|
||||||
// `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 } = {}) {
|
function spawn(type, { s = 0, theta = null, rho = null } = {}) {
|
||||||
const pool = pools[type];
|
const pool = pools[type];
|
||||||
if (!pool) { console.warn(`[combat] unknown enemy archetype "${type}"`); return null; }
|
if (!pool) { console.warn(`[combat] unknown enemy type "${type}"`); return null; }
|
||||||
if (pool.slots.length >= pool.cfg.pool) return null; // pool is the population cap
|
if (pool.slots.length >= pool.cfg.pool) return null; // pool is the population cap
|
||||||
|
|
||||||
const cfg = pool.cfg;
|
const cfg = pool.cfg;
|
||||||
s = THREE.MathUtils.clamp(s, 0, world.length);
|
s = THREE.MathUtils.clamp(s, 0, world.length);
|
||||||
const th = theta ?? rand() * Math.PI * 2;
|
const th = theta ?? rand() * Math.PI * 2;
|
||||||
// free room for this body's centre: the safe radius less its own half-width, so rho 1.0
|
const wall = world.wallRho(s, th) - cfg.radius;
|
||||||
// is flush against the wall rather than half-buried in it
|
// turrets bolt to the wall; everything else floats somewhere in the lumen
|
||||||
const free = Math.max(0, world.wallRho(s, th) - cfg.radius);
|
const r = rho ?? (type === 'turret' ? wall : wall * (0.25 + rand() * 0.6));
|
||||||
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 = {
|
const e = {
|
||||||
id: nextId++, type, cfg,
|
id: nextId++, type, cfg,
|
||||||
|
|||||||
@ -1,272 +0,0 @@
|
|||||||
// 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,61 +1,33 @@
|
|||||||
// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons + hazards
|
// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons and
|
||||||
// + pickups and feeds them Lane C's level events. All the wiring, none of the behaviour.
|
// feeds them Lane C's level events. All the wiring, none of the behaviour.
|
||||||
//
|
//
|
||||||
// Update order inside a frame is load-bearing:
|
// Update order inside a frame is load-bearing:
|
||||||
// boot: player.update(dt) -> combat.update(dt)
|
// boot: player.update(dt) -> combat.update(dt)
|
||||||
// here: enemies.update(dt) -> weapons.update(dt) -> hazards.update(dt) -> pickups.update(dt)
|
// here: enemies.update(dt) -> weapons.update(dt)
|
||||||
// Enemies move first so weapons hit-test against this frame's positions, not last frame's.
|
// 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 { createEnemies } from './enemies.js';
|
||||||
import { createWeapons } from './weapons.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 }) {
|
export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) {
|
||||||
const enemies = createEnemies({ scene, world, bus, rng, assets });
|
const enemies = createEnemies({ scene, world, bus, rng, assets });
|
||||||
const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies });
|
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.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur));
|
||||||
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
|
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
|
||||||
|
|
||||||
const score = { value: 0, kills: 0, samples: 0 };
|
const score = { value: 0, kills: 0 };
|
||||||
|
|
||||||
// Fan a group of `count` out along s. C's `spread` is the authored span of the group
|
// Lane C's events arrive on the bus as the player crosses their `s`. We only care about
|
||||||
// ("count 4 spread 120" = a field, not a wall); without one, a tight default fan keeps
|
// `spawn`; checkpoints/hazards/bosses belong to C and E this round. Unknown types are
|
||||||
// a count from stacking at a single arclength.
|
// ignored on purpose — C may add event kinds without breaking combat.
|
||||||
const fan = (ev, i) => {
|
|
||||||
const n = ev.count ?? 1;
|
|
||||||
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) => {
|
const offEvent = bus.on('level:event', (ev) => {
|
||||||
if (!ev) return;
|
if (!ev || ev.type !== 'spawn') return;
|
||||||
if (ev.type === 'spawn') {
|
const n = ev.count ?? 1;
|
||||||
// Levels name enemies in fiction ("bolus_chunk"); pools are keyed by archetype
|
for (let i = 0; i < n; i++) {
|
||||||
// ("floater"). levels/enemies.js is the join table (C's catalogue, round-2 task #1).
|
// fan the group out over a short stretch of s so a count of 6 is an encounter,
|
||||||
const spec = getEnemy(ev.enemy);
|
// not a wall of enemies at one arclength
|
||||||
if (!spec) { console.warn(`[combat] spawn "${ev.enemy}" is not in levels/enemies.js — skipped`); return; }
|
enemies.spawn(ev.enemy, { s: (ev.s ?? 0) + i * 3.5, theta: ev.theta ?? null, rho: ev.rho ?? null });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,36 +36,23 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
|
|||||||
score.kills++;
|
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) {
|
function update(dt) {
|
||||||
enemies.update(dt, player);
|
enemies.update(dt, player);
|
||||||
weapons.update(dt);
|
weapons.update(dt);
|
||||||
hazards.update(dt);
|
|
||||||
pickups.update(dt);
|
|
||||||
// combat's slice of the HUD state (E reads player:state for the flight half)
|
// combat's slice of the HUD state (E reads player:state for the flight half)
|
||||||
bus.emit('combat:state', {
|
bus.emit('combat:state', {
|
||||||
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
|
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
|
||||||
ammo: weapons.ammo, ammoMax: weapons.ammoMax,
|
ammo: weapons.ammo, ammoMax: weapons.ammoMax,
|
||||||
score: score.value, kills: score.kills, samples: score.samples, enemies: enemies.count,
|
score: score.value, kills: score.kills, enemies: enemies.count,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update,
|
update,
|
||||||
enemies, weapons, hazards, pickups, score,
|
enemies, weapons, 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() {
|
dispose() {
|
||||||
offEvent(); offDie(); offPickup();
|
offEvent(); offDie();
|
||||||
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
|
enemies.dispose(); weapons.dispose();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,114 +0,0 @@
|
|||||||
// 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,30 +70,6 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
|
|||||||
st.coat = Math.max(0, st.coat - amount);
|
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 ---------------------------------------------------------------------
|
// --- the loop ---------------------------------------------------------------------
|
||||||
function update(dt) {
|
function update(dt) {
|
||||||
const intent = input.sample();
|
const intent = input.sample();
|
||||||
@ -133,19 +109,8 @@ 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 });
|
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 ---
|
// 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;
|
st.speed = biome.flow * (st.throttle + st.surfBlend * T.surf.gain + st.boostBlend * T.boost.gain);
|
||||||
// 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);
|
st.s = clampS(st.s + st.speed * dt);
|
||||||
|
|
||||||
// disc movement
|
// disc movement
|
||||||
@ -299,9 +264,6 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
|
|||||||
get forward() { return world.sample(st.s).tan; },
|
get forward() { return world.sample(st.s).tan; },
|
||||||
damage,
|
damage,
|
||||||
drain,
|
drain,
|
||||||
kill,
|
|
||||||
shove,
|
|
||||||
refill,
|
|
||||||
respawn(s = 0) {
|
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 });
|
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;
|
cam.init = false; aimLagInit = false;
|
||||||
|
|||||||
@ -1,30 +1,28 @@
|
|||||||
// flight/tuning.js (Lane B) — THE FEEL. Every number that decides how GUTS handles lives
|
// 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.
|
// here and nowhere else. Tuned by hand on the stub esophagus; re-tune per biome later.
|
||||||
//
|
//
|
||||||
// Reference frame these are sized against — CORRECTED round 2 (C caught the stale numbers,
|
// Stub reference frame (js/stub/world_stub.js), which all of these are sized against:
|
||||||
// LANE_C_NOTES §→ Lane B #4). The round-1 header quoted the round-0 stub; A's real world has
|
// biome.flow = 14 u/s · radius 10 ±15% wobble => 8.5..11.5
|
||||||
// since measured the esophagus wave at amp 1.4 (not 0.9) + breathe 0.15, and `wallRho`
|
// wallRho = radius − waveAmp(0.9) − skin(0.6) => a ~7.0..10.0 playable disc radius
|
||||||
// subtracts BOTH plus SKIN:
|
// peristalsis crest travels at WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
|
||||||
// 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: FIXED THIS ROUND — it is a ride now, not a shove.
|
// SURF IS A SHOVE, NOT YET A RIDE — and that is a world-side problem, not a tuning one.
|
||||||
// Round 1 measured the mechanic as strictly dominated: a crest travelling at flow (13.64 u/s)
|
// Measured in-engine this round (numbers in LANE_B_NOTES §surf):
|
||||||
// cannot carry anything faster than itself, while throttle-mashing gave 19.6 u/s, so the
|
// crest phase speed = WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
|
||||||
// correct play was to ignore the level's signature mechanic. Tuning could not fix it (the
|
// player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
|
||||||
// crest window is fixed in SPACE, so a bigger bonus just ejected you sooner — measured ~3.0
|
// A wave travelling 13.64 u/s cannot carry anything faster than 13.64 u/s, so "ride the
|
||||||
// units gained per crest at gain 0.4, 0.75 AND 1.2, identical).
|
// 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.
|
||||||
//
|
//
|
||||||
// F's round-2 ruling #1 fixed it at the source: `crestSpeed = CREST_FACTOR(1.6) × flow`, which
|
// So surf currently reads as what the wave physically is: a rhythmic peristaltic SHOVE as
|
||||||
// beats throttleMax 1.4. So the wave now outruns the player and B **speed-locks to
|
// each crest sweeps over you (~0.3-0.6 s at 24.5 u/s), which is honest and feels good, but
|
||||||
// world.crestSpeed(s)** while riding instead of adding a flat bonus. Riding at flow 14 =
|
// it is not the level-2 speed line the GDD asks for. The fix belongs to whoever owns the
|
||||||
// 22.4 u/s vs 19.6 for mashing: +14%, and because you match the crest's own speed you STAY on
|
// wave constants (stub = F, world = A): crest speed must exceed 19.6 u/s. Escalated in
|
||||||
// it instead of being thrown off the front. Throttle still decides how long you hold it —
|
// LANE_B_NOTES §"-> Lane A / F" with options. Do not paper over it here.
|
||||||
// see surf.authority. Read the crest, don't hold W.
|
//
|
||||||
|
// gain below is therefore chosen for FEEL (punch of the shove), not for speed economy.
|
||||||
|
|
||||||
export const TUNING = {
|
export const TUNING = {
|
||||||
ship: {
|
ship: {
|
||||||
@ -48,21 +46,11 @@ export const TUNING = {
|
|||||||
},
|
},
|
||||||
surf: {
|
surf: {
|
||||||
threshold: 0.45, // world.flowPulse(s,t) above this = on the crest.
|
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
|
// pulse=pow(sin,3) so 0.45 => sin>0.766 => the peak 22% of
|
||||||
// wave. Under CREST 1.6 the wavelength grew to ~45.7 u at flow
|
// each wave: a ~6.3 u window in a 28.6 u wavelength.
|
||||||
// 14, so that window is now ~10 u long (was ~6.3) — the crest is
|
gain: 0.75, // +75% flow while on a crest => a 24.5 u/s shove for ~0.3-0.6 s.
|
||||||
// a wider, faster lane than it was in round 1.
|
// Feel-tuned only; see the surf note in the header before
|
||||||
authority: 0.85, // how completely the crest owns your speed while riding, 0..1.
|
// touching it expecting a speed change.
|
||||||
// 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
|
ramp: 3.0, // /s blend in/out so cresting doesn't pop
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema": 2,
|
"schema": 1,
|
||||||
"id": "L1_mouth",
|
"id": "L1_mouth",
|
||||||
"name": "Oral Cavity",
|
"name": "Oral Cavity",
|
||||||
"tagline": "Learn to swim",
|
"tagline": "Learn to swim",
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"schema": 2,
|
"schema": 1,
|
||||||
"id": "L2_esophagus",
|
"id": "L2_esophagus",
|
||||||
"name": "Esophagus",
|
"name": "Esophagus",
|
||||||
"tagline": "Ride the swallow",
|
"tagline": "Ride the swallow",
|
||||||
"seed": 20260716,
|
"seed": 20260716,
|
||||||
"par": { "time": 180, "score": 9000, "samples": 3 },
|
"par": { "time": 210, "score": 9000, "samples": 3 },
|
||||||
"next": "L3_stomach",
|
"next": "L3_stomach",
|
||||||
"design": {
|
"design": {
|
||||||
"owner": "Lane C",
|
"owner": "Lane C",
|
||||||
@ -12,7 +12,6 @@
|
|||||||
"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.",
|
"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.",
|
"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.",
|
"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)."
|
"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": [
|
"segments": [
|
||||||
@ -65,11 +64,10 @@
|
|||||||
"biome": "esophagus",
|
"biome": "esophagus",
|
||||||
"name": "Diaphragmatic Hiatus",
|
"name": "Diaphragmatic Hiatus",
|
||||||
"length": 400,
|
"length": 400,
|
||||||
"radius": { "base": 6.5, "wobble": 0.15 },
|
"radius": { "base": 6.8, "wobble": 0.15 },
|
||||||
"curviness": 0.3,
|
"curviness": 0.3,
|
||||||
"flow": 18,
|
"flow": 18,
|
||||||
"wave": { "amp": 0.6 },
|
"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."
|
||||||
"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",
|
"biome": "esophagus",
|
||||||
@ -129,7 +127,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": 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": 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": 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": 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": 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": 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": 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." },
|
{ "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": 2,
|
"schema": 1,
|
||||||
"id": "L3_stomach",
|
"id": "L3_stomach",
|
||||||
"name": "Stomach",
|
"name": "Stomach",
|
||||||
"tagline": "The acid sea",
|
"tagline": "The acid sea",
|
||||||
|
|||||||
@ -19,42 +19,8 @@ export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from
|
|||||||
|
|
||||||
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
|
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
|
||||||
|
|
||||||
/**
|
/** Schema version this module speaks. Levels declare `"schema": N`. */
|
||||||
* True only when THIS file is what node was asked to run — not when someone imports it.
|
export const SCHEMA = 1;
|
||||||
* `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. */
|
/** 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'];
|
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach'];
|
||||||
@ -93,38 +59,12 @@ export const BIOMES_EXPECTED = [
|
|||||||
'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix',
|
'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/** Throttle profiles for the pacing sim. GDD: throttle gives +/-40% of biome flow. */
|
||||||
* Speed profiles for the pacing sim, as multiples of the segment's `flow`.
|
export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4 });
|
||||||
* 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. */
|
/** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */
|
||||||
const CHASE_THROTTLE = 1.35;
|
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
|
// loading
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
@ -182,18 +122,17 @@ async function biomeRegistry() {
|
|||||||
if (m.BIOMES && Object.keys(m.BIOMES).length) {
|
if (m.BIOMES && Object.keys(m.BIOMES).length) {
|
||||||
REG = {
|
REG = {
|
||||||
ids: new Set(Object.keys(m.BIOMES)),
|
ids: new Set(Object.keys(m.BIOMES)),
|
||||||
ampFor: (id) => m.BIOMES[id]?.wave?.amp ?? PLAY.PERISTALSIS_AMP,
|
// Mirror A's waveMaxAt() exactly: amp + breathe, not amp alone.
|
||||||
breatheFor: (id) => m.BIOMES[id]?.wave?.breathe ?? 0,
|
marginFor: (id) => {
|
||||||
|
const w = m.BIOMES[id]?.wave;
|
||||||
|
return w ? w.amp + (w.breathe ?? 0) : PLAY.PERISTALSIS_AMP;
|
||||||
|
},
|
||||||
authoritative: true,
|
authoritative: true,
|
||||||
};
|
};
|
||||||
return REG;
|
return REG;
|
||||||
}
|
}
|
||||||
} catch { /* Lane A hasn't landed yet — fall back to C's expectation */ }
|
} catch { /* Lane A hasn't landed yet — fall back to C's expectation */ }
|
||||||
REG = {
|
REG = { ids: new Set(BIOMES_EXPECTED), marginFor: () => PLAY.PERISTALSIS_AMP, authoritative: false };
|
||||||
ids: new Set(BIOMES_EXPECTED),
|
|
||||||
ampFor: () => PLAY.PERISTALSIS_AMP, breatheFor: () => 0,
|
|
||||||
authoritative: false,
|
|
||||||
};
|
|
||||||
return REG;
|
return REG;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,25 +194,9 @@ export function scoreBudget(level) {
|
|||||||
return total;
|
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) {
|
function waveMargin(seg) {
|
||||||
const amp = (typeof seg.wave?.amp === 'number')
|
return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP;
|
||||||
? 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. */
|
/** Static free-flight radius of a segment at its narrowest wobble trough. */
|
||||||
@ -341,7 +264,7 @@ export function travelTime(level, a, b, k) {
|
|||||||
* hazard = 1 + a per-kind term (see hazardWeight)
|
* hazard = 1 + a per-kind term (see hazardWeight)
|
||||||
*/
|
*/
|
||||||
export function pace(level, { profile = 'par' } = {}) {
|
export function pace(level, { profile = 'par' } = {}) {
|
||||||
const k = profileK(profile);
|
const k = PROFILES[profile] ?? PROFILES.par;
|
||||||
const total = totalLength(level);
|
const total = totalLength(level);
|
||||||
const widest = Math.max(...level.segments.map((s) => s.radius.base));
|
const widest = Math.max(...level.segments.map((s) => s.radius.base));
|
||||||
|
|
||||||
@ -378,38 +301,6 @@ export function pace(level, { profile = 'par' } = {}) {
|
|||||||
return { id: level.id, profile, total, beats, checkpoints: gaps, seconds: tAcc };
|
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) {
|
function hazardWeight(h) {
|
||||||
switch (h.kind) {
|
switch (h.kind) {
|
||||||
case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
|
case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
|
||||||
@ -439,7 +330,7 @@ export async function validate(level, { expectId = null } = {}) {
|
|||||||
const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));
|
const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));
|
||||||
|
|
||||||
// --- structural -----------------------------------------------------------------
|
// --- structural -----------------------------------------------------------------
|
||||||
if (!SUPPORTED_SCHEMAS.has(level.schema)) E(`schema is ${level.schema}, this module speaks ${[...SUPPORTED_SCHEMAS].join('/')} (authoring v${SCHEMA})`);
|
if (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${SCHEMA}`);
|
||||||
if (typeof level.id !== 'string' || !level.id) E('missing id');
|
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 (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)})`);
|
if (!Number.isInteger(level.seed)) E(`seed must be an integer (got ${JSON.stringify(level.seed)})`);
|
||||||
@ -453,7 +344,6 @@ 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)`);
|
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();
|
const bal = await combatBalance();
|
||||||
if (!bal) W("Lane B's combat/balance.js not present — archetype rows and score budget unchecked");
|
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 -------------------------------------------------------------------
|
// --- segments -------------------------------------------------------------------
|
||||||
let s0 = 0;
|
let s0 = 0;
|
||||||
@ -467,16 +357,6 @@ 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.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`);
|
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".
|
// C's playability invariant — the check that catches "I made a cool tube you can't fly".
|
||||||
const clear = hazardClearance(level, seg, s0);
|
const clear = hazardClearance(level, seg, s0);
|
||||||
if (clear < PLAY.MIN_CLEARANCE) {
|
if (clear < PLAY.MIN_CLEARANCE) {
|
||||||
@ -555,28 +435,15 @@ 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`);
|
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')
|
const samples = (level.events ?? []).filter((e) => e.type === 'pickup' && e.kind === 'biopsy_sample')
|
||||||
.reduce((a, e) => a + (e.count ?? 1), 0);
|
.reduce((a, e) => a + (e.count ?? 1), 0);
|
||||||
if (samples !== 3) C(`${samples} biopsy samples — the GDD says exactly 3 per level`);
|
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.
|
// par sanity: an unbeatable par is a bug, a free par is a waste.
|
||||||
if (level.par?.time > 0) {
|
if (level.par?.time > 0) {
|
||||||
const fast = pace(level, { profile: 'surf' }).seconds;
|
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
||||||
const slow = pace(level, { profile: 'cautious' }).seconds;
|
const slow = pace(level, { profile: 'cautious' }).seconds;
|
||||||
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`);
|
if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a full-throttle run (${fast.toFixed(0)}s) — 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 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');
|
} else C('no par.time');
|
||||||
|
|
||||||
@ -628,15 +495,12 @@ async function cliSelfcheck() {
|
|||||||
async function cliSim(only) {
|
async function cliSim(only) {
|
||||||
const reg = await biomeRegistry(); // so clearance uses Lane A's real wave amplitudes
|
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 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(`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(`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) {
|
for (const id of CAMPAIGN) {
|
||||||
if (only && id !== only) continue;
|
if (only && id !== only) continue;
|
||||||
const level = await loadJSON(`./${id}.json`);
|
const level = await loadJSON(`./${id}.json`);
|
||||||
const p = pace(level, { profile: 'par' });
|
const p = pace(level, { profile: 'par' });
|
||||||
const surf = pace(level, { profile: 'surf' }).seconds;
|
|
||||||
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
||||||
const slow = pace(level, { profile: 'cautious' }).seconds;
|
const slow = pace(level, { profile: 'cautious' }).seconds;
|
||||||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
||||||
@ -645,12 +509,7 @@ async function cliSim(only) {
|
|||||||
const budget = scoreBudget(level);
|
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 ?? '?'}` +
|
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}%)` : ''));
|
(budget != null ? ` of ${budget} kill budget (${((level.par.score / budget) * 100) | 0}%)` : ''));
|
||||||
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)`);
|
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)`);
|
||||||
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);
|
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');
|
console.log('\n beat s-range len flow en dens tight haz PRESSURE clear t cum');
|
||||||
for (const b of p.beats) {
|
for (const b of p.beats) {
|
||||||
@ -664,23 +523,6 @@ async function cliSim(only) {
|
|||||||
const totEn = p.beats.reduce((a, b) => a + b.enemies, 0);
|
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)}`);
|
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) {
|
if (p.checkpoints.length) {
|
||||||
console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)');
|
console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)');
|
||||||
for (const g of p.checkpoints) {
|
for (const g of p.checkpoints) {
|
||||||
@ -691,90 +533,7 @@ 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')) {
|
if (process.argv.includes('--sim')) {
|
||||||
const i = process.argv.indexOf('--sim');
|
const i = process.argv.indexOf('--sim');
|
||||||
await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);
|
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 {THREE.Material} material a wall material built for this arena's biome
|
||||||
* @param {function} rng
|
* @param {function} rng
|
||||||
*/
|
*/
|
||||||
export function createArena({ spec, spline, material, rng, quality = 'high', waveAmpDefault = 0.7 }) {
|
export function createArena({ spec, spline, material, rng, quality = 'high' }) {
|
||||||
// three's polyhedron `detail` splits each edge into (detail+1) segments, so face count is
|
// 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
|
// 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
|
// 10-unit facet and the fbm displacement has nothing to displace. Solve for ~3u spacing
|
||||||
@ -57,7 +57,6 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav
|
|||||||
const uv = new Float32Array(n * 2);
|
const uv = new Float32Array(n * 2);
|
||||||
const aPhase = new Float32Array(n);
|
const aPhase = new Float32Array(n);
|
||||||
const aK = 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
|
// 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.
|
// room breathing rather than a corridor's transit wave.
|
||||||
@ -66,9 +65,6 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav
|
|||||||
const ref = new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z);
|
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 bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z);
|
||||||
const amp = spec.radius * 0.09;
|
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();
|
const v = new THREE.Vector3();
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
@ -84,7 +80,6 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav
|
|||||||
uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube
|
uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube
|
||||||
aPhase[i] = k * (spec.at + along);
|
aPhase[i] = k * (spec.at + along);
|
||||||
aK[i] = k;
|
aK[i] = k;
|
||||||
aWaveA[i] = waveAmp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it
|
// Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it
|
||||||
@ -104,7 +99,6 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav
|
|||||||
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||||
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||||
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
||||||
g.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1));
|
|
||||||
g.computeBoundingSphere();
|
g.computeBoundingSphere();
|
||||||
geo.dispose(); // the source icosphere was scaffolding
|
geo.dispose(); // the source icosphere was scaffolding
|
||||||
|
|
||||||
@ -118,7 +112,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav
|
|||||||
center,
|
center,
|
||||||
radius: spec.radius,
|
radius: spec.radius,
|
||||||
/** Conservative inner surface: shell minus displacement peak minus the shader's wave. */
|
/** Conservative inner surface: shell minus displacement peak minus the shader's wave. */
|
||||||
innerRadius: spec.radius - amp - waveAmp - 0.6,
|
innerRadius: spec.radius - amp - (material.uniforms?.uWaveA?.value ?? 0) - 0.6,
|
||||||
covers: (s) => Math.abs(s - spec.at) <= spec.radius,
|
covers: (s) => Math.abs(s - spec.at) <= spec.radius,
|
||||||
dispose() { g.dispose(); },
|
dispose() { g.dispose(); },
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { createRng } from '../core/rng.js';
|
import { createRng } from '../core/rng.js';
|
||||||
import { buildSpline, OMEGA, CREST_FACTOR } from './spline.js';
|
import { buildSpline, OMEGA } from './spline.js';
|
||||||
import { biome as biomeOf } from './biomes.js';
|
import { biome as biomeOf } from './biomes.js';
|
||||||
import { createWallMaterial } from './wall_material.js';
|
import { createWallMaterial } from './wall_material.js';
|
||||||
import { createTube } from './tube.js';
|
import { createTube } from './tube.js';
|
||||||
@ -20,25 +20,9 @@ import { createArena } from './arena.js';
|
|||||||
|
|
||||||
const SKIN = 0.6; // collision safety margin (units) — matches the stub
|
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 } = {}) {
|
export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) {
|
||||||
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
|
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
|
||||||
if (!levelData || !Array.isArray(levelData.segments)) throw new Error('[world] level.segments is required');
|
const spline = buildSpline(levelData, R);
|
||||||
const spline = buildSpline(normalizeLevel(levelData), R);
|
|
||||||
const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : [];
|
const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : [];
|
||||||
|
|
||||||
// --- assets: optional, always (TECH.md §Asset manifest contract) -----------------------
|
// --- assets: optional, always (TECH.md §Asset manifest contract) -----------------------
|
||||||
@ -60,28 +44,29 @@ 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
|
// 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.
|
// 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).
|
// -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D).
|
||||||
// TEMPORARY (ruling #3): D's round-1 keys aren't the biome ids — `small_intestine` ships as
|
const TEXTURE_SLUG = {
|
||||||
// `wall_smallint_a`. D renames to biome ids early this round and pings in NOTES; **delete
|
oral: 'oral', esophagus: 'esophagus', stomach: 'stomach',
|
||||||
// this map and the ?? fallback the moment they do.** D's assets.js now has a miss ledger
|
small_intestine: 'smallint', large_intestine: 'colon', appendix: 'appendix',
|
||||||
// (`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_FOR = (biomeId) => `wall_${TEXTURE_SLUG[biomeId] ?? biomeId}_a`;
|
||||||
const TEXTURE_SLUG = { small_intestine: 'smallint', large_intestine: 'colon' };
|
function detailFor(biomeId) {
|
||||||
const slug = (biomeId) => TEXTURE_SLUG[biomeId] ?? biomeId;
|
if (!assets) return { detail: null, tile: null };
|
||||||
|
const name = TEXTURE_FOR(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 {
|
try {
|
||||||
const a = assets.texture(`wall_${slug(biomeId)}_a`);
|
if (typeof assets.texture === 'function') {
|
||||||
if (a?.map) { out.detail = a.map; out.repeat = a.repeat ?? null; out.normalMap = a.normalMap ?? null; }
|
const t = assets.texture(name);
|
||||||
const b = assets.texture(`wall_${slug(biomeId)}_b`);
|
if (t && t.map) return { detail: t.map, tile: Array.isArray(t.tile) ? t.tile : null };
|
||||||
if (b?.map) { out.detailB = b.map; out.repeatB = b.repeat ?? null; }
|
}
|
||||||
if (typeof assets.matcap === 'function') out.matcap = assets.matcap('tissue_wet') ?? 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 };
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[world] assets lookup failed for ${biomeId}, using procedural wall —`, err.message);
|
console.warn(`[world] assets lookup failed for ${name}, using procedural wall —`, err.message);
|
||||||
}
|
}
|
||||||
return out;
|
return { detail: null, tile: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- materials -------------------------------------------------------------------------
|
// --- materials -------------------------------------------------------------------------
|
||||||
@ -95,10 +80,10 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
|
|||||||
};
|
};
|
||||||
|
|
||||||
function makeMaterial(biomeId, side, fog) {
|
function makeMaterial(biomeId, side, fog) {
|
||||||
|
const b = biomeOf(biomeId);
|
||||||
|
const { detail, tile } = detailFor(biomeId);
|
||||||
const m = createWallMaterial({
|
const m = createWallMaterial({
|
||||||
biome: biomeOf(biomeId), omega: OMEGA, side, fog,
|
biome: b, omega: OMEGA, detail, tile, radiusHint: radiusHintFor(biomeId), side, fog,
|
||||||
radiusHint: radiusHintFor(biomeId),
|
|
||||||
...texturesFor(biomeId),
|
|
||||||
});
|
});
|
||||||
owned.push(m);
|
owned.push(m);
|
||||||
return m;
|
return m;
|
||||||
@ -140,7 +125,6 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
|
|||||||
const a = createArena({
|
const a = createArena({
|
||||||
spec, spline, rng: R, quality,
|
spec, spline, rng: R, quality,
|
||||||
material: arenaMaterialFor(spec), // shell viewed from inside
|
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);
|
group.add(a.mesh);
|
||||||
return a;
|
return a;
|
||||||
@ -152,30 +136,20 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
|
|||||||
let time = 0;
|
let time = 0;
|
||||||
|
|
||||||
const biomeIdAt = (s) => spline.segmentAt(s).biome;
|
const biomeIdAt = (s) => spline.segmentAt(s).biome;
|
||||||
// Prefers the per-segment override (schema v2) over the biome default, via the blended
|
const waveMaxAt = (s) => { const b = biomeOf(biomeIdAt(s)); return b.wave.amp + b.wave.breathe; };
|
||||||
// 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;
|
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);
|
const f = spline.frameAt(s);
|
||||||
const o = out || { pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3() };
|
return {
|
||||||
o.pos.set(f.pos.x, f.pos.y, f.pos.z);
|
pos: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z),
|
||||||
o.tan.set(f.tan.x, f.tan.y, f.tan.z);
|
tan: new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z),
|
||||||
o.nor.set(f.nor.x, f.nor.y, f.nor.z);
|
nor: new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z),
|
||||||
o.bin.set(f.bin.x, f.bin.y, f.bin.z);
|
bin: new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z),
|
||||||
o.radius = f.radius;
|
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 = {
|
const world = {
|
||||||
level: levelData,
|
level: levelData,
|
||||||
length: spline.length,
|
length: spline.length,
|
||||||
@ -225,14 +199,10 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
|
|||||||
return a ? { center: a.center, radius: a.radius } : null;
|
return a ? { center: a.center, radius: a.radius } : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** JS mirror of the vertex shader's wave, exact (0 = trough, 1 = crest). E: pulse the mix. */
|
/** 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. */
|
||||||
flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3),
|
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) {
|
update(dt, playerS = 0) {
|
||||||
time += dt;
|
time += dt;
|
||||||
for (const m of owned) m.uniforms.uTime.value = time;
|
for (const m of owned) m.uniforms.uTime.value = time;
|
||||||
|
|||||||
@ -40,39 +40,26 @@ const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1);
|
|||||||
// --- tuning -----------------------------------------------------------------------------
|
// --- tuning -----------------------------------------------------------------------------
|
||||||
// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in
|
// 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
|
// 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 / crestSpeed(s), so
|
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / flow(s), so a
|
||||||
// phase is the integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes —
|
// wave crest travels at exactly the local flow speed and Lane B can surf one. Phase is the
|
||||||
// plain `k*s - w*t` does not, and tears the wave at every segment join.
|
// integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — plain
|
||||||
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's round-0 esophagus wave.
|
// `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.
|
||||||
export const OMEGA = 3.08;
|
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 DU = 0.5; // march step in curve parameter
|
||||||
const DS = 0.5; // frame/phase LUT spacing in arclength
|
const DS = 0.5; // frame/phase LUT spacing in arclength
|
||||||
const NOISE_TAB = 1024;
|
const NOISE_TAB = 1024;
|
||||||
const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle
|
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)
|
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. Nothing ever
|
// Blend widths (units) for smoothstep-crossfading segment params across a join. Radius and
|
||||||
// steps: a hard radius change would read as a level seam, and C's constrictions are supposed
|
// flow are local scalars — a tight 12u transition reads as a sphincter. Curviness feeds
|
||||||
// to feel like the body narrowing.
|
// 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.
|
||||||
// `base`/`wobble` at ±25 => a 50-unit taper, which is what C's L2 was authored against
|
// `curvBase` is baseRadius again, but read through the wide ramp: it divides the curvature
|
||||||
// (LANE_C_NOTES → A #4: "assumes a smooth blend over ~40–60 units", their biggest open
|
// budget, so a 12u step in it would swerve the centreline just as hard as curviness would.
|
||||||
// dependency on me). Round 1 was ±12; widened here to match the design. At L2's 12→9 step
|
const BLEND = { curviness: 60, curvBase: 60, base: 12, wobble: 12, flow: 12 };
|
||||||
// (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
|
// [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
|
// writhes vertically as hard as it does laterally would swing the parallel-transport frame
|
||||||
@ -95,11 +82,6 @@ const GET = {
|
|||||||
base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10),
|
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),
|
wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2),
|
||||||
flow: (g) => (typeof g.flow === 'number' ? g.flow : 10),
|
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;
|
GET.curvBase = GET.base;
|
||||||
|
|
||||||
@ -263,9 +245,7 @@ export function buildSpline(level, rng) {
|
|||||||
const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]);
|
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 ----------------------
|
// --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ----------------------
|
||||||
const crestSpeedAt = (s) => CREST_FACTOR * Math.max(1, paramAt(s, 'flow'));
|
const kAt = (s) => OMEGA / Math.max(1, paramAt(s, 'flow'));
|
||||||
const kAt = (s) => OMEGA / crestSpeedAt(s);
|
|
||||||
const waveAmpAt = (s) => paramAt(s, 'waveAmp');
|
|
||||||
const phaseArr = new Float64Array(M);
|
const phaseArr = new Float64Array(M);
|
||||||
for (let i = 1; i < M; i++)
|
for (let i = 1; i < M; i++)
|
||||||
phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
|
phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
|
||||||
@ -346,7 +326,6 @@ export function buildSpline(level, rng) {
|
|||||||
length: L, spans, uMax,
|
length: L, spans, uMax,
|
||||||
centre, sOfU, uOfS, ampAtU,
|
centre, sOfU, uOfS, ampAtU,
|
||||||
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
|
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
|
||||||
crestSpeedAt, waveAmpAt,
|
|
||||||
omega: OMEGA,
|
omega: OMEGA,
|
||||||
hash, stats,
|
hash, stats,
|
||||||
segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
|
segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
|
||||||
@ -428,37 +407,15 @@ async function selfcheck() {
|
|||||||
ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`);
|
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)}`);
|
ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`);
|
||||||
|
|
||||||
// wave: phase monotone, and the crest-speed law (TECH FROZEN v1.1)
|
// wave: phase monotone, and crest speed == local flow (the whole point of K(s))
|
||||||
let phaseMono = true;
|
let phaseMono = true;
|
||||||
for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false;
|
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);
|
ok('wave: phase K(s) strictly increasing', phaseMono);
|
||||||
const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
|
const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
|
||||||
ok('wave: k == OMEGA/(CREST_FACTOR*flow)', Math.abs(kEso - OMEGA / (CREST_FACTOR * 14)) < 1e-6, `k=${kEso.toFixed(4)}`);
|
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: crestSpeed == 1.6 x local flow, in every biome',
|
ok('wave: crest speed == local flow in each biome',
|
||||||
Math.abs(a.crestSpeedAt(50) - 1.6 * 14) < 0.01 && Math.abs(a.crestSpeedAt(a.length - 20) - 1.6 * 4) < 0.01,
|
Math.abs(OMEGA / kEso - 14) < 0.01 && Math.abs(OMEGA / kSto - 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)`);
|
`${(OMEGA / kEso).toFixed(2)} u/s eso, ${(OMEGA / kSto).toFixed(2)} u/s stomach`);
|
||||||
// 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(` 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`);
|
console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`);
|
||||||
|
|||||||
@ -52,7 +52,6 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans =
|
|||||||
const uv = new Float32Array(n * 2);
|
const uv = new Float32Array(n * 2);
|
||||||
const aPhase = new Float32Array(n);
|
const aPhase = new Float32Array(n);
|
||||||
const aK = new Float32Array(n);
|
const aK = new Float32Array(n);
|
||||||
const aWaveA = new Float32Array(n);
|
|
||||||
|
|
||||||
let p = 0, q = 0, w = 0;
|
let p = 0, q = 0, w = 0;
|
||||||
for (let r = 0; r < rings; r++) {
|
for (let r = 0; r < rings; r++) {
|
||||||
@ -60,7 +59,6 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans =
|
|||||||
const f = spline.frameAt(s);
|
const f = spline.frameAt(s);
|
||||||
const phase = spline.phaseAt(s);
|
const phase = spline.phaseAt(s);
|
||||||
const k = spline.kAt(s);
|
const k = spline.kAt(s);
|
||||||
const waveA = spline.waveAmpAt(s);
|
|
||||||
for (let j = 0; j < cols; j++) {
|
for (let j = 0; j < cols; j++) {
|
||||||
const th = (j / Q.radial) * TAU;
|
const th = (j / Q.radial) * TAU;
|
||||||
const ct = Math.cos(th), st = Math.sin(th);
|
const ct = Math.cos(th), st = Math.sin(th);
|
||||||
@ -74,7 +72,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;
|
aTangent[p] = f.tan.x; aTangent[p + 1] = f.tan.y; aTangent[p + 2] = f.tan.z;
|
||||||
p += 3;
|
p += 3;
|
||||||
uv[q++] = j / Q.radial; uv[q++] = s;
|
uv[q++] = j / Q.radial; uv[q++] = s;
|
||||||
aPhase[w] = phase; aK[w] = k; aWaveA[w] = waveA; w++;
|
aPhase[w] = phase; aK[w] = k; w++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +93,6 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans =
|
|||||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||||
geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||||
geo.setAttribute('aK', new THREE.BufferAttribute(aK, 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.setIndex(new THREE.BufferAttribute(idx, 1));
|
||||||
geo.computeBoundingSphere();
|
geo.computeBoundingSphere();
|
||||||
geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest
|
geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest
|
||||||
|
|||||||
@ -10,13 +10,10 @@
|
|||||||
// axis and the tiling axis, and it must stay continuous across chunks
|
// axis and the tiling axis, and it must stay continuous across chunks
|
||||||
// aPhase float K(s) = ∫k ds, baked (see spline.js)
|
// aPhase float K(s) = ∫k ds, baked (see spline.js)
|
||||||
// aK float local wavenumber, for the analytic d(pulse)/ds
|
// 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: `#include <colorspace_fragment>` at the end of main() is LAW (TECH §Shader law).
|
// Colorspace: like the stub, this writes its computed color straight out with no
|
||||||
// three converts THREE.Color inputs to linear but does not convert a raw shader's output back,
|
// <colorspace_fragment> conversion. That's a whole-game decision (it moves every color at
|
||||||
// so without it every ART_BIBLE colour ships wrong (B measured amber #ff5a2a displaying as
|
// once) so it stays matched to F's round-0 look until F rules on it — see LANE_A_NOTES.
|
||||||
// pure red). Round 1 shipped without it; this is the fix.
|
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
@ -24,63 +21,43 @@ export function createWallMaterial({
|
|||||||
biome,
|
biome,
|
||||||
omega,
|
omega,
|
||||||
fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js)
|
fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js)
|
||||||
|
waveAmp = biome.wave.amp,
|
||||||
breatheAmp = biome.wave.breathe,
|
breatheAmp = biome.wave.breathe,
|
||||||
detail = null, // THREE.Texture | null — Lane D's grayscale detail/AO map
|
detail = null, // THREE.Texture | null — Lane D's grayscale detail map
|
||||||
detailB = null, // THREE.Texture | null — the _b variant, macro variation
|
tile = null, // [repeatsAroundCircumference, unitsOfSPerRepeat]
|
||||||
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
|
radiusHint = 10, // used only to pick a square-ish default tiling
|
||||||
side = THREE.FrontSide,
|
side = THREE.FrontSide,
|
||||||
}) {
|
}) {
|
||||||
for (const t of [detail, detailB, normalMap]) { // we consume D's textures; set what we rely on
|
if (detail) { // defensive: we consume D's texture, so we set what we depend on
|
||||||
if (t) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.needsUpdate = true; }
|
detail.wrapS = detail.wrapT = THREE.RepeatWrapping;
|
||||||
|
detail.needsUpdate = true;
|
||||||
}
|
}
|
||||||
// Default tiling if D has no `tile` hint: ~square texels for this biome's radius.
|
const tileAround = tile ? tile[0] : 3;
|
||||||
const fallbackRepeat = [3, 3 / Math.max(4, (2 * Math.PI * radiusHint) / 3)];
|
const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround);
|
||||||
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];
|
|
||||||
|
|
||||||
const defines = {};
|
return new THREE.ShaderMaterial({
|
||||||
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,
|
side,
|
||||||
defines,
|
defines: detail ? { USE_DETAIL: '' } : {},
|
||||||
uniforms: {
|
uniforms: {
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uTint: { value: new THREE.Color(biome.palette.tint) },
|
uTint: { value: new THREE.Color(biome.palette.tint) },
|
||||||
uRim: { value: new THREE.Color(biome.palette.rim) },
|
uRim: { value: new THREE.Color(biome.palette.rim) },
|
||||||
uVoid: { value: new THREE.Color(biome.palette.void) },
|
uVoid: { value: new THREE.Color(biome.palette.void) },
|
||||||
uFog: { value: fog },
|
uFog: { value: fog },
|
||||||
|
uWaveA: { value: waveAmp },
|
||||||
uBreatheA: { value: breatheAmp },
|
uBreatheA: { value: breatheAmp },
|
||||||
uOmega: { value: omega },
|
uOmega: { value: omega },
|
||||||
uRimPow: { value: 2.2 },
|
uRimPow: { value: 2.2 },
|
||||||
uRimGain: { value: 0.9 },
|
uRimGain: { value: 0.9 },
|
||||||
uDetail: { value: detail },
|
uDetail: { value: detail },
|
||||||
uDetailB: { value: detailB },
|
uTile: { value: new THREE.Vector2(tileAround, tileAlong) },
|
||||||
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 */`
|
vertexShader: /* glsl */`
|
||||||
attribute vec3 aInward;
|
attribute vec3 aInward;
|
||||||
attribute vec3 aTangent;
|
attribute vec3 aTangent;
|
||||||
attribute float aPhase;
|
attribute float aPhase;
|
||||||
attribute float aK;
|
attribute float aK;
|
||||||
attribute float aWaveA;
|
uniform float uTime, uWaveA, uBreatheA, uOmega;
|
||||||
uniform float uTime, uBreatheA, uOmega;
|
|
||||||
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@ -89,13 +66,13 @@ export function createWallMaterial({
|
|||||||
float sn = max(0.0, sin(phi));
|
float sn = max(0.0, sin(phi));
|
||||||
float pulse = sn * sn * sn; // sharp crest, long trough: a muscle, not a sine
|
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 breathe = uBreatheA * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831853 * 3.0);
|
||||||
float disp = aWaveA * pulse + breathe;
|
float disp = uWaveA * pulse + breathe;
|
||||||
|
|
||||||
// Tilt the normal with the wave. Without this the crests are silhouette-only and the
|
// 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
|
// 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.
|
// 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;
|
float dPulse_ds = 3.0 * sn * sn * cos(phi) * aK;
|
||||||
vec3 nIn = normalize(aInward - aTangent * (aWaveA * dPulse_ds));
|
vec3 nIn = normalize(aInward - aTangent * (uWaveA * dPulse_ds));
|
||||||
|
|
||||||
vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
|
vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
|
||||||
vN = normalize(normalMatrix * nIn);
|
vN = normalize(normalMatrix * nIn);
|
||||||
@ -105,85 +82,33 @@ export function createWallMaterial({
|
|||||||
}`,
|
}`,
|
||||||
fragmentShader: /* glsl */`
|
fragmentShader: /* glsl */`
|
||||||
uniform vec3 uTint, uRim, uVoid;
|
uniform vec3 uTint, uRim, uVoid;
|
||||||
uniform float uFog, uRimPow, uRimGain, uNormalScale, uMatcapGain;
|
uniform float uFog, uRimPow, uRimGain;
|
||||||
uniform vec2 uRepeat, uRepeatB;
|
|
||||||
#ifdef USE_DETAIL
|
#ifdef USE_DETAIL
|
||||||
uniform sampler2D uDetail;
|
uniform sampler2D uDetail;
|
||||||
#endif
|
uniform vec2 uTile;
|
||||||
#ifdef USE_DETAIL_B
|
|
||||||
uniform sampler2D uDetailB;
|
|
||||||
#endif
|
|
||||||
#ifdef USE_NORMAL
|
|
||||||
uniform sampler2D uNormal;
|
|
||||||
#endif
|
|
||||||
#ifdef USE_MATCAP
|
|
||||||
uniform sampler2D uMatcap;
|
|
||||||
#endif
|
#endif
|
||||||
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
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() {
|
void main() {
|
||||||
vec2 duv = vUv * uRepeat;
|
|
||||||
|
|
||||||
#ifdef USE_DETAIL
|
#ifdef USE_DETAIL
|
||||||
// Lane D authors grayscale luminance/AO; the biome tint is applied here, so one
|
// Lane D authors grayscale; the biome tint is applied here, so one texture can serve
|
||||||
// texture serves two biomes at different tints (ART_BIBLE §FLUX prompt kit).
|
// two biomes at different tints (ART_BIBLE §FLUX prompt kit).
|
||||||
float detail = texture2D(uDetail, duv).r;
|
float detail = texture2D(uDetail, vec2(vUv.x * uTile.x, vUv.y / uTile.y)).r;
|
||||||
#ifdef USE_DETAIL_B
|
detail = mix(0.5, 1.2, detail);
|
||||||
// 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
|
#else
|
||||||
// Assets-optional law: no texture is the *shipping* look until D lands, not an error
|
// 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.
|
// 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 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));
|
float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2));
|
||||||
vec3 base = uTint * detail * 0.8;
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
vec3 N = normalize(vN);
|
vec3 base = uTint * detail * 0.8;
|
||||||
#ifdef USE_NORMAL
|
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), uRimPow);
|
||||||
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
|
// 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
|
// help beyond what its own geometry earns from the rim term
|
||||||
vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10;
|
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);
|
float d = length(vView);
|
||||||
gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0);
|
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