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:
1 parent
32401366fe
commit
55ddf5932e
8 files changed
+108
-23
No files matched your search
+52
-15
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user