Splatter: the pull IS the shot — direction aims, length is power

The ball economy made it harder in the wrong way: waiting for a ballboy is
friction, not difficulty, and walking with the finger was one gesture too many
for anyone to discover. Both are out — a spent ball hands you a fresh one
again, and the thumb only ever does the slingshot.

The brake moves into the gesture instead. The pull's LENGTH is now the shot's
power and nothing else: under TUNE.zSplatQ it merely staggers, at full draw
it's quality 1.0 with the PERFECT flash and hitstop. A lazy rhythm of short
pulls can't kill anything, so tempo stops being a strategy.

The pull's DIRECTION is a ray, not a landing point, which is the ergonomic fix
for having to drag off the bottom of the phone: distance to the target no
longer costs drag length. The first shambler standing in the lane eats the
ball, whether it's 5 or 21 metres away, and the cone widens with range so
distant lanes stay pickable. Full power now sits at 95px of travel.

Also gone: the free predictive auto-lock on a directionless aim (zombieTarget
returns null with no direction, and a bare hold no longer fires at all). Melee
goes back to cleaving the cluster — it's the panic button — with the 1s
cooldown left in as the brake. The reticle reads power throughout: cool blue
only staggers, hot red kills, the drawn band grows behind you, and the pull
clicks the moment it crosses the kill threshold.

Verified by simulated touch gestures: 10px cancels, 35px = 0.31 (stagger),
60px = 0.60, 95px = 1.00; the same 60px pull reaches shamblers at 5m, 21m and
diagonally; 22 serves in 30s, so the pace is back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-26 15:31:37 +03:00
1 parent 2787778ad2
commit ff973cf6bb
6 files changed
+82 -97

No files matched your search

+31 -53
View File
@@ -22,20 +22,30 @@ export function predictLanding() { // first-bounce point, closed form — exact,
return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t };
}
// splatter FREE-AIM: the flick vector maps to a landing point from where you stand — a short
// flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that
// point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked.
// splatter FREE-AIM (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the
// lane you fire down, its LENGTH is power — and nothing else. Pulling further does NOT reach
// further, which is the whole fix: a shambler at the far baseline costs the same short pull as
// one on top of you, so the gesture always fits on the screen. The first body standing in the
// lane is the target; the cone widens with range so distant lanes stay pickable.
export const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin)));
export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin);
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
if (!aim.mag) return { zt: zombieTarget(null) }; // pure hold, no direction yet: auto-target the most advanced
const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach
const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R));
const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z)
let zt = null, bd = TUNE.zSnap;
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z
let zt = null, near = 1e9;
for (const zb of zombies) {
const d = Math.hypot(zb.x - tx, zb.z - tz);
if (d < bd) { bd = d; zt = zb; }
const dx = zb.x - ch.x, dz = zb.z - ch.z;
const along = dx * ax + dz * az; // how far down the lane it stands
if (along <= 0) continue; // behind the shot
if (Math.abs(dx * az - dz * ax) > TUNE.zSnap + TUNE.aimCone * along) continue; // outside the cone
if (along < near) { near = along; zt = zb; } // the FIRST one in the lane eats it
}
return { tx, tz, zt };
const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it
const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R));
const tz = Math.max(1, Math.min(24.5, ch.z + az * R));
return { tx, tz, zt, q, f };
}
// splatter tap/lane aiming (no flick): the shot locks onto the best-matching zombie
@@ -43,15 +53,13 @@ export function flickTarget(ch, aim) { // exported: render draws the live reticl
function zombieTarget(aim) {
if (!zombies.length) return null;
const none = !aim || (!aim.ax && !aim.az);
if (none) return null; // no direction, no lock. Free predictive auto-aim was the reason a
// bare rhythm of taps could clear the court without any aiming at all.
let best = null, bs = 1e9;
for (const zb of zombies) {
let s;
if (none) s = COURT.L - zb.z; // no direction: the most advanced one is the most urgent
else {
s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand
if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones
if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side
}
let s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand
if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones
if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side
if (s < bs) { bs = s; best = zb; }
}
return best;
@@ -282,7 +290,7 @@ export function proceedAfterClear() {
function startChangeover() {
G.changeover = true;
G.phase = 'over'; G.overT = TUNE.changeoverT;
sendBoy(); giveBall(); // the breather always leaves you holding a ball
sendBoy();
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,20 +348,8 @@ 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;
@@ -588,7 +584,6 @@ 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);
}
@@ -599,10 +594,10 @@ export function dragServe(aim) {
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 !== 'toss') return; // one ball: no re-serving over a live rally
if (tryGunBurst()) return;
if (!armed()) return;
if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent
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);
hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
}
export function keyPressed(code) {
@@ -625,7 +620,6 @@ export function keyPressed(code) {
}
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') {
@@ -663,13 +657,11 @@ export function update(dt) {
crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt;
const w = BOUNCE_IDLE.tempo;
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 === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
// --- player movement (near half only, generous margins)
// 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.phase === 'rally') {
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 };
@@ -734,19 +726,6 @@ 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);
@@ -756,7 +735,6 @@ 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);