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:
1 parent
ff973cf6bb
commit
b61004a18d
4 files changed
+42
-7
No files matched your search
@@ -40,6 +40,10 @@ export const TUNE = {
|
||||
// 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
|
||||
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
|
||||
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
|
||||
|
||||
+26
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
// (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 = () =>
|
||||
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
|
||||
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
|
||||
|
||||
|
||||
@@ -55,9 +55,14 @@ export function fitViewport() { // landscape 480x270, portrait 270x480 with a lo
|
||||
}
|
||||
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) {
|
||||
const d = TUNE.camZ - z, s = TUNE.camF / d;
|
||||
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY - y) * s, s };
|
||||
const d = TUNE.camZ + aimCam * TUNE.aimCamZ - z, s = TUNE.camF / d;
|
||||
return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY + aimCam * TUNE.aimCamY - y) * s, s };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sprites ----
|
||||
@@ -330,6 +335,8 @@ function drawCard(title, sub1, sub2, color) {
|
||||
// ------------------------------------------------------------------ frame ----
|
||||
export function render() {
|
||||
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)
|
||||
const m = G.shake.mag * G.shake.t / 0.35;
|
||||
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
|
||||
|
||||
Reference in new issue
Block a user