[lane B] Round 2 WIP (session cut off): surf speed-lock, hazards + pickups modules, rho-fraction spawns
Landed before the cut: surf as a speed-lock onto world.crestSpeed(s) with surf.authority (ruling #1) — riding beats throttle and holding a crest is throttle discipline; tuning.js header corrected to the real wallRho frame (C's flag); enemies.js reads rho as a FRACTION of safe radius (C's placement law); balance.js hazard + pickup constants; NEW hazards.js (reflux_surge / aortic_squeeze / ring_gate, self-scheduling telegraphs, reset(fromS) built for respawn) and NEW pickups.js (all five kinds, shape-coded). Cut off before: wiring — nothing imports hazards/pickups yet; the fiction-id -> archetype resolve (round-2 task #1) does NOT exist despite the enemies.js comment saying index.js does it; player.kill/shove/refill are called but were never added; checkpoint/respawn and gate -> level:complete not started; NOTES unwritten. F lands the glue next commit. Committed by F to protect the shared tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f0982e70c3
commit
4ed3433e55
@ -53,6 +53,56 @@ 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,19 +78,28 @@ 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}). Level events give us only an `s` and a
|
// Charter API: spawn(type, {s, theta, rho}). `type` is an ARCHETYPE — combat/index.js
|
||||||
// count, so theta/rho are filled from the seeded stream — same seed, same encounter.
|
// resolves C's fiction ids (`bolus_chunk`) to archetypes before calling us.
|
||||||
|
//
|
||||||
|
// `rho` is a FRACTION of the safe radius (0..1), never an absolute distance — TECH §Event
|
||||||
|
// fields. That is C's placement law and it is the only thing that survives a tube whose
|
||||||
|
// radius varies with s: rho 0.92 means "pinned to the wall" everywhere, at any radius.
|
||||||
|
// Round 1 read it as absolute, which silently parked C's wall-pinned biopsy samples and
|
||||||
|
// candida blooms ~1 unit off the centreline, i.e. in the middle of the racing line.
|
||||||
function spawn(type, { s = 0, theta = null, rho = null } = {}) {
|
function spawn(type, { s = 0, theta = null, rho = null } = {}) {
|
||||||
const pool = pools[type];
|
const pool = pools[type];
|
||||||
if (!pool) { console.warn(`[combat] unknown enemy type "${type}"`); return null; }
|
if (!pool) { console.warn(`[combat] unknown enemy archetype "${type}"`); return null; }
|
||||||
if (pool.slots.length >= pool.cfg.pool) return null; // pool is the population cap
|
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;
|
||||||
const wall = world.wallRho(s, th) - cfg.radius;
|
// free room for this body's centre: the safe radius less its own half-width, so rho 1.0
|
||||||
// turrets bolt to the wall; everything else floats somewhere in the lumen
|
// is flush against the wall rather than half-buried in it
|
||||||
const r = rho ?? (type === 'turret' ? wall : wall * (0.25 + rand() * 0.6));
|
const free = Math.max(0, world.wallRho(s, th) - cfg.radius);
|
||||||
|
const r = rho != null
|
||||||
|
? THREE.MathUtils.clamp(rho, 0, 1) * free // C's fraction
|
||||||
|
: (type === 'turret' ? free : free * (0.25 + rand() * 0.6)); // unauthored: seeded
|
||||||
|
|
||||||
const e = {
|
const e = {
|
||||||
id: nextId++, type, cfg,
|
id: nextId++, type, cfg,
|
||||||
|
|||||||
272
web/js/combat/hazards.js
Normal file
272
web/js/combat/hazards.js
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
// combat/hazards.js (Lane B) — the three L2 hazards: reflux_surge, aortic_squeeze, ring_gate.
|
||||||
|
//
|
||||||
|
// C authors WHERE and HOW MUCH (level JSON); balance.js holds the mechanical constants; this
|
||||||
|
// file is the behaviour. C's params always win where they overlap.
|
||||||
|
//
|
||||||
|
// WHY THESE SCHEDULE THEMSELVES INSTEAD OF USING BOOT'S PUMP (→ Lane F in NOTES):
|
||||||
|
// every hazard carries a `warn` — seconds of telegraph before it bites — and TECH makes it a
|
||||||
|
// law (≥2 s for anything lethal). But the pump emits `level:event` when the player *crosses*
|
||||||
|
// `ev.s`, which is the moment the hazard arrives: zero lead time. A telegraph needs lookahead,
|
||||||
|
// so hazards read `world.level.events` directly and run their own timeline (idle → warned →
|
||||||
|
// active → done). We deliberately ignore `level:event` for `type:'hazard'` so nothing arms
|
||||||
|
// twice. This also makes respawn clean: the timeline is ours to rewind (see `reset`), which is
|
||||||
|
// the one piece of respawn that does NOT need F's pump.
|
||||||
|
//
|
||||||
|
// ART_BIBLE: acid/corrosive = sickly yellow-green; hostile = amber. The ring gate is the
|
||||||
|
// interesting case — it lerps violet (a gate you pass) → amber (a thing that will hurt you) as
|
||||||
|
// it closes, so the colour encodes what it currently MEANS, which is the actual law.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { BALANCE as B } from './balance.js';
|
||||||
|
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
|
||||||
|
|
||||||
|
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
|
||||||
|
const _v = new THREE.Vector3(), _up = new THREE.Vector3(0, 0, 1);
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
// shortest signed angular distance a → b
|
||||||
|
const angDelta = (a, b) => { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; };
|
||||||
|
|
||||||
|
export function createHazards({ scene, world, bus, rng, player }) {
|
||||||
|
const H = B.hazards;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
const discToWorld = (out, frame, x, y) =>
|
||||||
|
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
||||||
|
|
||||||
|
// --- visuals: one InstancedMesh per hazard family = 3 draws for the whole system --------
|
||||||
|
const surgeGeo = new THREE.CircleGeometry(1, 24); // a wall of acid across the lumen
|
||||||
|
const surgeMat = emissiveMat(EMISSIVE.acid, { additive: true, opacity: 0.55 });
|
||||||
|
surgeMat.side = THREE.DoubleSide;
|
||||||
|
const surgeMesh = new THREE.InstancedMesh(surgeGeo, surgeMat, 4);
|
||||||
|
|
||||||
|
const ridgeGeo = new THREE.SphereGeometry(1, 8, 6); // the aortic bulge
|
||||||
|
const ridgeMat = emissiveMat(EMISSIVE.hostile);
|
||||||
|
const ridgeMesh = new THREE.InstancedMesh(ridgeGeo, ridgeMat, 160);
|
||||||
|
|
||||||
|
const irisGeo = new THREE.SphereGeometry(1, 8, 6); // the peristaltic ring
|
||||||
|
const irisMat = emissiveMat(EMISSIVE.gate);
|
||||||
|
const irisMesh = new THREE.InstancedMesh(irisGeo, irisMat, 96);
|
||||||
|
|
||||||
|
const meshes = [surgeMesh, ridgeMesh, irisMesh];
|
||||||
|
for (const m of meshes) {
|
||||||
|
m.frustumCulled = false;
|
||||||
|
m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
m.count = 0;
|
||||||
|
scene.add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- timeline ---------------------------------------------------------------------------
|
||||||
|
const RIDGE_EVERY = 26; // u between bulge rings along a squeeze zone
|
||||||
|
const RIDGE_ARC = 5; // spheres per ring
|
||||||
|
const IRIS_BEADS = 16;
|
||||||
|
|
||||||
|
let specs = [];
|
||||||
|
function build() {
|
||||||
|
specs = (world.level?.events ?? [])
|
||||||
|
.filter((e) => e.type === 'hazard' && H && e.kind)
|
||||||
|
.map((e) => ({ ev: e, kind: e.kind, s: e.s ?? 0, warn: e.warn ?? 2.5, state: 'idle', surge: null }));
|
||||||
|
}
|
||||||
|
build();
|
||||||
|
|
||||||
|
// Rewind our own timeline (respawn). Anything at or ahead of `fromS` is armed again;
|
||||||
|
// anything behind stays done, so flying back over a spent zone doesn't re-telegraph it.
|
||||||
|
function reset(fromS = 0) {
|
||||||
|
for (const h of specs) {
|
||||||
|
h.surge = null;
|
||||||
|
h.state = (h.s + (h.ev.span ?? 0)) >= fromS ? 'idle' : 'done';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- antacid: B's own torpedo stalls a neutralizable surge (GDD dual-use) ---------------
|
||||||
|
// C's finale maths depends on this being a real stop, not a slow: "fired backward it stalls
|
||||||
|
// the surge ~10 s". We honour the torpedo's own duration from the bus, not a local number.
|
||||||
|
const offNeutralize = bus.on('level:neutralize', ({ s, radius, duration }) => {
|
||||||
|
for (const h of specs) {
|
||||||
|
if (!h.surge || h.kind !== 'reflux_surge' || !h.ev.neutralizable) continue;
|
||||||
|
if (Math.abs(h.surge.s - s) <= radius) {
|
||||||
|
h.surge.stall = Math.max(h.surge.stall, duration);
|
||||||
|
bus.emit('audio:cue', { name: 'surge_stall' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function update(dt) {
|
||||||
|
time += dt;
|
||||||
|
const ps = player.state;
|
||||||
|
const speed = Math.max(1, ps.speed);
|
||||||
|
|
||||||
|
for (const h of specs) {
|
||||||
|
if (h.state === 'done') continue;
|
||||||
|
const ev = h.ev;
|
||||||
|
|
||||||
|
// --- telegraph: the one thing the pump structurally cannot do ---
|
||||||
|
if (h.state === 'idle') {
|
||||||
|
const eta = (h.s - ps.s) / speed;
|
||||||
|
if (eta <= h.warn && h.s >= ps.s) {
|
||||||
|
h.state = 'warned';
|
||||||
|
bus.emit('hazard:warn', { kind: h.kind, s: h.s, eta: Math.max(0, eta) });
|
||||||
|
bus.emit('audio:cue', { name: `warn_${h.kind}` });
|
||||||
|
} else if (ps.s >= h.s) {
|
||||||
|
h.state = 'warned'; // arrived without lead (teleport/respawn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.state === 'warned' && ps.s >= h.s) {
|
||||||
|
h.state = 'active';
|
||||||
|
if (h.kind === 'reflux_surge') {
|
||||||
|
// `from` is the spawn offset relative to the trigger s; negative = behind you
|
||||||
|
h.surge = { s: h.s + (ev.from ?? -40), stall: 0, endS: h.s + (ev.span ?? 600), grazeT: 0 };
|
||||||
|
bus.emit('audio:cue', { name: 'surge_start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.state !== 'active') continue;
|
||||||
|
|
||||||
|
if (h.kind === 'reflux_surge') updateSurge(h, dt, ps);
|
||||||
|
else if (h.kind === 'aortic_squeeze') updateSqueeze(h, dt, ps);
|
||||||
|
else if (h.kind === 'ring_gate') updateGate(h, dt, ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reflux surge: the rear chaser ------------------------------------------------------
|
||||||
|
function updateSurge(h, dt, ps) {
|
||||||
|
const g = h.surge;
|
||||||
|
if (!g) { h.state = 'done'; return; }
|
||||||
|
|
||||||
|
if (g.stall > 0) g.stall = Math.max(0, g.stall - dt);
|
||||||
|
const factor = g.stall > 0 ? H.surge.stallFactor : 1;
|
||||||
|
g.s += (h.ev.speed ?? 20) * factor * dt;
|
||||||
|
|
||||||
|
const gap = ps.s - g.s; // >0 = you're ahead. This is the whole game.
|
||||||
|
if (Math.abs(gap) < H.surge.proximityRange) {
|
||||||
|
// C's most important UI dependency: the surge chases from behind in a forward-facing
|
||||||
|
// game, so the player can only feel the margin shrink if we say so every frame.
|
||||||
|
bus.emit('hazard:proximity', { kind: h.kind, distance: gap, stalled: g.stall > 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gap <= H.surge.catchRadius && ps.alive) {
|
||||||
|
if (h.ev.lethal) {
|
||||||
|
player.kill('acid'); // C's finale: dawdling is fatal
|
||||||
|
} else {
|
||||||
|
g.grazeT -= dt; // the s=1700 "burp": teaches the verb, never kills
|
||||||
|
if (g.grazeT <= 0) { player.damage(H.surge.graze, 'acid'); g.grazeT = H.surge.grazeTick; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.s > g.endS || g.s > world.length) { h.surge = null; h.state = 'done'; bus.emit('audio:cue', { name: 'surge_end' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- aortic squeeze: DIRECTIONAL, non-lethal ---------------------------------------------
|
||||||
|
// The arch presses from ONE side; the opposite arc is the answer. C: a symmetric squeeze
|
||||||
|
// would be an untimeable toll because a flow-locked player cannot stop and wait.
|
||||||
|
function bulgeAt(h, t) {
|
||||||
|
const period = h.ev.period ?? 2.4;
|
||||||
|
return 0.5 * (1 + Math.sin((t / period) * TAU)); // 0..1
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSqueeze(h, dt, ps) {
|
||||||
|
const span = h.ev.span ?? 400;
|
||||||
|
if (ps.s > h.s + span) { h.state = 'done'; return; }
|
||||||
|
if (ps.s < h.s || !ps.alive) return;
|
||||||
|
|
||||||
|
const theta = h.ev.theta ?? 0;
|
||||||
|
const amp = h.ev.amplitude ?? 0.45;
|
||||||
|
const pulse = bulgeAt(h, time);
|
||||||
|
const rho = Math.hypot(ps.x, ps.y);
|
||||||
|
if (rho < 1e-4) return;
|
||||||
|
const pTheta = Math.atan2(ps.x, ps.y);
|
||||||
|
// only the pressing arc bites; the far side stays clear and is the verb
|
||||||
|
if (Math.abs(angDelta(theta, pTheta)) > H.squeeze.arc * 0.5) return;
|
||||||
|
|
||||||
|
const safe = world.wallRho(ps.s, pTheta);
|
||||||
|
const limit = safe * (1 - amp * pulse);
|
||||||
|
if (rho <= limit) return;
|
||||||
|
|
||||||
|
h.grazeT = (h.grazeT ?? 0) - dt;
|
||||||
|
if (h.grazeT <= 0) { player.damage(H.squeeze.graze, 'squeeze'); h.grazeT = H.squeeze.grazeTick; }
|
||||||
|
player.shove(-(ps.x / rho) * H.squeeze.shove, -(ps.y / rho) * H.squeeze.shove);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ring gate: a POINT, therefore timeable ----------------------------------------------
|
||||||
|
const gateOpen = (h, t) => ((t % (h.ev.period ?? 3.0)) < (h.ev.open ?? 1.5));
|
||||||
|
|
||||||
|
function updateGate(h, dt, ps) {
|
||||||
|
if (ps.s > h.s + 6) { h.state = 'done'; return; }
|
||||||
|
if (h.passed || !ps.alive) return;
|
||||||
|
// the beat you arrive on is the beat that counts
|
||||||
|
if (ps.s >= h.s - 0.5) {
|
||||||
|
h.passed = true;
|
||||||
|
if (!gateOpen(h, time)) {
|
||||||
|
player.damage(H.gate.hit, 'gate');
|
||||||
|
const rho = Math.hypot(ps.x, ps.y) || 1e-4;
|
||||||
|
player.shove(-(ps.x / rho) * H.gate.shove, -(ps.y / rho) * H.gate.shove);
|
||||||
|
bus.emit('audio:cue', { name: 'gate_hit' });
|
||||||
|
} else {
|
||||||
|
bus.emit('audio:cue', { name: 'gate_pass' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- render -----------------------------------------------------------------------------
|
||||||
|
function render() {
|
||||||
|
let sn = 0, rn = 0, gn = 0;
|
||||||
|
let hotGate = 0;
|
||||||
|
|
||||||
|
for (const h of specs) {
|
||||||
|
if (h.state !== 'active') continue;
|
||||||
|
|
||||||
|
if (h.kind === 'reflux_surge' && h.surge && sn < 4) {
|
||||||
|
const f = world.sample(THREE.MathUtils.clamp(h.surge.s, 0, world.length));
|
||||||
|
const r = world.wallRho(h.surge.s, 0) + 1.2;
|
||||||
|
_q.setFromUnitVectors(_up, f.tan);
|
||||||
|
surgeMesh.setMatrixAt(sn++, _m.compose(f.pos, _q, _scale.set(r, r, r)));
|
||||||
|
} else if (h.kind === 'aortic_squeeze') {
|
||||||
|
const span = h.ev.span ?? 400, theta = h.ev.theta ?? 0, amp = h.ev.amplitude ?? 0.45;
|
||||||
|
const pulse = bulgeAt(h, time);
|
||||||
|
for (let s = h.s; s <= h.s + span && rn + RIDGE_ARC <= 160; s += RIDGE_EVERY) {
|
||||||
|
const f = world.sample(s);
|
||||||
|
const safe = world.wallRho(s, theta);
|
||||||
|
for (let j = 0; j < RIDGE_ARC; j++) {
|
||||||
|
const a = theta + (j / (RIDGE_ARC - 1) - 0.5) * H.squeeze.arc;
|
||||||
|
const rr = safe * (1 - amp * pulse * 0.85);
|
||||||
|
discToWorld(_v, f, Math.sin(a) * rr, Math.cos(a) * rr);
|
||||||
|
ridgeMesh.setMatrixAt(rn++, _m.compose(_v, _q.identity(), _scale.setScalar(1.5 + pulse * 0.8)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (h.kind === 'ring_gate') {
|
||||||
|
const open = gateOpen(h, time);
|
||||||
|
if (!open) hotGate = 1;
|
||||||
|
const f = world.sample(h.s);
|
||||||
|
const safe = world.wallRho(h.s, 0);
|
||||||
|
const r = open ? safe : safe * H.gate.irisMin;
|
||||||
|
for (let j = 0; j < IRIS_BEADS && gn < 96; j++) {
|
||||||
|
const a = (j / IRIS_BEADS) * TAU;
|
||||||
|
discToWorld(_v, f, Math.sin(a) * r, Math.cos(a) * r);
|
||||||
|
irisMesh.setMatrixAt(gn++, _m.compose(_v, _q.identity(), _scale.setScalar(open ? 0.9 : 1.5)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
surgeMesh.count = sn; ridgeMesh.count = rn; irisMesh.count = gn;
|
||||||
|
for (const m of meshes) m.instanceMatrix.needsUpdate = true;
|
||||||
|
// violet (a gate you pass) → amber (a thing that will hurt you). The colour IS the tell.
|
||||||
|
irisMat.uniforms.uFlash.value = hotGate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update, reset,
|
||||||
|
rebuild() { build(); },
|
||||||
|
get specs() { return specs; },
|
||||||
|
get activeSurge() { return specs.find((h) => h.surge)?.surge ?? null; },
|
||||||
|
dispose() {
|
||||||
|
offNeutralize();
|
||||||
|
for (const m of meshes) { scene.remove(m); m.dispose(); }
|
||||||
|
surgeGeo.dispose(); surgeMat.dispose();
|
||||||
|
ridgeGeo.dispose(); ridgeMat.dispose();
|
||||||
|
irisGeo.dispose(); irisMat.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
114
web/js/combat/pickups.js
Normal file
114
web/js/combat/pickups.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// combat/pickups.js (Lane B) — the five pickup kinds C places in L2.
|
||||||
|
//
|
||||||
|
// Split of concerns (settled with C): C owns WHERE and HOW MANY (level JSON, and the design
|
||||||
|
// reasons are worth reading — "orb lines teach the racing line before they score anything",
|
||||||
|
// "the game pays you right before it charges you"). B owns what each one is WORTH
|
||||||
|
// (balance.js §pickups) and what touching it does. Placement law: `rho` is a FRACTION of the
|
||||||
|
// safe radius, exactly as for enemies — C pins sample 1 at rho 0.92 meaning "against the
|
||||||
|
// wall", and that has to mean the same thing in a 9-unit tube and a 6.8-unit one.
|
||||||
|
//
|
||||||
|
// ART_BIBLE: pickups are soft green, all five of them — the emissive code is a readability
|
||||||
|
// LAW keyed to meaning ("this is good for you"), not a palette to decorate with. So identity
|
||||||
|
// comes from SHAPE instead: orb = sphere, mucin = blob, B12 = octahedron, antacid = capsule,
|
||||||
|
// biopsy = the big slow diamond you can spot from 200 units away. One InstancedMesh per kind:
|
||||||
|
// 5 draws for the whole economy.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { BALANCE as B } from './balance.js';
|
||||||
|
import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
|
||||||
|
|
||||||
|
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3();
|
||||||
|
const _v = new THREE.Vector3(), _axis = new THREE.Vector3(0.3, 1, 0.2).normalize();
|
||||||
|
|
||||||
|
const KINDS = {
|
||||||
|
nutrient_orb: { geo: () => new THREE.SphereGeometry(0.8, 10, 8), pool: 48, scale: 1 },
|
||||||
|
mucin_glob: { geo: () => new THREE.IcosahedronGeometry(1.0, 0), pool: 16, scale: 1 },
|
||||||
|
b12_cell: { geo: () => new THREE.OctahedronGeometry(1.0, 0), pool: 8, scale: 1 },
|
||||||
|
antacid_ammo: { geo: () => new THREE.CapsuleGeometry(0.45, 0.9, 3, 8), pool: 12, scale: 1 },
|
||||||
|
biopsy_sample: { geo: () => new THREE.OctahedronGeometry(1.6, 0), pool: 6, scale: 1.15 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPickups({ scene, world, bus, rng, player, weapons }) {
|
||||||
|
const pools = {};
|
||||||
|
for (const [kind, def] of Object.entries(KINDS)) {
|
||||||
|
const geo = def.geo();
|
||||||
|
const mat = emissiveMat(EMISSIVE.pickup, { additive: kind === 'biopsy_sample' });
|
||||||
|
const mesh = new THREE.InstancedMesh(geo, mat, def.pool);
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
mesh.count = 0;
|
||||||
|
scene.add(mesh);
|
||||||
|
pools[kind] = { mesh, geo, mat, def, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let time = 0;
|
||||||
|
const discToWorld = (out, frame, x, y) =>
|
||||||
|
out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);
|
||||||
|
|
||||||
|
function spawn(kind, { s = 0, theta = null, rho = null } = {}) {
|
||||||
|
const pool = pools[kind];
|
||||||
|
if (!pool) { console.warn(`[combat] unknown pickup kind "${kind}"`); return null; }
|
||||||
|
if (pool.items.length >= pool.def.pool) return null;
|
||||||
|
|
||||||
|
s = THREE.MathUtils.clamp(s, 0, world.length);
|
||||||
|
const th = theta ?? rng('pickups')() * Math.PI * 2;
|
||||||
|
const free = Math.max(0, world.wallRho(s, th) - B.pickups.radius);
|
||||||
|
// rho is C's fraction of the safe radius. Unauthored => near the racing line, because an
|
||||||
|
// unplaced pickup is a reward, not a puzzle.
|
||||||
|
const r = rho != null ? THREE.MathUtils.clamp(rho, 0, 1) * free : free * 0.3;
|
||||||
|
|
||||||
|
const p = { kind, s, x: Math.sin(th) * r, y: Math.cos(th) * r, pos: new THREE.Vector3(), alive: true };
|
||||||
|
pool.items.push(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies the effect, then announces it. E renders the feedback off `pickup {kind}`.
|
||||||
|
function collect(p) {
|
||||||
|
p.alive = false;
|
||||||
|
const v = B.pickups[p.kind] ?? {};
|
||||||
|
if (v.coat || v.hull || v.boost) player.refill({ coat: v.coat ?? 0, hull: v.hull ?? 0, boost: v.boost ?? 0 });
|
||||||
|
if (v.ammo) weapons?.addAmmo(v.ammo);
|
||||||
|
bus.emit('pickup', { kind: p.kind, s: p.s, score: v.score ?? 0, sample: v.sample ?? 0 });
|
||||||
|
bus.emit('audio:cue', { name: p.kind === 'biopsy_sample' ? 'pickup_sample' : 'pickup' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(dt) {
|
||||||
|
time += dt;
|
||||||
|
const ps = player.state;
|
||||||
|
const reach = B.pickups.radius + player.radius;
|
||||||
|
const reach2 = reach * reach;
|
||||||
|
|
||||||
|
for (const pool of Object.values(pools)) {
|
||||||
|
let n = 0;
|
||||||
|
for (let i = pool.items.length - 1; i >= 0; i--) {
|
||||||
|
const p = pool.items[i];
|
||||||
|
if (!p.alive) { pool.items.splice(i, 1); continue; }
|
||||||
|
discToWorld(p.pos, world.sample(p.s), p.x, p.y);
|
||||||
|
// cheap reject along s before the distance test — pickups outlive the player's window
|
||||||
|
if (ps.alive && Math.abs(p.s - ps.s) < reach + 2) {
|
||||||
|
const dx = p.x - ps.x, dy = p.y - ps.y, ds = p.s - ps.s;
|
||||||
|
if (dx * dx + dy * dy + ds * ds <= reach2) { collect(p); pool.items.splice(i, 1); continue; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of pool.items) {
|
||||||
|
if (n >= pool.def.pool) break;
|
||||||
|
_q.setFromAxisAngle(_axis, time * B.pickups.spin);
|
||||||
|
pool.mesh.setMatrixAt(n++, _m.compose(p.pos, _q, _scale.setScalar(pool.def.scale)));
|
||||||
|
}
|
||||||
|
pool.mesh.count = n;
|
||||||
|
pool.mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() { for (const pool of Object.values(pools)) pool.items.length = 0; }
|
||||||
|
|
||||||
|
return {
|
||||||
|
spawn, update, clear,
|
||||||
|
get count() { return Object.values(pools).reduce((a, p) => a + p.items.length, 0); },
|
||||||
|
dispose() {
|
||||||
|
for (const pool of Object.values(pools)) {
|
||||||
|
scene.remove(pool.mesh); pool.mesh.dispose(); pool.geo.dispose(); pool.mat.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -48,6 +48,14 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
|
|||||||
|
|
||||||
const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length);
|
const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length);
|
||||||
|
|
||||||
|
// `world.crestSpeed(s)` is world contract v1.1, but Lane A's world had not exposed it yet
|
||||||
|
// when this was written (their round-2 task #1, in flight — the stub already complies). The
|
||||||
|
// fallback mirrors TECH §Crest-speed law rather than inventing a number, so surf behaves
|
||||||
|
// identically the moment A lands theirs, and this guard can then be deleted.
|
||||||
|
const CREST_FACTOR = 1.6;
|
||||||
|
const crestSpeedAt = (s, biome) =>
|
||||||
|
(world.crestSpeed ? world.crestSpeed(s) : biome.flow * CREST_FACTOR);
|
||||||
|
|
||||||
// --- durability -------------------------------------------------------------------
|
// --- durability -------------------------------------------------------------------
|
||||||
// Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does
|
// Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does
|
||||||
// NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E
|
// NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E
|
||||||
@ -109,8 +117,19 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
|
|||||||
if (was !== st.surfBlend > 0.5) bus.emit('player:surf', { active: st.surfBlend > 0.5, s: st.s });
|
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 ---
|
||||||
st.speed = biome.flow * (st.throttle + st.surfBlend * T.surf.gain + st.boostBlend * T.boost.gain);
|
// Surf is a SPEED-LOCK onto the crest, not a bonus on top of throttle (round-2 ruling #1;
|
||||||
|
// round 1 proved a flat bonus is self-cancelling). The wave now outruns you
|
||||||
|
// (crestSpeed = 1.6 × flow > throttleMax 1.4), so riding means being *carried at the
|
||||||
|
// wave's own speed* — which is also why you stay on it instead of shooting off the front.
|
||||||
|
const flowSpeed = biome.flow * st.throttle;
|
||||||
|
const crest = crestSpeedAt(st.s, biome);
|
||||||
|
// 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
|
||||||
|
|||||||
@ -1,28 +1,30 @@
|
|||||||
// 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.
|
||||||
//
|
//
|
||||||
// Stub reference frame (js/stub/world_stub.js), which all of these are sized against:
|
// Reference frame these are sized against — CORRECTED round 2 (C caught the stale numbers,
|
||||||
// biome.flow = 14 u/s · radius 10 ±15% wobble => 8.5..11.5
|
// LANE_C_NOTES §→ Lane B #4). The round-1 header quoted the round-0 stub; A's real world has
|
||||||
// wallRho = radius − waveAmp(0.9) − skin(0.6) => a ~7.0..10.0 playable disc radius
|
// since measured the esophagus wave at amp 1.4 (not 0.9) + breathe 0.15, and `wallRho`
|
||||||
// peristalsis crest travels at WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
|
// subtracts BOTH plus SKIN:
|
||||||
|
// biome.flow = 14 u/s · radius 10 ±15% wobble => 8.50 .. 11.50
|
||||||
|
// wallRho = radius − (amp 1.4 + breathe 0.15) − SKIN 0.6 => 6.35 .. 9.35 playable disc
|
||||||
|
// minus ship.radius 0.9 => 5.45 .. 8.45 free disc
|
||||||
|
// The envelope shrank ~0.65 u under us. disc.maxSpeed/accel and the graze numbers were sized
|
||||||
|
// against the old 7.0..10.0 and still read fine at 6.35..9.35, but that is luck, not design —
|
||||||
|
// if A retunes the wave again, re-check `disc` and `wall` here, not just C's clearances.
|
||||||
//
|
//
|
||||||
// SURF IS A SHOVE, NOT YET A RIDE — and that is a world-side problem, not a tuning one.
|
// SURF: FIXED THIS ROUND — it is a ride now, not a shove.
|
||||||
// Measured in-engine this round (numbers in LANE_B_NOTES §surf):
|
// Round 1 measured the mechanic as strictly dominated: a crest travelling at flow (13.64 u/s)
|
||||||
// crest phase speed = WAVE_W/WAVE_K = 3.0/0.22 = 13.64 u/s
|
// cannot carry anything faster than itself, while throttle-mashing gave 19.6 u/s, so the
|
||||||
// player top speed = flow × throttleMax = 14 × 1.4 = 19.6 u/s
|
// correct play was to ignore the level's signature mechanic. Tuning could not fix it (the
|
||||||
// A wave travelling 13.64 u/s cannot carry anything faster than 13.64 u/s, so "ride the
|
// crest window is fixed in SPACE, so a bigger bonus just ejected you sooner — measured ~3.0
|
||||||
// crest" can never be the fast line while the wave is slower than the player. Worse, the
|
// units gained per crest at gain 0.4, 0.75 AND 1.2, identical).
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// So surf currently reads as what the wave physically is: a rhythmic peristaltic SHOVE as
|
// F's round-2 ruling #1 fixed it at the source: `crestSpeed = CREST_FACTOR(1.6) × flow`, which
|
||||||
// each crest sweeps over you (~0.3-0.6 s at 24.5 u/s), which is honest and feels good, but
|
// beats throttleMax 1.4. So the wave now outruns the player and B **speed-locks to
|
||||||
// it is not the level-2 speed line the GDD asks for. The fix belongs to whoever owns the
|
// world.crestSpeed(s)** while riding instead of adding a flat bonus. Riding at flow 14 =
|
||||||
// wave constants (stub = F, world = A): crest speed must exceed 19.6 u/s. Escalated in
|
// 22.4 u/s vs 19.6 for mashing: +14%, and because you match the crest's own speed you STAY on
|
||||||
// LANE_B_NOTES §"-> Lane A / F" with options. Do not paper over it here.
|
// it instead of being thrown off the front. Throttle still decides how long you hold it —
|
||||||
//
|
// see surf.authority. Read the crest, don't hold W.
|
||||||
// gain below is therefore chosen for FEEL (punch of the shove), not for speed economy.
|
|
||||||
|
|
||||||
export const TUNING = {
|
export const TUNING = {
|
||||||
ship: {
|
ship: {
|
||||||
@ -46,11 +48,21 @@ 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
|
// pulse=pow(sin,3) so 0.45 => sin>0.766 => the peak 22% of each
|
||||||
// each wave: a ~6.3 u window in a 28.6 u wavelength.
|
// wave. Under CREST 1.6 the wavelength grew to ~45.7 u at flow
|
||||||
gain: 0.75, // +75% flow while on a crest => a 24.5 u/s shove for ~0.3-0.6 s.
|
// 14, so that window is now ~10 u long (was ~6.3) — the crest is
|
||||||
// Feel-tuned only; see the surf note in the header before
|
// a wider, faster lane than it was in round 1.
|
||||||
// touching it expecting a speed change.
|
authority: 0.85, // how completely the crest owns your speed while riding, 0..1.
|
||||||
|
// THE knob. At 1.0 the wave takes you at exactly crestSpeed and
|
||||||
|
// throttle stops mattering while surfing — you'd ride forever
|
||||||
|
// and the mechanic would have no skill floor. At 0.85 you sit
|
||||||
|
// just under the crest and drift off the back at a rate YOU set:
|
||||||
|
// throttle 1.4 -> 21.98 vs crest 22.4 => drift 0.42 u/s, ~12 s ride
|
||||||
|
// throttle 1.0 -> 21.14 => drift 1.26 u/s, ~4 s ride
|
||||||
|
// throttle 0.6 -> 20.30 => drift 2.10 u/s, ~2 s ride
|
||||||
|
// So catching a crest is positioning; HOLDING one is throttle
|
||||||
|
// discipline. Never applied when it would slow you (boosting
|
||||||
|
// past the crest keeps your speed — see player.js).
|
||||||
ramp: 3.0, // /s blend in/out so cresting doesn't pop
|
ramp: 3.0, // /s blend in/out so cresting doesn't pop
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user