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;