Stop the dead for the changeover instead of letting the player wander
Oliver vetoed the free movement between points on sight: he never walks freely during play, so being able to only between points reads as the game breaking, not as a fix. Reverted to 'rally' only, and the rule is written into the settled-feel list so it doesn't get re-invented. The frozen changeover is answered his other way instead — the dead wait too. `held` in updateZombies stops their advance, their steering and the eat check for the breather; they still bleed and burn, they just stand there, because the umpire called time and it is still a tennis match. The path where this actually bit: losing a duel scheduled on a changeover beat spawns five fresh shamblers and THEN starts five seconds you have no agency in. The stuck pose was never about movement — 'run' is the one pose expirePose doesn't expire, and runT stops advancing outside the block, so a beat that began mid-stride welded him to a single frame. Reset it and it's fixed for every phase. And he could walk off the screen in ordinary play, not just during the beat I had added: P_BOUNDS.x is 6.5 m, portrait shows about ±4 m at the baseline. The xVis clamp already existed but only applied during auto-fire; it now always applies, floored at the sideline so every legal ball stays catchable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e00886f57e
commit
79fb43f60d
4 files changed
+68
-23
No files matched your search
+43
-13
@@ -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
|
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
|
||||||
detail of any individual change, read the git log.
|
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
|
Last updated 2026-07-30 after Oliver playtested the fixes. All eight retest issues are closed and
|
||||||
browser; they are recorded at the bottom as **CLOSED** with what the fix was, because each one is a
|
recorded at the bottom with what the fix was, because each one is a trap worth not re-digging.
|
||||||
trap worth not re-digging. **Awaiting Oliver's next phone playtest.**
|
Issue 1's first fix was **vetoed on feel and replaced** — read that entry before touching movement.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
|
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.
|
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.
|
`aimTie: 1` restores the old "nearest along the lane always wins" for A/B.
|
||||||
- `TUNE.fps` 60 — 30 was vetoed on feel.
|
- `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)
|
## Verification recipe (the regression net)
|
||||||
Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval
|
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.
|
`window.T`; the numbers quoted are from those runs.
|
||||||
|
|
||||||
### 1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH
|
### 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'` —
|
**Two fixes: the first was wrong.** The movement block — pose update included — lived inside
|
||||||
lived inside `if (G.phase === 'rally')`, so every 'over' beat (changeover, game beat, set card) and
|
`if (G.phase === 'rally')`, so every 'over' beat and every `pretoss` left the player standing while
|
||||||
every `pretoss` froze the player while `updateZombies` ran on. That `pretoss` case is issue 6:
|
zombies walked at him. The `pretoss` case is issue 6: `endHorde` calls `setupPoint()`.
|
||||||
`endHorde` calls `setupPoint()`, which hands back to `pretoss`.
|
|
||||||
The block now also runs whenever `G.mode === 'splatter' && G.phase !== 'toss'`. `'toss'` stays frozen
|
The first attempt widened the block to all of splatter except `'toss'`. Oliver vetoed it on sight:
|
||||||
on purpose — that is the aim, and the thumb is busy drawing the bow. **Tennis is deliberately
|
*"the mechanic where I suddenly can move freely around feels broken.. I basically can never walk
|
||||||
untouched** (verified: a keyboard-held ArrowLeft moves the player 0.00 m during a tennis 'over',
|
around freely - also I could walk off the screen"*. He is right — free movement for five seconds
|
||||||
−2.70 m during a splatter one).
|
between points, in a game where you otherwise only move while a ball is live, reads as a bug. It is
|
||||||
Oliver's other option — stopping the zombies for the breather — was NOT taken. `startChangeover`'s
|
reverted, and the rule is now in Settled feel decisions above.
|
||||||
"it's calm, not safe" is still the design; being able to walk was the actual complaint.
|
|
||||||
|
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
|
### 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
|
`sendBoy` now returns early on `G.interlude`, and `startDuel` sets `G.boy = null`. Note the ordering
|
||||||
|
|||||||
+14
-6
@@ -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; }
|
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)
|
// --- player movement (near half only, generous margins)
|
||||||
// In splatter it also runs between points ('over' — game beats, changeovers, set cards — and
|
// 'rally' ONLY, deliberately. Letting the player walk during the between-point beats was tried
|
||||||
// 'pretoss'): the zombies never stop walking, so gating movement on 'rally' froze the player
|
// and vetoed on feel: you never walk freely during play, so suddenly being able to between points
|
||||||
// mid-stride with the dead closing in, and left him stuck in whatever pose he held. 'toss' is
|
// reads as the game breaking, not as a fix. The frozen-changeover complaint is answered by
|
||||||
// the one exception — that's the aim, and the thumb is busy drawing the bow.
|
// holding the DEAD instead (see updateZombies), and the stuck pose by the reset below.
|
||||||
if (G.phase === 'rally' || (G.mode === 'splatter' && G.phase !== 'toss')) {
|
if (G.phase === 'rally') {
|
||||||
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
|
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);
|
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 };
|
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;
|
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.x = Math.max(-xLim, Math.min(xLim, P.x));
|
||||||
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
|
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
|
||||||
const moving = mx || mz || assist !== 0;
|
const moving = mx || mz || assist !== 0;
|
||||||
@@ -863,6 +867,10 @@ export function update(dt) {
|
|||||||
if (mx) P.facing = mx > 0 ? 1 : -1;
|
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
|
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
|
// 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
|
// 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.
|
// recovers you toward the baseline between shots on touch; the duel is plain tennis as ever.
|
||||||
|
|||||||
+1
-1
@@ -60,6 +60,6 @@
|
|||||||
<p class="start">TAP TO START</p>
|
<p class="start">TAP TO START</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=36"></script>
|
<script type="module" src="main.js?v=37"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+10
-3
@@ -269,23 +269,30 @@ export function updateZombies(dt) {
|
|||||||
// the only bodies left to catch were the ones it had already removed.
|
// the only bodies left to catch were the ones it had already removed.
|
||||||
if (ball.active && ball.flame && ball.y < 2.6) ignite(ball.x, ball.z, TUNE.burnBallR);
|
if (ball.active && ball.flame && ball.y < 2.6) ignite(ball.x, ball.z, TUNE.burnBallR);
|
||||||
|
|
||||||
|
// The umpire called time. The changeover is a MANDATORY breather for the player — no serving, no
|
||||||
|
// walking — so it is one for the dead too, which is the joke: they wait, because it's still a
|
||||||
|
// tennis match. They stand where they are, still bleeding, still burning, still lit by anything
|
||||||
|
// in the air, but nothing advances and nothing can eat you. (The alternative — letting the player
|
||||||
|
// walk freely during the beat — was tried and vetoed: he never walks freely during play either.)
|
||||||
|
const held = !!G.changeover;
|
||||||
|
|
||||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||||
const zb = zombies[i];
|
const zb = zombies[i];
|
||||||
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
|
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
|
||||||
zb.walkT += dt;
|
zb.walkT += dt;
|
||||||
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
|
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
|
||||||
zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
|
if (!held) zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
|
||||||
bleed(zb); // open wounds bleed every frame they're standing
|
bleed(zb); // open wounds bleed every frame they're standing
|
||||||
|
|
||||||
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
|
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
|
||||||
if (zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
|
if (held || zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
|
||||||
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
|
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
|
||||||
const tx = P.x + zb.homeOff;
|
const tx = P.x + zb.homeOff;
|
||||||
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
|
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
|
||||||
+ Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked
|
+ Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked
|
||||||
} else
|
} else
|
||||||
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
|
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
|
||||||
if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) { // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
|
if (!held && G.mode === 'splatter' && Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) { // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
|
||||||
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
|
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
|
||||||
for (let k = 0; k < 30; k++) parts.push({
|
for (let k = 0; k < 30; k++) parts.push({
|
||||||
x: P.x, y: 1.1, z: P.z,
|
x: P.x, y: 1.1, z: P.z,
|
||||||
|
|||||||
Reference in new issue
Block a user