From 60c5f72a0a06eb85de4a7079fde6953fbfe8e37a Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 16:38:27 +0200 Subject: [PATCH] Splatter aiming targets zombies anywhere; triple-tap the umpire to skip ahead - doHit/hitServe in splatter mode lock onto zombies instead of tennis court depths: your flick picks the lane and depth band, the shot targets the best match anywhere on the court (including your own side) with a walking lead; serves auto-track the most advanced shambler - hidden shortcut: tap/click the umpire three times within 1.6s during tennis mode to summon the transition immediately Verified: near-side and far zombies killable, cheat triggers transition, tennis mode targeting untouched. Co-Authored-By: Claude Fable 5 --- gameplay.js | 57 ++++++++++++++++++++++++++++++++++++++++++++--------- main.js | 19 ++++++++++++++++-- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/gameplay.js b/gameplay.js index d076b02..570f821 100644 --- a/gameplay.js +++ b/gameplay.js @@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POS import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { audio, pock, swish, applause, voice } from './audio.js'; import { axisX, axisZ, takeFlickAim } from './input.js'; -import { updateZombies, spawnZombie } from './zombies.js'; +import { updateZombies, spawnZombie, zombies } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -21,6 +21,22 @@ export function predictLanding() { // first-bounce point, closed form — exact, return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t }; } +// splatter aiming: your flick picks a lane (ax) and depth band (az); the shot locks onto +// the best-matching zombie anywhere on the court — including your own side of the net. +function zombieTarget(aim) { + if (!zombies.length) return null; + const lane = (aim ? aim.ax : 0) * 3.3; + const band = aim && aim.az !== 0 ? (aim.az < 0 ? 4 : 19) : null; + let best = null, bs = 1e9; + for (const zb of zombies) { + let s = Math.abs(zb.x - lane); + if (band !== null) s += Math.abs(zb.z - band) * 0.5; + if (!aim) s = COURT.L - zb.z; // no aim (serves): the nearest one is the most urgent + if (s < bs) { bs = s; best = zb; } + } + return best; +} + function doHit(ch, quality, aim) { // aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short) const toFar = ch.side === 'P'; @@ -28,11 +44,19 @@ function doHit(ch, quality, aim) { ball.x = ch.x + ch.facing * 0.5; ball.z = ch.z + (toFar ? -0.3 : 0.3); const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); - let tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide - const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; - const tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; - tx = Math.max(-5, Math.min(5, tx)); + let tx, tz; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; + const zt = G.mode !== 'tennis' && ch.side === 'P' ? zombieTarget(aim) : null; + if (zt) { + tx = zt.x + err() * 0.4; + const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; + tz = Math.min(23, zt.z + zt.v * T0) + err() * 0.3; // lead the shambler's walk + } else { + tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide + const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; + tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; + } + tx = Math.max(-5, Math.min(5, tx)); // topspin: quality shots dive under extra effective gravity, which is what lets real // pace arc over the net and still land in — the core tennis trade-off const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); @@ -71,11 +95,19 @@ export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gr function hitServe(sv, quality) { const toFar = sv.side === 'P'; - // target: diagonal service box. Serving from deuce side → target box is across. - const boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality); - const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6; const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; - const tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; + let boxX, tzs; + const zt = G.mode !== 'tennis' ? zombieTarget(null) : null; + if (zt) { // splatter serves auto-track the most advanced zombie + boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8; + const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; + tzs = Math.min(23, zt.z + zt.v * T0); + } else { + // target: diagonal service box. Serving from deuce side → target box is across. + boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality); + const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6; + tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; + } const g = TUNE.gravity * (1 + quality * 0.35); // kick serves dip a little too const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g); ball.g = g; @@ -144,6 +176,13 @@ export function splatPoint(side) { } } +// hidden shortcut: tapping the umpire three times summons them early +export function forceZombies() { + if (G.mode !== 'tennis') return; + ball.active = false; + startTransition(); +} + // -------------------------------------------------------------- transition ---- // You won your first game. The applause dies. The match... continues. function startTransition() { diff --git a/main.js b/main.js index f3f32f9..1ae09cc 100644 --- a/main.js +++ b/main.js @@ -3,12 +3,13 @@ // Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI) -import { TUNE } from './config.js'; +import { TUNE, COURT, UMP_H } from './config.js'; import { P, O, ball, score, G, setupPoint } from './state.js'; -import { update, keyPressed, solveShot, inServeBox, inCourt } from './gameplay.js'; +import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js'; import { fitViewport, render, proj } from './render.js'; import { voice } from './audio.js'; import { zombies, spawnZombie } from './zombies.js'; +import { cv } from './dom.js'; import './input.js'; let last = performance.now(); @@ -30,6 +31,20 @@ function dismissIntro() { intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); }); addEventListener('keydown', () => dismissIntro(), { once: true }); +// hidden shortcut: tap the umpire three times to summon zombie mode early +let umpTaps = []; +cv.addEventListener('pointerdown', e => { + const r = cv.getBoundingClientRect(); + const x = (e.clientX - r.left) / r.width * (cv.width / 2); // logical coords (RES = 2) + const y = (e.clientY - r.top) / r.height * (cv.height / 2); + const u = proj(7.1, 0, COURT.netZ); + if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return; + const now = performance.now(); + umpTaps = umpTaps.filter(t => now - t < 1600); + umpTaps.push(now); + if (umpTaps.length >= 3) { umpTaps = []; forceZombies(); } +}); + requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests