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:
1 parent
013fe8152d
commit
2787778ad2
6 files changed
+95
-35
No files matched your search
+60
-21
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user