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:
1 parent
2787778ad2
commit
ff973cf6bb
6 files changed
+82
-97
No files matched your search
+31
-53
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user