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

+75 -1
View File
@@ -5,6 +5,7 @@ import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, P
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
import { DRILL_COLS } from './drill.js';
// ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them.
@@ -662,6 +663,7 @@ function drawZombie(zb) {
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
if (zb.bib !== undefined) drawBib(zb, h, bob);
if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.bossWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the design's own wind
@@ -679,6 +681,77 @@ function drawZombie(zb) {
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
}
// THE TRAINING BIB — the drill's colour, worn. ponytail: this is a DRAWN OVERLAY, and it knowingly
// breaks the house rule that says add sprite frames rather than fake them (see PROJECT.md). It is
// here because the mode is a feel-test and six bib sheets x every walk frame is a week of art to
// answer a question a rectangle answers today. If the drill survives its playtest, this function is
// the thing to delete: the shipping version is a real bib sprite per colour, drawn like a wound skin.
//
// It is a TRAPEZOID, not a rectangle — a flat bar over a walking body reads as a UI element stuck to
// the screen; narrowing at the shoulders reads as cloth on a torso. And it carries PIPS as well as a
// hue: about 8% of men cannot separate the red from the green, and a mode whose entire input is
// "which colour is that" owes them a second channel.
function drawBib(zb, hMeters, bob) {
const C = DRILL_COLS[zb.bib]; if (!C) return;
const p = proj(zb.x, 0, zb.z); if (p.s <= 0) return;
const h = hMeters * p.s;
const y0 = p.y - h * 0.68 - bob, y1 = p.y - h * 0.42 - bob; // chest to waist
// SHOULDERS WIDER THAN WAIST. The first cut flared the other way and every body was wearing a
// sandwich board — a shape that widens as it goes down is furniture, not cloth on a torso.
const wTop = h * 0.185, wBot = h * 0.155;
const cx = Math.round(p.x);
ctx.fillStyle = C.col;
ctx.beginPath();
ctx.moveTo(cx - wTop, y0); ctx.lineTo(cx + wTop, y0);
ctx.lineTo(cx + wBot, y1); ctx.lineTo(cx - wBot, y1);
ctx.closePath(); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.30)'; // a seam, so it sits ON the body
ctx.fillRect(cx - wTop, Math.round(y0), wTop * 2, Math.max(1, h * 0.012));
// the pips: the redundant channel, big enough to count at a glance and never more than four
const r = Math.max(0.8, h * 0.026), gap = r * 2.6;
ctx.fillStyle = C.ink;
for (let i = 0; i < C.pips; i++) {
const px = cx + (i - (C.pips - 1) / 2) * gap;
ctx.beginPath(); ctx.arc(px, (y0 + y1) / 2, r, 0, 7); ctx.fill();
}
}
// THE CALLED LIST, on screen. Oliver's call: the umpire reads it aloud AND it shows as a pattern —
// audio is the channel that makes the mode this game's own, the strip is what stops a missed word
// costing a round. Chips left to right, the done ones struck through and dimmed, so the strip
// doubles as "where am I" and never has to be read from the start again mid-round.
function drawDrillHud() {
const d = G.drill; if (!d) return;
const n = d.seq.length; if (!n) return;
const w = 26, gap = 4, totalW = n * w + (n - 1) * gap;
let x = Math.round(W / 2 - totalW / 2);
const y = CARD_TOP + 6, h = 22;
for (let i = 0; i < n; i++, x += w + gap) {
const C = DRILL_COLS[d.seq[i]], done = i < d.step;
ctx.globalAlpha = done ? 0.28 : 1;
ctx.fillStyle = 'rgba(6,4,6,0.8)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2);
ctx.fillStyle = C.col; ctx.fillRect(x, y, w, h);
ctx.fillStyle = C.ink; // the same pips as the bib
const r = 2, gp = r * 2.8;
for (let k = 0; k < C.pips; k++) {
const px = x + w / 2 + (k - (C.pips - 1) / 2) * gp;
ctx.beginPath(); ctx.arc(px, y + h / 2, r, 0, 7); ctx.fill();
}
if (i === d.step) { // the one you owe them right now
ctx.strokeStyle = UI.cream; ctx.lineWidth = 2;
ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
}
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); }
// the round tally, quiet register, TUCKED UNDER THE STRIP — floated lower it sat in the middle
// of the crowd with nothing to belong to
ctx.font = F(8);
shadowText(d.won + ' - ' + 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
// well, a bevel, and a fill that runs gold -> orange -> red as the pool empties, so the colour says
// how the fight is going without reading the length. Integer-snapped and a FIXED pixel size — this
@@ -1931,7 +2004,8 @@ export function render() {
}
ctx.restore();
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
if (G.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel)
if (G.mode === 'splatter' && !G.interlude && !G.drill) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
drawDrillHud(); // the called list — no-op unless a drill is running
// phase C: the summary fades UP over the death animation instead of cutting on top of it
if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) {
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);