Let the player walk between points, and fix seven retest gripes
All eight issues from Oliver's 35-game retest. The freeze (1 and 6 — one root cause): the movement block, pose update included, lived inside `if (G.phase === 'rally')`, so every 'over' beat and every pretoss stranded the player mid-animation while the zombies walked on. It now also runs whenever splatter is in any phase but 'toss' — 'toss' is the aim, and the thumb is busy. Tennis keeps its old gating. The rest: - the mop boy is waved off the sudden-death duel and barred for its duration - triple-serve clones inherit the serve's topspin gravity, so the wings stop floating while the centre ball dives - "Time." belongs to the changeover's own countdown, not to whatever beat happened to expire while the breather ran - the tutorial illustration is a finger pressing and pulling back now, not a curved arrow that read as a scroll - aiming holds the pretoss stance; the smash plays when the shot fires - GAME N sits on the scoreboard permanently HANDOFF records all eight as closed with what the fix was, plus two ways a soak rig lies to you and why the CPU-return benchmark can't be reproduced from the number alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8dca9b6d45
commit
e00886f57e
7 files changed
+89
-52
No files matched your search
+63
-44
@@ -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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 51 KiB |
+19
-6
@@ -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.
|
||||
|
||||
+1
-1
@@ -60,6 +60,6 @@
|
||||
<p class="start">TAP TO START</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=35"></script>
|
||||
<script type="module" src="main.js?v=36"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user