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.
+26 -1
View File
@@ -599,6 +599,28 @@ export const TUNE = {
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
// ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely
// make mistakes any more — we need a twist"). The dead staff LEARN YOUR PATTERNS: land the same
// lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated
// (its bounce-read climbs toward perfect, so spam feeds the machine), while BREAKING the habit
// wrong-foots it for that one ball: the read collapses, the legs slow, the whiff climbs. Both
// effects scale with the RUNG ((opp-1)/(LADDER-1)), so the shambler never learns anything and
// the champion — "HE RETURNS EVERYTHING" — is exactly the one you beat by setting a habit and
// breaking it on the big point. Skill, not grind, is what opens the ladder past the coach.
// Rally lanes only (doHit's aimed player shots); serves keep their own game. Solo match only.
readHabit: 3, // same-lane returns in a row before the opponent has learned the pattern
readAnticipate: 0.97,// what aiRead climbs toward on an EXPECTED ball at the top rung
surpRead: 0.85, // fraction of aiRead a wrong-footed opponent loses (x rung scale)
surpSlow: 0.45, // fraction of chase speed it loses on the surprise ball (x rung scale)
surpFlub: 0.35, // whiff ADDED at contact on the surprise ball at the top rung
// ---- MOVEMENT READS (2026-08-28): the sprites run, the court answers. Two cheap effects, both
// live-tunable: feet kick the surface's own dust once a body crosses trailMin, and a body moving
// that fast leaves a two-ghost speed streak behind it (the modal masthead's own trick, sampled
// back along the velocity). Procedural register only — dust and afterimage, never painted into
// a sprite, and nothing draws at walking pace: trailMin sits above every shamble.
trailMin: 3.6, // m/s of ground speed before a body earns dust + ghosts (0 = both off)
trailK: 0.05, // s each ghost trails behind along the velocity (two samples: 1x and 2x)
runDustEvery: 0.5, // m of ground covered per dust puff while sprinting
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,
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
@@ -765,6 +787,9 @@ export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
// TRAINING (2026-08-28): pick any rung, play it one game at a time, nothing is banked. The same
// match machinery with the ladder parked — see startMatch's train parameter.
{ id: 'train', name: 'TRAINING', sub: 'PICK A BODY · ONE GAME · GO AGAIN', art: 'mode_train' },
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
// promising one: the match already turned, you just arrived late
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
@@ -776,7 +801,7 @@ export const MODES = [
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
export const MENU = [
{ mode: 'classic', diffs: true },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup', 'train'] },
{ mode: 'gore' },
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
+65 -8
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS } from './config.js';
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS, LADDER } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.js';
@@ -232,6 +232,21 @@ function doHit(ch, quality, aim) {
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
// THE READ (see config's dial block): the opponent studies where your rally shots GO. Three
// lanes, bucketed off the aimed target — run readHabit of the same lane together and the habit
// is learned: the NEXT same-lane ball is `expected` (anticipated in racketAI), a lane break is
// `surprised` (wrong-footed, for this one flight). Solo match rally shots only: netplay's
// opponent is a human who does his own reading, and serves keep their own game.
if (G.match && !G.net && ch.side === 'P' && aimedLane) {
const d = G.match, lane = tx < -1.1 ? -1 : tx > 1.1 ? 1 : 0;
d.surprised = (d.laneRun || 0) >= TUNE.readHabit && d.lastLane !== undefined && lane !== d.lastLane;
d.expected = (d.laneRun || 0) >= TUNE.readHabit && lane === d.lastLane;
d.laneRun = lane === d.lastLane ? (d.laneRun || 0) + 1 : 1;
d.lastLane = lane;
// the mechanic announces itself the moment it fires — on the rungs young enough to barely
// lean, the flash is the whole lesson for later
if (d.surprised && (d.opp - 1) / (LADDER - 1) > 0.2) G.flash = { text: 'WRONG-FOOTED.', t: 1.0 };
}
if (G.match && ch.side !== 'P') {
// ...AND ITS PACE IS THE FOURTH. Width alone did not fix "unreachable" because the returns left
// at ~27 m/s (baseShotSpeed + 0.55 quality) — crossing to your baseline in ~0.6 s, which is less
@@ -1119,9 +1134,17 @@ function matchFallWillPlay() {
// could not start in time (Oliver: the first ten opponents can be killed the moment you serve).
// It lasts exactly as long as the serve does — G.wasServe is cleared at the first bounce and at
// any return — so a rally is chased at the ordinary ladder speed and nothing else moves.
// How much this body has LEARNED — THE READ's rung scale, 0 at the shambler, 1 at the champion.
// One definition, because the read boost, the wrong-foot slow and the surprise whiff must all
// answer "how smart is this one" identically or the twist pulls in three directions at once.
const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0;
const rzChaseSpeed = rz => TUNE.racketSpeed
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1);
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
// so the intercept planner and the reach tests believe it too (a surprised champion plans like
// a body that genuinely cannot get there, which is the whole point)
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1);
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
@@ -1413,8 +1436,17 @@ function racketAI(rz, dt) {
// being wrong-footed is most of what makes the rally a rally. Serves only in spirit but applied
// to every flight — the same lag was there in a rally, just hidden by the shorter distances.
const pred = ball.y > 0.35 && ball.vy < 3 ? predictLanding() : null; // not while it is still climbing off the racket
const aimX = pred ? ball.x + (pred.x - ball.x) * TUNE.aiRead : ball.x;
const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z;
// THE READ rides on aiRead: an EXPECTED ball (you repeated your habit) is read almost perfectly
// — spam feeds the machine — and a SURPRISE ball collapses the read back toward the old pure
// pursuit, which is exactly the wrong-footing aiRead exists to permit. Both scale with the rung
// (learnK), so the shambler never learns and the champion is the one you beat by breaking a
// habit you set on purpose. Dials in config's THE READ block; the streak lives in doHit.
const learn = learnK(rz);
const read = G.match && G.match.surprised ? TUNE.aiRead * (1 - TUNE.surpRead * learn)
: G.match && G.match.expected ? TUNE.aiRead + (TUNE.readAnticipate - TUNE.aiRead) * learn
: TUNE.aiRead;
const aimX = pred ? ball.x + (pred.x - ball.x) * read : ball.x;
const aimZ = pred ? ball.z + (pred.z - ball.z) * read : ball.z;
const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) {
@@ -1434,7 +1466,15 @@ function racketAI(rz, dt) {
// vx is kept so a body that is knocked down mid-chase can carry the run into its collapse.
earnFlip(rz, 'face', Math.abs(dx) > 0.2 ? (dx > 0 ? 1 : -1) : 0, dt);
rz.vx = dx / d * step / dt;
} else { rz.vx = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
// vz beside vx (drawZombie's speed ghosts read both), and MOVEMENT READS: a genuine sprint
// kicks the surface up behind it — one puff per runDustEvery metres, the same per-court dust
// the ball's bounce already throws, so the effect can never wear the wrong colour
rz.vz = dz / d * step / dt;
if (TUNE.trailMin && step / dt > TUNE.trailMin
&& (rz.dustAcc = (rz.dustAcc || 0) + step) > TUNE.runDustEvery) {
rz.dustAcc = 0; dustPuff(rz.x, rz.z, 2, 0.6);
}
} else { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
// THE WOUNDS PLAY. Score-is-the-body was a picture until now: it lost an arm and swung exactly
// as far, lost its head and missed exactly as often. Both dials read the same racketStage the
// SKINS do, so what you see it lose is what it loses. Match only — see config.matchWound.
@@ -1444,10 +1484,16 @@ function racketAI(rz, dt) {
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
// which across a game is an opponent that beats itself.
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
: TUNE.racketFlub;
const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
: TUNE.racketFlub)
// THE READ: a wrong-footed racket reaches the ball late even when the legs got there —
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
// body had actually learned. The champion's near-zero flub is exactly what this opens.
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
// the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet
if (G.match) G.match.surprised = G.match.expected = false;
}
}
@@ -1792,6 +1838,7 @@ export function update(dt) {
// steer on the phase froze the player mid-burst the moment he was winning. The settled "no
// walking between points" rule is untouched — nothing arms a burst except the player.
if (G.phase === 'rally' || G.mgT > 0) {
const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
@@ -1860,11 +1907,21 @@ export function update(dt) {
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
if (mx) { P.facing = mx > 0 ? 1 : -1; P.flipT = 0; } // manual: instant, and it resets the earn clock
else earnFlip(P, 'facing', Math.abs(assist) > 0.25 ? (assist > 0 ? 1 : -1) : 0, dt);
// MOVEMENT READS: the player's own travel this frame, measured AFTER the clamps so a body
// pinned against the bound reads as standing (drawChar's speed ghosts read vx/vz). A real
// sprint — the auto-run chase, the horde steer — kicks the same per-court dust the bodies do.
P.vx = (P.x - px0) / dt; P.vz = (P.z - pz0) / dt;
const pSpd = Math.hypot(P.vx, P.vz);
if (TUNE.trailMin && pSpd > TUNE.trailMin
&& (P.dustAcc = (P.dustAcc || 0) + pSpd * dt) > TUNE.runDustEvery) {
P.dustAcc = 0; dustPuff(P.x, P.z, 2, 0.6);
}
}
// ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only
// handles swing/serve), and P.runT stops advancing outside the block, so a beat that began
// mid-stride left him welded to a single run frame — the "stuck mid-animation" report.
else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; }
// vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead.
else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } }
// The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent
// in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him
// off the edge of the screen either.
+23 -3
View File
@@ -3,7 +3,7 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe, LADDER } from './config.js';
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
@@ -383,10 +383,24 @@ document.getElementById('brBack').addEventListener('click', () => {
});
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// ---- THE TRAINING PICKER (Oliver, 2026-08-28: "ein Trainingsbereich — Auswahl des Gegners, ein
// Spiel, tap to replay, bis man keine Lust mehr hat"). The whole ladder face-up, one tile per rung,
// wearing the same z_*head crop the draw and the walk-on wear. Tap = start — the wizard's own
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
let trainRung = 1;
const trainList = document.getElementById('trainList');
for (let n = 1; n <= LADDER; n++) {
const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
const boardFor = m => m === 'zombie' || m === 'cup' ? stats.MODE_MATCH
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// what the setup footer says you are about to play, and what it is worth beating
@@ -404,7 +418,9 @@ function summary() {
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
// training banks nothing, so there is no mark to beat and the footer must not invent one
+ `<span>${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS'
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
@@ -440,6 +456,8 @@ function start(modeId = pickedMode) {
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
// means "roll it yourself", which is what the mode did before the screen existed.
if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); }
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
}
let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
@@ -523,6 +541,7 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle
// carries its own start button, so this stays the one place a run begins from the setup screen.
document.getElementById('cStart').addEventListener('click', () => {
if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; }
if (pickedMode === 'train') { go('train'); return; } // the picker starts it — tap advances
start();
});
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
@@ -713,6 +732,7 @@ addEventListener('keydown', e => {
// no-ops on an empty run, so closing the tab at the menu writes nothing.
addEventListener('pagehide', () => {
if (G.mode !== 'splatter') return;
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
+26 -3
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_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, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } from './config.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_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, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH, matchFoe } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
@@ -805,9 +805,21 @@ function drawMachine(zb) {
}
const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres
function drawZombie(zb) {
function drawZombie(zb, ghost) {
if (zb.variant === 'o') return drawBarrel(zb);
if (zb.variant === 'm') return drawMachine(zb);
// MOVEMENT READS: a body genuinely sprinting (only racketAI writes vx/vz — the shamble never
// crosses trailMin) leaves two ghosts sampled back along its own velocity, the modal masthead's
// speed streak applied to a body. Ghosts draw first so the live sprite lands on top, and never
// during a named frame — a collapsing corpse must not smear on stale velocity.
if (!ghost && TUNE.trailMin && !zb.showFrame
&& Math.hypot(zb.vx || 0, zb.vz || 0) > TUNE.trailMin) {
for (const [k, a] of [[2, 0.10], [1, 0.22]]) {
ctx.save(); ctx.globalAlpha *= a;
drawZombie({ ...zb, x: zb.x - zb.vx * TUNE.trailK * k, z: zb.z - (zb.vz || 0) * TUNE.trailK * k }, true);
ctx.restore();
}
}
const rk = zb.variant === 'r';
// THE MATCH OPPONENT HAS ITS OWN SPRITES NOW. It used to borrow the turned CPU's ('c') — a body
// drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing
@@ -1782,10 +1794,19 @@ export function drawLook(cv2, t, k, sk) {
const bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx;
const bounceApex = () => tour.apex ?? BOUNCE_IDLE.apex;
function drawChar(ch) {
function drawChar(ch, ghost) {
// No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top
// of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads
// as the man skating — and the idle pair's own weight shift is the whole of what he should be doing.
// MOVEMENT READS: the same two-ghost speed streak the bodies wear (see drawZombie) — gameplay's
// mover writes P.vx/vz, so only a genuine sprint (auto-run chase, horde steer) ever draws one.
if (!ghost && TUNE.trailMin && Math.hypot(ch.vx || 0, ch.vz || 0) > TUNE.trailMin) {
for (const [k, a] of [[2, 0.10], [1, 0.22]]) {
ctx.save(); ctx.globalAlpha *= a;
drawChar({ ...ch, x: ch.x - ch.vx * TUNE.trailK * k, z: ch.z - ch.vz * TUNE.trailK * k }, true);
ctx.restore();
}
}
const p = proj(ch.x, 0, ch.z);
// the readability boost is what makes the MATCH OPPONENT legible: at the far baseline he was
// ~34 logical px of faithful anatomy, which is mush. The near player anchors the curve (farB
@@ -2822,6 +2843,8 @@ export function render() {
// the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here
ctx.fillText(G.match && G.match.cup
? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED`
// training: the ladder is parked, so "opponent N · felled" would lie — name the rep instead
: G.match && G.match.train ? `TRAINING · ${matchFoe(G.match.opp).short}`
: G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}`
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);