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

+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.