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

+9
View File
@@ -31,6 +31,15 @@ export const TUNE = {
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
zPushback: 1.4, // m a staggered zombie is knocked back zPushback: 1.4, // m a staggered zombie is knocked back
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
// ball economy: a dead ball LIES on the court. No serve until it's back in your hand.
pickupR: 1.4, // m — walk over your loose ball to pick it up instantly (the fast way)
eatBallR: 0.8, // m — a shambler this close to a loose ball swallows it
looseMax: 2.0, // s before the boys hand you one anyway — the ceiling on standing ball-less. THE fire-rate knob.
fetchEaten: 3.5, // s until fresh balls arrive after one got eaten — the only real punishment in the economy
backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it
backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower
backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here) ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here)
ballChangeEvery: 4, // games between deliveries after the first ballChangeEvery: 4, // games between deliveries after the first
+60 -21
View File
@@ -282,7 +282,7 @@ export function proceedAfterClear() {
function startChangeover() { function startChangeover() {
G.changeover = true; G.changeover = true;
G.phase = 'over'; G.overT = TUNE.changeoverT; 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 if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery
G.tinDue = false; G.tinDue = false;
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds 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; ball.active = false;
G.phase = 'over'; G.phase = 'over';
if (!G.changeover) G.overT = TUNE.splatterOverT; 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 // the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
function startHorde() { function startHorde() {
if (G.horde) return; if (G.horde) return;
@@ -510,7 +522,10 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const t = ch.swingT; const t = ch.swingT;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return; if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
if (!ball.active || G.phase !== 'rally') 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; 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 (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 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 // 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. // you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss.
export function serveToss() { 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); if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
} }
@@ -581,10 +597,9 @@ export function serveToss() {
export function dragServe(aim) { export function dragServe(aim) {
if (G.mode !== 'splatter' || score.server !== 'P') return; 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.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 (tryGunBurst()) return;
if (ball.active && G.phase !== 'toss') // rapid fire: banish the live rally ball as a ballistic clone — but NOT our fresh toss if (!armed()) return;
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 });
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; 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; 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, 0.62, aim);
@@ -593,28 +608,24 @@ export function dragServe(aim) {
export function keyPressed(code) { export function keyPressed(code) {
if (code !== 'Space') return; if (code !== 'Space') return;
audio(); // unlock on first gesture 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.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.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 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 // 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(); } 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 // splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from
// time, even with a ball still in flight: the old ball flies on as a ballistic clone so the // pretoss WITH a ball in hand: one ball, and it has to come back before you fire it again.
// toss+fire pair chains without waiting for it to leave. A flick is the aimed one-motion // A flick is the aimed one-motion variant (the reticle). The duel is exempt: it serves like tennis.
// variant (the reticle). The duel is exempt: it serves like tennis. if (G.mode === 'splatter' && !G.interlude && score.server === 'P' && G.phase === 'pretoss') {
if (G.mode === 'splatter' && !G.interlude && score.server === 'P'
&& (G.phase === 'pretoss' || G.phase === 'rally')) {
const flick = takeFlickAim(); const flick = takeFlickAim();
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the live-ball banish if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check
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 (G.phase === 'pretoss' && score.server === 'P') { if (G.phase === 'pretoss' && score.server === 'P') {
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball 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) tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
} }
if (G.phase === 'toss' && score.server === 'P') { if (G.phase === 'toss' && score.server === 'P') {
@@ -652,11 +663,13 @@ export function update(dt) {
crowd.cheer = Math.max(0, crowd.cheer - dt); crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt; G.idleT += dt;
const w = BOUNCE_IDLE.tempo; 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; } if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
// --- player movement (near half only, generous margins) // --- 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 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); 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 }; 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; P.z += mz * TUNE.playerSpeed * slow * dt;
let assist = 0; let assist = 0;
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist 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 // 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 rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55);
const tgt = chasing ? intercept('P') : HOME.P; // run to the ball, then recover home 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 // --- zombies + transition timeline
if (G.mode !== 'tennis') updateZombies(dt); 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.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt); 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) { && 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 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 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 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 zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4); 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 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 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. // 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) // 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. // heads to the far court (vz<0), so it must not read as "out the back" on frame one.
+3 -1
View File
@@ -41,8 +41,10 @@ export function takeFlickAim(maxAgeMs = 450) {
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle // the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
// (G.aim, drawn by render); release fires the serve there, dragging back home cancels. // (G.aim, drawn by render); release fires the serve there, dragging back home cancels.
// With no ball in hand this is false, so the same drag steers your legs instead — which is
// exactly what you need then: walking your loose ball down.
const canAimServe = () => const canAimServe = () =>
G.mode === 'splatter' && (G.phase === 'pretoss' || G.phase === 'rally') // rally too: rapid serves mid-flight G.mode === 'splatter' && G.phase === 'pretoss' && !G.loose && !(G.fetchT > 0)
&& score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle && !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
+6 -3
View File
@@ -396,6 +396,7 @@ export function render() {
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)');
} }
if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048'); if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048');
if (G.loose) drawRing(ball.x, ball.z, '#f8e048'); // your ball is down there — go and get it
// slingshot serve aim: an angry-birds dotted trajectory from the racket to the aim point; // slingshot serve aim: an angry-birds dotted trajectory from the racket to the aim point;
// red = the magnet has a zombie locked, yellow = a free spot on the court // red = the magnet has a zombie locked, yellow = a free spot on the court
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) { if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
@@ -434,14 +435,14 @@ export function render() {
zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie); zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie);
// server idly bouncing the ball while waiting // server idly bouncing the ball while waiting
if (G.phase === 'pretoss') { if (G.phase === 'pretoss' && !(G.mode === 'splatter' && (G.loose || G.fetchT > 0))) { // empty-handed = no ball to bounce
const sv = score.server === 'P' ? P : O; const sv = score.server === 'P' ? P : O;
const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1); const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1);
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex; const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex;
drawBall(bx, by, sv.z); drawBall(bx, by, sv.z);
} }
// the match ball (and any triple-serve side balls in flight) // the match ball (and any triple-serve side balls in flight)
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame); if ((ball.active || G.phase === 'over' || G.loose) && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame);
for (const e of extras) drawBall(e.x, e.y, e.z); for (const e of extras) drawBall(e.x, e.y, e.z);
// splatter particles (fire embers ride the same array) // splatter particles (fire embers ride the same array)
for (const pt of parts) { for (const pt of parts) {
@@ -529,7 +530,9 @@ export function render() {
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH'; const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
const txt = G.mode === 'splatter' const txt = G.mode === 'splatter'
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN') ? (G.loose ? 'RUN YOUR BALL DOWN'
: G.fetchT > 0 ? 'NEW BALLS PLEASE...'
: G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: G.aim ? 'PULL BACK TO AIM · RELEASE TO FIRE' : G.aim ? 'PULL BACK TO AIM · RELEASE TO FIRE'
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}` : G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
: touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM') : touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
+3
View File
@@ -45,6 +45,9 @@ export const G = {
tinDue: false, // the rules called for new balls — deliver at the next changeover tinDue: false, // the rules called for new balls — deliver at the next changeover
pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms
aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss)
loose: false, // splatter: {t} while your ball lies on the court — no serving until it's in your hand again
fetchT: 0, // s until a ball is handed back to you (boy delivery, or fresh balls after one got eaten)
meleeT: 0, // s of racket-swish cooldown left
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
+14 -10
View File
@@ -6,7 +6,7 @@
import { TUNE, COURT } from './config.js'; import { TUNE, COURT } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js'; import { splatPoint, cheer, spentBall, resolveInterlude, giveBall } from './gameplay.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -80,16 +80,20 @@ function woundBurst(zb) {
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 }); t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
} }
// swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off) // 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)
export function meleeSwipe(x, z, range) { export function meleeSwipe(x, z, range) {
let hit = 0; let best = -1, bd = range;
for (let i = zombies.length - 1; i >= 0; i--) { for (let i = 0; i < zombies.length; i++) {
const zb = zombies[i]; const zb = zombies[i];
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop if (zb.variant === 'r') continue;
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; } const d = Math.hypot(zb.x - x, zb.z - z);
if (d < bd) { bd = d; best = i; }
} }
if (hit) swish(0.35); if (best < 0) return 0;
return hit; killZombie(best, !!G.horde); swish(0.35);
return 1;
} }
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
@@ -119,7 +123,7 @@ export function spawnRacketZombie() { // one takes up residence on the far court
function endHorde() { function endHorde() {
G.horde = null; pellets.length = 0; G.horde = null; pellets.length = 0;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up giveBall(); setupPoint(); // the wave is over: a ball in hand, and any zombies still standing are yours to mop up
voice('quiet_dark', 0.5, 0.85); voice('quiet_dark', 0.5, 0.85);
} }
function tickHorde(dt) { function tickHorde(dt) {
@@ -180,7 +184,7 @@ export function updateZombies(dt) {
else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
G.mgT -= dt; G.mgT -= dt;
if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes if (G.mgT <= 0) { giveBall(); setupPoint(); } // ammo dry — the serve loop resumes, ball in hand
} }
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) 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; spawnT -= dt;