Splatter: one gesture always, a serve that clears the net, an aim camera

Three things from the phone test.

The drag meant two different things depending on invisible state: aiming from
pretoss, but walking the player around while the last ball was still in the
air. That "mode between the hits" read as a bug, so it's gone — canAimServe now
covers every phase of splatter, and firing over a live ball is allowed again
(the old one flies on as a ballistic clone). That was the mid-rally re-toss
that used to be the spam vector, and it's safe now because the brake moved into
the pull: a rhythm of lazy 30px pulls fires 100 shots in 30s and kills NOTHING
(the court fills up instead), while 85 committed full pulls kill 11.

hitServe never had doHit's net-clearance loop, so every shot at a shambler
hugging the tape died in the net — not balance, a missing loop. Both paths now
trade pace for arc until the ball is genuinely over, and the ceiling goes 8 ->
12 tries because a target right behind the net needs a near-vertical drop
(measured: clears the tape by 0.20m and connects). The trade is real: the loft
arrives slower and the walk-lead was computed for the original pace, so a lob
is honestly the less reliable shot.

And the camera now rises and leans back a little while the slingshot is drawn.
That steeper look is what makes the gap between the net and the bodies behind
it readable — the shot that used to be blind. Eased once per frame before
anything projects, since one sheared frame would be visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-26 15:45:45 +03:00
1 parent ff973cf6bb
commit b61004a18d
4 files changed
+42 -7

No files matched your search

+26 -4
View File
@@ -96,7 +96,7 @@ function doHit(ch, quality, aim) {
// low contact → trade pace for loft until the net is cleared; good timing gets a
// comfortable margin, sloppy timing keeps some tape risk
const margin = 0.06 + 0.22 * quality;
for (let i = 0; i < 8; i++) {
for (let i = 0; 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;
@@ -128,9 +128,13 @@ function tossBall(sv) {
}
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
// how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target
// sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35).
const NET_LOFT_TRIES = 12;
function hitServe(sv, quality, aim = null) {
const toFar = sv.side === 'P';
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
let boxX, tzs;
const ft = G.mode !== 'tennis' && !G.interlude // the duel serves like tennis: to the box, not free-aim
? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null;
@@ -148,7 +152,19 @@ function hitServe(sv, quality, aim = null) {
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);
// CLEAR THE NET. A shambler standing just the other side of the tape is only reachable on a
// loft, so trade pace for arc until the ball actually gets over. doHit has done this since
// forever; the serve never did, which is why every shot at a net-hugger died in the tape.
// The trade is real: the loftier it goes the slower it arrives, and the walk-lead below was
// computed for the original pace — a lob is a genuinely less reliable shot.
let v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
const margin = 0.06 + 0.22 * quality;
for (let i = 0; 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;
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
}
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0;
@@ -592,9 +608,15 @@ export function serveToss() {
export function dragServe(aim) {
if (G.mode !== 'splatter' || score.server !== 'P') return;
if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle
if (G.phase !== 'pretoss' && G.phase !== 'toss') return; // one ball: no re-serving over a live rally
if (G.phase === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now
if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return;
if (tryGunBurst()) return;
if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent
// Firing while the last ball still flies is fine again — the brake is the pull itself, not the
// rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that
// walked the player around between shots was the confusing part, not the fire rate.
if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5 });
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;
hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all