The rounds stay, the seams go: one continuous stream

Oliver: "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".

The round survives as the unit. Every seam around it is deleted: no walk-on
to a mark, no announcement beat, no between-rounds pause, no deal. Bodies
walk in from the far baseline forever and a round is drawn from whoever is on
court — finish one and the next is drawn in the same frame, off the same
rolling population. The bodies you were ignoring a second ago are the ones
being called now.

- Daniel is gone from this mode. Each colour has a pitch; a round announces
  itself as a four-note figure in half a second (blip()). drillTones 0 mutes
  it. The recordings stay in assets.
- PLACEMENTS is deleted with the marks — nothing rushes anywhere and nothing
  stops, so there are no shapes to place.
- A DRILL BODY HOLDS ITS LANE. updateZombies homes bodies onto the player's x
  past the net, so they arrived spread and bunched into a knot right in front
  of you; spawn spacing cannot survive that. Their lane is the puzzle here.
- One ramp on TIME SURVIVED drives speed, spawn gap and round length. Speed
  is stamped per body at spawn, so the ramp reads as newcomers outpacing the
  ones already walking, not the court accelerating under you.
- The empty-court watchdog is gone and nothing replaced it: with a stream
  there is no state to be stuck in. One line covers every case.

27 tests, rewritten around the stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 19:06:48 +02:00
1 parent 435a470ec7
commit 1d49c4343b
9 files changed
+281 -282

No files matched your search

+1 -1
View File
@@ -399,6 +399,6 @@
</div>
</div>
</div>
<script type="module" src="src/main.js?v=154"></script>
<script type="module" src="src/main.js?v=155"></script>
</body>
</html>
+16
View File
@@ -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;
+3 -3
View File
@@ -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;
}
+20 -21
View File
@@ -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
+142 -168
View File
@@ -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;
-2
View File
@@ -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(); }
+4 -4
View File
@@ -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
+7
View File
@@ -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)
+88 -83
View File
@@ -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');
if (wrong) {
drillKillBib(wrong.bib);
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
assert.equal(G.drill.won, 0);
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 });
drillPlaying();
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');
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', () => {