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:
1 parent
3379e83efc
commit
5b2f1d55ce
7 files changed
+197
-145
No files matched your search
+10
-12
@@ -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,
|
||||
|
||||
+58
-47
@@ -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;
|
||||
+23
-48
@@ -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);
|
||||
}
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user