diff --git a/HANDOFF.md b/HANDOFF.md index 66ab1c3..169f31b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,9 +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-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.** +Last updated 2026-07-30 after Oliver playtested the fixes. All eight retest issues are closed and +recorded at the bottom with what the fix was, because each one is a trap worth not re-digging. +Issue 1's first fix was **vetoed on feel and replaced** — read that entry before touching movement. ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game @@ -102,6 +102,15 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t nearer one still wins, so a straight-ahead lane still eats the nearest body first. `aimTie: 1` restores the old "nearest along the lane always wins" for A/B. - `TUNE.fps` 60 — 30 was vetoed on feel. +- **The player only walks during `rally`.** Never between points. Letting him walk during the 'over' + beats and `pretoss` was shipped once and vetoed immediately: *"I basically can never walk around + freely"* — so suddenly being able to, only between points, reads as the game breaking rather than + as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (`held` in + `updateZombies`), never by handing back movement. +- **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about + ±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the + frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so + every legal ball stays catchable. ## Verification recipe (the regression net) Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval @@ -190,16 +199,37 @@ Kept because each one is a trap worth not re-digging. Every fix was verified in `window.T`; the numbers quoted are from those runs. ### 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. +**Two fixes: the first was wrong.** The movement block — pose update included — lived inside +`if (G.phase === 'rally')`, so every 'over' beat and every `pretoss` left the player standing while +zombies walked at him. The `pretoss` case is issue 6: `endHorde` calls `setupPoint()`. + +The first attempt widened the block to all of splatter except `'toss'`. Oliver vetoed it on sight: +*"the mechanic where I suddenly can move freely around feels broken.. I basically can never walk +around freely - also I could walk off the screen"*. He is right — free movement for five seconds +between points, in a game where you otherwise only move while a ball is live, reads as a bug. It is +reverted, and the rule is now in Settled feel decisions above. + +What actually ships, three separate pieces: +- **The dead wait too.** `held = !!G.changeover` in `updateZombies` stops every zombie's `z` advance, + its x steering, and the eat check for the breather's duration. They still bleed, still burn, still + animate — they just stand there, because the umpire called time and it is still a tennis match. + This is Oliver's own second option and the characterful one (the boys mop, everyone waits). +- **The stuck pose.** `expirePose` only ever expired `swing`/`serve`; `'run'` was the pose nothing + reset, and `P.runT` stops advancing outside the movement block, so a beat that began mid-stride + welded him to one run frame. An `else if (P.pose === 'run')` on the movement block fixes it for + every phase at once. +- **The on-screen clamp** (see Settled feel decisions) — `xVis` already existed but was applied only + in auto-fire modes, so ordinary play could walk clean off the edge. Now it always applies. + +**The path where this actually bit** — worth knowing, because a clear court makes it look impossible: +a changeover normally starts only after `G.clearing` has emptied the court, so nothing is walking. +But `resolveInterlude('O')` spawns **five fresh zombies** ("the dead lose their patience") and *then* +hands back to `startChangeover` via `G.afterDuel`. Lose a duel scheduled on a changeover beat and you +get five shamblers plus five seconds with no agency. Verified fixed: they advance 0.000 m across the +breather and resume 0.75 m/s the instant "Time." is called. + +If the 5 s still drags on a phone, `TUNE.changeoverT` is the knob — it is now a safe, watchable beat +rather than a dangerous one, so shortening it is purely a pacing call. ### 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 diff --git a/gameplay.js b/gameplay.js index b86f926..adb6cb4 100644 --- a/gameplay.js +++ b/gameplay.js @@ -814,11 +814,11 @@ export function update(dt) { 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) - // 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')) { + // 'rally' ONLY, deliberately. Letting the player walk during the between-point beats was tried + // and vetoed on feel: you never walk freely during play, so suddenly being able to between points + // reads as the game breaking, not as a fix. The frozen-changeover complaint is answered by + // holding the DEAD instead (see updateZombies), and the stuck pose by the reset below. + if (G.phase === 'rally') { 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 }; @@ -854,7 +854,11 @@ export function update(dt) { assist = dx; } } - const xLim = mgMode ? xVis : P_BOUNDS.x; // auto-fire keeps even keyboard players on screen + // Stay ON SCREEN — always, not just in auto-fire. P_BOUNDS.x is 6.5 m, but at the baseline in + // portrait the camera only shows about ±4 m either side of the centre line, so walking to the + // bound put the player clean off the edge (x = -6.5 projects to screen -61 of 270). Floored at + // the sideline so every legal tennis ball stays catchable — the court is never out of bounds. + const xLim = Math.max(xVis, COURT.W / 2); P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); const moving = mx || mz || assist !== 0; @@ -863,6 +867,10 @@ export function update(dt) { if (mx) P.facing = mx > 0 ? 1 : -1; else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing } + // ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only + // handles swing/serve), and P.runT stops advancing outside the block, so a beat that began + // mid-stride left him welded to a single run frame — the "stuck mid-animation" report. + else if (P.pose === 'run') { P.pose = 'idle'; P.poseT = 0; } // splatter: you START at the baseline but you're free to advance up-court (bounded at the net by // P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still // recovers you toward the baseline between shots on touch; the duel is plain tennis as ever. diff --git a/index.html b/index.html index 65c3a6b..b2cfe2c 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@
TAP TO START
- +