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
+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;
|
||||
Reference in new issue
Block a user