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:
1 parent
69e3a0c310
commit
60c5f72a0a
2 files changed
+65
-11
No files matched your search
+48
-9
@@ -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 { 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 } from './zombies.js';
|
import { updateZombies, spawnZombie, zombies } 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.
|
||||||
@@ -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 };
|
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) {
|
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)
|
// 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';
|
const toFar = ch.side === 'P';
|
||||||
@@ -28,11 +44,19 @@ function doHit(ch, quality, aim) {
|
|||||||
ball.x = ch.x + ch.facing * 0.5;
|
ball.x = ch.x + ch.facing * 0.5;
|
||||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
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
|
let tx, tz;
|
||||||
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 s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
|
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
|
// 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
|
// pace arc over the net and still land in — the core tennis trade-off
|
||||||
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
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) {
|
function hitServe(sv, quality) {
|
||||||
const toFar = sv.side === 'P';
|
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 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 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);
|
const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
|
||||||
ball.g = 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 ----
|
// -------------------------------------------------------------- transition ----
|
||||||
// You won your first game. The applause dies. The match... continues.
|
// You won your first game. The applause dies. The match... continues.
|
||||||
function startTransition() {
|
function startTransition() {
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
// 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)
|
// 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 { 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 { fitViewport, render, proj } from './render.js';
|
||||||
import { voice } from './audio.js';
|
import { voice } from './audio.js';
|
||||||
import { zombies, spawnZombie } from './zombies.js';
|
import { zombies, spawnZombie } from './zombies.js';
|
||||||
|
import { cv } from './dom.js';
|
||||||
import './input.js';
|
import './input.js';
|
||||||
|
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
@@ -30,6 +31,20 @@ function dismissIntro() {
|
|||||||
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
|
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
|
||||||
addEventListener('keydown', () => dismissIntro(), { once: true });
|
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);
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
// exposed for in-browser sanity checks and simulated playtests
|
// exposed for in-browser sanity checks and simulated playtests
|
||||||
|
|||||||
Reference in new issue
Block a user