diff --git a/.claude/launch.json b/.claude/launch.json index c8083af..7957fe6 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,26 +4,62 @@ { "name": "guts", "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "8140", "--directory", "web"], + "runtimeArgs": [ + "-m", + "http.server", + "8140", + "--directory", + "web" + ], "port": 8140 }, { "name": "guts-f", "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "8141", "--directory", "web"], + "runtimeArgs": [ + "-m", + "http.server", + "8141", + "--directory", + "web" + ], "port": 8141 }, { "name": "guts-a", "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "8145", "--directory", "web"], + "runtimeArgs": [ + "-m", + "http.server", + "8145", + "--directory", + "web" + ], "port": 8145 }, { "name": "guts-e", "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "8146", "--directory", "web"], + "runtimeArgs": [ + "-m", + "http.server", + "8146", + "--directory", + "web" + ], "port": 8146 + }, + { + "name": "guts-b", + "runtimeExecutable": "python3", + "runtimeArgs": [ + "-m", + "http.server", + "8142", + "--directory", + "web" + ], + "port": 8142 } ] -} +} \ No newline at end of file diff --git a/docs/TECH.md b/docs/TECH.md index 89a44ea..3837f86 100644 --- a/docs/TECH.md +++ b/docs/TECH.md @@ -173,7 +173,10 @@ A/C acid zones) · `boss:start {id}` / `boss:phase` / `boss:end` · `combo {n}` `audio:cue {name}`. Ratified round 2 (round-1 requests): -- `player:state {coat,coatMax,hull,hullMax,speed,flow,throttle,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive}` — every frame, read-only, don't retain (B → E). +- `player:state {coat,coatMax,hull,hullMax,speed,flow,throttle,mode,boostReady,boostCd,boostCdMax,surfing,iframes,s,length,progress,biome,alive}` — every frame, read-only, don't retain (B → E). + `mode` is `'tube'|'arena'` (mirrors `world.modeAt(s)`). In `'arena'`: `flow` reads **0** (there is + no current in a room), `speed` is **signed** (negative = reversing), and `throttle` is inert — + the stick is thrust, not a trim on the flow. - `combat:state {heat,heatMax,overheated,ammo,ammoMax,score,kills,enemies}` — every frame (B → E). - `player:surf {active, s}` — edge-triggered (B → E). - `hazard:warn {kind, s, eta}` — fires `warn` seconds before a hazard (C events carry `warn`). diff --git a/web/js/flight/player.js b/web/js/flight/player.js index c5290b9..0c5f659 100644 --- a/web/js/flight/player.js +++ b/web/js/flight/player.js @@ -29,6 +29,7 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null const st = { s: 2, x: 0, y: 0, // spline-space position vx: 0, vy: 0, // disc velocity (u/s) + vs: 0, // AXIAL velocity — arena mode only (tube mode: flow owns s) ax: 0, ay: 0, // aim offset in the disc plane at s + aim.distance throttle: 1, boostT: 0, boostCd: 0, boostBlend: 0, iframeT: 0, @@ -122,10 +123,17 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null const boostTarget = st.boostT > 0 ? 1 : 0; st.boostBlend += (boostTarget - st.boostBlend) * Math.min(1, (boostTarget ? 14 : 3.5) * dt); + // Which mode is the anatomy in? 'arena' is an authored room (level.arenas) or a segment + // C marked `mode: "open"` — both opt-in, so a level that declares neither behaves exactly + // as it always has. This is the guard the header has promised since round 1. + const inArena = world.modeAt ? world.modeAt(st.s) === 'arena' : false; + // surf: ride the peristalsis crest. flowPulse is Lane A's JS mirror of the wall's vertex // displacement — see NOTES, it's charter-specified but missing from the TECH contract. - const pulse = world.flowPulse ? world.flowPulse(st.s) : 0; - st.onCrest = pulse > T.surf.threshold; + // Never in a room: a chamber has no travelling wave to ride, and letting surfBlend hang on + // would keep speed-locking you to a crest that isn't there. + const pulse = (!inArena && world.flowPulse) ? world.flowPulse(st.s) : 0; + st.onCrest = !inArena && pulse > T.surf.threshold; const surfTarget = st.onCrest ? 1 : 0; if (Math.abs(surfTarget - st.surfBlend) > 0.001) { const was = st.surfBlend > 0.5; @@ -138,15 +146,29 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null // 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); + if (inArena) { + // ARENA: you own the forward axis. `intent.throttle` is the raw lean (-1..1), so the same + // stick that trims your speed in a tube becomes bidirectional thrust in a room — hold back + // and you stop, hold further and you reverse. st.speed goes SIGNED here, which is honest: + // the HUD reading -4.2 u/s means you are backing out of the pylorus, and it is true. + st.vs += intent.throttle * T.arena.thrust * dt; + st.vs *= Math.exp(-T.arena.damping * dt); + const vmax = T.arena.maxSpeed * (1 + T.boost.gain * st.boostBlend); + st.vs = THREE.MathUtils.clamp(st.vs, -vmax, vmax); + st.speed = st.vs; + st.s = clampS(st.s + st.vs * dt); + } else { + 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.vs = st.speed; // so leaving a room into a tube doesn't inherit a stale axial velocity + } // disc movement st.vx += intent.disc.x * T.disc.accel * dt; @@ -193,7 +215,7 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null poseShip(dt, frame); updateCamera(dt, frame); - emitState(biome); + emitState(biome, inArena); } // Arcade wall response: shove + graze damage, never a hard stop. Forward motion is the @@ -271,11 +293,14 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null // Lane E's HUD is bus-driven only, so continuous state has to arrive as an event. One // small object per frame; treat it as read-only, don't retain it. - function emitState(biome) { + function emitState(biome, inArena = false) { bus.emit('player:state', { coat: st.coat, coatMax: T.coat.max, hull: st.hull, hullMax: T.hull.max, - speed: st.speed, flow: biome.flow, throttle: st.throttle, + // `flow` reads 0 in a room, because in a room there is none — printing the biome's + // current next to a throttle that is really thrust would be a lie on the instrument. + speed: st.speed, flow: inArena ? 0 : biome.flow, throttle: st.throttle, + mode: inArena ? 'arena' : 'tube', boostReady: st.boostCd <= 0, boostCd: st.boostCd, boostCdMax: T.boost.cooldown, surfing: st.surfBlend > 0.5, iframes: st.iframeT > 0, s: st.s, length: world.length, progress: world.length ? st.s / world.length : 0, @@ -303,7 +328,7 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null shove, refill, respawn(s = 0) { - Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true }); + Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, vs: 0, coat: T.coat.max, hull: T.hull.max, alive: true }); cam.init = false; aimLagInit = false; bus.emit('player:spawn', { s }); }, diff --git a/web/js/flight/tuning.js b/web/js/flight/tuning.js index a83f9bd..f393378 100644 --- a/web/js/flight/tuning.js +++ b/web/js/flight/tuning.js @@ -73,6 +73,17 @@ export const TUNING = { damping: 6.5, // /s exponential drag => coasts to rest in ~0.3 s }, + // --- arena / 6DOF: in a room, the anatomy stops carrying you ----------------------- + // GDD: "the same controller unlocked to free 6DOF, Descent-style, no gravity. Slower, + // spatial, exploratory." The felt difference is NOT extra axes — spline space already spans + // a room (s is the axial one, x/y are the cross-section) — it is that YOU own the forward + // axis. You can stop. You can reverse. Nothing is shoving you at the boss. + arena: { + thrust: 44, // u/s² axial. Softer than disc accel 78: a room is read, not strafed + maxSpeed: 11, // u/s — under the esophagus's 12-20 flow on purpose (GDD: "slower") + damping: 2.2, // /s — a long coast (no gravity), but it does come to rest + }, + // --- aim: the reticle leads, the ship follows (Star Fox lag) ----------------------- aim: { distance: 26, // u ahead (in s) that the reticle plane floats diff --git a/web/js/ui/hud.js b/web/js/ui/hud.js index 015359a..d3f7fc9 100644 --- a/web/js/ui/hud.js +++ b/web/js/ui/hud.js @@ -177,7 +177,11 @@ export function createHUD({ bus, flags = {}, level = null, mount = null } = {}) spd.set(`${p.speed.toFixed(1)}`); spd.node.style.color = p.surfing ? C.surf : C.line; - flowT.set(`u/s · flow ${p.flow.toFixed(0)} · thr ${(p.throttle * 100) | 0}%`); + // In a room there is no current and the throttle is thrust, so printing "flow 0 · thr 100%" + // would be an instrument telling two lies at once. Say what the controller actually is. + flowT.set(p.mode === 'arena' + ? 'u/s · 6dof · free' + : `u/s · flow ${p.flow.toFixed(0)} · thr ${(p.throttle * 100) | 0}%`); if (p.boostReady) { boostT.set('· boost rdy'); boostT.node.style.color = C.good; } else { boostT.set(`· boost ${(p.boostCd ?? 0).toFixed(1)}`);