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

+4
View File
@@ -40,6 +40,10 @@ export const TUNE = {
// zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob. // zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob.
aimCone: 0.45, // lane half-width per meter of range — the aim cone widens with distance aimCone: 0.45, // lane half-width per meter of range — the aim cone widens with distance
aimReach: 22, // m the aim ray runs when no shambler stands in the lane aimReach: 22, // m the aim ray runs when no shambler stands in the lane
aimCamY: 1.2, // m the camera rises while you're drawing — a steeper look, so the gap between
aimCamZ: 1.2, // m it also leans back, so the lift doesn't push the near court off-screen.
// Together: you can read the depth behind the net, which is exactly the shot
// that used to be blind. Set both to 0 for the old fixed camera.
backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it
backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower
backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
+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 // 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
const margin = 0.06 + 0.22 * quality; 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 tN = (COURT.netZ - ball.z) / v.vz;
const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN; const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN;
if (tN <= 0 || yN >= COURT.netH + margin) break; 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 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) { function hitServe(sv, quality, aim = null) {
const toFar = sv.side === 'P'; const toFar = sv.side === 'P';
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
let boxX, tzs; let boxX, tzs;
const ft = G.mode !== 'tennis' && !G.interlude // the duel serves like tennis: to the box, not free-aim 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; ? (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; 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); // 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.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0; ball.lastHit = sv.side; ball.bounces = 0;
@@ -592,9 +608,15 @@ export function serveToss() {
export function dragServe(aim) { export function dragServe(aim) {
if (G.mode !== 'splatter' || score.server !== 'P') return; 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.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 (tryGunBurst()) return;
if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent 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.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; 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 hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
+3 -1
View File
@@ -42,8 +42,10 @@ export function takeFlickAim(maxAgeMs = 450) {
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle // the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
// (G.aim, drawn by render); release fires the serve there, dragging back home cancels. // (G.aim, drawn by render); release fires the serve there, dragging back home cancels.
// EVERY phase of splatter play, so the drag never silently turns into something else. The old
// "drag walks the player" mode between shots was invisible state and read as a bug.
const canAimServe = () => const canAimServe = () =>
G.mode === 'splatter' && G.phase === 'pretoss' G.mode === 'splatter' // every phase of it; 'eaten' is its own mode and never gets here
&& score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle && !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
+9 -2
View File
@@ -55,9 +55,14 @@ export function fitViewport() { // landscape 480x270, portrait 270x480 with a lo
} }
addEventListener('resize', fitViewport); addEventListener('resize', fitViewport);
// The aim camera: while the slingshot is drawn the view rises and leans back a little, which
// steepens the look down the court. That extra depth is what makes a shambler standing right
// behind the net a shot you can actually judge. Eased once per frame in render(), never mid-frame
// — every proj() call within one frame must share the same camera or the scene shears.
let aimCam = 0;
export function proj(x, y, z) { export function proj(x, y, z) {
const d = TUNE.camZ - z, s = TUNE.camF / d; const d = TUNE.camZ + aimCam * TUNE.aimCamZ - z, s = TUNE.camF / d;
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY - y) * s, s }; return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s };
} }
// ---------------------------------------------------------------- sprites ---- // ---------------------------------------------------------------- sprites ----
@@ -330,6 +335,8 @@ function drawCard(title, sub1, sub2, color) {
// ------------------------------------------------------------------ frame ---- // ------------------------------------------------------------------ frame ----
export function render() { export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
// ease the aim camera FIRST, before anything projects — one camera per frame (see proj)
aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15;
if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD) if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD)
const m = G.shake.mag * G.shake.t / 0.35; const m = G.shake.mag * G.shake.t / 0.35;
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m); ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);