diff --git a/config.js b/config.js index 999936f..8198d99 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index 0e67480..8145dc8 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 diff --git a/input.js b/input.js index 150b89d..8b288d8 100644 --- a/input.js +++ b/input.js @@ -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 diff --git a/render.js b/render.js index 830f504..ed85bab 100644 --- a/render.js +++ b/render.js @@ -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);