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:
1 parent
57e96d629e
commit
6a16e77480
8 files changed
+313
-459
No files matched your search
+99
-170
@@ -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;
|
||||
Reference in new issue
Block a user