diff --git a/HANDOFF.md b/HANDOFF.md index ceed4a9..520e4ae 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -182,6 +182,35 @@ Oliver rejected the procedural fakes on sight. The rule this round: **animation - Editing a full-scene image (`x_crowd` → `x_crowd2`) preserved the layout far better than expected: mean difference 3.2/255, railing and tier rows unmoved. Worth reusing for other 2-frame swaps. +## Third feedback round (2026-07-27) +- **THE FREEZE (serving dead, gun racket stuck over your head).** `G.mgT` only counted down inside + `if (G.mgT > 0 && G.phase === 'rally')`. Anything that moved the phase mid-burst — a scheduled + duel calling `setupPoint()` — stranded `mgT` above zero *forever*, and `mgT > 0` is one of + `keyPressed`'s early returns, so no tap could ever serve again. The yellow blob Oliver saw 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 (`startDuel` cancels any + burst — the duel is tennis). If another "can't serve" report ever comes in, check these flags + first: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT` — each one blocks `keyPressed`. +- **The mop boy has a job now** (`mopRound` in gameplay.js): walk to the biggest nearby stain, + stand on it for `TUNE.boyMopDwell` while the push cycle animates in place, scrub it out, pick the + next, then walk off. `TUNE.boyMaxJobs` caps the round so a kill streak can't employ him forever. + Gore fades ~3.5× faster while he is actually stopped and mopping than when merely walking past. + A straight-line constant-speed crossing was what read as moonwalking past the mess. +- **Applause is floored at the audio length.** `assets/audio/tennis-applaus.wav` is 4.16s and always + plays out in full, but every `cheer()` call passed a shorter beat, so the stand sat down while the + clap was still audible. `cheer()` now does `Math.max(crowd.cheer, seconds, APPLAUSE_T)`. +- **Regenerating a crowd frame: colour-match it or don't ship it.** The bolder re-roll came back + much brighter and bluer; swapping to it would have flashed the whole stand. Fixed by a per-channel + linear map onto frame one's mean and sd (means now agree to within half a level), then quantised + to 64 colours. Verify with a mean-difference number before trusting any regenerated frame. +- **Net and umpire were both wrong since forever.** `COURT.netW` was the singles-stick width 10.06 + while the court is drawn to the doubles lines, so the net stopped 0.46 m short each side — now + 10.97, flush. `UMP_X` 5.6 put the chair's near legs 0.45 m *inside* the doubles line (x_ump is + 42×112 px at UMP_H 3.0 = 1.12 m wide as drawn); 6.05 stands them exactly on the line. Do not push + past ~6.3 or the chair clips the right edge of the portrait viewport. + The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape); + serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma. + ## Gotchas learned the hard way - Browser-pane verification: python http.server sends no cache headers, so edited ES modules often load STALE even after location.reload(). Reliable fix: preview_stop + diff --git a/assets/_sheet_crowd2.png b/assets/_sheet_crowd2.png new file mode 100644 index 0000000..e7fbdc1 Binary files /dev/null and b/assets/_sheet_crowd2.png differ diff --git a/assets/x_crowd2.png b/assets/x_crowd2.png index 2ad76d3..361df44 100644 Binary files a/assets/x_crowd2.png and b/assets/x_crowd2.png differ diff --git a/config.js b/config.js index 4415b5b..4afabfb 100644 --- a/config.js +++ b/config.js @@ -32,6 +32,8 @@ export const TUNE = { zMaxCap: 14, // absolute shambler cap changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand) boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5) + boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one + boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so // the feet cannot slide: change a boy's speed and his legs keep up on their own. gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games @@ -109,6 +111,11 @@ export const TUNE = { // the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore // · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap // during the burst can't skip it. +// assets/audio/tennis-applaus.wav runs 4.16s and always plays in full, so the crowd must keep +// clapping at least that long — every cheer() call was passing a shorter beat, which left the +// stand sitting back down while the applause was still audible. +export const APPLAUSE_T = 4.2; + export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2; // UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately @@ -117,7 +124,10 @@ export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2; export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc', dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' }; -export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 }; +// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the +// net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts +// now stand exactly on the doubles sidelines. +export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.97 }; COURT.svcFar = COURT.netZ - 6.4; // far service line COURT.svcNear = COURT.netZ + 6.4; // near service line @@ -128,7 +138,10 @@ export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck // waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out. export const BOY_ENTER = { mop: 9.5, run: 13 }; export const UMP_H = 3.0; -export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait +// The chair is 1.12 m wide as drawn (x_ump is 42x112 at UMP_H 3.0), so at 5.6 its near legs stood +// 0.45 m INSIDE the doubles line, on the court. 6.05 puts them exactly on the line; anything much +// beyond ~6.3 clips the right edge of the portrait viewport (7.1 was fully off-screen). +export const UMP_X = 6.05; // contact rules: outside this height band the ball can't be hit (below = it's past you) export const CONTACT = { minY: 0.24, maxY: 2.3 }; diff --git a/gameplay.js b/gameplay.js index b16ff13..4be3995 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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') { diff --git a/index.html b/index.html index bd3c06f..499a57d 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/state.js b/state.js index 8f2cb8d..e9aa103 100644 --- a/state.js +++ b/state.js @@ -1,6 +1,6 @@ // All mutable game state in one place, plus the point-reset that re-arms it. -import { HOME, BOY_ENTER } from './config.js'; +import { HOME, BOY_ENTER, TUNE } from './config.js'; export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; @@ -105,7 +105,7 @@ export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') { // ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride, // which is why he flickered in and out: a kill streak restarted him several times a second. // A boy already out on the court keeps working — he just adds the new mess to his round. - if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = (G.boy.jobs || 1) + 1; } return; } + if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = Math.min(TUNE.boyMaxJobs, (G.boy.jobs || 1) + 1); } return; } const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run const e = mop ? BOY_ENTER.mop : BOY_ENTER.run; G.boy = { x: dir > 0 ? -e : e, z: tz, dir, runT: 0, dist: 0, mop, jobs: 1 }; diff --git a/zombies.js b/zombies.js index ad396a2..2afd186 100644 --- a/zombies.js +++ b/zombies.js @@ -228,9 +228,12 @@ export function updateZombies(dt) { if (G.mode === 'splatter') { // transition spawns are scripted, not timed G.splatT += dt; if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap - else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules + else if (G.mgT > 0) { // machine-gun tin: horde firepower, regular rules + // The burst clock ticks in EVERY phase. It used to tick only during 'rally', so anything that + // moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and + // `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen. G.mgT -= dt; - if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } + if (G.phase === 'rally' && (G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes } if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns) @@ -400,7 +403,10 @@ export function updateZombies(dt) { const boy = G.boy; // the boy mops any gore he passes over for (let i = gore.length - 1; i >= 0; i--) { const d = gore[i]; - if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * (d.scorch ? 0.16 : 1.6); // craters scar the lawn 10× longer + // He scrubs HARD only while stopped and working the mop; walking past barely touches it, so + // the stain visibly dies under him rather than fading as he drifts by. + if (boy && Math.abs(boy.x - d.x) < 0.8 && Math.abs(boy.z - d.z) < 1.3) + d.a -= dt * (d.scorch ? 0.16 : boy.mopT > 0 ? 0.7 : 0.2); // craters scar the lawn far longer if (d.a <= 0) gore.splice(i, 1); } for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins