From 943b7da9fdd690c7424d2d1ff288e45d500e0db4 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 19:54:09 +0200 Subject: [PATCH] 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 --- HANDOFF.md | 65 ++++++++++++++++++----------------------------------- gameplay.js | 5 +++++ main.js | 5 +++++ render.js | 12 ++++++++-- state.js | 1 + zombies.js | 17 ++++++++++++-- 6 files changed, 58 insertions(+), 47 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 3a35866..8179073 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/gameplay.js b/gameplay.js index 18df7bf..7882788 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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; diff --git a/main.js b/main.js index 1ae09cc..54cddc2 100644 --- a/main.js +++ b/main.js @@ -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 => { diff --git a/render.js b/render.js index 1507748..1a936db 100644 --- a/render.js +++ b/render.js @@ -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') diff --git a/state.js b/state.js index 4ca004e..72b4981 100644 --- a/state.js +++ b/state.js @@ -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'; diff --git a/zombies.js b/zombies.js index 42e784a..13a255b 100644 --- a/zombies.js +++ b/zombies.js @@ -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--) {