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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 16:38:27 +02:00
1 parent 69e3a0c310
commit 60c5f72a0a
2 files changed
+65 -11

No files matched your search

+48 -9
View File
@@ -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() {
+17 -2
View File
@@ -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