The roster is the alphabet: the sequence is the zombies themselves

Oliver's insight, and it retires a whole subsystem: "we don't need colors at
all! we already have different zombie types.. so the sequence is the zombies
themselves".

The bib and then the ground disc were both inventions bolted on to give a
body an identity it already had. A brute does not look like a runner, the
game is built on reading those silhouettes at speed, and the strip can show
the body's own walk frame instead of a swatch — no legend, no mapping, no new
art ever. It also deletes the accessibility problem outright rather than
deferring it, because a silhouette is not a hue. DRILL_COLS, drawColourRing,
drawBib, PIP_FACES, blip(), drillRingR and drillTones are all gone.

Two rules keep a type unambiguous: the plain shambler is never in the
alphabet (it is what the eaters are, so "not the puzzle" needs no marking),
and one of each type is on court at a time.

The new failure mode this created is instructive. Types move at different
speeds and drill bodies hold their lane, so one in a lane you are not in
walks the whole court and off the back untouched — stranding the sequence
asking for a body that no longer existed, i.e. the un-winnable state the mode
exists to prevent, reintroduced by a change that looked cosmetic. A batch
body crossing your baseline counts as caught now ("GOT PAST YOU"), which is
also just better design, and the speed spread is narrowed to 1.35..0.70 so a
batch stays coherent. Their real difference is HP and that is untouched: a
runner is one shot, a roller is seven.

Verified: 14400 frames of simulation, zero frames where the sequence named a
body that was not on court.

29 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 20:24:58 +02:00
1 parent 3379e83efc
commit 5b2f1d55ce
7 files changed
+197 -145

No files matched your search

+23 -48
View File
@@ -5,7 +5,6 @@ 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,7 +661,6 @@ function drawZombie(zb) {
// Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the
// 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.
if (zb.bib !== undefined) drawColourRing(zb); // BEFORE the sprite: he stands ON it
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
@@ -681,54 +679,34 @@ function drawZombie(zb) {
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
}
// THE COLOUR IS A DISC ON THE COURT, and the body walks on it. It was a bib worn on the chest and
// Oliver's verdict was "the sign they're holding is not nice" — which it was: a flat quad pinned to
// a walking sprite reads as UI stuck onto a character rather than as part of the world, and it fought
// the artwork it was covering.
//
// On the ground it is better on every count. It sits in the court's own perspective, so it shrinks
// and squashes with distance like everything else; it is far LARGER in pixels than a chest patch at
// the same depth, which is what fixes the far-baseline readability the die-face dots could never
// solve; and it hides none of the sprite. The obvious risk — a coloured disc reading as another gore
// decal — is answered by the ring: gore is a soft flat blob, this has a hard bright rim and a lit
// centre, so it reads as painted court marking rather than as something spilled.
//
// ponytail: still procedural rather than art, and that is right here — this is a court MARKING, the
// same family as the tramlines, not a character. It is not the sprite rule being bent twice.
function drawColourRing(zb) {
const C = DRILL_COLS[zb.bib]; if (!C) return;
const p = proj(zb.x, 0, zb.z); if (p.s <= 0) return;
const rx = p.s * TUNE.drillRingR, ry = rx * 0.34; // squashed by the camera, like every ground oval
ctx.save();
ctx.globalAlpha = 0.92;
ctx.fillStyle = C.col;
ctx.beginPath(); ctx.ellipse(p.x, p.y, rx, ry, 0, 0, 7); ctx.fill();
// a dark rim, then a bright inner line: two edges are what stop it reading as a puddle, and they
// survive being drawn over clay, grass and blood alike
ctx.globalAlpha = 0.75;
ctx.lineWidth = Math.max(1, p.s * 0.012);
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.beginPath(); ctx.ellipse(p.x, p.y, rx, ry, 0, 0, 7); ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.ellipse(p.x, p.y, rx * 0.72, ry * 0.72, 0, 0, 7); ctx.stroke();
ctx.restore();
}
// 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.
// THE CALLED LIST, on screen — and it is the ROSTER now, not a palette. Each chip is the body's own
// walk frame, which is the same picture you are reading on the court: no legend to learn, no colour
// to map, and no new art. Done ones dim and the next wears the cream frame, so the strip doubles as
// "where am I" and never has to be re-read from the start 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;
// Sized to the widest case the ramp can deal: six chips at 30 + five 3px gaps = 195 px, inside a
// 240 px portrait canvas with room either side. A silhouette is the whole readout here, so the
// chips take every pixel the band can spare — at 26 the steward, the ball boy and the bouncer were
// three similar green figures.
const w = 30, gap = 3, totalW = n * w + (n - 1) * gap;
let x = Math.round(W / 2 - totalW / 2);
const y = CARD_TOP + 6, h = 22;
const y = CARD_TOP + 4, h = 34;
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);
const done = i < d.step;
ctx.globalAlpha = done ? 0.3 : 1;
ctx.fillStyle = 'rgba(8,6,10,0.82)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2);
ctx.fillStyle = 'rgba(30,26,34,0.9)'; ctx.fillRect(x, y, w, h);
// the body itself, fitted into the chip on its own aspect — a roller is wide, a runner is not,
// and that difference is exactly the thing being read
const img = SPR[SK('z_' + d.seq[i] + '1')];
if (img) {
const pad = 2, bw = w - pad * 2, bh = h - pad * 2;
const sc = Math.min(bw / img.width, bh / img.height);
const iw = Math.round(img.width * sc), ih = Math.round(img.height * sc);
ctx.drawImage(img, Math.round(x + (w - iw) / 2), Math.round(y + h - pad - ih), iw, ih);
}
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);
@@ -737,9 +715,6 @@ function drawDrillHud() {
}
ctx.textAlign = 'center';
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. (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.rounds + ' - ' + d.lost, W / 2, y + h + 5, UI.dim);
}