From bfaafe6727e27536c58b37b9527cfdb79a6a29a1 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 12 Jul 2026 00:28:10 +0200 Subject: [PATCH] Revert splatter rapid-fire serve; fix horde pose + re-trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oliver's feedback on the feel pass: 1. Rapid-fire serving was way too easy. Reverted splatter serving to the proper model: a tap does the toss ritual (apex timing = quality), a flick is a quick aimed serve. Dropped the multi-ball barrage and removed the extraBalls plumbing (array, update, render, hooks). Melee swish and zombie homing stay. If multi-ball is wanted back, it should require a proper serve per ball. 2. The player was stuck in the serve/swing pose when a horde started (the triggering shot's pose lingered) — startHorde now clears it. 3. A horde re-triggered immediately: machine-gun kills scored points, so games were won mid-horde and the next kill after it ended won another game → instant new horde. A horde is now survival: it doesn't score (splatPoint skipped while G.horde), so it can't chain. After a horde you must win a full game again to summon the next. Verified via window.T: a tap tosses (not an instant serve) and the full toss→serve→rally completes; melee still kills adjacent shamblers; pose is idle/-1 at horde start; no scoring during a horde and the next kill after scores 15 without restarting it; tennis 11/12, bomb consume, homing all still green; no console errors. Co-Authored-By: Claude Opus 4.8 --- gameplay.js | 33 ++++++++++++++++++--------------- main.js | 4 ++-- render.js | 6 ++---- zombies.js | 31 +++---------------------------- 4 files changed, 25 insertions(+), 49 deletions(-) diff --git a/gameplay.js b/gameplay.js index f7d78eb..e0a8e1e 100644 --- a/gameplay.js +++ b/gameplay.js @@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DE import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { audio, pock, swish, applause, voice } from './audio.js'; import { axisX, axisZ, takeFlickAim } from './input.js'; -import { updateZombies, spawnZombie, zombies, explode, meleeSwipe, stashExtraBall } from './zombies.js'; +import { updateZombies, spawnZombie, zombies, explode, meleeSwipe } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -197,6 +197,7 @@ function startHorde() { if (G.horde) return; G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 }; ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P + P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose voice('remain_seated', 0.3); G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; } @@ -375,22 +376,24 @@ function trySwing(ch, isAI) { export function keyPressed(code) { if (code !== 'Space') return; audio(); // unlock on first gesture - if (G.mode === 'splatter') { - // splatter is a shooter: every swish cleaves adjacent shamblers, and — outside a horde — - // fires a ball immediately. Serving while one still flies stashes it, so you can barrage. - meleeSwipe(P.x, P.z, TUNE.meleeRange); - if (G.horde) return; // horde: MG auto-fires, the tap only swishes (no serve) - if (ball.active) stashExtraBall(); // keep the in-flight ball alive - const flick = takeFlickAim(); // flick aims; a plain tap auto-targets the nearest zombie - 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, 0.62, flick); - return; + if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers + if (G.horde) return; // horde: the racket auto-fires; a tap only swishes, never serves + // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately + if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } + if (G.phase === 'pretoss' && score.server === 'P') { + if (G.mode === 'splatter') { + const flick = takeFlickAim(); + if (flick) { // flick = a quick aimed serve; a plain tap still does the full toss ritual + 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, 0.62, flick); + return; + } + } + tossBall(P); return; } - // tennis: the toss ritual + rally swing (unchanged) - if (G.phase === 'pretoss' && score.server === 'P') { tossBall(P); return; } if (G.phase === 'toss' && score.server === 'P') { - // serve timing: distance from apex = quality + // serve timing: distance from apex = quality — a real serve, not a free tap const dy = Math.abs(ball.y - serveApexY()); if (ball.y < 2.0) return; // too low, let it drop (re-toss) let q = Math.max(0.05, 1 - dy / 0.9); diff --git a/main.js b/main.js index 4988f84..d35d239 100644 --- a/main.js +++ b/main.js @@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint } from './state.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js'; import { fitViewport, render, proj } from './render.js'; import { voice } from './audio.js'; -import { zombies, spawnZombie, piles, gore, parts, pellets, extraBalls } from './zombies.js'; +import { zombies, spawnZombie, piles, gore, parts, pellets } from './zombies.js'; import { cv } from './dom.js'; import './input.js'; @@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extraBalls, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index cb23c7b..2a11771 100644 --- a/render.js +++ b/render.js @@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { P, O, ball, score, crowd, G } from './state.js'; import { predictLanding } from './gameplay.js'; -import { zombies, gore, piles, parts, pellets, extraBalls } from './zombies.js'; +import { zombies, gore, piles, parts, pellets } from './zombies.js'; // ---------------------------------------------------------------- palette ---- // tennis palette -> drained splatter palette; the transition lerps between them @@ -304,8 +304,6 @@ export function render() { const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex; drawBall(bx, by, sv.z); } - // earlier serves still in flight (multi-ball barrage) - for (const b of extraBalls) drawBall(b.x, b.y, b.z, false, b.bomb); // the match ball if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb); // splatter particles @@ -351,7 +349,7 @@ export function render() { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; const touch = matchMedia('(pointer: coarse)').matches; const txt = G.mode === 'splatter' - ? (touch ? 'TAP TO FIRE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE FIRES · ARROWS AIM') + ? (touch ? 'TAP TO SERVE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE SERVES · ARROWS AIM') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); ctx.fillText(txt, W / 2, H - 16); diff --git a/zombies.js b/zombies.js index 48b403b..2f79bc8 100644 --- a/zombies.js +++ b/zombies.js @@ -13,7 +13,6 @@ export const gore = []; // lawn decals {x, z, r, a} — the flat ground laye export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark} export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t} -export const extraBalls = []; // earlier serves still in flight while you fire the next {x,y,z,vx,vy,vz,g,pow,bomb,bounces} let spawnT = 1.5, moanT = 3; @@ -40,7 +39,7 @@ function killZombie(i, quiet) { }); } splat(); sendBoy(zb.x, zb.z); // a boy sprints out to mop - splatPoint('P', quiet); // horde kills rate-limit the umpire so he doesn't machine-gun scores + if (!G.horde) splatPoint('P', quiet); // a horde is survival — it doesn't score, so it can't chain into itself } // swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off) @@ -54,14 +53,6 @@ export function meleeSwipe(x, z, range) { return hit; } -// serving again while a ball still flies: stash the current one so it keeps killing (a barrage) -export function stashExtraBall() { - if (!ball.active) return; - extraBalls.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, - g: ball.g, pow: ball.pow, bomb: ball.bomb, bounces: ball.bounces }); - if (extraBalls.length > 6) extraBalls.shift(); // cap the barrage -} - function splatter(i, fanfare = true) { // the MAIN ball hit a zombie killZombie(i, !!G.horde); ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next @@ -150,7 +141,7 @@ export function updateZombies(dt) { splat(1); voice('game_set_massacre', 0.8); G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience - G.horde = null; pellets.length = 0; extraBalls.length = 0; // the swarm stops; the eaten screen takes over + G.horde = null; pellets.length = 0; // the swarm stops; the eaten screen takes over } if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); @@ -165,7 +156,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); if (G.mode !== 'eaten') splatPoint('O', !!G.horde); } // it reached your baseline + if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde) splatPoint('O'); } // it reached your baseline } for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills @@ -177,22 +168,6 @@ export function updateZombies(dt) { if (hit || pe.t <= 0) pellets.splice(i, 1); } - for (let i = extraBalls.length - 1; i >= 0; i--) { // earlier serves keep arcing and killing - const b = extraBalls[i]; - b.vy -= (b.g || TUNE.gravity) * dt; - b.x += b.vx * dt; b.y += b.vy * dt; b.z += b.vz * dt; - let done = false; - if (b.y <= 0 && b.vy < 0) { b.y = 0; b.vy = -b.vy * TUNE.bounceY; b.vx *= TUNE.bounceXZ; b.vz *= TUNE.bounceXZ; b.bounces++; - if (b.bomb) { explode(b.x, b.z); done = true; } } - if (!done && b.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) { - if (zombies[j].variant === 'r' || Math.hypot(zombies[j].x - b.x, zombies[j].z - b.z) >= 0.55) continue; - if (b.bomb) { explode(b.x, b.z); done = true; } - else if ((b.pow || 0) >= TUNE.zSplatQ) { killZombie(j, !!G.horde); b.vx *= TUNE.zPierce; b.vz *= TUNE.zPierce; } - break; - } - if (done || b.bounces >= 2 || b.z < -2 || b.z > 25.5 || Math.abs(b.x) > 7.5) extraBalls.splice(i, 1); - } - for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; p.t -= dt; p.vy -= 14 * dt;