Triage: always kill the body that would reach you first

The sequence drill tested nothing — the strip was always visible (no memory),
the batch was slow (no urgency), the aim was easy (no execution). A checklist
with gore, and Oliver said so: "not interesting enough to keep me playing".

Settled in discussion, he picked triage + streak. One rule: always kill the
body that would reach you first. Nothing is called, nothing shown — the court
is the puzzle. arrival = distance / speed down a held lane, and the roster's
speed spread means the answer is not the closest body: a runner at the far
baseline beats a roller at the service line. Court sense — and it trains the
exact target-priority read the gore mode rewards.

Wounds are free: only the kill is judged, so pre-softening a heavy while it
is third in line is legal and eventually necessary. The slack (0.9s) makes
near-ties legal both ways, or the rule is a coin flip. The streak is both
punishment and payout: x1..x5, a misread shows OUT OF TURN and resets it, and
nothing else happens — the queue keeps walking. Caught is the real setback.
Best score banks to localStorage; the world board can carry it if it ships.

drillMark draws a chevron over the queue head for playtest calibration. It
answers the exact question the mode asks, so it must be off in anything
shipped.

Deleted: the alphabet, the batch, the strip, the eaters. Shamblers are
first-class stream members with pts 20; a roller is 100.

30 tests, rewritten around arithmetic riggings (known speeds and distances)
instead of luck; 20 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 21:32:17 +02:00
1 parent 57e96d629e
commit 6a16e77480
8 files changed
+313 -459

No files matched your search

+1 -1
View File
@@ -136,7 +136,7 @@ export function endBossFight() {
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
export function startDrill() {
if (G.drill || G.interlude || G.horde || G.bossFight) return false;
G.drill = { seq: [], step: 0, faults: 0, t: 0, spawnT: 0, rounds: 0, lost: 0, msg: null };
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
G.changeover = false; G.clearing = false; G.afterClear = null;
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
// horde, an owed chapter or a due duel is a latch that keeps trying to fire behind the drill,
+27 -33
View File
@@ -345,42 +345,36 @@ 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. 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
// ---- THE TRIAGE DRILL (src/drill.js) — the cognitive mode, fourth design. ONE RULE: always
// kill the body that would reach you first. Nothing is called and nothing is shown — the court is
// the puzzle, and the skill is reading speeds against distances (a runner at the far baseline
// beats a roller at the service line). The streak multiplier is the hook; the score is what you
// come back to beat.
drillMaxOnCourt: 7, // bodies on court at once — the queue you are reading. Past ~7 it is noise
drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second
drillSeedGap: 2.6,// m of depth between those seeded bodies
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0)
drillNearZ: 9.0, // spawn spacing is measured against bodies nearer than this
drillSpread: 3.4, // m either side of the centre line the lanes fan across
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
// interruption, and the puzzle is the point of the mode — a threat you have to break off and deal
// 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 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: 0, // eaters allowed on court at once. **0 = OFF, which is where it is now** —
// Oliver: "remove the fake zombies for now", so the drill is currently the
// pure puzzle with no second threat. The whole mechanism is intact
// (spawnEater, the wide entry, the never-a-fault rule); this one number
// brings it back. Past 3 the puzzle stops being readable.
drillEaterSpeed: 1.15,// they walk a touch faster than the batch: the batch is a list, this is a threat
drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)...
drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets
// arrival() be a plain division, so the queue is honest
drillSpawnEvery: 3.2, // s between arrivals at the bottom of the ramp...
drillSpawnMin: 1.1, // ...and at the top. THE pressure dial: the queue outgrows your clearing rate
drillRampT: 210, // s to go from the bottom of every curve above to the top
// TESTING KNOB, and it is OFF again after one playtest: "one-shot is bad - way too easy - also
// we're losing all the nice gore animation sprites". Both halves matter and the second is the one
// that is easy to miss — a 1 hp body never passes through any of its WOUND STAGES, so every skin
// in ZWOUND (arm off, ribs out, still walking) is skipped entirely and the roster's best art never
// renders. The HP is also what makes the called ORDER matter: a roller is seven serves, so being
// told to take it third means starting on it first. Left here as a switch, not a default.
drillOneShot: 0, // >= 0.5: batch bodies AND eaters all drop to 1 hp
// THE JUDGEMENT SLACK. Two bodies arriving within this of each other are BOTH legal kills —
// without it, near-simultaneous arrivals made the rule a coin flip and a fault read as theft.
// Tightening it is the real difficulty curve for good players; below ~0.4 it starts punishing
// reads the eye genuinely cannot separate.
drillSlack: 0.9,
drillMultEvery: 4, // correct kills per multiplier step...
drillMultCap: 5, // ...capped: a deep streak should feel valuable, not become the whole score
drillMark: 1, // >= 0.5 draws a chevron over the queue head — the CALIBRATION AID, so a
// playtest can check the game's read against the player's. Off for the real
// thing: the marker answers the exact question the mode exists to ask
drillOneShot: 0, // >= 0.5: every drill body drops to 1 hp. OFF after one playtest: "way too easy"
// AND a 1 hp body skips every ZWOUND stage, so the gore art never renders.
// Also flattens triage — pre-softening a heavy IS the planning layer
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,
+99 -170
View File
@@ -1,221 +1,150 @@
// THE COACHING DRILL — the cognitive mode. Second design, after Oliver played the first:
// THE TRIAGE DRILL — the cognitive mode, fourth design, and the first one built on a SKILL.
// Settled in discussion after Oliver's verdict on the sequence version ("it feels not interesting
// enough to keep me playing"): the called order tested nothing — the strip was always visible so
// there was no memory, the batch was slow so there was no urgency, and what remained was a checklist.
//
// "daniel announcing is maximum annoying"
// "don't let the rush in position just let them coming from the back in steady speed..
// try to space them not too close"
// "when one combination is done just let the new one in.. in a continuous stream that
// increases in speed and difficulty" / "the rounds can stay, they should be continuous"
// ONE RULE NOW: **always kill the body that would reach you first.** Nothing is called, nothing is
// shown — the court is the puzzle. Types walk at different speeds from different depths, so the
// answer is NOT the closest body: a runner at the far baseline beats a roller at the service line,
// and every kill (and every new arrival) re-asks the question. That is court sense — reading speeds
// and distances under pressure — which is both the most tennis-flavoured skill available and
// exactly the target-priority read the gore mode rewards.
//
// So the ROUND survives as the unit — a called combination, put down in order, counted — and every
// SEAM around it is gone: no walk-on to a mark, no announcement beat, no between-rounds pause, no
// deal. Bodies walk in from the far baseline at a steady speed for as long as the mode runs, and a
// round is drawn from whoever happens to be on court. Finish one and the next is drawn on the spot,
// out of the same rolling population — the bodies you were ignoring a second ago are the ones being
// called now. Speed, spawn rate and combination length all ramp with time survived, so the stream
// IS the difficulty curve.
// THE HOOK is the streak: correct kills build a multiplier, a wrong kill breaks it, and the score is
// what you come back to beat (best kept in localStorage; the world board can carry it later if the
// mode ships). Two subtleties that make it a game rather than a quiz:
// - WOUNDS ARE FREE. Only the KILL is judged, so pre-softening a roller while it is still third
// in line is legal and eventually necessary — the finishing blow is what must land in turn.
// - THE SLACK. Two bodies arriving within TUNE.drillSlack of each other are BOTH legal kills.
// Without it, near-simultaneous arrivals made the rule a coin flip; with it, only a genuine
// misread faults.
//
// THE MODE TRANSITIONS ARE NOT HERE — startDrill/endDrill live in beats.js with every other one
// (CLAUDE.md rule 3). This file is the stream, the round and the rules. It imports no renderer:
// render.js reads DRILL_COLS and draws.
// (CLAUDE.md rule 3). This file is the stream, the urgency rule and the scoring.
import { TUNE } from './config.js';
import { TUNE, COURT } from './config.js';
import { G, ball } from './state.js';
import { zombies, spawnZombie } from './zombies.js';
import { voice } from './audio.js';
// 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"*.
//
// 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.
//
// 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.
// The roster in the stream, weighted: shamblers are the crowd, heavies are events. vMul is the
// drill's own speed character (deliberately narrower than the roster's 3.2x..0.45x so the queue
// stays coherent); `pts` is what a kill banks BEFORE the streak multiplier — roughly the effort it
// took, so a roller is worth chasing and a shambler is volume.
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 },
{ v: 'a', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 },
{ v: 'b', name: 'SHAMBLER', vMul: 1.00, w: 5, pts: 20 },
{ v: 'f', name: 'RUNNER', vMul: 1.45, w: 2, pts: 60 },
{ v: 'e', name: 'BALL BOY', vMul: 1.10, w: 3, pts: 40 },
{ v: 'd', name: 'STEWARD', vMul: 1.00, w: 3, pts: 60 },
{ v: 'h', name: 'BOUNCER', vMul: 0.90, w: 2, pts: 70 },
{ v: 'g', name: 'BRUTE', vMul: 0.80, w: 2, pts: 80 },
{ v: 'k', name: 'ROLLER', vMul: 0.70, w: 1, pts: 100 },
];
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.
// ---- THE RAMP. One number, 0..1 off time survived. Spawn gap and walk speed ride it; the streak
// multiplier does NOT (skill should pay the same at minute one as at minute five).
const ramp = d => Math.min(1, d.t / TUNE.drillRampT);
const lerp = (a, b, k) => a + (b - a) * k;
export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d));
const batchSize = d => Math.round(lerp(TUNE.drillBatch, TUNE.drillBatchMax, ramp(d)));
const eaterGap = d => lerp(TUNE.drillEaterEvery, TUNE.drillEaterMin, ramp(d));
const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d));
// TUNE.drillOneShot: strip a body down to a single hit. Applied at spawn to the batch AND the
// eaters, so nothing on a drill court ever needs a second serve.
const oneShot = zb => { if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; return zb; };
// ---- THE URGENCY RULE, and it is the whole mode. Seconds until this body crosses your baseline at
// its own pace down its own lane (drill bodies hold their lane, so this is honest — nothing swerves).
export const arrival = zb => (COURT.L - zb.z) / Math.max(0.01, zb.v);
// The queue, most urgent first. Exported: render marks the head, tests assert against it.
export const drillQueue = () => zombies.filter(z => z.drill).sort((a, b) => arrival(a) - arrival(b));
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 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
// color combination is shown.. for now no colors that are not in the current combination".
//
// Two rules follow from that and they are the whole shape of this file now:
// 1. every colour on court is IN the current combination. There are no coloured decoys — so a
// colour you can see is a colour you will be asked for, and the strip can never name a body
// that is not there.
// 2. a combination is only ever shown once its bodies are ALL standing on the far side, which is
// free here because the batch is born there: they walk the full length of the court at you,
// and that length is the time you are given to work through the list.
// 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_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 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 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, t.v);
const zb = oneShot(zombies[zombies.length - 1]);
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);
function pickType() {
const pool = DRILL_TYPES.flatMap(t => Array(t.w).fill(t));
return pool[Math.floor(Math.random() * pool.length)];
}
// ---- 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 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.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 g = gang();
// ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against the back half — a retry loop
// would stall the stream exactly when it should be getting harder), holding its lane all the way in.
function spawnDrillBody(d) {
if (zombies.filter(z => z.drill).length >= TUNE.drillMaxOnCourt) return null;
const near = zombies.filter(z => z.drill && z.z < TUNE.drillNearZ);
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 = g.length ? Math.min(...g.map(z => Math.abs(z.x - x))) : 1e9;
const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.5;
const s = near.length ? Math.min(...near.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 = oneShot(zombies[zombies.length - 1]);
zb.v = drillSpeed(d) * TUNE.drillEaterSpeed * (0.9 + Math.random() * 0.2);
const t = pickType();
spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, t.v);
const zb = zombies[zombies.length - 1];
if (TUNE.drillOneShot >= 0.5) zb.hp = zb.hpMax = 1; // the testing switch, normally off
zb.drill = true; // holds its lane (see updateZombies) — the lane is what makes
// arrival() honest
zb.pts = t.pts;
// stamped at spawn, never re-read: the ramp arrives as new bodies outpacing old ones, not as the
// court accelerating under you — and a constant v is what lets arrival() be a plain division
zb.v = drillSpeed(d) * t.vMul * (0.92 + Math.random() * 0.16);
return zb;
}
// ---- 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 = gang();
d.step = 0; d.faults = 0;
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.
export function startDrillStream() {
const d = G.drill; if (!d) return;
zombies.length = 0;
d.seq = []; d.step = 0; d.faults = 0; d.t = 0; d.eaterT = TUNE.drillEaterEvery; d.rounds = 0; d.lost = 0; d.msg = null;
spawnBatch(d); // the mode has to be whole the moment it starts — a batch on the far side and a
// combination already called off it
d.t = 0; d.spawnT = 0; d.score = 0; d.streak = 0; d.kills = 0; d.faults = 0; d.lost = 0; d.msg = null;
try { d.best = Number(localStorage.getItem('drillBest')) || 0; } catch { d.best = 0; }
// seed a small spread so there is a queue to read from the first second
for (let i = 0; i < TUNE.drillSeed; i++) {
const zb = spawnDrillBody(d);
if (zb) zb.z += i * TUNE.drillSeedGap;
}
}
export function updateDrill(dt) {
const d = G.drill; if (!d) return;
d.t += dt;
if (d.msg) d.msg.t -= dt;
// the eaters keep coming on their own clock, independently of the puzzle
if ((d.eaterT -= dt) <= 0) { spawnEater(d); d.eaterT = eaterGap(d) * (0.75 + Math.random() * 0.5); }
// THE CONTINUOUS PART. The batch is the round; kill the last of it and the next batch walks in on
// 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 (!gang().length) { spawnBatch(d); return; }
if (!d.seq.length) newRound(d); // survivors after a double fault: same bodies, fresh order
if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); }
}
// 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).
// The streak's payout curve: x1 x2 x3 x4 x5, capped — deep streaks should feel valuable without a
// single fault deleting a four-digit number, which reads as theft rather than tension.
export const drillMult = d => Math.min(TUNE.drillMultCap, 1 + Math.floor(d.streak / TUNE.drillMultEvery));
function bankBest(d) {
if (d.score <= d.best) return;
d.best = d.score;
try { localStorage.setItem('drillBest', String(d.best)); } catch {}
}
// A drill body went down — judge the kill. Called from killZombie (the single kill funnel, so no
// weapon path can miss it), AFTER the splice, so the queue we judge against is the court as it now
// stands. Only the kill is judged: wound() never lands here, which is what makes pre-softening legal.
export function drillKill(zb) {
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
if (d.step >= d.seq.length) { // round complete — and the next one is already here
d.rounds++;
d.msg = { text: 'GOOD', t: 0.9 };
// THE SHOT THAT ENDS A ROUND IS SPENT. A ball keeps pace through a kill (TUNE.zPierce) and it
// is travelling UP-COURT — which is exactly where the next batch is about to be born. So the
// ball that finished your round flew on into bodies that did not exist when you took the shot
// and faulted you on a round you had not been shown yet. Pierce WITHIN a batch stays: that is
// honest occlusion you could have read. This is not.
ball.active = false;
newRound(d);
}
const q = drillQueue(); // zb is already spliced out — this is who is LEFT
const t0 = arrival(zb);
const legal = !q.length || t0 <= arrival(q[0]) + TUNE.drillSlack;
if (legal) {
d.streak++; d.kills++;
d.score += (zb.pts || 20) * drillMult(d);
bankBest(d);
return;
}
// Wrong body — one never called, or one due later. It is dead either way, so drop it from the list
// 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.
// A misread: something out there was going to arrive first and you spent the shot here. The
// streak IS the punishment — no round to lose, no court sweep, the queue just keeps walking.
d.faults++;
d.seq = d.seq.filter((c, i) => i < d.step || c !== zb.variant);
if (d.faults >= 2) {
d.lost++;
voice('double_fault', 0.1);
d.msg = { text: 'DOUBLE FAULT', t: 1.6, bad: true };
return newRound(d); // scrubbed, and a fresh one off the same court
}
d.streak = 0;
voice('fault', 0.1);
d.msg = { text: 'FAULT', t: 1.2, bad: true };
if (d.step >= d.seq.length) { d.rounds++; newRound(d); } // the wrong kill was the last one outstanding
d.msg = { text: 'OUT OF TURN', t: 1.3, bad: true };
}
// A drill body reached you. Called from killPlayer, the single death path for BOTH damage channels.
// 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.
// A body reached you — by contact or by walking its lane past your baseline. The one real setback:
// court swept, streak dead, ramp handed back a little. Score survives; the run has no end.
export function drillCaught(text = 'CAUGHT') {
const d = G.drill;
d.lost++;
d.streak = 0;
voice('game_opponent', 0.1);
d.msg = { text, t: 1.8, bad: true };
zombies.length = 0;
ball.active = false; // same reason as the round-complete case: the court refills up-court
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
ball.active = false; // the court refills up-court; a live ball would meet it
d.spawnT = 0;
d.t = Math.max(0, d.t - TUNE.drillCaughtBack);
}
// 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.drill && z.variant === G.drill.seq[G.drill.step]) || null;
+30 -34
View File
@@ -4,6 +4,7 @@ import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
import { drillQueue, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
// ---------------------------------------------------------------- palette ----
@@ -679,44 +680,39 @@ function drawZombie(zb) {
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
}
// 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.
// THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and
// no called list any more; the court is the puzzle, so the HUD only carries what the court cannot
// say: how much the run is worth and how hot the streak is.
function drawDrillHud() {
const d = G.drill; if (!d) return;
const n = d.seq.length; if (!n) return;
// 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 + 4, h = 34;
for (let i = 0; i < n; i++, x += w + gap) {
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);
}
ctx.globalAlpha = 1;
}
const y = CARD_TOP + 4;
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); }
ctx.font = B(24);
shadowText(String(d.score), W / 2, y, UI.cream);
const m = drillMult(d);
ctx.font = B(14);
// the multiplier is the streak made visible — gold once it is working, dim while it rebuilds
shadowText('x' + m + ' · ' + d.streak + ' STREAK', W / 2, y + 26, m > 1 ? UI.warm : UI.dim);
ctx.font = F(8);
shadowText(d.rounds + ' - ' + d.lost, W / 2, y + h + 5, UI.dim);
shadowText('BEST ' + Math.max(d.best, d.score), W / 2, y + 44, UI.dim);
if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + 60, d.msg.bad ? UI.bad : UI.pick); }
// THE CALIBRATION MARKER (TUNE.drillMark): a chevron over the body the rule says is next. For the
// playtest only — it exists so Oliver can check the game's read against his own, and it answers
// the exact question the mode is asking, so it must be OFF in anything shipped.
if (TUNE.drillMark >= 0.5) {
const head = drillQueue()[0];
if (head) {
const p = proj(head.x, zombieH(head) + 0.5, head.z);
if (p.s > 0) {
const bob = Math.sin(G.idleT * 6) * 2;
const px = Math.round(p.x), py = Math.round(p.y + bob);
ctx.fillStyle = UI.warm;
ctx.beginPath();
ctx.moveTo(px - 5, py - 7); ctx.lineTo(px + 5, py - 7); ctx.lineTo(px, py);
ctx.closePath(); ctx.fill();
}
}
}
}
// The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk
+4 -3
View File
@@ -104,9 +104,10 @@ export const G = {
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
drill: null, // the coaching drill (src/drill.js): {seq, step, faults, t, eaterT, rounds, lost, msg}.
// Owns the court like every other mode — see beats.startDrill. `t` is time
// survived and it is the whole difficulty curve; there is no round clock.
drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults,
// lost, best, msg}. Owns the court like every other mode — see beats.startDrill.
// One rule: always kill the body that would reach you first. `t` is time
// survived and drives the whole difficulty curve.
killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint)
runGames: 0, // games finished this run (either side) — the difficulty director
sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde