Files
splatter-tennis/src/drill.js
T

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);
}