[lane B+E+F] Arena mode: in a room, you own the forward axis
The 6DOF unlock the player header has promised since round 1 ("Arena/6DOF mode is round 2;
modeAt() is guarded below"). It unblocks L1 and L3, which are both arena levels in the GDD and
have been skeletons waiting on this.
The insight that made it small: spline space ALREADY spans a room — s is the axial coordinate,
x/y are the cross-section — and world.collide() already resolves against arena spheres
(arenaSpatial, Lane A round 2). So this is not a new controller. The only real difference
between a tube and a room is WHO OWNS s: in a tube the flow carries you and you may only trim
it +/-40%; in a room `intent.throttle` becomes signed thrust with its own damping, so you can
stop dead and you can reverse. That is the whole feature, and it is what "slower, spatial,
exploratory" actually means in play.
- tuning.js: arena{thrust 44, maxSpeed 11, damping 2.2}. maxSpeed sits UNDER the esophagus's
12-20 flow on purpose — the GDD asks for slower.
- player.js: st.vs (axial velocity), the mode branch, surf disabled in rooms (no travelling
wave to ride, and a hanging surfBlend would speed-lock you to a crest that isn't there),
respawn() clears vs so a death in a room can't leak momentum into a tube.
- player:state gains `mode`; `flow` reads 0 in a room and `speed` goes signed.
- hud.js prints "6dof · free" instead of "flow 0 · thr 100%" — two lies on one instrument.
Safe by construction: both arena triggers are opt-in (level.arenas, or a segment marked
mode:"open"). L1/L2/L4 declare neither and are byte-for-byte unaffected.
Verified in L3's stomach: tube hands-off carries you at 4.53 u/s, acid-sea hands-off coasts to
a dead stop (0.0), and a room lets you travel BACKWARDS (-3.8u). Bus reports
{mode:arena,flow:0,speed:0} vs {mode:tube,flow:3,speed:3}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1e4775ab12
commit
4ee6ae49a1
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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`).
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user