Let the player walk between points, and fix seven retest gripes

All eight issues from Oliver's 35-game retest.

The freeze (1 and 6 — one root cause): the movement block, pose update
included, lived inside `if (G.phase === 'rally')`, so every 'over' beat and
every pretoss stranded the player mid-animation while the zombies walked on.
It now also runs whenever splatter is in any phase but 'toss' — 'toss' is the
aim, and the thumb is busy. Tennis keeps its old gating.

The rest:
- the mop boy is waved off the sudden-death duel and barred for its duration
- triple-serve clones inherit the serve's topspin gravity, so the wings stop
  floating while the centre ball dives
- "Time." belongs to the changeover's own countdown, not to whatever beat
  happened to expire while the breather ran
- the tutorial illustration is a finger pressing and pulling back now, not a
  curved arrow that read as a scroll
- aiming holds the pretoss stance; the smash plays when the shot fires
- GAME N sits on the scoreboard permanently

HANDOFF records all eight as closed with what the fix was, plus two ways a
soak rig lies to you and why the CPU-return benchmark can't be reproduced
from the number alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 09:33:37 +02:00
1 parent 8dca9b6d45
commit e00886f57e
7 files changed
+89 -52

No files matched your search

+19 -6
View File
@@ -196,7 +196,10 @@ function hitServe(sv, quality, aim = null) {
G.triples--;
for (const deg of [-6, 6]) {
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5,
// g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat
// TUNE.gravity floated long while the centre ball dived, which is the "wings lob off at
// the wrong height" report. They are the SAME shot, fanned.
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, g: ball.g,
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
}
}
@@ -335,7 +338,7 @@ export function proceedAfterClear() {
// changeover (after odd games, as the rules demand): a forced breather — no spawns,
// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
function startChangeover() {
G.changeover = true;
G.changeover = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out
G.phase = 'over'; G.overT = TUNE.changeoverT;
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
@@ -387,6 +390,7 @@ export function startDuel() {
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
ball.active = false; setupPoint();
zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4);
@@ -683,7 +687,7 @@ export function dragServe(aim) {
// rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that
// walked the player around between shots was the confusing part, not the fire rate.
if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5 });
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g });
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
@@ -797,14 +801,24 @@ export function update(dt) {
if (G.modal) { G.idleT += dt; return; }
crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt;
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
// other beat that overwrote overT while the breather ran (a melee kill taking a game, say)
// had the umpire calling time on a pause that was never a changeover.
if (G.changeover > 0 && (G.changeover -= dt) <= 0) { G.changeover = false; voice('time', 0, 0.95); }
const w = BOUNCE_IDLE.tempo;
// idle in the serve stance and not touching anything: after a beat, show the pull gesture
if (G.phase === 'pretoss' && !G.aim) G.idleHintT += dt; else G.idleHintT = 0;
if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
// the toss/smash pose belongs to the SHOT, not to the aim: while the slingshot is drawn (G.aim)
// the server stays in the pretoss stance and only snaps into 'serve' when hitServe fires.
if (G.phase === 'toss' && !G.aim) { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
// --- player movement (near half only, generous margins)
if (G.phase === 'rally') {
// In splatter it also runs between points ('over' — game beats, changeovers, set cards — and
// 'pretoss'): the zombies never stop walking, so gating movement on 'rally' froze the player
// mid-stride with the dead closing in, and left him stuck in whatever pose he held. 'toss' is
// the one exception — that's the aim, and the thumb is busy drawing the bow.
if (G.phase === 'rally' || (G.mode === 'splatter' && G.phase !== 'toss')) {
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 };
@@ -1003,7 +1017,6 @@ export function update(dt) {
onDismiss: startHorde });
return;
}
if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into
// setupPoint() -> pretoss left the player locked in the serve posture while the gun
// fired (no movement outside 'rally', no serving during auto-fire). Back to rally.