Splatter feel pass: melee, homing, multi-ball, clearer MG mode

From Oliver's feedback round:

- Racket melee: any swish (tap) in splatter cleaves shamblers within
  TUNE.meleeRange (1.7m) — a close-range survival tool. Works during a
  horde too (tap = swish only, still no serve). The racket-zombie boss
  (Phase 6, not built) is immune by design.
- Zombies home in on you: on YOUR half (z > netZ) they now drift toward
  P.x instead of walking a straight lane; far half stays an aimless
  shamble.
- Multi-ball / rapid fire: splatter is now a shooter — every tap/flick
  fires immediately (dropped the toss ritual there), and serving while a
  ball still flies stashes the old one into a new extraBalls array so it
  keeps arcing and killing (cap 6). Refactored the kill into a shared
  killZombie() with no ball coupling, used by ball hits, pellets, melee,
  and extra balls.
- Machine-gun mode clarity: confirmed free-walk (drag/arrows move you,
  auto-assist off) and tap-doesn't-serve during a horde; added a
  code-drawn "rambo" racket held forward with a muzzle flash over the
  player, and a splatter-specific serve prompt.

REVIEW on your phone — two opinionated calls, both easy to revert:
1. Dropped the toss in splatter (tap = instant quick-serve, pow 0.62).
   Rapid fire, but no apex-timing quality/pierce. Want the toss back as
   an option?
2. MG posture is a code-drawn forward racket, not a real body sprite —
   good enough, or worth a proper p_mg sprite later?
Also: each rapid serve still auto-consumes a stocked bomb, so bombs
deplete fast now — flag if that feels wrong.

Verified via window.T: melee kills a 1.0m zombie and spares a 4.5m one;
a near-half zombie drifts x 3→1.9 toward you; firing twice leaves 1
stashed ball + a fresh serve (barrage screenshot); extra balls kill and
pierce; free-walk during a horde (x 0→2.7); horde-tap melees without
serving; tennis toss-serve and Phase-4 bomb consume still work; no
console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-11 22:54:31 +02:00
1 parent bf9553bd49
commit 9100bd5a23
5 files changed
+84 -23

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ export const TUNE = {
hordeLen: 12, // s a horde wave lasts hordeLen: 12, // s a horde wave lasts
hordeRate: 2.5, // zombies spawned per second during a horde hordeRate: 2.5, // zombies spawned per second during a horde
mgRate: 6, // machine-gun pellets fired per second during a horde mgRate: 6, // machine-gun pellets fired per second during a horde
meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
missHud: 1, // show the last miss reason on screen (full log: T.G.missLog) missHud: 1, // show the last miss reason on screen (full log: T.G.missLog)
}; };
+9 -11
View File
@@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DE
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice } from './audio.js'; import { audio, pock, swish, applause, voice } from './audio.js';
import { axisX, axisZ, takeFlickAim } from './input.js'; import { axisX, axisZ, takeFlickAim } from './input.js';
import { updateZombies, spawnZombie, zombies, explode } from './zombies.js'; import { updateZombies, spawnZombie, zombies, explode, meleeSwipe, stashExtraBall } from './zombies.js';
// ------------------------------------------------------------- ballistics ---- // ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -375,22 +375,20 @@ function trySwing(ch, isAI) {
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.horde) return; // horde: the racket auto-fires, taps do nothing — the thumb drag steers you
// splatter: a tap/flick during the brief 'over' beat arms the next serve immediately —
// reset inline, then fall through to pretoss so the finger is the only cadence limit
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
if (G.phase === 'pretoss' && score.server === 'P') {
if (G.mode === 'splatter') { if (G.mode === 'splatter') {
const flick = takeFlickAim(); // splatter is a shooter: every swish cleaves adjacent shamblers, and — outside a horde —
if (flick) { // quick serve: no toss ritual, modest pace, aimed by the flick // fires a ball immediately. Serving while one still flies stashes it, so you can barrage.
meleeSwipe(P.x, P.z, TUNE.meleeRange);
if (G.horde) return; // horde: MG auto-fires, the tap only swishes (no serve)
if (ball.active) stashExtraBall(); // keep the in-flight ball alive
const flick = takeFlickAim(); // flick aims; a plain tap auto-targets the nearest zombie
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, flick); hitServe(P, 0.62, flick);
return; return;
} }
} // tennis: the toss ritual + rally swing (unchanged)
tossBall(P); return; if (G.phase === 'pretoss' && score.server === 'P') { tossBall(P); return; }
}
if (G.phase === 'toss' && score.server === 'P') { if (G.phase === 'toss' && score.server === 'P') {
// serve timing: distance from apex = quality // serve timing: distance from apex = quality
const dy = Math.abs(ball.y - serveApexY()); const dy = Math.abs(ball.y - serveApexY());
+2 -2
View File
@@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js';
import { fitViewport, render, proj } from './render.js'; import { fitViewport, render, proj } from './render.js';
import { voice } from './audio.js'; import { voice } from './audio.js';
import { zombies, spawnZombie, piles, gore, parts, pellets } from './zombies.js'; import { zombies, spawnZombie, piles, gore, parts, pellets, extraBalls } from './zombies.js';
import { cv } from './dom.js'; import { cv } from './dom.js';
import './input.js'; import './input.js';
@@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extraBalls, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies,
step: dt => update(dt), render, press: keyPressed, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+20 -3
View File
@@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js';
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js'; import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding } from './gameplay.js'; import { predictLanding } from './gameplay.js';
import { zombies, gore, piles, parts, pellets } from './zombies.js'; import { zombies, gore, piles, parts, pellets, extraBalls } from './zombies.js';
// ---------------------------------------------------------------- palette ---- // ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them // tennis palette -> drained splatter palette; the transition lerps between them
@@ -211,6 +211,17 @@ function drawFallbackChar(ch, p, h) { // shown only until sprites load
ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u); ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u);
ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u);
} }
function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style
const hip = proj(P.x + 0.15, 1.05, P.z);
const tip = proj(P.x, 0.95, P.z - 1.35); // pointing up-court, where the pellets stream out
ctx.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke();
ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward
ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke();
if (G.horde && G.horde.mgT > 1 / TUNE.mgRate - 0.06) { // muzzle flash right after a shot
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill();
}
}
// ------------------------------------------------------------------ frame ---- // ------------------------------------------------------------------ frame ----
export function render() { export function render() {
@@ -293,6 +304,8 @@ export function render() {
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);
} }
// earlier serves still in flight (multi-ball barrage)
for (const b of extraBalls) drawBall(b.x, b.y, b.z, false, b.bomb);
// the match ball // the match ball
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb); if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb);
// splatter particles // splatter particles
@@ -308,6 +321,7 @@ export function render() {
ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2); ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2);
} }
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
if (G.horde && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player
// HUD — scoreboard // HUD — scoreboard
ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30); ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30);
@@ -336,8 +350,11 @@ export function render() {
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde) { if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde) {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
const touch = matchMedia('(pointer: coarse)').matches; const touch = matchMedia('(pointer: coarse)').matches;
ctx.fillText(G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') const txt = G.mode === 'splatter'
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'), W / 2, H - 16); ? (touch ? 'TAP TO FIRE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE FIRES · ARROWS AIM')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
ctx.fillText(txt, W / 2, H - 16);
} }
if (G.flash) { if (G.flash) {
ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
+52 -7
View File
@@ -5,7 +5,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 } from './audio.js'; import { splat, moan, pock, voice, boom, swish } from './audio.js';
import { splatPoint, cheer } from './gameplay.js'; import { splatPoint, cheer } 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'}
@@ -13,6 +13,7 @@ export const gore = []; // lawn decals {x, z, r, a} — the flat ground laye
export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster
export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark} export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark}
export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t} export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t}
export const extraBalls = []; // earlier serves still in flight while you fire the next {x,y,z,vx,vy,vz,g,pow,bomb,bounces}
let spawnT = 1.5, moanT = 3; let spawnT = 1.5, moanT = 3;
@@ -21,7 +22,9 @@ export function spawnZombie(x, z, variant) {
zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant }); zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant });
} }
function splatter(i, fanfare = true) { // core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
function killZombie(i, quiet) {
const zb = zombies[i]; const zb = zombies[i];
zombies.splice(i, 1); zombies.splice(i, 1);
gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 }); gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 });
@@ -36,9 +39,32 @@ function splatter(i, fanfare = true) {
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark, t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark,
}); } }); }
splat(); splat();
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
sendBoy(zb.x, zb.z); // a boy sprints out to mop sendBoy(zb.x, zb.z); // a boy sprints out to mop
splatPoint('P', !!G.horde); // horde kills rate-limit the umpire so he doesn't machine-gun scores splatPoint('P', quiet); // horde kills rate-limit the umpire so he doesn't machine-gun scores
}
// swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off)
export function meleeSwipe(x, z, range) {
let hit = 0;
for (let i = zombies.length - 1; i >= 0; i--) {
if (zombies[i].variant === 'r') continue;
if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < range) { killZombie(i, !!G.horde); hit++; }
}
if (hit) swish(0.35);
return hit;
}
// serving again while a ball still flies: stash the current one so it keeps killing (a barrage)
export function stashExtraBall() {
if (!ball.active) return;
extraBalls.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz,
g: ball.g, pow: ball.pow, bomb: ball.bomb, bounces: ball.bounces });
if (extraBalls.length > 6) extraBalls.shift(); // cap the barrage
}
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
killZombie(i, !!G.horde);
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
ball.kills++; // this shot's running streak ball.kills++; // this shot's running streak
// escalating multi-kill fanfare — one shot, ≥2 zombies // escalating multi-kill fanfare — one shot, ≥2 zombies
@@ -111,7 +137,10 @@ export function updateZombies(dt) {
const zb = zombies[i]; const zb = zombies[i];
zb.walkT += dt; zb.walkT += dt;
zb.z += zb.v * dt; zb.z += zb.v * dt;
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // shamblers don't walk straight lines if (G.mode === 'splatter' && zb.variant !== 'r' && zb.z > COURT.netZ) // on YOUR half they home in on you
zb.x += Math.sign(P.x - zb.x) * Math.min(Math.abs(P.x - zb.x), zb.v * 0.7 * dt);
else
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half / racket boss: aimless shamble
if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < 0.7) { // it got you if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < 0.7) { // it got you
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
for (let k = 0; k < 30; k++) parts.push({ for (let k = 0; k < 30; k++) parts.push({
@@ -122,7 +151,7 @@ export function updateZombies(dt) {
splat(1); splat(1);
voice('game_set_massacre', 0.8); voice('game_set_massacre', 0.8);
G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; // the swarm stops; the eaten screen takes over G.horde = null; pellets.length = 0; extraBalls.length = 0; // the swarm stops; the eaten screen takes over
} }
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
@@ -145,10 +174,26 @@ export function updateZombies(dt) {
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt; pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
let hit = false; let hit = false;
for (let j = zombies.length - 1; j >= 0; j--) for (let j = zombies.length - 1; j >= 0; j--)
if (Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { splatter(j, false); hit = true; break; } if (zombies[j].variant !== 'r' && Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { killZombie(j, true); hit = true; break; }
if (hit || pe.t <= 0) pellets.splice(i, 1); if (hit || pe.t <= 0) pellets.splice(i, 1);
} }
for (let i = extraBalls.length - 1; i >= 0; i--) { // earlier serves keep arcing and killing
const b = extraBalls[i];
b.vy -= (b.g || TUNE.gravity) * dt;
b.x += b.vx * dt; b.y += b.vy * dt; b.z += b.vz * dt;
let done = false;
if (b.y <= 0 && b.vy < 0) { b.y = 0; b.vy = -b.vy * TUNE.bounceY; b.vx *= TUNE.bounceXZ; b.vz *= TUNE.bounceXZ; b.bounces++;
if (b.bomb) { explode(b.x, b.z); done = true; } }
if (!done && b.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
if (zombies[j].variant === 'r' || Math.hypot(zombies[j].x - b.x, zombies[j].z - b.z) >= 0.55) continue;
if (b.bomb) { explode(b.x, b.z); done = true; }
else if ((b.pow || 0) >= TUNE.zSplatQ) { killZombie(j, !!G.horde); b.vx *= TUNE.zPierce; b.vz *= TUNE.zPierce; }
break;
}
if (done || b.bounces >= 2 || b.z < -2 || b.z > 25.5 || Math.abs(b.x) > 7.5) extraBalls.splice(i, 1);
}
for (let i = parts.length - 1; i >= 0; i--) { for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i]; const p = parts[i];
p.t -= dt; p.vy -= 14 * dt; p.t -= dt; p.vy -= 14 * dt;