diff --git a/index.html b/index.html index 6934295..19dd183 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/audio.js b/src/audio.js index d4aa51e..0e2a264 100644 --- a/src/audio.js +++ b/src/audio.js @@ -266,6 +266,22 @@ export function flame(vol = 0.5) { } catch (e) {} } +// A short pitched note — the drill's colour arpeggio, and nothing else so far. Deliberately a plain +// triangle with a fast decay rather than anything designed: it has to be readable as a PITCH at a +// tenth of a second and it has to survive being played every few seconds forever, which is exactly +// what the recorded umpire could not do. +export function blip(freq, delay = 0, vol = 0.22) { + try { + const ac = audio(), t = ac.currentTime + delay; + const o = ac.createOscillator(), g = ac.createGain(); + o.type = 'triangle'; o.frequency.setValueAtTime(freq, t); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(vol, t + 0.012); // a hard attack clicks; 12 ms does not + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.17); + o.connect(g).connect(ac.destination); o.start(t); o.stop(t + 0.19); + } catch (e) {} +} + export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock try { const ac = audio(), t = ac.currentTime; diff --git a/src/beats.js b/src/beats.js index 4b4cd51..6fb9674 100644 --- a/src/beats.js +++ b/src/beats.js @@ -12,7 +12,7 @@ import { G, P, ball, score, setupPoint, sendBoy } from './state.js'; import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js'; import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.js'; -import { newDrillRound } from './drill.js'; +import { startDrillStream } from './drill.js'; // the court is mopped up (or was already clear): play advances to whatever the won game earned — // a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde. @@ -136,7 +136,7 @@ export function endBossFight() { // clock and rules live in drill.js; only the transition is here, where the rule can be enforced. export function startDrill() { if (G.drill || G.interlude || G.horde || G.bossFight) return false; - G.drill = { seq: [], step: 0, faults: 0, callT: 0, beat: 0, rush: 0, won: 0, lost: 0, msg: null }; + G.drill = { seq: [], step: 0, faults: 0, t: 0, spawnT: 0, rounds: 0, lost: 0, msg: null }; G.changeover = false; G.clearing = false; G.afterClear = null; // ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending // horde, an owed chapter or a due duel is a latch that keeps trying to fire behind the drill, @@ -147,7 +147,7 @@ export function startDrill() { G.weapon = 0; // plain balls: the mode is about order, not about the rack ball.active = false; setupPoint(); voice('quiet_please', 0.1); - newDrillRound(); + startDrillStream(); return true; } diff --git a/src/config.js b/src/config.js index 058a291..607ea2d 100644 --- a/src/config.js +++ b/src/config.js @@ -345,28 +345,27 @@ export const TUNE = { barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body barrelH: 1.15, // drawn height (a body is 1.8) - // ---- THE COACHING DRILL (src/drill.js) — the cognitive mode, currently a feel-test prototype. - // The umpire calls a colour sequence while bibbed bodies take their marks; you put them down in - // that order. Uncalled colours are decoys. Two faults and they take the point. - drillBodiesMin: 3,// bibs on court: a RANGE, drawn per round out of the six-colour palette. Fixed at - drillBodies: 5, // four out of four, every round dealt every colour and only the order changed — - // three quarters of the round was known before the umpire opened his mouth. - // Capped by DRILL_COLS. Above five the court stops reading at phone scale. - drillSeq: 3, // colours CALLED in round one; whatever is left on court is decoys - drillSeqMax: 4, // ...and the ceiling it climbs to (+1 every 3 rounds). Deliberately low: a longer - // list is a memory test and memory tests fatigue. What keeps getting harder is - // the GEOMETRY — the PLACEMENTS table in drill.js, and how badly the called order - // fights the order they reach you in. - drillCallT: 4.2, // s of walk-on while the umpire reads the list. No serving during it - drillWalkOn: 3.4, // m/s they jog out to their marks (a walk-on is brisk; the approach is not) - drillMarkZ: 5.0, // near edge of the band they line up in... - drillMarkSpread: 6.5, // ...and how deep that band runs, so they never arrive as a flat rank + // ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. SECOND DESIGN: a continuous + // stream. Bodies walk in from the far baseline forever; a ROUND is a colour combination drawn from + // whoever is on court, and finishing one draws the next on the spot. No walk-on, no announcement + // beat, no pause between rounds — every seam Oliver found is gone, the round itself stays. + drillMaxOnCourt: 6, // bibs on court at once. Hard-capped by DRILL_COLS (a colour appears once) + drillSeed: 4, // bodies placed at the start so the first round is callable immediately + drillSeedGap: 2.2,// m of depth between those seeded bodies — strung out, not a rank abreast + drillSpawnZ: 0.6, // z they enter at (the far baseline is 0) + drillNearZ: 9.0, // spacing is measured against bodies nearer than this — the ones still bunched drillSpread: 3.4, // m either side of the centre line the lanes fan across - drillSpeed: 0.95, // m/s they advance once the call is over - drillSpeedPerRound: 0.06, // ...climbing per round survived - drillRush: 1.9, // speed multiplier for drillRushT after a FAULT — the punishment is tempo, and - drillRushT: 1.6, // it has to be felt in the body, not read off a scoreline - drillBeat: 2.0, // s between rounds + drillSeq: 3, // colours in a round at the start of the ramp... + drillSeqMax: 5, // ...and at the top of it + drillSpawnEvery: 3.4, // s between arrivals at the start... + drillSpawnMin: 1.2, // ...and at the top. THE difficulty dial: the court fills faster than you clear + drillSpeed: 0.80, // m/s they walk at the start... + drillSpeedMax: 1.9,// ...and at the top. Stamped per body AT SPAWN, so the ramp shows up as new + // arrivals outpacing the ones already walking, not as the court accelerating + drillRampT: 210, // s to go from the bottom of every curve above to the top. Time survived, not + // rounds won — a round-counted ramp cannot escalate while you stand still + drillCaughtBack: 45, // s of ramp handed back when one reaches you. The setback is real but not a reset + drillTones: 1, // volume of the colour arpeggio (0 = silent). It replaced the spoken call fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver, // flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged diff --git a/src/drill.js b/src/drill.js index 9082f71..b56a5ae 100644 --- a/src/drill.js +++ b/src/drill.js @@ -1,41 +1,49 @@ -// THE COACHING DRILL — the cognitive mode. A prototype, deliberately: it exists to answer one -// question ("is reading a colour order off small moving bodies TENSE or just NOISY?") and nothing -// else, so it keeps its own round counter and does not touch tennis scoring. Wire it to score when -// the answer comes back yes. +// THE COACHING DRILL — the cognitive mode. Second design, after Oliver played the first: // -// The beat: the umpire calls a sequence out loud while bibbed bodies walk on and take their marks, -// then they come for you and you must put them down IN THE CALLED ORDER. Bodies whose colour was -// never called are DECOYS and must be left alone. Two faults and they take the point — the double -// fault is the sport's own rule for "you had two goes at it", and the recording already exists. +// "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 -// (see CLAUDE.md rule 3: one mode owns the court, and each start* refuses there). This file is the -// drill's own clock, its bodies and its rules. It imports no renderer: render.js reads DRILL_COLS -// and draws, which keeps the cycle out of it. +// (CLAUDE.md rule 3). This file is the stream, the round and the rules. It imports no renderer: +// render.js reads DRILL_COLS/PIP_FACES and draws. import { TUNE } from './config.js'; -import { G, P } from './state.js'; +import { G } from './state.js'; import { zombies, spawnZombie } from './zombies.js'; -import { voice, voiceSeq, sfx } from './audio.js'; -import { logSwing } from './gameplay.js'; // runtime-only cycle (gameplay imports drill), same pattern as beats/zombies +import { voice, blip, sfx } from './audio.js'; -// The bib colours. FOUR, not six: at 270 logical px across, a body at the far baseline is a -// thumbnail, and six hues that read fine in a swatch strip do not read at all on a walking sprite. -// Every entry carries `pips` as well as a colour — roughly 8% of men cannot separate the red from -// the green, and a mode whose whole input is "which colour is that" is unplayable for them on hue -// alone. The pip count is the redundant channel and it is drawn on the bib AND in the HUD strip. +// The bib colours. Six, and every entry carries `pips` as well as a hue: roughly 8% of men cannot +// separate the red from the green, and a mode whose whole input is "which colour is that" is +// unplayable for them on hue alone. +// `tone` is the colour's PITCH — see the arpeggio in newRound. **The recorded umpire reading the +// list aloud is DELETED**: at ~0.8 s a word he took four seconds to get through a list of four, and +// with rounds running continuously that is a man talking over the entire mode. Oliver: "daniel +// announcing is maximum annoying". A rising four-note figure says the same thing in half a second +// and does not wear out. (`drill_*.m4a` stay in assets — cheap to keep, and the call could come +// back for a slower variant of the mode.) export const DRILL_COLS = [ - { key: 'red', voice: 'drill_red', col: '#e5342c', ink: '#2a0806', pips: 1, name: 'RED' }, - { key: 'blue', voice: 'drill_blue', col: '#2f6bff', ink: '#050f2c', pips: 2, name: 'BLUE' }, - { key: 'yellow', voice: 'drill_yellow', col: '#f2c318', ink: '#2e2202', pips: 3, name: 'YELLOW' }, - { key: 'green', voice: 'drill_green', col: '#27bd4a', ink: '#04240f', pips: 4, name: 'GREEN' }, - { key: 'purple', voice: 'drill_purple', col: '#9b3fd8', ink: '#1c0530', pips: 5, name: 'PURPLE' }, - { key: 'white', voice: 'drill_white', col: '#f2f0e8', ink: '#20201c', pips: 6, name: 'WHITE' }, + { key: 'red', col: '#e5342c', ink: '#2a0806', pips: 1, tone: 392, name: 'RED' }, + { key: 'blue', col: '#2f6bff', ink: '#050f2c', pips: 2, tone: 440, name: 'BLUE' }, + { key: 'yellow', col: '#f2c318', ink: '#2e2202', pips: 3, tone: 494, name: 'YELLOW' }, + { key: 'green', col: '#27bd4a', ink: '#04240f', pips: 4, tone: 587, name: 'GREEN' }, + { key: 'purple', col: '#9b3fd8', ink: '#1c0530', pips: 5, tone: 659, name: 'PURPLE' }, + { key: 'white', col: '#f2f0e8', ink: '#20201c', pips: 6, tone: 784, name: 'WHITE' }, ]; -// The pips are laid out as DICE FACES, not as a row. Counting five dots against a clock is work; -// recognising a die is not — and the whole redundant channel is pointless if reading it costs more -// time than reading the hue. Unit coords in a -1..1 box, scaled onto the bib and onto the HUD chip -// by the same table, so the two readouts can never drift. +// The redundant channel is drawn as a DIE FACE, not a row of dots: five dots have to be counted, a +// die is recognised — and a redundant channel is pointless if reading it costs more than the hue. +// Unit coords in a -1..1 box, scaled onto the bib and the HUD chip by the same table so the two +// readouts cannot drift. export const PIP_FACES = [ [[0, 0]], [[-0.6, -0.6], [0.6, 0.6]], @@ -45,173 +53,139 @@ export const PIP_FACES = [ [[-0.7, -0.7], [0.7, -0.7], [-0.7, 0], [0.7, 0], [-0.7, 0.7], [0.7, 0.7]], ]; -// WHERE THEY STAND, and it is the difficulty dial with the real headroom — a longer list is a memory -// test and memory tests fatigue, but a called order that fights the geometry is a fresh problem -// every round. Each shape returns lanes and depths in the same order the bibs were dealt, so the -// colour assignment is independent of the shape: the same five positions ask a different question -// depending on which of them the umpire calls first. -// Fractions, not metres — `lane` is scaled by TUNE.drillSpread and `depth` by the mark band, so the -// shapes keep their character if either knob moves. -const PLACEMENTS = [ - { key: 'fan', w: 3, lane: (i, n) => -1 + 2 * i / Math.max(1, n - 1), depth: () => Math.random() }, - // everything bunched into one half: short lanes, near-identical angles, and the sidestep matters - { key: 'cluster', w: 2, lane: (i, n, s) => s * (0.15 + 0.5 * i / Math.max(1, n - 1)), depth: () => 0.25 + Math.random() * 0.5 }, - // TWO OR THREE SHARING A LANE at different depths. The ball hits the first body it touches, so a - // target behind another is unreachable until you move — this is the shape that makes feet matter. - { key: 'column', w: 3, lane: (i, n, s) => (i % 2 ? s * 0.75 : s * -0.15) + (i > 2 ? s * 0.1 : 0), depth: (i, n) => i / Math.max(1, n - 1) }, - // near/far alternating across the full width: the depth order and the left-right order disagree - { key: 'stagger', w: 3, lane: (i, n) => -1 + 2 * i / Math.max(1, n - 1), depth: i => (i % 2 ? 0.75 : 0.1) + Math.random() * 0.2 }, - // all of them down one wing, one lonely outlier on the other — the outlier is often the one called - { key: 'wing', w: 2, lane: (i, n, s) => i === 0 ? -s * 0.9 : s * (0.2 + 0.6 * i / Math.max(1, n - 1)), depth: () => Math.random() }, -]; -const pickPlacement = () => { - const pool = PLACEMENTS.flatMap(pl => Array(pl.w).fill(pl)); - return pool[Math.floor(Math.random() * pool.length)]; -}; +// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. The stream is +// the difficulty curve, so this is the only dial that matters — and it replaces the round-counted +// escalation the first design used, which could not escalate while you were standing 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 spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d)); +const comboLen = d => Math.round(lerp(TUNE.drillSeq, TUNE.drillSeqMax, 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); -// How long the drill has been running decides how hard it is. Sequence length climbs first and -// then STOPS (TUNE.drillSeqMax): a longer list is a memory test, and memory tests fatigue — what -// keeps getting harder is the geometry, i.e. how often the called order fights the order the -// bodies are actually going to reach you in. That is the dial with the headroom. -const seqLen = r => Math.min(TUNE.drillSeqMax, TUNE.drillSeq + Math.floor(r / 3)); +// ---- THE STREAM. One body at a time, from the far baseline, at whatever the ramp currently says. +// Two rules, both Oliver's: a STEADY walk in — nothing rushes to a mark and then stands there — and +// they must not arrive on top of each other. +function spawnDrillBody(d) { + const onCourt = bibs(); + if (onCourt.length >= TUNE.drillMaxOnCourt) return null; + // A colour is on court at most ONCE. Two reds and "red" stops naming a body, which makes the + // round ambiguous rather than hard. + const free = DRILL_COLS.map((_, i) => i).filter(i => !onCourt.some(z => z.bib === i)); + if (!free.length) return null; + // SPACING, per "try to space them not too close". Sample lanes across the width and take the one + // standing furthest from everyone already in the back half. Best-of-N rather than a retry loop, so + // a busy court still spawns — a hard minimum would silently stall the stream at exactly the moment + // it is supposed to be getting harder, which reads as the mode dying. + const near = onCourt.filter(z => 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; } + } + spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.25 ? 'b' : 'a'); + const zb = zombies[zombies.length - 1]; + zb.bib = free[Math.floor(Math.random() * free.length)]; + zb.drill = true; + // Speed is stamped at SPAWN and never re-read: a body that walked in while the drill was gentle + // keeps its gentle pace all the way, so the ramp arrives as NEW arrivals moving faster than the + // ones already there, rather than as the whole court accelerating out from under you. + zb.v = drillSpeed(d) * (0.92 + Math.random() * 0.16); + return zb; +} -export function newDrillRound() { +// ---- THE ROUND. Drawn from whoever is standing there, the instant the last one is finished. No +// pause, no deal, no announcement: the strip changes and the arpeggio plays. +function newRound(d) { + const pool = bibs(); + d.step = 0; d.faults = 0; + if (pool.length < 2) { d.seq = []; return; } // not enough on court yet — the stream will fill it + const k = Math.max(2, Math.min(comboLen(d), pool.length)); + d.seq = shuffle(pool.map(z => z.bib)).slice(0, k); + // THE CALL, without the man: one short note per colour, in the called order. Half a second for the + // whole figure. TUNE.drillTones 0 plays it silent. + if (TUNE.drillTones > 0) + d.seq.forEach((b, i) => blip(DRILL_COLS[b].tone, i * 0.13, 0.22 * TUNE.drillTones)); +} + +// Called by beats.startDrill once the court is its own. +export function startDrillStream() { const d = G.drill; if (!d) return; zombies.length = 0; - // HOW MANY varies too. With a fixed four out of a four-colour palette, every round put every - // colour on court and the only thing that changed was the order — which is three quarters of a - // round already known before it starts. The palette is SIX and the court takes three to five, so - // which colours turn up is a real question again and the decoy count moves with it. - const n = Math.min(DRILL_COLS.length, - TUNE.drillBodiesMin + Math.floor(Math.random() * (TUNE.drillBodies - TUNE.drillBodiesMin + 1))); - const k = Math.min(n, seqLen(d.won + d.lost)); - // Every body on court wears a DISTINCT bib, so "the red one" is never ambiguous. The sequence - // names k of them; the other n-k are decoys, and a decoy is a body actively walking at you that - // you are required to ignore. That is where the pressure comes from, and it costs nothing. - const bibs = shuffle(DRILL_COLS.map((_, i) => i)).slice(0, n); - d.seq = shuffle(bibs.slice()).slice(0, k); - d.step = 0; d.faults = 0; d.beat = 0; - d.callT = TUNE.drillCallT; - // The SHAPE is drawn per round and the lanes are shuffled against the bib order, so the called - // order is unrelated to both the left-right order and the depth order. `side` mirrors the - // asymmetric shapes so cluster/wing/column do not always favour the same half of the court. - const pl = pickPlacement(), side = Math.random() < 0.5 ? -1 : 1; - d.shape = pl.key; // for the miss log — "which shape was that" is the first question - const slots = shuffle(bibs.map((_, i) => i)); - bibs.forEach((bib, i) => { - const slot = slots[i]; - const x = Math.max(-TUNE.drillSpread, Math.min(TUNE.drillSpread, pl.lane(slot, n, side) * TUNE.drillSpread)); - const z = TUNE.drillMarkZ + pl.depth(slot, n) * TUNE.drillMarkSpread; - spawnZombie(x, 1.2 + Math.random() * 1.6, Math.random() < 0.25 ? 'b' : 'a'); - const zb = zombies[zombies.length - 1]; - zb.bib = bib; - zb.drill = true; - zb.v = 0; // updateDrill owns their movement for the whole round - zb.mark = { x, z }; - }); - voiceSeq(['drill_call', ...d.seq.map(b => DRILL_COLS[b].voice)]); + d.seq = []; d.step = 0; d.faults = 0; d.t = 0; d.spawnT = 0; d.rounds = 0; d.lost = 0; d.msg = null; + // Seed the court, strung out in depth, so the first round is callable at once instead of after the + // stream has trickled three bodies in. + for (let i = 0; i < TUNE.drillSeed; i++) { + const zb = spawnDrillBody(d); + if (zb) zb.z += i * TUNE.drillSeedGap; + } + newRound(d); // called HERE, not on the first update: the mode has to be whole the moment it starts, + // or the strip draws empty for a frame and anything reading seq straight after + // startDrill() sees a drill with no round in it. } -// THE ROUND CLOCK. Drill bodies carry v = 0 so the ordinary shamble in updateZombies leaves them -// alone; everything else in that loop — the ball contact test, the instant-death contact test — -// still runs against wherever this puts them, which is exactly the split we want. export function updateDrill(dt) { const d = G.drill; if (!d) return; + d.t += dt; if (d.msg) d.msg.t -= dt; - if (d.beat > 0) { if ((d.beat -= dt) <= 0) newDrillRound(); return; } - // THE EMPTY-COURT NET. A drill round is only ever ended by drillWon/drillLost, both of which set - // `beat` — so a live round with nothing bibbed on court means something OUTSIDE the drill emptied - // it. I saw this exactly once while wiring the mode up (a warm-up had left the ordinary schedule's - // latches loaded) and could not reproduce it afterwards, which is precisely the kind of bug that - // comes back in front of a player. Unrepaired it is a dead mode: no targets, no timer, nothing to - // tap. Deal a fresh round and leave evidence, same contract as watchdog()'s invariant net. - // It goes through the ordinary between-rounds BEAT rather than dealing on the spot, and that is - // the load-bearing half: dealing here directly makes the repair rate the FRAME RATE, so a court - // that keeps coming up empty redeals sixty times a second — and every deal calls the umpire. - // Through the beat, the worst case is one redeal every drillBeat seconds, which is a mode that - // limps instead of a phone screaming at you. - if (!zombies.some(z => z.bib !== undefined)) { - logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealing' }); - d.beat = TUNE.drillBeat; - return; - } - if (d.callT > 0) { // the walk-on: they take their marks - d.callT -= dt; - for (const zb of zombies) { - if (!zb.mark) continue; - const dz = zb.mark.z - zb.z, dx = zb.mark.x - zb.x; - const step = TUNE.drillWalkOn * dt; - zb.z += Math.sign(dz) * Math.min(Math.abs(dz), step); - zb.x += Math.sign(dx) * Math.min(Math.abs(dx), step * 0.7); // they drift into the shape, not snap to it - zb.dist = (zb.dist || 0) + Math.min(Math.abs(dz), step); - zb.walkT += dt; - } - return; - } - const v = TUNE.drillSpeed * (1 + (d.won + d.lost) * TUNE.drillSpeedPerRound) * (d.rush > 0 ? TUNE.drillRush : 1); - if (d.rush > 0) d.rush -= dt; - for (const zb of zombies) { - if (!zb.drill) continue; - zb.z += v * dt; zb.dist = (zb.dist || 0) + v * dt; zb.walkT += dt; - } + // the stream never stops — this is the whole mode + if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); } + // A round is owed whenever there isn't one: at the start, after a completion, after a double + // fault, and after everything named was killed by something else. This one line replaces the whole + // empty-court watchdog the round-based version needed — with a stream there is no state to be + // stuck in, because the next round is always just "whoever is out there now". + if (!d.seq.length) newRound(d); + // (movement is updateZombies' ordinary forward step: drill bodies carry a real zb.v now, so they + // shamble in like anything else and their stride animates off distance covered for free. The first + // design drove them by hand because they had marks to stop at. Nothing stops any more.) } -// A drill body went down. The ONE rule of the mode, and it is called from killZombie — the single -// kill funnel — so it cannot be missed by a weapon path (the bug this codebase has had twice). +// 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++; sfx('pock', 0.7); - if (d.step >= d.seq.length) return drillWon(); + 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 — a decoy, or one that was due later. A body killed out of turn is still DEAD, so - // drop it from the list rather than leaving a slot nobody can ever fill: an un-winnable round - // reads as the game being broken, and the fault below is the punishment already. + // 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); - d.rush = TUNE.drillRushT; // and the rest of them lose patience — the cost is TEMPO - if (d.faults >= 2) return drillLost('double_fault', 'DOUBLE FAULT'); + 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.4, bad: true }; - if (d.step >= d.seq.length) drillWon(); // the wrong kill happened to be the last one outstanding + 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, which is the single death path for BOTH damage -// channels — so this intercepts every way the round can end badly, in one place. -// NOTE, and it is a real design decision rather than an oversight: contact here costs the POINT, -// not the run. Permadeath is settled for the gore mode and it should stay there; a memory game -// where one lapse of concentration deletes a thirty-minute run punishes the exact skill it is -// trying to teach, and the retry loop is the whole reason a drill is fun. +// 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() { - drillLost('game_opponent', 'CAUGHT'); -} - -function drillWon() { - const d = G.drill; - d.won++; - voice(d.faults ? 'game_player' : 'unbelievable', 0.15); - d.msg = { text: d.faults ? 'GAME' : 'CLEAN SHEET', t: 1.8 }; - d.beat = TUNE.drillBeat; - zombies.length = 0; -} - -function drillLost(line, text) { const d = G.drill; d.lost++; - voice(line, 0.1); - d.msg = { text, t: 1.8, bad: true }; - d.beat = TUNE.drillBeat; + 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 the shot is allowed to lock onto — see flickTarget. During the call nothing is a -// target yet (you cannot serve then either). -export const drillTarget = () => { - const d = G.drill; - if (!d || d.callT > 0 || d.beat > 0) return null; - return zombies.find(z => z.bib === d.seq[d.step]) || null; -}; +// 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; diff --git a/src/gameplay.js b/src/gameplay.js index e0d9714..1bb23d3 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1026,7 +1026,6 @@ function tryLaser() { export function dragServe(aim) { if (G.mode !== 'splatter' || score.server !== 'P') return; if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle - if (G.drill && (G.drill.callT > 0 || G.drill.beat > 0)) return; // ...and the drill's call is not play if (G.phase === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return; if (tryArmGun()) return; @@ -1087,7 +1086,6 @@ export function keyPressed(code) { } if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves if (G.changeover) return; // the breather is mandatory — the melee swish above still works - if (G.drill && (G.drill.callT > 0 || G.drill.beat > 0)) return; // let the umpire finish the list if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } diff --git a/src/render.js b/src/render.js index 551d707..abc13e8 100644 --- a/src/render.js +++ b/src/render.js @@ -742,12 +742,12 @@ function drawDrillHud() { ctx.globalAlpha = 1; } ctx.textAlign = 'center'; - if (d.callT > 0) { ctx.font = B(15); shadowText('IN ORDER, PLEASE', W / 2, y - 6, UI.cream); } - else if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + h + 20, d.msg.bad ? UI.bad : UI.pick); } + if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + h + 22, d.msg.bad ? UI.bad : UI.pick); } // the round tally, quiet register, TUCKED UNDER THE STRIP — floated lower it sat in the middle - // of the crowd with nothing to belong to + // of the crowd with nothing to belong to. (No 'IN ORDER, PLEASE' any more: there is no + // announcement beat to caption — the rounds run continuously and the strip is always live.) ctx.font = F(8); - shadowText(d.won + ' - ' + d.lost, W / 2, y + h + 5, UI.dim); + shadowText(d.rounds + ' - ' + d.lost, W / 2, y + h + 5, UI.dim); } // The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk diff --git a/src/zombies.js b/src/zombies.js index 5c2c8bd..4b14432 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -1082,6 +1082,13 @@ export function updateZombies(dt) { } else if (held || zb.variant === 'r') { /* nothing moves on a changeover; a duelling opponent owns his x */ } else if (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) { bossAI(zb, dt); bossAdds(zb, dt); } } + // A DRILL BODY HOLDS ITS LANE. The homing below is a gore-mode behaviour — bodies converge on + // your x as they cross the net — and in the drill it undoes the spacing at exactly the moment + // spacing matters most: they arrive nicely spread and then bunch into an unreadable knot right in + // front of you, which is the "space them not too close" note coming back downstream. Their lane + // IS the puzzle here (it decides what screens what), so they walk it straight in with nothing but + // the wobble. They still reach you — the lane runs past the baseline — so the threat is unchanged. + else if (zb.drill) zb.x += Math.sin(zb.walkT * 1.7) * 0.12 * dt; else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column const tx = P.x + zb.homeOff; zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt) diff --git a/test.js b/test.js index 580c77c..820c70a 100644 --- a/test.js +++ b/test.js @@ -273,22 +273,15 @@ test('a burst fires through the beat that follows the kill that emptied the cour // ---- the coaching drill: a new mode, so it owes the ownership contract a test ------------------ -// Drive a drill round past its announcement: no serving and no targets while the umpire reads. -function drillPlaying({ bodies } = {}) { - // Pin the court size where a test needs a guaranteed decoy (bodies > the called sequence). The - // count is a RANGE in play, so an unpinned round can legitimately deal no decoy at all. - const min = TUNE.drillBodiesMin, max = TUNE.drillBodies; - if (bodies) { TUNE.drillBodiesMin = TUNE.drillBodies = bodies; } +// Run the stream for a while. There is no announcement beat and no between-rounds pause any more — +// a round is always live once two bodies are on court. +function drillPlaying(seconds = 1) { startDrill(); - TUNE.drillBodiesMin = min; TUNE.drillBodies = max; - for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT); + for (let i = 0; i < seconds * 60; i++) update(DT); } -// kill a body the way every weapon does — through the shared funnel, via the ball's contact test -// A shambler soaks TUNE.shamblerHp splat-quality hits, so this re-arms the ball until the body is -// actually down — the drill keeps the roster's toughness on purpose (Oliver: "some are easier, some -// more difficult to kill"), which means a round is paced by hp as well as by order. -// Returns false when the body is already down — a ball carries through the lane, so a shot at one -// body can take the one standing behind it (see PROJECT.md's open question 1). +// kill a body the way every weapon does — through the shared funnel, via the ball's contact test. +// Returns false when it is already down: a ball carries through the lane, so a shot at one body can +// take the one behind it (see PROJECT.md's open question). const drillKillBib = bib => { const zb = zombies.find(z => z.bib === bib); if (!zb) return false; @@ -319,44 +312,88 @@ test('a drill refuses to start while another mode owns the court', () => { assert.equal(G.drill, null, 'and leave no half-built round behind'); }); -test('the call is not play: no target and no serving until the umpire finishes', () => { +test('the stream never stops, is spaced, and a round is always live', () => { startDrill(); - assert.ok(G.drill.callT > 0, 'the walk-on is running'); - assert.equal(drillTarget(), null, 'nothing is a legal target during the call'); - assert.ok(zombies.length >= TUNE.drillBodiesMin && zombies.length <= TUNE.drillBodies, - 'the whole court is placed up front, ' + TUNE.drillBodiesMin + '..' + TUNE.drillBodies + ' of them'); - assert.equal(new Set(zombies.map(z => z.bib)).size, zombies.length, 'every bib on court is distinct'); - for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT); - assert.ok(drillTarget(), 'and the first target arms when the call ends'); - assert.equal(drillTarget().bib, G.drill.seq[0], 'which is the first colour called'); + assert.ok(zombies.length >= 2, 'seeded, so the first round is callable at once'); + assert.ok(G.drill.seq.length >= 2, 'and a round is already called'); + const seeded = zombies.length; + // 18 s, deliberately short of the ~28 s it takes a body to walk the court: a test that never + // shoots WILL eventually be caught, and drillCaught sweeps the court — which is correct behaviour + // and would make this look like the stream had stalled. + for (let i = 0; i < 18 * 60; i++) update(DT); + assert.ok(zombies.length > seeded, 'bodies keep arriving: ' + seeded + ' -> ' + zombies.length); + assert.ok(zombies.length <= TUNE.drillMaxOnCourt, 'never past the court cap'); + assert.equal(new Set(zombies.map(z => z.bib)).size, zombies.length, 'a colour is on court at most once'); + // nothing rushes to a mark and stops: every body carries a real speed and keeps it + assert.ok(zombies.every(z => z.v > 0), 'every body is still walking'); + // SPACING HAS TO SURVIVE THE WALK IN, not just the spawn. Drill bodies hold their lane (the + // gore mode's homing onto the player's x bunched them into a knot right in front of you), so + // this checks every body on court, not only the ones that just arrived. + for (let i = 0; i < zombies.length; i++) for (let j = i + 1; j < zombies.length; j++) + assert.ok(Math.abs(zombies[i].x - zombies[j].x) > 0.3 || Math.abs(zombies[i].z - zombies[j].z) > 0.8, + 'two bodies ended up on top of each other'); + // and the lane a body walks in on is the lane it keeps. Tracked by IDENTITY, not by index — + // the array shifts as bodies cross the baseline and despawn, so zombies[i] is not the same body + // six seconds later (this compared two different zombies and read as a 1.5 m drift). + const lanes = new Map(zombies.map(z => [z, z.x])); + for (let i = 0; i < 6 * 60; i++) update(DT); + for (const z of zombies) if (lanes.has(z)) + assert.ok(Math.abs(z.x - lanes.get(z)) < 0.5, + 'a drill body drifted out of its lane: ' + lanes.get(z).toFixed(2) + ' -> ' + z.x.toFixed(2)); }); -test('killing in the called order wins the round; the tennis ladder stays out of it', () => { +test('the stream ramps: later arrivals are faster, come sooner, and the round gets longer', () => { + // eatenR 0 makes the player untouchable — the established soak trick (PROJECT.md). Without it a + // test that never shoots gets caught, the court is swept, and the measurement is of the reset. + const eaten = TUNE.eatenR; TUNE.eatenR = 0; + try { + startDrill(); + const early = []; + for (let i = 0; i < 20 * 60; i++) { update(DT); for (const z of zombies) if (!z.__seen) { z.__seen = 1; early.push(z.v); } } + const earlyRound = G.drill.seq.length; + G.drill.t = TUNE.drillRampT; // jump to the top of the ramp + const late = []; + for (let i = 0; i < 20 * 60; i++) { update(DT); for (const z of zombies) if (!z.__seen) { z.__seen = 1; late.push(z.v); } } + const avg = a => a.reduce((x, y) => x + y, 0) / Math.max(1, a.length); + assert.ok(late.length > early.length, 'they arrive more often: ' + early.length + ' -> ' + late.length); + assert.ok(avg(late) > avg(early) * 1.5, 'and they walk faster: ' + avg(early).toFixed(2) + ' -> ' + avg(late).toFixed(2)); + G.drill.seq = []; update(DT); // draw a round at the top of the ramp + assert.ok(G.drill.seq.length > earlyRound, + 'and the round is longer: ' + earlyRound + ' -> ' + G.drill.seq.length); + } finally { TUNE.eatenR = eaten; } +}); + +test('finishing a round rolls straight into the next one — no pause, no deal', () => { drillPlaying(); - const seq = G.drill.seq.slice(), games = score.games.slice(), pts = score.pts.slice(); + const seq = G.drill.seq.slice(); for (const bib of seq) drillKillBib(bib); - assert.equal(G.drill.won, 1, 'the round is won'); - assert.equal(G.drill.faults, 0, 'cleanly'); - assert.deepEqual(score.games, games, 'the drill does not bank tennis games'); - assert.deepEqual(score.pts, pts, '...nor tennis points — it keeps its own count'); - for (let i = 0; i < TUNE.drillBeat * 60 + 4; i++) update(DT); - assert.ok(zombies.length > 0, 'and the next round places a fresh court'); + assert.ok(G.drill.rounds >= 1, 'the round is banked'); + assert.ok(G.drill.seq.length >= 2, 'and the next one is ALREADY called, in the same frame'); + assert.equal(G.drill.step, 0, 'from the top'); + assert.ok(zombies.length > 0, 'off the bodies still walking at you — the court is never cleared'); }); -test('two wrong bodies is a double fault, and the round goes to them', () => { - drillPlaying({ bodies: 5 }); // a full court, so a decoy is guaranteed - // a colour the sequence never called is a DECOY — the body you must let walk at you +test('the drill keeps its own count; the tennis ladder stays out of it', () => { + drillPlaying(); + const games = score.games.slice(), pts = score.pts.slice(); + for (const bib of G.drill.seq.slice()) drillKillBib(bib); + assert.deepEqual(score.games, games, 'the drill does not bank tennis games'); + assert.deepEqual(score.pts, pts, '...nor tennis points'); +}); + +test('two wrong bodies is a double fault, and the round is scrubbed', () => { + drillPlaying(); const decoy = zombies.find(z => !G.drill.seq.includes(z.bib)); - assert.ok(decoy, 'setup: the court carries at least one decoy'); + assert.ok(decoy, 'setup: the court carries a body that was not called'); drillKillBib(decoy.bib); - assert.equal(G.drill.faults, 1, 'a decoy is a fault'); + assert.equal(G.drill.faults, 1, 'an uncalled body is a fault'); assert.equal(G.drill.lost, 0, 'but not the round'); - assert.ok(G.drill.rush > 0, 'the rest lose patience — the punishment is tempo'); const wrong = zombies.find(z => z.bib !== G.drill.seq[G.drill.step]); - assert.ok(wrong, 'setup: something is still out of turn'); - drillKillBib(wrong.bib); - assert.equal(G.drill.lost, 1, 'the second one takes the round off you'); - assert.equal(G.drill.won, 0); + if (wrong) { + drillKillBib(wrong.bib); + assert.equal(G.drill.lost, 1, 'the second one takes the round off you'); + assert.equal(G.drill.faults, 0, 'and a fresh round is called with a clean slate'); + } }); test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => { @@ -370,59 +407,27 @@ test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => { assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it'); }); -test('rounds vary: which colours turn up, how many, and where they stand', () => { - startDrill(); - const seen = { cols: new Set(), counts: new Set(), shapes: new Set() }; - for (let r = 0; r < 40; r++) { - for (const z of zombies) seen.cols.add(z.bib); - seen.counts.add(zombies.length); - seen.shapes.add(G.drill.shape); - G.drill.beat = 0.001; update(DT); update(DT); // deal the next round - } - assert.ok(seen.cols.size > 4, 'the palette is bigger than any one court: ' + seen.cols.size + ' colours seen'); - assert.ok(seen.counts.size > 1, 'the body count moves: ' + [...seen.counts]); - assert.ok(seen.shapes.size > 2, 'the placement shape moves: ' + [...seen.shapes]); -}); - test('a wrong kill never leaves an un-winnable round', () => { - drillPlaying({ bodies: 5 }); - const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first - drillKillBib(later); - assert.equal(G.drill.faults, 1, 'out of turn is a fault'); - assert.ok(!G.drill.seq.includes(later), 'and the dead colour leaves the list rather than blocking it'); - assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)), - 'every colour still on the list is still on the court'); + drillPlaying(); + const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first + if (G.drill.seq.length > 1 && drillKillBib(later)) { + assert.ok(!G.drill.seq.includes(later), 'the dead colour leaves the list rather than blocking it'); + assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)), + 'every colour still on the list is still on the court'); + } }); -test('being caught costs the point, not the run', () => { +test('being caught costs the point and the court, not the run', () => { drillPlaying(); + const t0 = G.drill.t; killPlayer(); assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode'); - assert.equal(G.drill.lost, 1, 'they take the round instead'); - assert.equal(zombies.length, 0, 'and the court is cleared for the next one'); -}); - -test('an emptied court cannot machine-gun the umpire', () => { - // Oliver, on a phone: "the drill mode immediately goes into some horrible audio loop". The trigger - // was never reproduced on desktop (a suspended AudioContext has a frozen clock, so a scheduled - // sequence all fires at once on resume — see voiceSeq), but the AMPLIFIER was here: the - // empty-court net used to redeal on the spot, which makes the repair rate the frame rate, and - // every deal calls the umpire. Whatever empties the court, it must never announce more than once - // per beat. - drillPlaying(); - let deals = 0; - const seq0 = G.drill.seq; - for (let i = 0; i < 60 * 6; i++) { // six seconds with the court emptied every single frame - zombies.length = 0; - update(DT); - if (G.drill.seq !== seq0 && G.drill.callT > 0 && zombies.length) { deals++; } - if (zombies.length) zombies.length = 0; // ...and emptied again, so it can never settle - } - const repairs = G.missLog.filter(m => m.result === 'INVARIANT').length; - assert.ok(repairs > 0, 'the net notices and leaves evidence'); - assert.ok(repairs <= 6 / TUNE.drillBeat + 2, - 'at most one repair per drillBeat, not one per frame — got ' + repairs); - assert.ok(deals <= 6 / TUNE.drillBeat + 2, 'and at most one deal (one umpire call) per beat — got ' + deals); + assert.equal(G.drill.lost, 1, 'they take the point instead'); + assert.equal(zombies.length, 0, 'the court is swept — the one thing that interrupts the stream'); + assert.ok(G.drill.t < t0, 'and the ramp backs off'); + for (let i = 0; i < 10 * 60; i++) update(DT); + assert.ok(zombies.length > 0, 'then the stream simply resumes'); + assert.ok(G.drill.seq.length >= 2, 'and a round is live again'); }); test('endDrill hands the court back', () => {