Splatter: ball economy — one ball, fetch it back, no more toss-spam

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-25 14:18:07 +03:00
1 parent 013fe8152d
commit 2787778ad2
6 files changed
+95 -35

No files matched your search

+60 -21
View File
@@ -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.