The staff learn your habits, the walk-on waits for your tap, and the club opens a practice court

Four answers to one phone report:
- THE READ: repeat a lane and the opponent anticipates it; break the habit
  and it is wrong-footed for that ball — scaled by rung, so surprise is the
  weapon that opens the ladder past the HEAD COACH (TUNE block THE READ).
- The opponent walk-on is a real modal now (head, punchline, tap to dismiss)
  in arcade, tournament and training — readable without a clock.
- THE TRAINING COURT: third card under ZOMBIE TENNIS, pick any rung, one
  game at a time, the felled body walks straight back on, nothing banks,
  bleeding out is a popup instead of a death. mode_train.png rolled in the
  banner style.
- MOVEMENT READS: sprinting feet kick per-court dust and fast bodies leave
  a two-ghost speed streak (trailMin gates both; feel verdict pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-28 16:08:18 +02:00
1 parent a42dc6fc81
commit 43784c5b33
9 files changed
+360 -43

No files matched your search

+51 -22
View File
@@ -356,9 +356,13 @@ export function watchdog(dt) {
// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is
// optional and null means "roll each round as it comes", which is what the mode did before the
// screen existed and is still what the #cup back door gets.
export function startMatch(cup = false, draw = null) {
// `train` (2026-08-28) is a RUNG NUMBER and turns the run into the TRAINING COURT: that one
// opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a
// felled body walks straight back on, and bleeding out costs a modal instead of the run. All of
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
export function startMatch(cup = false, draw = null, train = 0) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: 1, ending: false };
G.match = { opp: train || 1, ending: false, train: !!train };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
@@ -372,16 +376,19 @@ export function startMatch(cup = false, draw = null) {
zombies.length = 0;
G.boy = null;
score.pts = [0, 0];
matchOpponent();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
if (cup && !G.seen.cup) {
// The mode's rules modal fires BEFORE matchOpponent, so it lands FIRST and the opponent's own
// walk-on modal queues behind it (showModal's modalQ keeps call order) — rules, then the body.
if (train && !G.seen.train) {
G.seen.train = true;
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
body: ['ONE BODY, ONE GAME AT A TIME',
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NOTHING IS BANKED HERE'] });
} else if (cup && !G.seen.cup) {
G.seen.cup = true;
showModal({ title: 'THE TOURNAMENT', color: UI.hot,
body: ['FOUR ROUNDS UP THE DEAD HIERARCHY',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
} else if (!cup && !G.seen.match) {
} else if (!cup && !train && !G.seen.match) {
G.seen.match = true;
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
// number, so the modal can never drift from the pool it describes.
@@ -389,6 +396,10 @@ export function startMatch(cup = false, draw = null) {
body: ['WIN THE POINT — WOUND THE ZOMBIE',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
}
matchOpponent();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
return true;
}
@@ -418,6 +429,8 @@ export function matchReady() { // exported for net.js: a paired death match re-p
ball.active = false;
G.serveNum = 1;
if (G.match) G.match.decided = false; // the next rally may be decided early again
// ...and THE READ's one-ball states die with the point: a lean only lasts the flight it answered
if (G.match) G.match.surprised = G.match.expected = false;
setupPoint();
const rz = zombies.find(z => z.variant === 'r');
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
@@ -627,13 +640,18 @@ function matchOpponent() {
// so killZombie can file the kill under it without asking G which opponent we were on.
zb.foe = matchFoe(G.match.opp);
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS)
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art).
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
// of the player should last longer"). In the cup the round names itself on the third line.
// THE WALK-ON IS A MODAL NOW, not a timed card (2026-08-28, Oliver: "als richtiges Modal, das
// man wegtappen muss — you can actually read it without time pressure, and tap it away fast when
// you have read it a hundred times"). The walk-on is a guaranteed-calm court (the mop beat has
// just ended, the intro bar is armed), so the popup policy allows it; update()'s freeze holds the
// 1.6 s intro beat until the tap, and the head wears the same z_<fam>head crop the card wore.
// In the cup the round names itself on the second line.
const cup = G.match.cup;
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam,
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined };
showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64,
body: [zb.foe.line].concat(cup ? [CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '')] : []) });
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
// game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
@@ -680,10 +698,18 @@ function matchPoint(side) {
// clip over a second long, so every one of these was being swallowed silently.
aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2);
if (G.hp <= 0) {
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
// the other phone turns it into its win screen (netMatchWon)
if (G.net && G.net.ready) netSendDead();
return killPlayer(); // eaten — the run summary, as ever
// THE TRAINING COURT HAS NO DEATH IN IT. Bleeding out costs a modal and a fresh pool — the
// point still goes to the body (advancePoint below), so a game you were losing stays lost.
if (d.train) {
G.hp = G.hpMax;
showModal({ title: 'IT BLED YOU OUT', color: UI.bad,
body: ['THE TRAINER MOPS YOU UP — GO AGAIN', 'NOTHING IS BANKED HERE'] });
} else {
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
// the other phone turns it into its win screen (netMatchWon)
if (G.net && G.net.ready) netSendDead();
return killPlayer(); // eaten — the run summary, as ever
}
}
}
const over = advancePoint(side); // banks the game and resets the points itself
@@ -737,7 +763,7 @@ function matchGameWon(zb, banked = false) {
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++; // the ladder metric, same as every mode
if (!d.train) G.runGames++; // the ladder metric — training banks nothing
voice('game_player', 0.3); cheer(2.4, 0.6);
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
@@ -745,8 +771,9 @@ function matchGameWon(zb, banked = false) {
G.flash = { text: 'GAME.', t: 1.4 };
// netplay: the SAME opponent walks back on — the body across the net is one particular friend,
// and handing them the roster's next name and face would break the fiction (the ladder dials the
// opp number drives are AI-only and dormant here anyway)
if (!G.net) d.opp++;
// opp number drives are AI-only and dormant here anyway). Training parks the ladder the same
// way: the rung you picked is the rung that walks back on, as many times as you want it.
if (!G.net && !d.train) d.opp++;
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
@@ -772,7 +799,9 @@ function matchGameWon(zb, banked = false) {
}
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => {
matchHealBeat();
// training resets the blood WITHOUT the WOUNDS TREATED ceremony — every game starts whole,
// and a popup between every single rep is exactly the tap-tax the mode exists to avoid
if (d.train) G.hp = G.hpMax; else matchHealBeat();
// ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately
// the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the
// near court where the player is about to serve. Between games only — see sendBoy's match note.