The umpire reads a list, and the bibs come out to meet it

The late game is repetitive because the optimal move never changes: kill the
nearest thing, now. This is the first swing at a mode where it does — a
coaching drill where the umpire CALLS a colour sequence aloud while bibbed
bodies take their marks, and you must put them down in that order while they
walk at you. Uncalled colours are decoys you have to ignore. Two faults is a
double fault and the round is theirs.

Back room only (DRILL TEST, or #drill): this is a feel-test for one question —
tense, or just noisy? — so it keeps its own round count and touches neither
tennis scoring nor the run.

- voiceSeq: voice() drops a line that would start while the umpire is still
  talking, which is fatal for a list. Decode first, queue at accumulated
  offsets, and the anti-overlap rule is satisfied instead of fought.
- Four colours with PIPS as well as hue — red/green alone makes the mode
  unplayable for ~8% of men.
- drawBib is a ponytail-marked overlay that knowingly breaks the sprite rule;
  it is the thing to delete if the mode survives.
- BOTH target pickers snap (flickTarget and zombieTarget). Snapping only the
  slingshot left fifteen tap-serves in a row scoring nothing.
- Contact costs the point, not the run — permadeath stays in the gore mode.
- startDrill disarms the ordinary schedule; updateDrill redeals and logs an
  INVARIANT if anything empties its court.

Eight new tests (24 total) pin the ownership contract both ways, the call
beat, the win, the double fault, the un-winnable-round guard and the death.
PROJECT.md carries the four open questions for the playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 18:01:33 +02:00
1 parent c659b6882f
commit 22df96cc1d
17 files changed
+556 -19

No files matched your search

+29 -2
View File
@@ -5,6 +5,7 @@ import { P, O, ball, score, crowd, G, court, other, deuceSide, setupPoint, sendB
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateDrill, drillTarget } from './drill.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
@@ -72,6 +73,16 @@ function dustPuff(x, z, n = 5, sp = 1) {
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
// THE DRILL SNAPS. Ordinary splatter wants a forgiving magnet (one thumb, a moving crowd); the
// drill's whole subject is WHICH body, so the magnet must never pick one for you — it locks to
// the called target and to nothing else. The shot can still be BLOCKED by a body standing in the
// lane (updateZombies hits the first thing the ball touches), which is the footwork half of the
// mode and is deliberately left alone: sidestep to open the angle.
if (G.drill) {
const zt = drillTarget();
if (!zt) return { q, f, tx: ch.x + aim.fx * TUNE.aimReach, tz: ch.z + aim.fy * TUNE.aimReach };
return { q, f, zt, tx: zt.x, tz: zt.z };
}
const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z
let zt = null, near = 1e9, bestErr = 1e9;
for (const zb of zombies) {
@@ -101,6 +112,13 @@ export function flickTarget(ch, aim) { // exported: render draws the live reticl
// anywhere on the court — including your own side of the net.
function zombieTarget(aim) {
if (!zombies.length) return null;
// THE DRILL SNAPS HERE TOO. flickTarget (the slingshot) and this (the plain tap) are the two ways
// a splatter shot ever picks a body, and they are separate functions — snapping only the first one
// left every tap-fired serve in the drill aiming at whatever zombieTarget liked, which in practice
// was nothing at all: fifteen serves in a row scored zero. Unconditional, and ABOVE the
// no-direction guard: in this mode a bare tap is a legal shot at the called body, because the
// subject is which body comes next and not which lane you can find it in.
if (G.drill) return drillTarget();
const none = !aim || (!aim.ax && !aim.az);
if (none) return null; // no direction, no lock. Free predictive auto-aim was the reason a
// bare rhythm of taps could clear the court without any aiming at all.
@@ -471,6 +489,8 @@ function pointTo(side, reason) {
const blocksClear = z => !'rmo'.includes(z.variant);
export function splatPoint(side, quiet = false) {
if (G.drill) return; // the drill keeps its own round count — see drill.js. Routing it through here
// would arm the barrel/machine/boss schedule inside a mode that has no wave.
// The duel is one clean tennis point and resolveInterlude is its ONLY exit. A machine or leftover
// killed mid-duel used to score here, and a game banked mid-duel re-entered proceedAfterClear —
// which can start a boss, a card, or a horde while the duel still owns the court.
@@ -1017,6 +1037,7 @@ 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;
@@ -1057,7 +1078,9 @@ export function keyPressed(code) {
audio(); // unlock on first gesture
// a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the
// default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool.
if (G.mode === 'splatter' && G.meleeT <= 0) {
if (G.mode === 'splatter' && G.meleeT <= 0 && !G.drill) { // no swish in the drill: it cleaves whatever
// is adjacent, which is the mode picking
// your targets for you
// measure the crowd BEFORE the swipe — it kills everything in reach, so counting afterwards
// counts the SURVIVORS and the cooldown barely moved no matter how swarmed you were
const cool = meleeCooldown();
@@ -1075,6 +1098,7 @@ 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(); }
@@ -1330,7 +1354,10 @@ export function update(dt) {
// --- zombies + transition timeline
if (G.mode !== 'tennis') updateZombies(dt);
else updateParts(dt); // tennis has no zombies but its bounces still kick up dust that must settle
else updateParts(dt);
updateDrill(dt); // the coaching drill's round clock. It MOVES its bodies (they carry v = 0, so
// updateZombies' shamble skips them) and updateZombies still runs the contact
// and ball-hit tests against wherever this put them — that split is the design. // tennis has no zombies but its bounces still kick up dust that must settle
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
if (G.meleeT > 0) G.meleeT -= dt;
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out