From 2787778ad20d23073ef948b1e534d8f6b151477d Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 25 Jul 2026 14:18:07 +0300 Subject: [PATCH] =?UTF-8?q?Splatter:=20ball=20economy=20=E2=80=94=20one=20?= =?UTF-8?q?ball,=20fetch=20it=20back,=20no=20more=20toss-spam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slingshot serve didn't fix the gameplay because four things were free at once: pure-hold auto-target with predictive lead, a hardcoded 0.62 serve quality, infinite ammo with an instant mid-rally re-toss, and a melee swish on every single tap that cleaved the whole cluster. Perfecting the pretoss/toss rhythm beat the game; no skill required. The ball is scarce now. A spent ball LIES where it died (G.loose, ringed on court) and there is no serve until it's back in your hand: walk it down (instant, and the only reason to leave the baseline), let a boy scoop it, wait out TUNE.looseMax, or watch a shambler swallow it — the one real punishment. Fire rate is a positioning decision instead of a thumb-tempo one. Also: the far backstop rebounds a ball with pace left, still lethal on the way home and volleyable (trySwing lets P re-hit their own ball in splatter); melee drops to nearest-one-only on a 1s cooldown; and the mid-rally re-toss is gone. With no ball in hand the thumb-drag steers your legs instead of the reticle — without that, walking your ball down is impossible on touch. TUNE.looseMax is the fire-rate knob. Measured: ~4s per serve standing still, ~0.4s if you go and get it. 600 taps in 10s now yield exactly one serve. Co-Authored-By: Claude Opus 5 --- config.js | 9 ++++++ gameplay.js | 81 +++++++++++++++++++++++++++++++++++++++-------------- input.js | 4 ++- render.js | 9 ++++-- state.js | 3 ++ zombies.js | 24 +++++++++------- 6 files changed, 95 insertions(+), 35 deletions(-) diff --git a/config.js b/config.js index 6c4fd27..e47b26f 100644 --- a/config.js +++ b/config.js @@ -31,6 +31,15 @@ export const TUNE = { zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback zPushback: 1.4, // m a staggered zombie is knocked back splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed + // ball economy: a dead ball LIES on the court. No serve until it's back in your hand. + pickupR: 1.4, // m — walk over your loose ball to pick it up instantly (the fast way) + eatBallR: 0.8, // m — a shambler this close to a loose ball swallows it + looseMax: 2.0, // s before the boys hand you one anyway — the ceiling on standing ball-less. THE fire-rate knob. + fetchEaten: 3.5, // s until fresh balls arrive after one got eaten — the only real punishment in the economy + backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it + backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower + backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts + meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here) ballChangeEvery: 4, // games between deliveries after the first diff --git a/gameplay.js b/gameplay.js index 76fbe7c..1277a07 100644 --- a/gameplay.js +++ b/gameplay.js @@ -282,7 +282,7 @@ export function proceedAfterClear() { function startChangeover() { G.changeover = true; G.phase = 'over'; G.overT = TUNE.changeoverT; - sendBoy(); + sendBoy(); giveBall(); // the breather always leaves you holding a ball if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery G.tinDue = false; if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds @@ -340,8 +340,20 @@ export function spentBall() { ball.active = false; G.phase = 'over'; if (!G.changeover) G.overT = TUNE.splatterOverT; + // The ball economy: a spent ball doesn't evaporate into a fresh serve — it LIES where it died. + // Reclaim it by walking it down (fast, but it drags you off the baseline), let a boy fetch it + // (slow and safe), or watch a shambler swallow it (very slow). This is what stops serve spam: + // the fire rate is now a positioning decision, not a thumb-tempo one. + if (G.mode === 'splatter' && !G.interlude && !G.horde && !(G.mgT > 0)) { + G.loose = { t: 0 }; ball.y = 0; ball.collected = false; + sendBoy(ball.x, ball.z); + } } +// a ball is in your hand again — serving is armed. Called wherever the rules hand you one. +export function giveBall() { G.loose = false; G.fetchT = 0; } +export const armed = () => !G.loose && G.fetchT <= 0; + // the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you function startHorde() { if (G.horde) return; @@ -510,7 +522,10 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const t = ch.swingT; if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return; if (!ball.active || G.phase !== 'rally') return; - if (ball.lastHit === ch.side) return; + // splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you — + // volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.) + const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P'; + if (ball.lastHit === ch.side && !own) return; const dx = ball.x - ch.x, dz = ball.z - ch.z; if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return; if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable @@ -573,6 +588,7 @@ function tryGunBurst() { // slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while // you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss. export function serveToss() { + if (G.mode === 'splatter' && !armed()) return; // no ball in hand — nothing to toss if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P); } @@ -581,10 +597,9 @@ export function serveToss() { export function dragServe(aim) { if (G.mode !== 'splatter' || score.server !== 'P') return; if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle - if (G.phase !== 'pretoss' && G.phase !== 'rally' && G.phase !== 'toss') return; // toss = our own slingshot wind-up + if (G.phase !== 'pretoss' && G.phase !== 'toss') return; // one ball: no re-serving over a live rally if (tryGunBurst()) return; - if (ball.active && G.phase !== 'toss') // rapid fire: banish the live rally ball as a ballistic clone — but NOT our fresh toss - 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 }); + if (!armed()) return; 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, aim); @@ -593,28 +608,24 @@ export function dragServe(aim) { export function keyPressed(code) { if (code !== 'Space') return; audio(); // unlock on first gesture - if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers + // a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the + // default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool. + if (G.mode === 'splatter' && G.meleeT <= 0 && meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = TUNE.meleeCool; if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves if (G.changeover) return; // the breather is mandatory — the melee swish above still works if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel // 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(); } - // splatter serve stays a TWO-STAGE ritual — toss up, THEN fire — but the toss can start any - // time, even with a ball still in flight: the old ball flies on as a ballistic clone so the - // toss+fire pair chains without waiting for it to leave. A flick is the aimed one-motion - // variant (the reticle). The duel is exempt: it serves like tennis. - if (G.mode === 'splatter' && !G.interlude && score.server === 'P' - && (G.phase === 'pretoss' || G.phase === 'rally')) { + // splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from + // pretoss WITH a ball in hand: one ball, and it has to come back before you fire it again. + // A flick is the aimed one-motion variant (the reticle). The duel is exempt: it serves like tennis. + if (G.mode === 'splatter' && !G.interlude && score.server === 'P' && G.phase === 'pretoss') { const flick = takeFlickAim(); - if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the live-ball banish - if (G.phase === 'rally') { // a tap mid-rally re-arms the TOSS (stage one); the next tap fires - if (tryGunBurst()) return; - if (ball.active) 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 }); - tossBall(P); return; - } + if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check } if (G.phase === 'pretoss' && score.server === 'P') { if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball + if (G.mode === 'splatter' && !armed()) return; // empty-handed: the tap was just the swish above tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel) } if (G.phase === 'toss' && score.server === 'P') { @@ -652,11 +663,13 @@ export function update(dt) { crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; const w = BOUNCE_IDLE.tempo; - 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 === 'pretoss' && (G.mode !== 'splatter' || armed()) + && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce (only if you hold one) if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } // --- player movement (near half only, generous margins) - if (G.phase === 'rally') { + // splatter also moves during pretoss: with a ball lying on the court, walking is the whole point + if (G.phase === 'rally' || (G.mode === 'splatter' && G.phase === 'pretoss')) { 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 }; @@ -679,7 +692,9 @@ export function update(dt) { P.z += mz * TUNE.playerSpeed * slow * dt; let assist = 0; if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist - const chasing = ball.lastHit === 'O'; + // splatter: anything travelling back toward you is yours to run down — your own rebound + // included. The assist puts you there; the volley timing stays honest. + const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0); // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55); const tgt = chasing ? intercept('P') : HOME.P; // run to the ball, then recover home @@ -718,6 +733,20 @@ export function update(dt) { // --- zombies + transition timeline if (G.mode !== 'tennis') updateZombies(dt); + if (G.meleeT > 0) G.meleeT -= dt; + + // --- the loose ball: three ways it comes back, each with its own price in seconds + if (G.loose) { + G.loose.t += dt; + if (Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8) < TUNE.pickupR) { giveBall(); pock(0.5, 900); } // you ran it down + else if (ball.collected) giveBall(); // a boy scooped it and tossed it back + else if (zombies.some(zb => zb.variant !== 'r' && Math.hypot(zb.x - ball.x, zb.z - ball.z) < TUNE.eatBallR)) { + G.loose = false; G.fetchT = TUNE.fetchEaten; // one of them swallowed it. New balls, please. + G.flash = { text: 'BALL EATEN', t: 1.3 }; + pock(0.9, 130); voice('new_balls', 0.4); + } + else if (G.loose.t > TUNE.looseMax) giveBall(); // out of reach (far court, off court): the boys just bring you one + } else if (G.fetchT > 0 && (G.fetchT -= dt) <= 0) G.fetchT = 0; if (G.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance if (G.mode === 'transition') transition(dt); @@ -727,6 +756,7 @@ export function update(dt) { && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) { zombies[0].dueled = true; // one duel per opponent — after that it just rallies G.weapon = 0; // tennis rules for the duel: force plain balls, no tins + giveBall(); // ...and a fresh one in hand, whatever happened to the last G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return zombieDuck(true); // fade the zombie music out for the 1:1 duel voice('play_resume', 0.2); voice('sudden_death', 2.4); @@ -814,6 +844,15 @@ export function update(dt) { if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way } + // the far backstop: a LIVE ball doesn't leave the field, it rebounds off the fence and comes + // back down-court — still lethal on the way home, and yours to volley. Spent balls just die. + if (G.mode === 'splatter' && !G.interlude && ball.active && ball.lastHit === 'P' + && ball.z < TUNE.backstopZ && ball.vz < -3) { // only a ball with real pace left comes back; a roller just stops + ball.z = TUNE.backstopZ; + ball.vz *= -TUNE.backstopKeep; ball.vx *= 0.7; + ball.vy = Math.max(ball.vy, TUNE.backstopPop); // the fence kicks it up, so it carries back through the crowd + pock(0.5, 200); // chain-link thud + } // splatter: retire spent or hopeless balls fast so the next serve comes right up. // z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26) // heads to the far court (vz<0), so it must not read as "out the back" on frame one. diff --git a/input.js b/input.js index a215296..6fdf600 100644 --- a/input.js +++ b/input.js @@ -41,8 +41,10 @@ export function takeFlickAim(maxAgeMs = 450) { // the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle // (G.aim, drawn by render); release fires the serve there, dragging back home cancels. +// With no ball in hand this is false, so the same drag steers your legs instead — which is +// exactly what you need then: walking your loose ball down. const canAimServe = () => - G.mode === 'splatter' && (G.phase === 'pretoss' || G.phase === 'rally') // rally too: rapid serves mid-flight + G.mode === 'splatter' && G.phase === 'pretoss' && !G.loose && !(G.fetchT > 0) && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle && !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle diff --git a/render.js b/render.js index f4b02a4..346d9a1 100644 --- a/render.js +++ b/render.js @@ -396,6 +396,7 @@ export function render() { if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); } if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048'); + if (G.loose) drawRing(ball.x, ball.z, '#f8e048'); // your ball is down there — go and get it // slingshot serve aim: an angry-birds dotted trajectory from the racket to the aim point; // red = the magnet has a zombie locked, yellow = a free spot on the court if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) { @@ -434,14 +435,14 @@ export function render() { zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie); // server idly bouncing the ball while waiting - if (G.phase === 'pretoss') { + if (G.phase === 'pretoss' && !(G.mode === 'splatter' && (G.loose || G.fetchT > 0))) { // empty-handed = no ball to bounce const sv = score.server === 'P' ? P : O; const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1); const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex; drawBall(bx, by, sv.z); } // the match ball (and any triple-serve side balls in flight) - if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame); + if ((ball.active || G.phase === 'over' || G.loose) && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame); for (const e of extras) drawBall(e.x, e.y, e.z); // splatter particles (fire embers ride the same array) for (const pt of parts) { @@ -529,7 +530,9 @@ export function render() { const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH'; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; const txt = G.mode === 'splatter' - ? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN') + ? (G.loose ? 'RUN YOUR BALL DOWN' + : G.fetchT > 0 ? 'NEW BALLS PLEASE...' + : G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN') : G.aim ? 'PULL BACK TO AIM · RELEASE TO FIRE' : G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}` : touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM') diff --git a/state.js b/state.js index e32fed9..bd8c73a 100644 --- a/state.js +++ b/state.js @@ -45,6 +45,9 @@ export const G = { tinDue: false, // the rules called for new balls — deliver at the next changeover pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) + loose: false, // splatter: {t} while your ball lies on the court — no serving until it's in your hand again + fetchT: 0, // s until a ball is handed back to you (boy delivery, or fresh balls after one got eaten) + meleeT: 0, // s of racket-swish cooldown left interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving diff --git a/zombies.js b/zombies.js index 025b943..c010332 100644 --- a/zombies.js +++ b/zombies.js @@ -6,7 +6,7 @@ import { TUNE, COURT } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; -import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js'; +import { splatPoint, cheer, spentBall, resolveInterlude, giveBall } from './gameplay.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer @@ -80,16 +80,20 @@ function woundBurst(zb) { t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 }); } -// swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off) +// swishing the racket takes out the NEAREST shambler in reach — one swing, one body. It used to +// cleave the whole cluster, which made standing still and tapping better than playing tennis. +// (the racket-zombie boss shrugs melee off entirely) export function meleeSwipe(x, z, range) { - let hit = 0; - for (let i = zombies.length - 1; i >= 0; i--) { + let best = -1, bd = range; + for (let i = 0; i < zombies.length; i++) { const zb = zombies[i]; - if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop - if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; } + if (zb.variant === 'r') continue; + const d = Math.hypot(zb.x - x, zb.z - z); + if (d < bd) { bd = d; best = i; } } - if (hit) swish(0.35); - return hit; + if (best < 0) return 0; + killZombie(best, !!G.horde); swish(0.35); + return 1; } function splatter(i, fanfare = true) { // the MAIN ball hit a zombie @@ -119,7 +123,7 @@ export function spawnRacketZombie() { // one takes up residence on the far court function endHorde() { G.horde = null; pellets.length = 0; if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays - setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up + giveBall(); setupPoint(); // the wave is over: a ball in hand, and any zombies still standing are yours to mop up voice('quiet_dark', 0.5, 0.85); } function tickHorde(dt) { @@ -180,7 +184,7 @@ export function updateZombies(dt) { else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules G.mgT -= dt; if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } - if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes + if (G.mgT <= 0) { giveBall(); setupPoint(); } // ammo dry — the serve loop resumes, ball in hand } if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns) spawnT -= dt;