diff --git a/HANDOFF.md b/HANDOFF.md index be168f6..66ab1c3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,7 +5,9 @@ everything they specified is shipped, and what still matters from them is record long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the detail of any individual change, read the git log. -Last updated 2026-07-27 after Oliver's 35-game retest. **Open issues are at the bottom — start there.** +Last updated 2026-07-30. All eight issues from Oliver's 35-game retest are fixed and verified in the +browser; they are recorded at the bottom as **CLOSED** with what the fix was, because each one is a +trap worth not re-digging. **Awaiting Oliver's next phone playtest.** ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game @@ -116,9 +118,22 @@ Clear the intro first: `document.getElementById('intro')?.remove()`. **Benchmarks that must keep holding** (re-run after any physics/aim change): - CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%). Watch this — the last few sit under the bar rather than scattered around it. + **The harness for this number was never written down and I could not reconstruct it.** A rig that + counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU + gets another swing at the SAME ball (`aiSwingCool = 0.5`) and connects — 91.7% ≈ 1 − aiFlub, so the + recorded number must count only the FIRST swing opportunity, or only returns that land in. + Until someone writes the rig down, prove "no tennis regression" **differentially** instead: run + your rig, `git stash` the change, reload the modules, run the identical rig, compare. Equal + numbers on the same rig is the real claim; a bare percentage against this line is not. - 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**. + This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts). - A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall, - `mgT` back to 0, and gore/piles arrays bounded. + `mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30). + **Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing` + makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after + each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is + the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect + `clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable. - **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150. ## Asset pipeline @@ -170,52 +185,56 @@ Known generator quirks, all still true: --- -# OPEN ISSUES — from Oliver's 35-game retest (2026-07-27) -Nothing below is fixed. Diagnoses are from reading the code this session; verify before trusting. +# CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30 +Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via +`window.T`; the numbers quoted are from those runs. -### 1. Player frozen (and stuck mid-animation) during the between-game pauses — HIGH -During the ~5s changeover the player cannot move but the zombies keep walking. The whole movement -block, including the `P.pose = moving ? 'run' : 'idle'` update, lives inside -`if (G.phase === 'rally')` in `update()`. Every 'over' beat (changeover, game beat, set card) -therefore freezes the player in whatever pose he held while `updateZombies` runs on. -**This is almost certainly also issue 6** (freeze after the horde) — `endHorde` hands back to a -phase where the movement block doesn't run. -Oliver's stated options: *drop the 5s entirely, or stop the zombies for its duration.* Stopping the -zombies is the more characterful of the two (the boys mop, everyone waits) but letting the player -walk during 'over' in splatter is the smaller diff and fixes the stuck pose too. Consider both. +### 1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH +Same root cause, one fix. The whole movement block — including `P.pose = moving ? 'run' : 'idle'` — +lived inside `if (G.phase === 'rally')`, so every 'over' beat (changeover, game beat, set card) and +every `pretoss` froze the player while `updateZombies` ran on. That `pretoss` case is issue 6: +`endHorde` calls `setupPoint()`, which hands back to `pretoss`. +The block now also runs whenever `G.mode === 'splatter' && G.phase !== 'toss'`. `'toss'` stays frozen +on purpose — that is the aim, and the thumb is busy drawing the bow. **Tennis is deliberately +untouched** (verified: a keyboard-held ArrowLeft moves the player 0.00 m during a tennis 'over', +−2.70 m during a splatter one). +Oliver's other option — stopping the zombies for the breather — was NOT taken. `startChangeover`'s +"it's calm, not safe" is still the design; being able to walk was the actual complaint. -### 2. The mop boy walks on during the 1:1 duel — LOW -`sendBoy` has no `G.interlude` guard and `startDuel` doesn't clear `G.boy`. The duel is meant to be -clean tennis; nobody should be mopping through it. +### 2. The mop boy walks on during the 1:1 duel — was LOW +`sendBoy` now returns early on `G.interlude`, and `startDuel` sets `G.boy = null`. Note the ordering +that makes this safe: `resolveInterlude('P')` clears `G.interlude` **before** calling `deliverTin()`, +so the boy still comes out with the tin. -### 3. Triple serve: the two wing balls lob off at the wrong height — MEDIUM -Confirmed cause. The real serve carries topspin gravity `ball.g = TUNE.gravity * (1 + quality * -TUNE.serveSpin)` — up to **2.3×** `TUNE.gravity` at full power. The clones in `extras` are -integrated at flat `TUNE.gravity` (`zombies.js`, the `for (let i = extras.length - 1 …)` loop: -`e.vy -= TUNE.gravity * dt`). So the centre ball dives and the wings float long. -Fix: copy `g: ball.g` onto each extra where they're pushed (`gameplay.js` in `hitServe`, and the -re-fire clone lower down) and integrate with `e.g || TUNE.gravity`. +### 3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM +The diagnosis in the old text was right. `g: ball.g` is now copied onto each clone (both push sites: +`hitServe`'s triple fan and `dragServe`'s re-fire clone) and `zombies.js` integrates +`e.vy -= (e.g || TUNE.gravity) * dt`. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2 +base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both). -### 4. The umpire calls "Time" on pauses that aren't changeovers — LOW -`voice('time')` fires in the `over` branch of `update()` whenever `G.changeover` clears. Needs to -distinguish a real changeover from other forced waits, or the line needs to move. +### 4. The umpire calls "Time" on pauses that aren't changeovers — was LOW +`G.changeover` is now the **seconds left**, not a flag (still truthy, so every `!G.changeover` guard +reads the same), and it counts itself down near the top of `update()`, calling `voice('time')` when it +hits zero. It no longer rides the generic `G.overT` expiry — anything that overwrote `overT` mid- +breather (a melee kill taking a game, say) used to make the umpire call time on a pause that was +never a changeover. Verified with a `fetch` spy on `assets/audio/time.m4a`: an unrelated `overT` +expiry at t=0.4 s says nothing and leaves 4.6 s on the breather; the line fires exactly once, when +the breather's own clock runs out. -### 5. The transition tutorial illustration confuses people — MEDIUM -`assets/x_howto.png`. The curved arrow plus thumbprint reads as a rounding/scroll gesture rather -than a pull-back. Oliver: *"maybe display a pulling and aiming finger?"* Worth regenerating as a -literal finger pressing down and dragging back with a motion trail, and re-checking it at the -modal's drawn size (~89×120 logical px in portrait) rather than at full resolution. -Note `drawPullHint()` now demos the gesture live in-game, so the illustration can lean on that. +### 5. The transition tutorial illustration confuses people — was MEDIUM +`assets/x_howto.png` regenerated (Gemini 3 Pro, edit mode with the old sheet as `--input`, then +`assets/_key.py … 240`). The curved arrow and thumbprint are gone; it is now a press ring at the top, +a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the +yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is +unchanged (240×321 vs 240×322), so the modal still draws it at 90×120 in portrait. Checked at that +drawn size, not at full resolution. -### 6. Sprite freeze after the horde — HIGH -See issue 1; very likely the same root cause. +### 7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM +`update()`'s forced `sv.pose = 'serve'` for `phase === 'toss'` is now gated on `!G.aim`: while the +slingshot is drawn the server holds the pretoss stance, and only `hitServe` puts him into the +toss/smash. Verified: plain tap → pose `serve`; drawn bow → pose `idle`. -### 7. Aiming plays the toss animation; it should stay in the pretoss stance — MEDIUM -`input.js` touchmove calls `serveToss()` on the first drag, which sets `G.phase = 'toss'`, and -`update()` forces `sv.pose = 'serve'` for the whole of `phase === 'toss'`. So the player is in the -toss/serve pose the entire time he is aiming. Oliver wants the **pretoss** stance while aiming, with -the toss/smash playing when the shot actually fires. - -### 8. Show the current game number persistently — LOW -Today it only appears as a 1.1s `GAME N` flash when the next game arms. Wants it visible somewhere -permanent — the scoreboard is the obvious home (it already has a sets column in splatter). +### 8. Show the current game number persistently — was LOW +`GAME N` now sits permanently under the scoreboard, right-aligned to the board's width, on the same +row as the NEXT pips and in the same dim tone (`render.js`, just before the pips block). The 1.1 s +flash when a game arms is still there. diff --git a/assets/x_howto.png b/assets/x_howto.png index ab67f56..f935521 100644 Binary files a/assets/x_howto.png and b/assets/x_howto.png differ diff --git a/gameplay.js b/gameplay.js index 1ca70aa..b86f926 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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. diff --git a/index.html b/index.html index daf87d5..65c3a6b 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 0e315ff..58074e2 100644 --- a/render.js +++ b/render.js @@ -681,6 +681,10 @@ export function render() { ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot } + if (splat) { // which game you're in, permanently — the 1.1s GAME N flash was the only place it showed + ctx.textAlign = 'right'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; + ctx.fillText('GAME ' + (G.runGames + 1), bW, bH + 7); + } if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, just under the board — no box ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 2, bH + 7); for (let k = 0; k < TUNE.killsPerPoint; k++) { diff --git a/state.js b/state.js index 840a4e2..cb35a07 100644 --- a/state.js +++ b/state.js @@ -103,6 +103,7 @@ export function setupPoint() { // walk, because mopping is not sprinting. Mopping is the DEFAULT once the gore starts; the plain // running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball. export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') { + if (G.interlude) return; // the sudden-death duel is clean tennis: nobody mops through it if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go // 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. diff --git a/zombies.js b/zombies.js index 44e0c82..41929ef 100644 --- a/zombies.js +++ b/zombies.js @@ -382,7 +382,7 @@ export function updateZombies(dt) { for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones const e = extras[i]; - e.t -= dt; e.vy -= TUNE.gravity * dt; + e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt; if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; } let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5;