From 5b2f1d55cee7cd442192e0e4c2c223d978659a76 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 11 Aug 2026 20:24:58 +0200 Subject: [PATCH] The roster is the alphabet: the sequence is the zombies themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT.md | 35 +++++++++++++++++ index.html | 2 +- src/config.js | 22 +++++------ src/drill.js | 105 +++++++++++++++++++++++++++---------------------- src/render.js | 71 +++++++++++---------------------- src/zombies.js | 13 +++++- test.js | 94 ++++++++++++++++++++++++++----------------- 7 files changed, 197 insertions(+), 145 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 5f86de3..d6b6c28 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -694,6 +694,41 @@ not so close to the color gang"*. They still converge on you afterwards, which is what they are for; this only decides where they come in, so they stop screening the bodies you are trying to read. +**NINTH PASS — THE ROSTER IS THE ALPHABET, and it deletes a whole subsystem.** Oliver: *"we don't +need colors at all! we already have different zombie types.. so the sequence is the zombies +themselves"*. He is right, and it is better on every axis the colour version was losing on. + +The bib, 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** instead of +deferring it, because a silhouette is not a hue. `DRILL_COLS`, `drawColourRing`, `drawBib`, +`PIP_FACES`, `blip()`, `drillRingR`, `drillTones` and the seven `drill_*.m4a` call recordings are +all now gone or unused — the mode is materially smaller than it was three passes ago. + +TWO RULES keep a type unambiguous and both are load-bearing: +1. **the plain shambler is never in the alphabet** — 'a'/'b' is what the EATERS are, so "not part of + the puzzle" is legible at a glance and needs no marking on anything; +2. **one of each type on court at a time**, guaranteed by the batch dealing distinct types and by + eaters only ever being shamblers. "The brute" always names exactly one body. + +**The new failure mode this created, and it is a good example of the class.** Types move at +different speeds, and drill bodies hold their lane — so a body in a lane you are not standing in +walks the full court and off the back untouched. Left as the ordinary despawn that stranded the +sequence asking for a body which **no longer existed**: precisely the un-winnable state the mode +exists to prevent, reintroduced by a change that looked purely cosmetic. Two fixes, both needed: +- **a batch body crossing your baseline counts as CAUGHT** ('GOT PAST YOU'), which is also just + better — the drill is "don't let them through" and now the lanes mean something; +- **the speed spread is narrowed to 1.35..0.70**, not the roster's own 3.2x..0.45x, so a batch stays + coherent. The types still read as different because their real difference is **HP, untouched**: a + runner is one shot and a roller is seven, so the called order still decides how early you must + start on what. Verified: four minutes of simulation, 14400 frames, **zero frames** in which the + sequence named a body that was not on court. + +Watch: **a roller in the middle of a sequence is seven aimed serves.** Oliver asked for the roster's +robustness to be kept, so it is — but if a round ever feels like grinding rather than reading, a +drill-only HP scale is the knob, not a change to the roster. + **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this diff --git a/index.html b/index.html index 9dd8435..34154a3 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/config.js b/src/config.js index 05a88fb..026a488 100644 --- a/src/config.js +++ b/src/config.js @@ -345,19 +345,19 @@ 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. THIRD DESIGN, and the one that - // makes it winnable: a BATCH of bibbed bodies is born on the far side, its combination is called - // off them immediately, and every colour on court is in that combination — no coloured decoys, so - // the strip can never name something you cannot see. Kill the last of a batch and the next walks - // in on the same frame. The danger is a separate channel: unbibbed EATERS on their own clock that - // converge on you the way ordinary shamblers do. - drillBatch: 3, // colours in a batch at the bottom of the ramp... - drillBatchMax: 6, // ...and at the top. Capped by DRILL_COLS — a colour is on court at most once + // ---- THE COACHING DRILL (src/drill.js) — the cognitive mode. THE SEQUENCE IS THE ZOMBIES + // THEMSELVES: a BATCH of distinct roster types is born on the far side and the combination is + // called off them at once, so everything the strip asks for is already in front of you. There are + // no colours, signs or markers anywhere — a brute does not look like a runner, and that is the + // whole point. The danger is a separate channel: plain shambler EATERS on their own clock, never + // in the alphabet, converging on you the way ordinary shamblers do. + drillBatch: 3, // TYPES in a batch at the bottom of the ramp... + drillBatchMax: 6, // ...and at the top. Capped by DRILL_TYPES — one of each type on court at most drillBatchDepth: 3.0, // m of depth a batch is staggered over. All of it still on the far side, // which is the promise: you see every colour before you are asked for it drillSpawnZ: 0.6, // z they enter at (the far baseline is 0) drillSpread: 3.4, // m either side of the centre line the lanes fan across - drillSpeed: 0.80, // m/s a bibbed body walks at the bottom of the ramp... + drillSpeed: 0.80, // m/s a batch body walks at the bottom of the ramp (x its type's vMul)... 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 the court accelerating // The eaters are RARE on purpose. At every 7 s / 2.2 s they were a second crowd rather than an @@ -365,13 +365,11 @@ export const TUNE = { // with only works if breaking off is occasional. They also enter wide of the colour gang // (spawnEater), because the one body out there that is neither the puzzle nor worth anything has // no business standing in front of the puzzle. - drillEaterEvery: 12.0, // s between unbibbed eaters at the bottom of the ramp... + drillEaterEvery: 12.0, // s between plain-shambler eaters at the bottom of the ramp... drillEaterMin: 5.0, // ...and at the top. Still THE difficulty dial, just a slower one drillEaterMax: 3, // eaters allowed on court at once — past this the puzzle is unreadable drillEaterSpeed: 1.15,// they walk a touch faster than the batch: the batch is a list, this is a threat drillRampT: 210, // s to go from the bottom of every curve above to the top - drillRingR: 0.62, // m — radius of the colour disc a bibbed body walks on. Big enough to read at - // the far baseline, which is the whole reason the colour left the chest drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset 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, diff --git a/src/drill.js b/src/drill.js index fa75a41..b10039b 100644 --- a/src/drill.js +++ b/src/drill.js @@ -23,29 +23,40 @@ import { G } from './state.js'; import { zombies, spawnZombie } from './zombies.js'; import { voice } from './audio.js'; -// The colours. Six, and a colour is on court at most once, so a colour always names exactly one body. +// THE ROSTER IS THE ALPHABET. Oliver's insight, and it retires an entire subsystem: *"we don't need +// colors at all! we already have different zombie types.. so the sequence is the zombies themselves"*. // -// THE MODE HAS NO SOUND OF ITS OWN, and that is now deliberate. Two attempts have been vetoed: the -// recorded umpire reading the list aloud ("daniel announcing is maximum annoying" — at ~0.8 s a word -// he needed four seconds for a list of four, over and over), and the arpeggio that replaced him -// ("no bleeps please"). What is left is the game's own vocabulary: a kill bursts and splats like -// every other kill in the game, and the strip carries the list. Do not put a third noise here -// without a reason — the two that were tried both read as the mode talking over itself. +// He is right, and it is better on every axis the colour version was struggling on. The chest sign +// 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, and the whole game is built on reading those silhouettes at +// speed. So: no colours, no signs, no discs, and no new art of any kind. It also deletes the +// accessibility problem outright rather than deferring it, because a silhouette is not a hue. // -// THERE IS NO REDUNDANT CHANNEL ANY MORE, and that is a known accessibility gap rather than an -// oversight. Die-face dots rode on the bib and the HUD chip so the mode could be played without -// separating red from green (~8% of men cannot) — but they were noise to everyone who can, and they -// shrank to nothing on a body at distance, which is exactly where the channel had to work. Cut on -// Oliver's call. If the mode ships, the answer is bibs that differ by SHAPE or marking at sprite -// scale, not dots: a channel that fails at the far baseline is not a channel. -export const DRILL_COLS = [ - { key: 'red', col: '#e5342c', name: 'RED' }, - { key: 'blue', col: '#2f6bff', name: 'BLUE' }, - { key: 'yellow', col: '#f2c318', name: 'YELLOW' }, - { key: 'green', col: '#27bd4a', name: 'GREEN' }, - { key: 'purple', col: '#9b3fd8', name: 'PURPLE' }, - { key: 'white', col: '#f2f0e8', name: 'WHITE' }, +// TWO RULES keep a type unambiguous, and both are load-bearing: +// 1. **the plain shambler is never in the alphabet.** 'a'/'b' is what the EATERS are, so "not part +// of the puzzle" is legible at a glance and needs no marking of any kind. +// 2. **one of each type on court at a time** — guaranteed by the batch dealing distinct types and +// by eaters only ever being shamblers. "The brute" always names exactly one body. +// +// `vMul` is the drill's own speed character per type, NOT spawnZombie's (which is keyed on the gore +// mode's ramp): the roller is a slow tank you have to start early, the runner is gone if you dither. +// Their HP is left at the roster's own values on purpose — Oliver: "the existing robustness of +// zombies remains (some are easier, some more difficult to kill)" — which means a roller in the +// middle of a sequence is genuinely several aimed serves. See the open questions. +// The spread is DELIBERATELY NARROW (1.35 down to 0.70, not the roster's own 3.2x-to-0.45x). A batch +// has to stay roughly coherent: at the wide spread the runner crossed the baseline while the roller +// was still in the far third, so a sequence naming the runner last could not be finished by anyone. +// The types still read as different — and their real difference is HP, which is untouched: a runner +// is one shot, a roller is seven, so the order you are given still decides how early you must start. +export const DRILL_TYPES = [ + { v: 'f', name: 'RUNNER', vMul: 1.35 }, + { v: 'e', name: 'BALL BOY', vMul: 1.10 }, + { v: 'd', name: 'STEWARD', vMul: 1.00 }, + { v: 'h', name: 'BOUNCER', vMul: 0.90 }, + { v: 'g', name: 'BRUTE', vMul: 0.80 }, + { v: 'k', name: 'ROLLER', vMul: 0.70 }, ]; +export const drillType = v => DRILL_TYPES.find(t => t.v === v); // ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. Time, not // rounds won: a round-counted ramp cannot escalate while you stand still. @@ -56,7 +67,7 @@ const batchSize = d => Math.round(lerp(TUNE.drillBatch, TUNE.drillBatchMax, ramp const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, 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); +const gang = () => zombies.filter(z => z.drill); // the batch: a member's identity IS its variant // ---- THE BATCH IS THE ROUND, AND IT IS WHAT MAKES THE MODE WINNABLE. Oliver: "make the game that // it is actually winable meaning that all required colors are visible on the far side before when a @@ -72,47 +83,47 @@ const bibs = () => zombies.filter(z => z.bib !== undefined); // The pressure moved to the EATERS below instead, which is a better place for it — the puzzle stays // fair and the danger is a separate thing arriving on its own clock. function spawnBatch(d) { - const k = Math.min(DRILL_COLS.length, batchSize(d)); - // distinct colours, distinct lanes, fanned across the width and shuffled so the called order has + const k = Math.min(DRILL_TYPES.length, batchSize(d)); + // distinct TYPES, distinct lanes, fanned across the width and shuffled so the called order has // nothing to do with the left-to-right order - const cols = shuffle(DRILL_COLS.map((_, i) => i)).slice(0, k); - const lanes = shuffle(cols.map((_, i) => -TUNE.drillSpread + (2 * TUNE.drillSpread) * (i / Math.max(1, k - 1)))); - cols.forEach((bib, i) => { + const types = shuffle(DRILL_TYPES.slice()).slice(0, k); + const lanes = shuffle(types.map((_, i) => -TUNE.drillSpread + (2 * TUNE.drillSpread) * (i / Math.max(1, k - 1)))); + types.forEach((t, i) => { // staggered in depth as well as across, so the batch arrives as a loose group rather than as a - // rank walking in abreast — but every one of them still starts on the far side, which is the - // promise the mode now makes. + // rank walking in abreast — but every one still starts on the far side, which is the promise. spawnZombie(lanes[i] + (Math.random() - 0.5) * 0.4, - TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, Math.random() < 0.25 ? 'b' : 'a'); + TUNE.drillSpawnZ + Math.random() * TUNE.drillBatchDepth, t.v); const zb = zombies[zombies.length - 1]; - zb.bib = bib; - zb.drill = true; // holds its lane (see updateZombies) — the lane is the puzzle - zb.v = drillSpeed(d) * (0.92 + Math.random() * 0.16); + zb.drill = true; // marks it as the puzzle AND holds its lane (see updateZombies) + zb.v = drillSpeed(d) * t.vMul * (0.92 + Math.random() * 0.16); }); newRound(d); } -// ---- THE EATERS. Plain, unbibbed shamblers on their own clock: no colour, no place in any +// ---- THE EATERS. Plain shamblers on their own clock: never in the alphabet, no place in any // combination, nothing to remember about them. Killing one is never a fault (drillKill ignores a -// body with no bib) and letting one reach you is the ordinary caught. They exist because with the +// body that is not flagged) and letting one reach you is the ordinary caught. They exist because with the // puzzle made fair the mode needed its danger back from somewhere, and a threat you simply have to // DEAL WITH is a cleaner source of it than a puzzle that cheats. // Deliberately NOT flagged `drill`, so they keep the gore mode's homing: they converge on you while // the bibbed batch walks its lanes, which is exactly the "stop what you are doing" interruption the // mode was missing. function spawnEater(d) { - if (zombies.filter(z => z.bib === undefined).length >= TUNE.drillEaterMax) return; + if (zombies.filter(z => !z.drill).length >= TUNE.drillEaterMax) return; // THEY ENTER WIDE OF THE COLOUR GANG. An eater walking in on top of the batch screens the very // bodies you are trying to read, and it is the one body on court that is neither the puzzle nor // worth points — so it has no business standing in front of the puzzle. Best-of-N: sample lanes // across the width and take the one furthest from every coloured body. They still converge on you // afterwards (that is what they are for); this only decides where they come IN. - const gang = bibs(); + const g = gang(); 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.4; - const s = gang.length ? Math.min(...gang.map(z => Math.abs(z.x - x))) : 1e9; + const s = g.length ? Math.min(...g.map(z => Math.abs(z.x - x))) : 1e9; if (s > bs) { bs = s; bx = x; } } + // ALWAYS A PLAIN SHAMBLER — rule 1 above doing its work: the alphabet never contains 'a'/'b', so + // an eater can never be mistaken for a body the sequence is asking for, with no marking on either. spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.3 ? 'b' : 'a'); const zb = zombies[zombies.length - 1]; zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2); @@ -121,10 +132,10 @@ function spawnEater(d) { // ---- THE ROUND: the order the batch has to be taken in. Drawn from every bibbed body on court, // which — see rule 1 above — is exactly the batch and nothing else. function newRound(d) { - const pool = bibs(); + const pool = gang(); d.step = 0; d.faults = 0; - d.seq = pool.length ? shuffle(pool.map(z => z.bib)) : []; - // (no sound: the strip IS the call — see the note on DRILL_COLS) + d.seq = pool.length ? shuffle(pool.map(z => z.variant)) : []; + // (no sound: the strip IS the call — see the note on DRILL_TYPES) } // Called by beats.startDrill once the court is its own. @@ -146,15 +157,15 @@ export function updateDrill(dt) { // the same frame with its combination already called. No pause, no deal, no announcement beat — // and because a batch is born on the far side, "all the colours are visible before you are asked // for them" holds by construction rather than by a check that could be wrong. - if (!bibs().length) { spawnBatch(d); return; } + if (!gang().length) { spawnBatch(d); return; } if (!d.seq.length) newRound(d); // survivors after a double fault: same bodies, fresh order } // 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]) { + const d = G.drill; if (!d || !zb.drill) return; + if (zb.variant === d.seq[d.step]) { d.step++; // no tick here either — killZombie is already bursting the body and playing the splat, and that // burst is a far better "yes, that one" than a beep laid over the top of it @@ -169,7 +180,7 @@ export function drillKill(zb) { // 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.seq = d.seq.filter((c, i) => i < d.step || c !== zb.variant); if (d.faults >= 2) { d.lost++; voice('double_fault', 0.1); @@ -185,15 +196,15 @@ export function drillKill(zb) { // 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() { +export function drillCaught(text = 'CAUGHT') { const d = G.drill; d.lost++; voice('game_opponent', 0.1); - d.msg = { text: 'CAUGHT', t: 1.8, bad: true }; + d.msg = { text, 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 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; +export const drillTarget = () => zombies.find(z => G.drill && z.drill && z.variant === G.drill.seq[G.drill.step]) || null; diff --git a/src/render.js b/src/render.js index fe8a544..01f8790 100644 --- a/src/render.js +++ b/src/render.js @@ -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); } diff --git a/src/zombies.js b/src/zombies.js index 647b07f..1d580d7 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -493,7 +493,7 @@ function killZombie(i, quiet) { // and no early return, because everything below is the DEATH — the blood decal, the pile, the // particle burst, the splat and the headshot crack. The first cut returned here and bibbed // bodies died silently and cleanly, which is not what this game does to anybody. - if (G.drill && zb.bib !== undefined) drillKill(zb); + if (G.drill && zb.drill) drillKill(zb); // `drill` marks a batch member; eaters are never flagged if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy @@ -1196,7 +1196,16 @@ export function updateZombies(dt) { } } if (Math.abs(zb.x - faceX0) > 0.004) zb.face = Math.sign(zb.x - faceX0); // sticky: a body standing still keeps facing where it last walked - if (zb.z >= COURT.L + 0.7) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) + if (zb.z >= COURT.L + 0.7) { + // A BATCH BODY THAT GETS PAST YOU IS A FAILURE, not a free despawn. Drill bodies hold their + // lane, so one in a lane you are not standing in walks the whole court and off the back + // without ever touching you — and since the sequence names it, the round is left asking for a + // body that no longer exists. That is the un-winnable state the whole mode is built to + // prevent, and it only appeared once the roster became the alphabet: types move at different + // speeds, so the runner leaves long before the roller. It counts as caught. + if (G.drill && zb.drill) { zombies.splice(i, 1); drillCaught('GOT PAST YOU'); continue; } + aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); + } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) } for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc diff --git a/test.js b/test.js index 4b864af..50190b6 100644 --- a/test.js +++ b/test.js @@ -65,7 +65,7 @@ const { update, forceZombies, splatPoint, restartRun, flickTarget } = await impo const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js'); const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); -const { drillTarget } = await import('./src/drill.js'); +const { drillTarget, DRILL_TYPES } = await import('./src/drill.js'); const { TUNE, bossArt, COURT } = await import('./src/config.js'); const DT = 1 / 60; @@ -282,8 +282,13 @@ function drillPlaying(seconds = 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); +// the gang's mean speed — per-TYPE vMul means a single body is not a fair sample of the ramp +const avgGangSpeed = () => { + const g = zombies.filter(z => z.drill); + return g.reduce((a, z) => a + z.v / (DRILL_TYPES.find(t => t.v === z.variant) || { vMul: 1 }).vMul, 0) / Math.max(1, g.length); +}; +const drillKillType = v => { + const zb = zombies.find(z => z.drill && z.variant === v); if (!zb) return false; for (let i = 0; i < 40 && zombies.includes(zb); i++) { ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; @@ -312,34 +317,36 @@ 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 puzzle is WINNABLE: every called colour is on the far side, and nothing else wears one', () => { +test('the puzzle is WINNABLE: every called TYPE is on the far side, and nothing else is in the gang', () => { startDrill(); const called = G.drill.seq; assert.ok(called.length >= 2, 'a combination is called at once'); - const bibbed = zombies.filter(z => z.bib !== undefined); - assert.equal(bibbed.length, called.length, 'exactly as many bibs on court as colours called'); - assert.deepEqual(bibbed.map(z => z.bib).sort(), called.slice().sort(), - 'the bibs on court ARE the combination — no coloured decoys'); - for (const z of bibbed) + const gang = zombies.filter(z => z.drill); + assert.equal(gang.length, called.length, 'exactly as many bodies in the gang as types called'); + assert.deepEqual(gang.map(z => z.variant).sort(), called.slice().sort(), + 'the gang on court IS the combination — nothing called that is not there'); + for (const z of gang) assert.ok(z.z < COURT.netZ, 'every called body starts on the far side, z=' + z.z.toFixed(1)); - assert.equal(new Set(bibbed.map(z => z.bib)).size, bibbed.length, 'a colour names exactly one body'); - // ...and it stays true as the batch walks in: nothing new ever puts an uncalled colour on court + assert.equal(new Set(gang.map(z => z.variant)).size, gang.length, 'a type names exactly one body'); + assert.ok(gang.every(z => !'ab'.includes(z.variant)), 'the plain shambler is never in the alphabet'); + // ...and it stays true as the batch walks in: no new body ever joins the gang mid-round for (let i = 0; i < 25 * 60; i++) { update(DT); - const live = zombies.filter(z => z.bib !== undefined).map(z => z.bib); - for (const b of live) assert.ok(G.drill.seq.includes(b), 'an uncalled colour appeared on court'); + for (const z of zombies) if (z.drill) + assert.ok(G.drill.seq.includes(z.variant), 'an uncalled type joined the gang'); } }); -test('the eaters are the danger: unbibbed, homing, and killing one is never a fault', () => { +test('the eaters are the danger: plain shamblers, homing, and killing one is never a fault', () => { const eaten = TUNE.eatenR; TUNE.eatenR = 0; // the soak trick: don't get caught mid-measurement try { startDrill(); for (let i = 0; i < 40 * 60; i++) update(DT); - const eaters = zombies.filter(z => z.bib === undefined); + const eaters = zombies.filter(z => !z.drill); assert.ok(eaters.length > 0, 'plain shamblers arrive on their own clock'); assert.ok(eaters.length <= TUNE.drillEaterMax, 'and are capped at ' + TUNE.drillEaterMax); - assert.ok(eaters.every(z => !z.drill), 'they are NOT lane-holders — they converge on the player'); + assert.ok(eaters.every(z => 'ab'.includes(z.variant)), + 'an eater is ALWAYS a plain shambler — that is what keeps a called type unambiguous'); // killing one must not touch the combination const seq = G.drill.seq.slice(), faults = G.drill.faults; const e = eaters[0]; @@ -358,15 +365,14 @@ test('the ramp raises the batch, the walk and the eater rate', () => { const eaten = TUNE.eatenR; TUNE.eatenR = 0; try { startDrill(); - const earlyBatch = G.drill.seq.length, earlyV = zombies.find(z => z.bib !== undefined).v; - let earlyEaters = 0; + const earlyBatch = G.drill.seq.length; + const earlyV = avgGangSpeed(); for (let i = 0; i < 40 * 60; i++) { update(DT); } - earlyEaters = zombies.filter(z => z.bib === undefined).length; G.drill.t = TUNE.drillRampT; // jump to the top of the ramp zombies.length = 0; update(DT); // force a fresh batch at full difficulty - const lateBatch = G.drill.seq.length, lateV = zombies.find(z => z.bib !== undefined).v; + const lateBatch = G.drill.seq.length, lateV = avgGangSpeed(); assert.ok(lateBatch > earlyBatch, 'the batch grows: ' + earlyBatch + ' -> ' + lateBatch); - assert.ok(lateV > earlyV * 1.5, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2)); + assert.ok(lateV > earlyV * 1.4, 'and it walks faster: ' + earlyV.toFixed(2) + ' -> ' + lateV.toFixed(2)); assert.ok(TUNE.drillEaterMin < TUNE.drillEaterEvery, 'and the eaters come sooner'); } finally { TUNE.eatenR = eaten; } }); @@ -374,20 +380,20 @@ test('the ramp raises the batch, the walk and the eater rate', () => { test('finishing a batch rolls straight into the next one — no pause, no deal', () => { drillPlaying(); const seq = G.drill.seq.slice(); - for (const bib of seq) drillKillBib(bib); + for (const bib of seq) drillKillType(bib); assert.ok(G.drill.rounds >= 1, 'the round is banked'); update(DT); - const bibbed = zombies.filter(z => z.bib !== undefined); - assert.ok(bibbed.length >= 2, 'a fresh batch is already walking in'); + const gang = zombies.filter(z => z.drill); + assert.ok(gang.length >= 2, 'a fresh batch is already walking in'); assert.ok(G.drill.seq.length >= 2, 'with its combination already called'); assert.equal(G.drill.step, 0, 'from the top'); - for (const z of bibbed) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised'); + for (const z of gang) assert.ok(z.z < COURT.netZ, 'and it starts on the far side, as promised'); }); 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); + for (const bib of G.drill.seq.slice()) drillKillType(bib); assert.deepEqual(score.games, games, 'the drill does not bank tennis games'); assert.deepEqual(score.pts, pts, '...nor tennis points'); }); @@ -396,16 +402,16 @@ test('two out of turn is a double fault, and the survivors are re-called', () => startDrill(); if (G.drill.seq.length < 3) return; // needs a batch with room to be wrong twice const wrong1 = G.drill.seq[G.drill.seq.length - 1]; // the LAST call, killed first - drillKillBib(wrong1); + drillKillType(wrong1); assert.equal(G.drill.faults, 1, 'out of turn is a fault'); assert.equal(G.drill.lost, 0, 'but not the round'); const wrong2 = G.drill.seq[G.drill.seq.length - 1]; if (wrong2 !== undefined && G.drill.seq.length > 1) { - drillKillBib(wrong2); + drillKillType(wrong2); assert.equal(G.drill.lost, 1, 'the second one takes the round off you'); assert.equal(G.drill.faults, 0, 'and the survivors are re-called with a clean slate'); assert.deepEqual(G.drill.seq.slice().sort(), - zombies.filter(z => z.bib !== undefined).map(z => z.bib).sort(), + zombies.filter(z => z.drill).map(z => z.variant).sort(), 'the new combination is exactly what is still standing'); } }); @@ -415,22 +421,40 @@ test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => { // and the fault unreachable — Oliver: "no skills involved at all... there is no penalty". drillPlaying(); const called = G.drill.seq[G.drill.step]; - const wrong = zombies.find(z => z.bib !== called); + const wrong = zombies.find(z => z.drill && z.variant !== called); assert.ok(wrong, 'setup: something else is on court'); const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 }); - assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it'); + assert.notEqual(ft.zt && ft.zt.variant, called, 'pointing away from the called body must not hit it'); }); test('a wrong kill never leaves an un-winnable round', () => { 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'); + if (G.drill.seq.length > 1 && drillKillType(later)) { + assert.ok(!G.drill.seq.includes(later), 'the dead type leaves the list rather than blocking it'); + assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)), + 'every type still on the list is still on the court'); } }); +test('a batch body that walks past you counts as caught, never as a free escape', () => { + // Types move at different speeds now, and drill bodies hold their lane — so one in a lane you are + // not standing in can cross the baseline untouched. Left as a plain despawn that stranded the + // sequence asking for a body which no longer existed: the un-winnable state the mode forbids. + drillPlaying(); + const runaway = zombies.find(z => z.drill); + assert.ok(runaway, 'setup: a batch is on court'); + runaway.x = P.x + 5; // a lane the player is nowhere near + runaway.z = COURT.L + 1; // ...and over the line + const lost = G.drill.lost; + update(DT); + assert.equal(G.drill.lost, lost + 1, 'it costs the point'); + assert.ok(!zombies.includes(runaway), 'and it is gone'); + for (let i = 0; i < 5 * 60; i++) update(DT); + assert.ok(G.drill.seq.every(v => zombies.some(z => z.drill && z.variant === v)), + 'the sequence never names a body that is not on court'); +}); + test('being caught costs the point and the court, not the run', () => { drillPlaying(); const t0 = G.drill.t;