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

+9 -5
View File
@@ -31,11 +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. // the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED —
pickupR: 1.4, // m — walk over your loose ball to pick it up instantly (the fast way) // reaching a far shambler must not cost a longer drag than the screen has room for. The old
eatBallR: 0.8, // m — a shambler this close to a loose ball swallows it // mapping (pull length = reach) is why you had to drag off the bottom of the phone.
looseMax: 2.0, // s before the boys hand you one anyway — the ceiling on standing ball-less. THE fire-rate knob. pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent
fetchEaten: 3.5, // s until fresh balls arrive after one got eaten — the only real punishment in the economy 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 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 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 backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
+31 -53
View File
@@ -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 }; 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 // splatter FREE-AIM (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the
// flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that // lane you fire down, its LENGTH is power — and nothing else. Pulling further does NOT reach
// point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked. // 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 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 q = pullQuality(aim.mag), f = pullFrac(aim.mag);
const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R)); const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z
const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z) let zt = null, near = 1e9;
let zt = null, bd = TUNE.zSnap;
for (const zb of zombies) { for (const zb of zombies) {
const d = Math.hypot(zb.x - tx, zb.z - tz); const dx = zb.x - ch.x, dz = zb.z - ch.z;
if (d < bd) { bd = d; zt = zb; } 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 // 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) { function zombieTarget(aim) {
if (!zombies.length) return null; if (!zombies.length) return null;
const none = !aim || (!aim.ax && !aim.az); 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; let best = null, bs = 1e9;
for (const zb of zombies) { for (const zb of zombies) {
let s; let s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand
if (none) s = COURT.L - zb.z; // no direction: the most advanced one is the most urgent if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones
else { if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side
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; } if (s < bs) { bs = s; best = zb; }
} }
return best; return best;
@@ -282,7 +290,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(); 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 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,20 +348,8 @@ 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;
@@ -588,7 +584,6 @@ 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);
} }
@@ -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.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 (G.phase !== 'pretoss' && G.phase !== 'toss') return; // one ball: no re-serving over a live rally
if (tryGunBurst()) return; 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.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, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
} }
export function keyPressed(code) { export function keyPressed(code) {
@@ -625,7 +620,6 @@ export function keyPressed(code) {
} }
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') {
@@ -663,13 +657,11 @@ 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' && (G.mode !== 'splatter' || armed()) if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
&& 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)
// splatter also moves during pretoss: with a ball lying on the court, walking is the whole point if (G.phase === 'rally') {
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 };
@@ -734,19 +726,6 @@ 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; 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);
@@ -756,7 +735,6 @@ 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);
+14 -11
View File
@@ -3,9 +3,10 @@
// functions at runtime, never during module evaluation. // functions at runtime, never during module evaluation.
import { cv } from './dom.js'; 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 { 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 { rackHit } from './render.js';
import { togglePanel } from './panel.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 // 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.loose && !(G.fetchT > 0) G.mode === 'splatter' && G.phase === 'pretoss'
&& 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
@@ -62,7 +61,7 @@ cv.addEventListener('touchstart', e => {
if (!stick) { if (!stick) {
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), 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 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 }; if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
else steerPX = (t.clientX - r.left) / r.width * 2 - 1; else steerPX = (t.clientX - r.left) / r.width * 2 - 1;
} }
@@ -77,10 +76,14 @@ cv.addEventListener('touchmove', e => {
if (t.identifier !== stick.id) continue; if (t.identifier !== stick.id) continue;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
if (dist > DEAD) stick.moved = true; 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 (dist > DEAD) {
if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation
G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist }; 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 }; } else G.aim = { fx: 0, fy: 0, mag: 0 };
continue; continue;
} }
@@ -98,10 +101,10 @@ const endTouch = e => {
const dt = performance.now() - stick.t0; const dt = performance.now() - stick.t0;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); 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; const aim = G.aim; G.aim = null; steerPX = null;
if (stick.aimable && (stick.moved || dt >= 250)) { // two-stage release if (stick.aimable && stick.moved) { // slingshot release
// a pure HOLD (never moved) fires at the auto-target; a drag fires at the reticle; // only a real pull fires — walked back under pullMin, it cancels and nothing is spent.
// a drag walked back home cancels — the tin stays in stock // (a bare hold used to fire at a free auto-target; that was the whole exploit)
if (aim && (!stick.moved || dist > DEAD * 1.5)) dragServe(aim); if (aim && dist >= TUNE.pullMin) dragServe(aim);
} }
else if (!stick.moved && dt < 250) keyPressed('Space'); // tap else if (!stick.moved && dt < 250) keyPressed('Space'); // tap
else if (dt < 300 && dist > 28) { // flick = aimed swing else if (dt < 300 && dist > 28) { // flick = aimed swing
+16 -12
View File
@@ -396,18 +396,24 @@ 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 aim: the pull's direction is the lane, its length is POWER. Everything on screen
// slingshot serve aim: an angry-birds dotted trajectory from the racket to the aim point; // reads power, because that's the decision — a dim thin line only staggers, a hot fat one kills.
// 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')) {
const ft = flickTarget(P, G.aim); const ft = flickTarget(P, G.aim);
const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz; const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz;
if (tx !== undefined) { 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); 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.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); 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); } 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); 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' && !(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 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' || 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); 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) {
@@ -530,10 +536,8 @@ 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.loose ? 'RUN YOUR BALL DOWN' ? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: G.fetchT > 0 ? 'NEW BALLS PLEASE...' : G.aim ? 'PULL BACK: FURTHER = HARDER'
: G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: 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')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
-2
View File
@@ -45,8 +45,6 @@ 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 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
+12 -14
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, 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 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,20 +80,18 @@ 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 takes out the NEAREST shambler in reach — one swing, one body. It used to // swishing the racket cleaves every shambler in reach — it's the panic button for when they're
// cleave the whole cluster, which made standing still and tapping better than playing tennis. // already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it
// (the racket-zombie boss shrugs melee off entirely) // from being the default attack. (the racket-zombie boss shrugs melee off entirely)
export function meleeSwipe(x, z, range) { export function meleeSwipe(x, z, range) {
let best = -1, bd = range; let hit = 0;
for (let i = 0; i < zombies.length; i++) { for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i]; const zb = zombies[i];
if (zb.variant === 'r') continue; if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
const d = Math.hypot(zb.x - x, zb.z - z); if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
if (d < bd) { bd = d; best = i; }
} }
if (best < 0) return 0; if (hit) swish(0.35);
killZombie(best, !!G.horde); swish(0.35); return hit;
return 1;
} }
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie 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() { 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
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); voice('quiet_dark', 0.5, 0.85);
} }
function tickHorde(dt) { 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 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) { 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) 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;