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

+12 -14
View File
@@ -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, giveBall } from './gameplay.js';
import { splatPoint, cheer, spentBall, resolveInterlude } 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,20 +80,18 @@ function woundBurst(zb) {
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
}
// 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)
// swishing the racket cleaves every shambler in reach — it's the panic button for when they're
// already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it
// from being the default attack. (the racket-zombie boss shrugs melee off entirely)
export function meleeSwipe(x, z, range) {
let best = -1, bd = range;
for (let i = 0; i < zombies.length; i++) {
let hit = 0;
for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i];
if (zb.variant === 'r') continue;
const d = Math.hypot(zb.x - x, zb.z - z);
if (d < bd) { bd = d; best = 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 (best < 0) return 0;
killZombie(best, !!G.horde); swish(0.35);
return 1;
if (hit) swish(0.35);
return hit;
}
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
@@ -123,7 +121,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
giveBall(); setupPoint(); // the wave is over: a ball in hand, and any zombies still standing are yours to mop up
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
voice('quiet_dark', 0.5, 0.85);
}
function tickHorde(dt) {
@@ -184,7 +182,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) { giveBall(); setupPoint(); } // ammo dry — the serve loop resumes, ball in hand
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
}
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;