180 lines
9.8 KiB
JavaScript
180 lines
9.8 KiB
JavaScript
// THE TRIAGE DRILL — the cognitive mode, fourth design, and the first one built on a SKILL.
|
|
// Settled in discussion after Oliver's verdict on the sequence version ("it feels not interesting
|
|
// enough to keep me playing"): the called order tested nothing — the strip was always visible so
|
|
// there was no memory, the batch was slow so there was no urgency, and what remained was a checklist.
|
|
//
|
|
// ONE RULE NOW: **always kill the body that would reach you first.** Nothing is called, nothing is
|
|
// shown — the court is the puzzle. Types walk at different speeds from different depths, so the
|
|
// answer is NOT the closest body: a runner at the far baseline beats a roller at the service line,
|
|
// and every kill (and every new arrival) re-asks the question. That is court sense — reading speeds
|
|
// and distances under pressure — which is both the most tennis-flavoured skill available and
|
|
// exactly the target-priority read the gore mode rewards.
|
|
//
|
|
// THE HOOK is the streak: correct kills build a multiplier, a wrong kill breaks it, and the score is
|
|
// what you come back to beat (best kept in localStorage; the world board can carry it later if the
|
|
// mode ships). Two subtleties that make it a game rather than a quiz:
|
|
// - WOUNDS ARE FREE. Only the KILL is judged, so pre-softening a roller while it is still third
|
|
// in line is legal and eventually necessary — the finishing blow is what must land in turn.
|
|
// - THE SLACK. Two bodies arriving within TUNE.drillSlack of each other are BOTH legal kills.
|
|
// Without it, near-simultaneous arrivals made the rule a coin flip; with it, only a genuine
|
|
// misread faults.
|
|
//
|
|
// THE MODE TRANSITIONS ARE NOT HERE — startDrill/endDrill live in beats.js with every other one
|
|
// (CLAUDE.md rule 3). This file is the stream, the urgency rule and the scoring.
|
|
|
|
import { TUNE, COURT, UI } from './config.js';
|
|
import { G, ball } from './state.js';
|
|
import { zombies, spawnZombie } from './zombies.js';
|
|
import { voice } from './audio.js';
|
|
import { showModal } from './gameplay.js'; // runtime-only cycle (gameplay imports drill), same pattern as beats
|
|
|
|
// The roster in the stream, weighted: shamblers are the crowd, heavies are events. vMul is the
|
|
// drill's own speed character (deliberately narrower than the roster's 3.2x..0.45x so the queue
|
|
// stays coherent); `pts` is what a kill banks BEFORE the streak multiplier — roughly the effort it
|
|
// took, so a roller is worth chasing and a shambler is volume.
|
|
export const DRILL_TYPES = [
|
|
{ v: 'a', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 },
|
|
{ v: 'b', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 },
|
|
{ v: 'f', name: 'RUNNER', vMul: 1.45, w: 2, pts: 60 },
|
|
{ v: 'e', name: 'BALL BOY', vMul: 1.10, w: 3, pts: 40 },
|
|
{ v: 'd', name: 'STEWARD', vMul: 1.00, w: 3, pts: 60 },
|
|
{ v: 'h', name: 'BOUNCER', vMul: 0.90, w: 2, pts: 70 },
|
|
{ v: 'g', name: 'BRUTE', vMul: 0.80, w: 2, pts: 80 },
|
|
{ v: 'k', name: 'ROLLER', vMul: 0.70, w: 1, pts: 100 },
|
|
];
|
|
|
|
// ---- THE RAMP. One number, 0..1 off time survived. Spawn gap and walk speed ride it; the streak
|
|
// multiplier does NOT (skill should pay the same at minute one as at minute five).
|
|
const ramp = d => Math.min(1, d.t / TUNE.drillRampT);
|
|
const lerp = (a, b, k) => a + (b - a) * k;
|
|
export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d));
|
|
const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d));
|
|
|
|
// ---- THE URGENCY RULE, and it is the whole mode. Seconds until this body crosses your baseline at
|
|
// its own pace down its own lane (drill bodies hold their lane, so this is honest — nothing swerves).
|
|
export const arrival = zb => (COURT.L - zb.z) / Math.max(0.01, zb.v);
|
|
// The queue, most urgent first. Exported: render marks the legal set, tests assert against it.
|
|
export const drillQueue = () => zombies.filter(z => z.drill).sort((a, b) => arrival(a) - arrival(b));
|
|
// EVERY legal kill right now — the head plus anything within the slack of it. THE MARKER MUST DRAW
|
|
// THIS SET, not the bare head: the rule accepts any near-tie, so a marker showing only q[0] was
|
|
// STRICTER than the rule it explained, and every kill made it jump between tied bodies in a way
|
|
// that read as the rule changing mid-flight. (With lanes held and speeds constant, arrival
|
|
// differences never change while bodies walk — the queue only reorders on a kill or an arrival, so
|
|
// marking the whole legal set is stable by construction.)
|
|
export const drillLegal = () => {
|
|
const q = drillQueue();
|
|
if (!q.length) return q;
|
|
const t0 = arrival(q[0]);
|
|
return q.filter(z => arrival(z) <= t0 + TUNE.drillSlack);
|
|
};
|
|
|
|
function pickType() {
|
|
const pool = DRILL_TYPES.flatMap(t => Array(t.w).fill(t));
|
|
return pool[Math.floor(Math.random() * pool.length)];
|
|
}
|
|
|
|
// ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against EVERY live body — a retry loop
|
|
// would stall the stream exactly when it should be getting harder), holding its lane all the way in.
|
|
// The measure used to ignore anything past the back half, which was wrong for a court where lanes are
|
|
// held for life and speeds are stamped per body: a lane 12 m down is still OWNED, and handing it to a
|
|
// faster body just books the overtake for ten seconds later, where it lands as two sprites walking
|
|
// through each other mid-court. A lane is free when its body is gone, not when it is far away.
|
|
function spawnDrillBody(d) {
|
|
const live = zombies.filter(z => z.drill);
|
|
if (live.length >= TUNE.drillMaxOnCourt) return null;
|
|
let bx = 0, bs = -1;
|
|
for (let k = 0; k < 9; k++) {
|
|
const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.5;
|
|
const s = live.length ? Math.min(...live.map(z => Math.abs(z.x - x))) : 1e9;
|
|
if (s > bs) { bs = s; bx = x; }
|
|
}
|
|
const t = pickType();
|
|
spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, t.v);
|
|
const zb = zombies[zombies.length - 1];
|
|
if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; // the testing switch, normally off
|
|
zb.drill = true; // holds its lane (see updateZombies) — the lane is what makes
|
|
// arrival() honest
|
|
zb.pts = t.pts;
|
|
// stamped at spawn, never re-read: the ramp arrives as new bodies outpacing old ones, not as the
|
|
// court accelerating under you — and a constant v is what lets arrival() be a plain division
|
|
zb.v = drillSpeed(d) * t.vMul * (0.92 + Math.random() * 0.16);
|
|
return zb;
|
|
}
|
|
|
|
export function startDrillStream() {
|
|
const d = G.drill; if (!d) return;
|
|
zombies.length = 0;
|
|
// The rule gets ONE sentence, once per run, on the sanctioned calm court (the seeded bodies are
|
|
// all at the far baseline — nothing is near). Oliver's worry is right: "most urgent first" is NOT
|
|
// guessable from a chevron alone, and this is the modal policy's exact use case.
|
|
if (!G.seen.drillRule) {
|
|
G.seen.drillRule = true;
|
|
showModal({ title: 'CLEARANCE DRILL', color: UI.pick,
|
|
body: ['DROP WHOEVER WOULD REACH YOU FIRST', 'FAST BEATS CLOSE · STREAKS PAY'],
|
|
// the body keeps the old words for the suite; the zoned card decrypts them ("FAST BEATS
|
|
// CLOSE" meant nothing until you had already failed it — 2026-08-29 copy audit)
|
|
rules: [{ lead: 'DROP WHOEVER WOULD REACH YOU FIRST', sub: 'THE FASTEST BODY — NOT THE NEAREST' },
|
|
{ lead: 'KILL STREAKS SCORE EXTRA', sub: 'THE WRONG BODY BREAKS THE STREAK' }] });
|
|
}
|
|
d.t = 0; d.spawnT = 0; d.score = 0; d.streak = 0; d.kills = 0; d.faults = 0; d.lost = 0; d.msg = null;
|
|
try { d.best = Number(localStorage.getItem('drillBest')) || 0; } catch { d.best = 0; }
|
|
// seed a small spread so there is a queue to read from the first second
|
|
for (let i = 0; i < TUNE.drillSeed; i++) {
|
|
const zb = spawnDrillBody(d);
|
|
if (zb) zb.z += i * TUNE.drillSeedGap;
|
|
}
|
|
}
|
|
|
|
export function updateDrill(dt) {
|
|
const d = G.drill; if (!d) return;
|
|
d.t += dt;
|
|
if (d.msg) d.msg.t -= dt;
|
|
if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); }
|
|
}
|
|
|
|
// The streak's payout curve: x1 x2 x3 x4 x5, capped — deep streaks should feel valuable without a
|
|
// single fault deleting a four-digit number, which reads as theft rather than tension.
|
|
export const drillMult = d => Math.min(TUNE.drillMultCap, 1 + Math.floor(d.streak / TUNE.drillMultEvery));
|
|
|
|
function bankBest(d) {
|
|
if (d.score <= d.best) return;
|
|
d.best = d.score;
|
|
try { localStorage.setItem('drillBest', String(d.best)); } catch {}
|
|
}
|
|
|
|
// A drill body went down — judge the kill. Called from killZombie (the single kill funnel, so no
|
|
// weapon path can miss it), AFTER the splice, so the queue we judge against is the court as it now
|
|
// stands. Only the kill is judged: wound() never lands here, which is what makes pre-softening legal.
|
|
export function drillKill(zb) {
|
|
const d = G.drill; if (!d || !zb.drill) return;
|
|
const q = drillQueue(); // zb is already spliced out — this is who is LEFT
|
|
const t0 = arrival(zb);
|
|
const legal = !q.length || t0 <= arrival(q[0]) + TUNE.drillSlack;
|
|
if (legal) {
|
|
d.streak++; d.kills++;
|
|
d.score += (zb.pts || 20) * drillMult(d);
|
|
bankBest(d);
|
|
return;
|
|
}
|
|
// A misread: something out there was going to arrive first and you spent the shot here. The
|
|
// streak IS the punishment — no round to lose, no court sweep, the queue just keeps walking.
|
|
d.faults++;
|
|
d.streak = 0;
|
|
voice('fault', 0.1);
|
|
d.msg = { text: 'OUT OF TURN', t: 1.3, bad: true };
|
|
}
|
|
|
|
// A body reached you — by contact or by walking its lane past your baseline. The one real setback:
|
|
// court swept, streak dead, ramp handed back a little. Score survives; the run has no end.
|
|
export function drillCaught(text = 'CAUGHT') {
|
|
const d = G.drill;
|
|
d.lost++;
|
|
d.streak = 0;
|
|
voice('game_opponent', 0.1);
|
|
d.msg = { text, t: 1.8, bad: true };
|
|
zombies.length = 0;
|
|
ball.active = false; // the court refills up-court; a live ball would meet it
|
|
d.spawnT = 0;
|
|
d.t = Math.max(0, d.t - TUNE.drillCaughtBack);
|
|
}
|