From ff973cf6bbb755febcd643fe53ee4b8134c63b36 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 26 Jul 2026 15:31:37 +0300 Subject: [PATCH] =?UTF-8?q?Splatter:=20the=20pull=20IS=20the=20shot=20?= =?UTF-8?q?=E2=80=94=20direction=20aims,=20length=20is=20power?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config.js | 14 +++++---- gameplay.js | 84 ++++++++++++++++++++--------------------------------- input.js | 25 +++++++++------- render.js | 28 ++++++++++-------- state.js | 2 -- zombies.js | 26 ++++++++--------- 6 files changed, 82 insertions(+), 97 deletions(-) diff --git a/config.js b/config.js index e47b26f..999936f 100644 --- a/config.js +++ b/config.js @@ -31,11 +31,15 @@ export const TUNE = { zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback 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 - // 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 + // the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED — + // reaching a far shambler must not cost a longer drag than the screen has room for. The old + // mapping (pull length = reach) is why you had to drag off the bottom of the phone. + pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent + pullFull: 95, // px of pull for a full-power shot. Keep this well inside one thumb's travel. + pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below + // zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob. + aimCone: 0.45, // lane half-width per meter of range — the aim cone widens with distance + aimReach: 22, // m the aim ray runs when no shambler stands in the lane 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 diff --git a/gameplay.js b/gameplay.js index 1277a07..0e67480 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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); diff --git a/input.js b/input.js index 6fdf600..150b89d 100644 --- a/input.js +++ b/input.js @@ -3,9 +3,10 @@ // functions at runtime, never during module evaluation. import { cv } from './dom.js'; -import { audio } from './audio.js'; +import { audio, pock } from './audio.js'; +import { TUNE } from './config.js'; import { G, score } from './state.js'; -import { keyPressed, dragServe, selectWeapon, serveToss } from './gameplay.js'; +import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality } from './gameplay.js'; import { rackHit } from './render.js'; import { togglePanel } from './panel.js'; @@ -41,10 +42,8 @@ export function takeFlickAim(maxAgeMs = 450) { // 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. -// 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 = () => - G.mode === 'splatter' && G.phase === 'pretoss' && !G.loose && !(G.fetchT > 0) + G.mode === 'splatter' && G.phase === 'pretoss' && 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 @@ -62,7 +61,7 @@ cv.addEventListener('touchstart', e => { if (!stick) { stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve - // the reticle appears the moment the thumb lands: mag 0 = auto-target the most advanced + // the reticle appears the moment the thumb lands, but mag 0 aims at nothing — you have to pull if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 }; else steerPX = (t.clientX - r.left) / r.width * 2 - 1; } @@ -77,10 +76,14 @@ cv.addEventListener('touchmove', e => { if (t.identifier !== stick.id) continue; const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); if (dist > DEAD) stick.moved = true; - if (stick.aimable) { // slingshot: PULL BACK, launch forward — aim is the inverse of the drag (home = auto-target) + if (stick.aimable) { // slingshot: PULL BACK, launch forward — the shot is the inverse of the drag if (dist > DEAD) { if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist }; + // the moment the pull is strong enough to actually splatter, it clicks — you can feel + // where the threshold is without watching the reticle + const hot = pullQuality(dist) >= TUNE.zSplatQ; + if (hot !== !!stick.hot) { stick.hot = hot; if (hot) pock(0.45, 1250); } } else G.aim = { fx: 0, fy: 0, mag: 0 }; continue; } @@ -98,10 +101,10 @@ const endTouch = e => { const dt = performance.now() - stick.t0; const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); const aim = G.aim; G.aim = null; steerPX = null; - if (stick.aimable && (stick.moved || dt >= 250)) { // two-stage release - // a pure HOLD (never moved) fires at the auto-target; a drag fires at the reticle; - // a drag walked back home cancels — the tin stays in stock - if (aim && (!stick.moved || dist > DEAD * 1.5)) dragServe(aim); + if (stick.aimable && stick.moved) { // slingshot release + // only a real pull fires — walked back under pullMin, it cancels and nothing is spent. + // (a bare hold used to fire at a free auto-target; that was the whole exploit) + if (aim && dist >= TUNE.pullMin) dragServe(aim); } else if (!stick.moved && dt < 250) keyPressed('Space'); // tap else if (dt < 300 && dist > 28) { // flick = aimed swing diff --git a/render.js b/render.js index 346d9a1..830f504 100644 --- a/render.js +++ b/render.js @@ -396,18 +396,24 @@ export function render() { 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.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; - // red = the magnet has a zombie locked, yellow = a free spot on the court + // slingshot aim: the pull's direction is the lane, its length is POWER. Everything on screen + // reads power, because that's the decision — a dim thin line only staggers, a hot fat one kills. if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) { const ft = flickTarget(P, G.aim); const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz; if (tx !== undefined) { - const col = ft.zt ? '#ff5050' : '#f8e048'; + const kills = ft.q >= TUNE.zSplatQ; + // cool blue-white = this pull only staggers. It has to stay READABLE on the dark lawn — + // the player still needs to see where they're aiming, they just need to know it's weak. + const col = !kills ? '#9db4d0' : ft.zt ? '#ff5050' : '#f8e048'; const a = proj(P.x, 1.4, P.z), b = proj(tx, 0, tz); - ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.setLineDash([3, 5]); + // the drawn band: it grows BEHIND you as you pull, so the gesture has a visible cost + const bl = proj(P.x - G.aim.fx * ft.f * 3, 1.2, P.z - G.aim.fy * ft.f * 3); + ctx.strokeStyle = col; ctx.lineWidth = 1 + ft.f * 2; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(bl.x, bl.y); ctx.stroke(); + ctx.setLineDash([3, Math.max(2, 7 - ft.f * 5)]); // dashes tighten up as the shot gets hot ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); - ctx.setLineDash([]); + ctx.setLineDash([]); ctx.lineWidth = 1; drawRing(tx, tz, col); if (ft.zt) { const hp = proj(ft.zt.x, 2.2, ft.zt.z); ctx.fillStyle = col; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3); } } @@ -435,14 +441,14 @@ export function render() { zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie); // server idly bouncing the ball while waiting - if (G.phase === 'pretoss' && !(G.mode === 'splatter' && (G.loose || G.fetchT > 0))) { // empty-handed = no ball to bounce + if (G.phase === 'pretoss') { const sv = score.server === 'P' ? P : O; 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; drawBall(bx, by, sv.z); } // the match ball (and any triple-serve side balls in flight) - if ((ball.active || G.phase === 'over' || G.loose) && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame); + if ((ball.active || G.phase === 'over') && !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); // splatter particles (fire embers ride the same array) for (const pt of parts) { @@ -530,10 +536,8 @@ export function render() { 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 txt = G.mode === 'splatter' - ? (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.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN') + : G.aim ? 'PULL BACK: FURTHER = HARDER' : G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}` : touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') diff --git a/state.js b/state.js index bd8c73a..900142c 100644 --- a/state.js +++ b/state.js @@ -45,8 +45,6 @@ export const G = { 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 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 duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel diff --git a/zombies.js b/zombies.js index c010332..8f6d534 100644 --- a/zombies.js +++ b/zombies.js @@ -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;