Fix the serve deadlock, give the mop boy a job, align net and umpire

The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so
anything that moved the phase mid-burst — a scheduled duel calling setupPoint —
stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so
no tap could serve again, and the yellow shape over the player's head was
drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks
in every phase, only the pellets are gated on rally) and semantically: starting a
duel cancels any burst, because the duel is tennis.

The mop boy now does an actual round: walk to the biggest nearby stain, stand on
it and push the mop while the cycle animates in place, scrub it out, move to the
next, then leave. Gore fades ~3.5x faster while he is stopped and working than
when walking past, so the stain dies under him instead of fading as he drifts by.
boyMaxJobs caps the round so a kill streak can't employ him forever.

Applause: the clip is 4.16s and always plays in full, but every cheer() passed a
shorter beat, so the stand sat down while it was still audible. Floored at the
clip length. New, much bolder clap frame — the re-roll came back brighter and
bluer, which would have flashed the whole stand, so it is colour-matched to frame
one per channel on mean and sd before quantising.

Net and umpire were both wrong since forever: netW was the singles-stick width
(10.06) against a court drawn to the doubles lines, so the net stopped 0.46m
short each side; the chair's near legs stood 0.45m inside the sideline. Both now
sit exactly on the doubles line.

26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the
wider net (the bar is inside one sigma), 30 human-tapped points = 50 hits /
0 whiffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-27 18:09:18 +03:00
1 parent 32401366fe
commit 55ddf5932e
8 files changed
+108 -23

No files matched your search

+52 -15
View File
@@ -1,11 +1,11 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { TUNE, UI, APPLAUSE_T, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts, playerGore } from './zombies.js';
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, gore, explode, meleeSwipe, extras, parts, pellets, playerGore } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -222,7 +222,9 @@ export function dismissModal() {
export function cheer(seconds, vol) {
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
crowd.cheer = Math.max(crowd.cheer, seconds);
// floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its
// feet until it stops. `seconds` still lets a big moment run longer than the audio.
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
}
function advancePoint(side) { // shared scoring math; returns whether a game was won
@@ -373,6 +375,7 @@ export function startDuel() {
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
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
ball.active = false; setupPoint();
zombieDuck(true); // fade the zombie music out for the 1:1 duel
@@ -718,6 +721,51 @@ function expirePose(ch, dt) {
if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; }
}
// ------------------------------------------------------------- ball boys ----
// The fetch boy is unchanged: sprint the width of the court, scoop the dead ball in passing.
function fetchRound(b, dt) {
const step = b.dir * 7.5 * dt;
b.x += step; b.runT += dt; b.dist = (b.dist || 0) + Math.abs(step);
if (!ball.collected && !ball.active) {
const dz = ball.z - b.z;
b.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
if (Math.abs(b.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
}
if (Math.abs(b.x) > 13.5) G.boy = null;
}
// The mop boy actually does a JOB: walk to a stain, stand on it and push the mop until it's
// gone, then move to the next one, then leave. He used to slide the width of the court in a
// straight line at constant speed, which read as moonwalking past the mess rather than cleaning it.
function mopRound(b, dt) {
b.runT += dt;
if (b.mopT > 0) { // ON a stain: no travel, but the push cycle keeps animating (dist drives frames)
b.mopT -= dt;
b.dist = (b.dist || 0) + TUNE.boyMopSpeed * 0.6 * dt; // the mop travels even when he doesn't
if (b.mopT <= 0) b.jobs--; // that one's done — look for the next
return;
}
// pick the next target: the biggest stain he can still reach, ignoring ones already scrubbed out
if (b.tx === undefined && b.jobs > 0) {
let best = null, bs = -1;
for (const d of gore) {
if (d.a < 0.15 || d.z < COURT.netZ - 2) continue; // faded, or up the far end where he can't work
const score = d.a * d.r - Math.hypot(d.x - b.x, d.z - b.z) * 0.04; // big and close first
if (score > bs) { bs = score; best = d; }
}
if (best) { b.tx = best.x; b.tz = best.z; }
else b.jobs = 0; // nothing left to clean — go home
}
const working = b.tx !== undefined && b.jobs > 0;
const gx = working ? b.tx : b.dir * 14, gz = working ? b.tz : b.z; // no target left: walk off the wing
const dx = gx - b.x, dz = gz - b.z, d = Math.hypot(dx, dz);
const step = Math.min(TUNE.boyMopSpeed * dt, d);
if (d > 0.001) { b.x += dx / d * step; b.z += dz / d * step; b.dist = (b.dist || 0) + step; }
if (Math.abs(dx) > 0.05) b.dir = dx > 0 ? 1 : -1; // face the way he's walking
if (working && d <= 0.12) { b.mopT = TUNE.boyMopDwell; b.tx = undefined; b.tz = undefined; } // arrived: get to work
if (Math.abs(b.x) > 13.5) G.boy = null; // off the wing — round over
}
// ---------------------------------------------------------------- update ----
export function update(dt) {
if (G.hitstop > 0) { G.hitstop -= dt; return; }
@@ -930,18 +978,7 @@ export function update(dt) {
// ball boy: sprint across, scoop the ball in passing, despawn off the far side
const boy = G.boy;
if (boy) {
// mopping is not sprinting. `dist` is what drives his animation frames (render.drawBoy), so
// the legs are tied to the ground he covers and can never out-run or lag behind the walk.
const step = boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt;
boy.x += step; boy.runT += dt; boy.dist = (boy.dist || 0) + Math.abs(step);
if (!boy.mop && !ball.collected && !ball.active) { // a mop boy has no ball to chase — he must hold his lane and clean it
const dz = ball.z - boy.z;
boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
}
if (Math.abs(boy.x) > 13.5) G.boy = null; // off the far wing — his round is done
}
if (boy) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt);
if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null;
if (G.phase === 'over') {