// 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;