Files
splatter-tennis/src/drill.js
T
wuwiandClaude Opus 5 be191dba82 The marker stops lying, and the rule announces itself
"the marker flips all the time even during a ball being midair.. i am not
sure that a player would get the mechanic intuitively let alone without the
marker".

The flip had a specific cause and it was a design bug, not jitter. With lanes
held and speeds constant, arrival differences never change while bodies walk
— the queue only reorders on a kill or an arrival. The marker flipped because
it showed only q[0] while the rule accepts any near-tie within the slack:
every kill made it jump between tied bodies, so the marker was stricter than
the rule it was explaining. drillLegal() marks the whole slack set now,
bright head and dim ties, which is stable by construction. Measured: zero
head-changes across 12s of walking with no kills.

Learnability, two answers. The rule gets one modal, once per run, on the
mode's own calm court — CLEARANCE DRILL / DROP WHOEVER WOULD REACH YOU FIRST
/ FAST BEATS CLOSE / STREAKS PAY — because "most urgent first" is not
guessable from a chevron alone. And the markers are training wheels that take
themselves off: hidden at streak >= 8 (the streak is the evidence you can
read the court), back on any fault. The teacher steps in exactly when you
demonstrably need it.

31 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:44:58 +02:00

172 lines
9.1 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 the back half — a retry loop
// would stall the stream exactly when it should be getting harder), holding its lane all the way in.
function spawnDrillBody(d) {
if (zombies.filter(z => z.drill).length >= TUNE.drillMaxOnCourt) return null;
const near = zombies.filter(z => z.drill && z.z < TUNE.drillNearZ);
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 = near.length ? Math.min(...near.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'] });
}
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);
}