Files
splatter-tennis/src/drill.js
T
wuwiandClaude Opus 5 d775ac40ec The colour goes on the court, and the dead go off like everyone else
"the colored zombies should also explode like they did before and make the
normal splash sound on destruction.. no bleeps please.. can we make the color
as a color circle the walk on? the sign they're holding is not nice. also
remove the ball slicing in this mode.. this leads to unwanted hits".

The silent deaths were my bug: the hook in killZombie did drillKill(zb) and
then RETURNED, and everything below that return is the death — decal, pile,
36-particle burst, splat, headshot crack. It resolves after the splice and
without returning now. Both halves are load-bearing and each was a bug in
turn: splice first or a freshly drawn combination names the corpse; don't
return, or the body simply vanishes.

The colour is a disc on the court the body walks on, drawn before the sprite.
The bib was a flat quad pinned to a walking sprite — UI stuck onto a
character, covering the art it sat on. On the ground it takes the court's own
perspective, measures 12.4px across at the spawn line against a chest patch's
few, and hides none of the sprite. A hard dark rim plus a bright inner line
is what keeps it from reading as another gore decal.

No slicing in the drill: a cut ball leaves the corpse and strikes whatever it
finds next, which in a mode where the wrong body is the failure is the game
choosing your target for you, badly.

And the mode has no sound of its own now, deliberately. The umpire reading
the list was vetoed, the arpeggio that replaced him was vetoed. blip() is
deleted. A kill bursts and splats like every other kill in this game; the
strip carries the list.

28 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:57:56 +02:00

189 lines
11 KiB
JavaScript

// THE COACHING DRILL — the cognitive mode. Second design, after Oliver played the first:
//
// "daniel announcing is maximum annoying"
// "don't let the rush in position just let them coming from the back in steady speed..
// try to space them not too close"
// "when one combination is done just let the new one in.. in a continuous stream that
// increases in speed and difficulty" / "the rounds can stay, they should be continuous"
//
// So the ROUND survives as the unit — a called combination, put down in order, counted — and every
// SEAM around it is gone: no walk-on to a mark, no announcement beat, no between-rounds pause, no
// deal. Bodies walk in from the far baseline at a steady speed for as long as the mode runs, and a
// round is drawn from whoever happens to be on court. Finish one and the next is drawn on the spot,
// out of the same rolling population — the bodies you were ignoring a second ago are the ones being
// called now. Speed, spawn rate and combination length all ramp with time survived, so the stream
// IS the difficulty curve.
//
// 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 round and the rules. It imports no renderer:
// render.js reads DRILL_COLS and draws.
import { TUNE } from './config.js';
import { G } from './state.js';
import { zombies, spawnZombie } from './zombies.js';
import { voice } from './audio.js';
// The colours. Six, and a colour is on court at most once, so a colour always names exactly one body.
//
// THE MODE HAS NO SOUND OF ITS OWN, and that is now deliberate. Two attempts have been vetoed: the
// recorded umpire reading the list aloud ("daniel announcing is maximum annoying" — at ~0.8 s a word
// he needed four seconds for a list of four, over and over), and the arpeggio that replaced him
// ("no bleeps please"). What is left is the game's own vocabulary: a kill bursts and splats like
// every other kill in the game, and the strip carries the list. Do not put a third noise here
// without a reason — the two that were tried both read as the mode talking over itself.
//
// THERE IS NO REDUNDANT CHANNEL ANY MORE, and that is a known accessibility gap rather than an
// oversight. Die-face dots rode on the bib and the HUD chip so the mode could be played without
// separating red from green (~8% of men cannot) — but they were noise to everyone who can, and they
// shrank to nothing on a body at distance, which is exactly where the channel had to work. Cut on
// Oliver's call. If the mode ships, the answer is bibs that differ by SHAPE or marking at sprite
// scale, not dots: a channel that fails at the far baseline is not a channel.
export const DRILL_COLS = [
{ key: 'red', col: '#e5342c', name: 'RED' },
{ key: 'blue', col: '#2f6bff', name: 'BLUE' },
{ key: 'yellow', col: '#f2c318', name: 'YELLOW' },
{ key: 'green', col: '#27bd4a', name: 'GREEN' },
{ key: 'purple', col: '#9b3fd8', name: 'PURPLE' },
{ key: 'white', col: '#f2f0e8', name: 'WHITE' },
];
// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. Time, not
// rounds won: a round-counted ramp cannot escalate while you stand still.
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 batchSize = d => Math.round(lerp(TUNE.drillBatch, TUNE.drillBatchMax, ramp(d)));
const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, ramp(d));
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const bibs = () => zombies.filter(z => z.bib !== undefined);
// ---- THE BATCH IS THE ROUND, AND IT IS WHAT MAKES THE MODE WINNABLE. Oliver: "make the game that
// it is actually winable meaning that all required colors are visible on the far side before when a
// color combination is shown.. for now no colors that are not in the current combination".
//
// Two rules follow from that and they are the whole shape of this file now:
// 1. every colour on court is IN the current combination. There are no coloured decoys — so a
// colour you can see is a colour you will be asked for, and the strip can never name a body
// that is not there.
// 2. a combination is only ever shown once its bodies are ALL standing on the far side, which is
// free here because the batch is born there: they walk the full length of the court at you,
// and that length is the time you are given to work through the list.
// The pressure moved to the EATERS below instead, which is a better place for it — the puzzle stays
// fair and the danger is a separate thing arriving on its own clock.
function spawnBatch(d) {
const k = Math.min(DRILL_COLS.length, batchSize(d));
// distinct colours, distinct lanes, fanned across the width and shuffled so the called order has
// nothing to do with the left-to-right order
const cols = shuffle(DRILL_COLS.map((_, i) => i)).slice(0, k);
const lanes = shuffle(cols.map((_, i) => -TUNE.drillSpread + (2 * TUNE.drillSpread) * (i / Math.max(1, k - 1))));
cols.forEach((bib, i) => {
// staggered in depth as well as across, so the batch arrives as a loose group rather than as a
// rank walking in abreast — but every one of them still starts on the far side, which is the
// promise the mode now makes.
spawnZombie(lanes[i] + (Math.random() - 0.5) * 0.4,
TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, Math.random() < 0.25 ? 'b' : 'a');
const zb = zombies[zombies.length - 1];
zb.bib = bib;
zb.drill = true; // holds its lane (see updateZombies) — the lane is the puzzle
zb.v = drillSpeed(d) * (0.92 + Math.random() * 0.16);
});
newRound(d);
}
// ---- THE EATERS. Plain, unbibbed shamblers on their own clock: no colour, no place in any
// combination, nothing to remember about them. Killing one is never a fault (drillKill ignores a
// body with no bib) and letting one reach you is the ordinary caught. They exist because with the
// puzzle made fair the mode needed its danger back from somewhere, and a threat you simply have to
// DEAL WITH is a cleaner source of it than a puzzle that cheats.
// Deliberately NOT flagged `drill`, so they keep the gore mode's homing: they converge on you while
// the bibbed batch walks its lanes, which is exactly the "stop what you are doing" interruption the
// mode was missing.
function spawnEater(d) {
if (zombies.filter(z => z.bib === undefined).length >= TUNE.drillEaterMax) return;
spawnZombie((Math.random() * 2 - 1) * TUNE.drillSpread, TUNE.drillSpawnZ + Math.random() * 0.8,
Math.random() < 0.3 ? 'b' : 'a');
const zb = zombies[zombies.length - 1];
zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2);
}
// ---- THE ROUND: the order the batch has to be taken in. Drawn from every bibbed body on court,
// which — see rule 1 above — is exactly the batch and nothing else.
function newRound(d) {
const pool = bibs();
d.step = 0; d.faults = 0;
d.seq = pool.length ? shuffle(pool.map(z => z.bib)) : [];
// (no sound: the strip IS the call — see the note on DRILL_COLS)
}
// Called by beats.startDrill once the court is its own.
export function startDrillStream() {
const d = G.drill; if (!d) return;
zombies.length = 0;
d.seq = []; d.step = 0; d.faults = 0; d.t = 0; d.eaterT = TUNE.drillEaterEvery; d.rounds = 0; d.lost = 0; d.msg = null;
spawnBatch(d); // the mode has to be whole the moment it starts — a batch on the far side and a
// combination already called off it
}
export function updateDrill(dt) {
const d = G.drill; if (!d) return;
d.t += dt;
if (d.msg) d.msg.t -= dt;
// the eaters keep coming on their own clock, independently of the puzzle
if ((d.eaterT -= dt) <= 0) { spawnEater(d); d.eaterT = eaterGap(d) * (0.75 + Math.random() * 0.5); }
// THE CONTINUOUS PART. The batch is the round; kill the last of it and the next batch walks in on
// the same frame with its combination already called. No pause, no deal, no announcement beat —
// and because a batch is born on the far side, "all the colours are visible before you are asked
// for them" holds by construction rather than by a check that could be wrong.
if (!bibs().length) { spawnBatch(d); return; }
if (!d.seq.length) newRound(d); // survivors after a double fault: same bodies, fresh order
}
// A drill body went down. Called from killZombie — the single kill funnel — so no weapon path can
// miss it (the bug this codebase has had twice).
export function drillKill(zb) {
const d = G.drill; if (!d || zb.bib === undefined) return;
if (zb.bib === d.seq[d.step]) {
d.step++;
// no tick here either — killZombie is already bursting the body and playing the splat, and that
// burst is a far better "yes, that one" than a beep laid over the top of it
if (d.step >= d.seq.length) { // round complete — and the next one is already here
d.rounds++;
d.msg = { text: 'GOOD', t: 0.9 };
newRound(d);
}
return;
}
// Wrong body — one never called, or one due later. It is dead either way, so drop it from the list
// rather than leaving a slot nobody can fill: an un-winnable round reads as a broken game, and the
// fault below is the punishment already.
d.faults++;
d.seq = d.seq.filter((c, i) => i < d.step || c !== zb.bib);
if (d.faults >= 2) {
d.lost++;
voice('double_fault', 0.1);
d.msg = { text: 'DOUBLE FAULT', t: 1.6, bad: true };
return newRound(d); // scrubbed, and a fresh one off the same court
}
voice('fault', 0.1);
d.msg = { text: 'FAULT', t: 1.2, bad: true };
if (d.step >= d.seq.length) { d.rounds++; newRound(d); } // the wrong kill was the last one outstanding
}
// A drill body reached you. Called from killPlayer, the single death path for BOTH damage channels.
// Contact costs the POINT and a cleared court, never the run: permadeath is settled for the gore
// mode, and a memory game where one lapse deletes a long run punishes the skill it is teaching.
// This is the ONE thing that interrupts the stream, which is exactly what makes it read as a setback.
export function drillCaught() {
const d = G.drill;
d.lost++;
voice('game_opponent', 0.1);
d.msg = { text: 'CAUGHT', t: 1.8, bad: true };
zombies.length = 0;
d.seq = []; d.step = 0; d.faults = 0; d.spawnT = 0;
d.t = Math.max(0, d.t - TUNE.drillCaughtBack); // ...and the ramp backs off a little
}
// Which body is owed right now — the HUD highlights it. NOT used for aiming: the auto-aim is gone.
export const drillTarget = () => zombies.find(z => G.drill && z.bib === G.drill.seq[G.drill.step]) || null;