Aim the ball at the chest, and stop lobbing at a net that isn't there

THE BALL WAS SOLVED TO THE GROUND. solveShot's terminal height was a hardcoded
0.08 — ball-on-the-grass — so a splatter serve came down past the shambler and
registered at the ankles however the mark was drawn. It now takes an arrival
height, and a lane shot aims at the body: zombieH x Z_CHEST, scaled off the
same height table render draws with, so a brute is struck on the brute rather
than at the height a shambler's chest happens to be. Measured at the frame the
wound registers: shambler 1.11 m of 1.8, brute 1.64 m of 2.7, and an empty lane
still lands on the grass. The aim mark reads the same `ty`, so mark, arc end
and impact are now one point instead of three guesses.

AND A BUG FOUND ON THE WAY. The net-clearing loop ran for shots that never
reach the net. A shambler on your own side sits short of the tape, so `tN` is
still positive — the net is ahead in the direction of travel, the ball simply
lands first — the height test there is far below the tape, and the loop spent
all twelve cuts. Every same-side shot left the racket at 28% of nominal pace as
a slow loop. That is exactly the shot that needs to be fast: those are the ones
about to eat you. Guarded on the shot actually crossing the net, in serveArc
and in doHit's copy. Flight time to a body at z=16 drops 0.71 s -> 0.20 s.

THIS ONE IS A FEEL CHANGE YOU DID NOT ASK FOR — near shots are now fast and
flat where they used to loop. Say if you want the lob back.

Tennis and the duel still serve into the box, far-side shots still clear the
tape, 900 frames of splatter render clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-02 18:49:47 +02:00
1 parent ff9e09e3eb
commit 2edabe6c64
3 files changed
+52 -23

No files matched your search

+35 -19
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
@@ -8,10 +8,12 @@ import { W } from './render.js'; // runtime-only cycle (render imports gameplay)
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) {
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
// `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a
// shambler's chest, and solving to the ground under one is what made the ball strike the ankles.
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) {
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * g * T * T) / T };
return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T };
}
export function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters
@@ -107,9 +109,12 @@ function doHit(ch, quality, aim) {
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
// low contact → trade pace for loft until the net is cleared; good timing gets a
// comfortable margin, sloppy timing keeps some tape risk
// comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's,
// for the same reason: a splatter volley at a body on your own side has no tape to clear, and
// without this it was lobbed at a fraction of its pace. (In tennis the ball always crosses.)
const margin = 0.06 + 0.22 * quality;
for (let i = 0; i < NET_LOFT_TRIES; i++) {
const crossesNet = (ball.z - COURT.netZ) * (tz - COURT.netZ) < 0;
for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) {
const tN = (COURT.netZ - ball.z) / v.vz;
const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN;
if (tN <= 0 || yN >= COURT.netH + margin) break;
@@ -172,19 +177,29 @@ function splatServeTarget(sv, aim, s, x0, z0, ex = 0) {
const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) };
if (ft.zt) {
const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s;
return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), ft }; // lead the walk
// ty: aim at the CHEST of whatever this is, so the ball arrives on the body it is going to
// wound. Scaled off the same height render draws it at, so a brute is struck on the brute.
return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), // lead the walk
ty: zombieH(ft.zt) * Z_CHEST, ft };
}
if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ft };
if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ty: 0.08, ft }; // empty lane: onto the grass
return null;
}
// The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared.
// A shambler just the other side of the tape is only reachable on a loft.
function serveArc(x0, y0, z0, tx, tz, quality) {
function serveArc(x0, y0, z0, tx, tz, quality, ty = 0.08) {
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips
let v = solveShot(x0, y0, z0, tx, tz, s, g);
for (let i = 0; i < NET_LOFT_TRIES; i++) {
let v = solveShot(x0, y0, z0, tx, tz, s, g, ty);
// THERE IS ONLY A NET TO CLEAR IF THE SHOT CROSSES IT. A shambler on your own side sits short of
// the tape, so the ball never reaches that plane — but `tN` is still positive (the net is ahead
// in the direction of travel, the ball simply lands first), the height test there is far below
// the tape, and the loop cut pace all twelve times. Measured: every same-side shot left the
// racket at 28% of nominal, as a slow loop, which is exactly the shot you need to be fast —
// those are the ones about to eat you.
const crossesNet = (z0 - COURT.netZ) * (tz - COURT.netZ) < 0;
for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) {
const tN = (COURT.netZ - z0) / v.vz;
const yN = y0 + v.vy * tN - 0.5 * g * tN * tN;
// The sim only samples the net plane once a frame, so a steeply DIVING ball can test below
@@ -193,7 +208,7 @@ function serveArc(x0, y0, z0, tx, tz, quality) {
// cushion is paid for in pace, and it was taxing precisely the shots meant to hurt.
const margin = 0.06 + Math.abs(v.vy - g * tN) * 0.04; // 0.04 s = main.js's dt clamp
if (tN <= 0 || yN >= COURT.netH + margin) break;
s *= 0.9; v = solveShot(x0, y0, z0, tx, tz, s, g);
s *= 0.9; v = solveShot(x0, y0, z0, tx, tz, s, g, ty);
}
return { v, g };
}
@@ -207,15 +222,16 @@ export function previewServe(aim) {
const s = TUNE.serveSpeed + q * TUNE.serveQualitySpeed;
const st = splatServeTarget(P, aim, s, c.x, c.z);
if (!st) return null;
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q);
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, !!st.ft.zt, q);
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty);
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q);
}
// The drawable form of a shot: everything render needs and nothing that would pin a zombie object
// alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at).
function shotBand(x0, y0, z0, v, g, tx, tz, zt, q) {
// alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). `ty` is
// the arrival height, so the mark render paints sits exactly where the ball is going.
function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q) {
const T = Math.hypot(tx - x0, tz - z0) / (Math.hypot(v.vx, v.vz) || 1); // flight time to target
return { x0, y0, z0, v, g, tx, tz, zt, q, f: qualityFrac(q), T, t: 0 };
return { x0, y0, z0, v, g, tx, tz, ty, zt, q, f: qualityFrac(q), T, t: 0 };
}
function hitServe(sv, quality, aim = null) {
@@ -232,12 +248,12 @@ function hitServe(sv, quality, aim = null) {
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
}
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality);
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08);
// THE BALL ERASES ITS OWN AIM. Cutting the band at the instant of release made a fast rally
// strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it
// flies, so the aim resolves into the shot rather than blinking out of existence. This is the
// ACTUAL launch, error included, so the ball tracks the band it is consuming exactly.
if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, !!st.ft.zt, quality);
if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality);
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0;