The umpire reads a list, and the bibs come out to meet it
The late game is repetitive because the optimal move never changes: kill the nearest thing, now. This is the first swing at a mode where it does — a coaching drill where the umpire CALLS a colour sequence aloud while bibbed bodies take their marks, and you must put them down in that order while they walk at you. Uncalled colours are decoys you have to ignore. Two faults is a double fault and the round is theirs. Back room only (DRILL TEST, or #drill): this is a feel-test for one question — tense, or just noisy? — so it keeps its own round count and touches neither tennis scoring nor the run. - voiceSeq: voice() drops a line that would start while the umpire is still talking, which is fatal for a list. Decode first, queue at accumulated offsets, and the anti-overlap rule is satisfied instead of fought. - Four colours with PIPS as well as hue — red/green alone makes the mode unplayable for ~8% of men. - drawBib is a ponytail-marked overlay that knowingly breaks the sprite rule; it is the thing to delete if the mode survives. - BOTH target pickers snap (flickTarget and zombieTarget). Snapping only the slingshot left fifteen tap-serves in a row scoring nothing. - Contact costs the point, not the run — permadeath stays in the gore mode. - startDrill disarms the ordinary schedule; updateDrill redeals and logs an INVARIANT if anything empties its court. Eight new tests (24 total) pin the ownership contract both ways, the call beat, the win, the double fault, the un-winnable-round guard and the death. PROJECT.md carries the four open questions for the playtest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c659b6882f
commit
22df96cc1d
17 files changed
+556
-19
No files matched your search
+33
-4
@@ -12,6 +12,7 @@ import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js';
|
||||
import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.js';
|
||||
import { newDrillRound } from './drill.js';
|
||||
|
||||
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
|
||||
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
|
||||
@@ -62,7 +63,7 @@ function startChangeover() {
|
||||
export function startDuel() {
|
||||
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
|
||||
// another control mode owns the court. Each of these has ended a run when they overlapped.
|
||||
if (G.interlude || G.horde || G.bossFight) return;
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill) return;
|
||||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
|
||||
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
|
||||
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
|
||||
@@ -85,7 +86,7 @@ export function startDuel() {
|
||||
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
|
||||
// instant death. G.bossFight holds the chapter number while it runs.
|
||||
export function startBossFight(chapter) {
|
||||
if (G.bossFight) return;
|
||||
if (G.bossFight || G.drill) return;
|
||||
G.bossFight = chapter;
|
||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
||||
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
|
||||
@@ -129,6 +130,34 @@ export function endBossFight() {
|
||||
if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); }
|
||||
}
|
||||
|
||||
// ---- THE COACHING DRILL: the cognitive mode. Same contract as every mode above — it refuses to
|
||||
// start while anything else owns the court, and it clears the court it takes over, so nothing that
|
||||
// was walking around a moment ago is still walking around inside a drill round. The drill's own
|
||||
// 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, callT: 0, beat: 0, rush: 0, won: 0, lost: 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,
|
||||
// and the mode it belongs to has no bodies of its own here to fire at.
|
||||
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
|
||||
G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0;
|
||||
G.boy = null; // nobody mops through a drill
|
||||
G.weapon = 0; // plain balls: the mode is about order, not about the rack
|
||||
ball.active = false; setupPoint();
|
||||
voice('quiet_please', 0.1);
|
||||
newDrillRound();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endDrill() {
|
||||
if (!G.drill) return;
|
||||
G.drill = null;
|
||||
zombies.length = 0;
|
||||
ball.active = false; setupPoint();
|
||||
}
|
||||
|
||||
// ---- THE DEADLOCK WATCHDOG.
|
||||
// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
|
||||
// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
|
||||
@@ -211,7 +240,7 @@ export function resolveInterlude(side) {
|
||||
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
|
||||
// counts it down under the flash), and startHorde still owns the transition itself.
|
||||
export function warnHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight) return false; // same court-ownership contract as the rest
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same court-ownership contract as the rest
|
||||
G.hordeWarn = TUNE.hordeWarnT;
|
||||
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
|
||||
@@ -222,7 +251,7 @@ export function warnHorde() {
|
||||
// Returns false when another mode owns the court — the caller re-arms the latch rather than
|
||||
// silently eating the wave the set paid for.
|
||||
export function startHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight) return false; // same contract as startDuel: one mode owns the court
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same contract as startDuel: one mode owns the court
|
||||
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
||||
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
|
||||
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
|
||||
|
||||
Reference in new issue
Block a user