Zombies get faster over time; when one reaches you, you're eaten

splatT now ticks while in splatter mode and scales spawn speed by
1 + min(1.5, splatT/70). A zombie within 0.7m of the player ends the
run: gore burst, 'game, set, massacre', dark EATEN. overlay with final
games and a tap-to-restart (gated 0.7s so death-spam doesn't skip it).
Zombies keep shambling on the end screen but no longer score. Also
verified the two prior unverified items: quick-serve cadence (0.25s
between balls, flick = instant aimed serve) and flick aiming across
the net (far/near lane selection works as designed). HANDOFF updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 19:54:09 +02:00
1 parent a5a6f550a3
commit 943b7da9fd
6 files changed
+58 -47

No files matched your search

+22 -43
View File
@@ -38,51 +38,30 @@ Two runtime-only import cycles (documented in headers): input↔gameplay, zombie
Ball boys mop gore they pass. HUD shows THEM instead of CPU.
- Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback.
## ACTIVE TASK — Oliver's last message (his exact words):
> much better!!!
> the time between each serve is too long in zombie mode.
> right now it auto aims to the closest. i should also be able to aim anywhere includibg over the net.
> the speed of zombies should increase over time
> when they reach me i should be eaten
## ACTIVE TASK — all four items from Oliver's feedback round are DONE and VERIFIED
(serve cadence · flick-aim anywhere incl. over the net · zombie speed ramp · eaten):
Status per item (working tree runs clean, no console errors, but items 1–2 are UNVERIFIED):
1. **Serve cadence — verified.** Dead splatter ball → 15 frames (0.25s) to next pretoss;
setupPoint leaves P where he stands; FLICK in pretoss fires an instant quick serve
(no toss, pow 0.62, aimed), plain TAP still does the toss ritual.
2. **Aim anywhere — verified.** Flick up targeted a far-side zombie (marker z=5.5 for a
zombie at z=5, over the net), flick down a near-side one (z=18.1). If the lane system
still fights Oliver, next step is true free-aim (flick → landing point, splash radius).
3. **Speed ramp — done.** G.splatT ticks in updateZombies (splatter only); spawnZombie
multiplies speed by `1 + min(1.5, splatT/70)`. Verified 0.76 m/s fresh vs ~1.5 at splatT=140.
Spawn interval / zMax do NOT ramp yet — add if Oliver wants more pressure.
4. **Eaten — done.** updateZombies: zombie within 0.7m of P (splatter only) → gore + 30
particles + splat(1) + voice('game_set_massacre') + mode 'eaten', ball dead, flash cleared.
gameplay.update early-exits in 'eaten' (ticks idleT/eatenT, still updates zombies —
they shamble on; baseline crossings no longer score). render: P not drawn, dark overlay
EATEN. / games score / TAP TO PLAY AGAIN (restart prompt appears after G.eatenT > 0.7 so
death-spam taps don't skip the screen). main.js: tap/key while eaten → location.reload().
1. **Serve cadence — code DONE, not verified.**
- gameplay.js dead-ball branch: splatter overT 0.7 → 0.25, new_balls voice freq 0.2 → 0.12.
- state.js setupPoint splatter branch no longer repositions P (serve from where you stand).
- keyPressed pretoss+splatter: a FLICK now fires an instant quick serve (no toss, quality
0.62, aimed by the flick via hitServe(P, 0.62, flick)); a plain TAP still does the toss
ritual (perfect serves remain possible). hitServe gained an `aim` param.
2. **Aim anywhere incl. over the net — code DONE, not verified.**
- `zombieTarget(aim)` in gameplay.js rewritten: no aim → most advanced zombie (max z);
with aim → lane relative to the PLAYER (`P.x + ax*4`), az<0 prefers far zombies,
az>0 prefers close ones on your own side. All z candidates included.
- NOTE Oliver's complaint was that it "auto aims to the closest" — if the rewrite still
feels wrong, the next step is true free-aim: flick direction → landing point directly
(no zombie snap), with a generous splash/hit radius. Ask him which feel he wants if
the lane system still fights him.
3. **Zombie speed ramps over time — NOT DONE.**
- `G.splatT` field was added to state.js but is NEVER incremented — wire it up in
updateZombies (only while `G.mode==='splatter'`).
- Planned approach (my edit failed on a stale string, zombies.js current content is in
this repo, read it): in `spawnZombie`, multiply speed by
`1 + Math.min(1.5, G.splatT / 70)` (ramp at spawn time keeps the shot-lead math exact),
and optionally shrink the spawn interval / grow zMax with splatT.
4. **Eaten when they reach you — NOT DONE.** Plan agreed with Oliver:
- In updateZombies: `G.mode==='splatter' && Math.hypot(zb.x-P.x, zb.z-P.z) < 0.7` →
eat: gore decal + big particle burst at P, splat(1), voice('game_set_massacre', 0.8),
`G.mode='eaten'`, ball.active=false. Zombies keep shambling for ambience (guard the
eat check to mode==='splatter' so it fires once).
- gameplay.update: early-exit branch for mode 'eaten' (still tick idleT + updateZombies).
- render.js: skip drawChar(P) when eaten; dark overlay with 'EATEN.', final games score,
'TAP TO PLAY AGAIN'. Also gate the serve prompt on mode!=='eaten'.
- main.js: in the cv pointerdown listener (and a keydown), if mode==='eaten' →
location.reload() (laziest honest restart; fine for now).
- Existing baseline-cross rule (splatPoint('O') at z >= L+0.7) stays: crossing without
touching you is a point for THEM, touching you is death.
ALSO: tennis volley fix (Oliver's follow-up "impossible to volley when the marker is behind
me"): pre-bounce player contact widens reach x1.4 and swing window x1.8 (VOLLEY in config.js,
applied in trySwing + the expiry in update). Root cause was no hard block, just a ~0.28s
no-cue timing window at full ball pace; now ~0.5s. doHit also clears G.wasServe so volleying
a serve back doesn't fault-check the return. CPU still returns 11/12 serves (benchmark holds).
## Verification recipe (the regression net — use it after the above)
Drive the game deterministically via `window.T` with javascript_tool: the Browser-pane eval
+5
View File
@@ -329,6 +329,11 @@ function expirePose(ch, dt) {
// ---------------------------------------------------------------- update ----
export function update(dt) {
if (G.hitstop > 0) { G.hitstop -= dt; return; }
if (G.mode === 'eaten') { // the match is over in the truest sense
G.idleT += dt; G.eatenT += dt;
updateZombies(dt);
return;
}
if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; }
crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt;
+5
View File
@@ -31,6 +31,11 @@ function dismissIntro() {
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
addEventListener('keydown', () => dismissIntro(), { once: true });
// eaten: any tap or key (after a beat, so death-spam doesn't skip the screen) restarts
const maybeRestart = () => { if (G.mode === 'eaten' && G.eatenT > 0.7) location.reload(); }; // ponytail: reload = honest full reset
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
// hidden shortcut: tap the umpire three times to summon zombie mode early
let umpTaps = [];
cv.addEventListener('pointerdown', e => {
+10 -2
View File
@@ -258,7 +258,7 @@ export function render() {
ctx.fillStyle = 'rgba(170,22,22,0.9)';
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
}
drawChar(P);
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
// HUD — scoreboard
ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30);
@@ -272,7 +272,15 @@ export function render() {
ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc';
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22);
ctx.textAlign = 'center';
if (G.phase === 'pretoss' && score.server === 'P') {
if (G.mode === 'eaten') {
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#c02020'; ctx.font = 'bold 22px monospace';
ctx.fillText('EATEN.', W / 2, H / 2 - 30);
ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace';
ctx.fillText('GAMES YOU ' + score.games[0] + ' — THEM ' + score.games[1], W / 2, H / 2);
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 20); }
}
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten') {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
const touch = matchMedia('(pointer: coarse)').matches;
ctx.fillText(G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
+1
View File
@@ -29,6 +29,7 @@ export const G = {
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
wasServe: false, // the ball in flight is a serve (first bounce must hit the box)
splatT: 0, // time survived in splatter mode — drives the difficulty ramp
eatenT: 0, // time since you were eaten — gates the restart tap
};
export const other = s => s === 'P' ? 'O' : 'P';
+15 -2
View File
@@ -15,7 +15,8 @@ export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t}
let spawnT = 1.5, moanT = 3;
export function spawnZombie(x, z, variant) {
zombies.push({ x, z, v: TUNE.zSpeed * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant });
const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive
zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant });
}
function splatter(i) {
@@ -39,6 +40,7 @@ export function updateZombies(dt) {
if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; }
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
G.splatT += dt;
spawnT -= dt;
if (spawnT <= 0 && zombies.length < TUNE.zMax) {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
@@ -51,6 +53,17 @@ export function updateZombies(dt) {
zb.walkT += dt;
zb.z += zb.v * dt;
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // shamblers don't walk straight lines
if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < 0.7) { // it got you
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
for (let k = 0; k < 30; k++) parts.push({
x: P.x, y: 1.1, z: P.z,
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
t: 0.5 + Math.random() * 0.5,
});
splat(1);
voice('game_set_massacre', 0.8);
G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience
}
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
if (d < 0.55) {
@@ -60,7 +73,7 @@ export function updateZombies(dt) {
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball
}
}
if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); splatPoint('O'); } // it reached your baseline
if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten') splatPoint('O'); } // it reached your baseline
}
for (let i = parts.length - 1; i >= 0; i--) {