From f3dcf0b16f0f2310a7e5b11954acbde330a96987 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 26 Jul 2026 16:36:06 +0300 Subject: [PATCH] Splatter: chevrons in perspective, and a smash that actually flies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chevrons were stepped along the SCREEN line at a fixed size, so they read as a flat sticker over a 3D court. They now step through world space and take their size from their own projection scale — near ones roughly twice the far ones — and they fade IN toward the target so the eye is walked up the lane instead of being handed a uniform stripe. The arrowhead scales with the target's own depth too. (proj is a true projective map, so the screen direction stays constant along the line; only size and alpha needed to vary.) Hard shots now fly hard: serve pace is weighted onto quality (19 base + 22×q, so 21 -> 41 m/s) instead of 21 -> 28. Two things had to give way for it. Serve topspin was a fixed 1 + 0.35q, which meant a fast flat ball could not both clear the tape and land on a target, so the net-loft loop throttled every smash back to walking pace — measured 21.8 m/s whether you drew 60px or 95px. TUNE.serveSpin 1.3 lets it leave flat and DIVE, which is what a smash is. And the loft loop's margin was doHit's quality cushion, which taxed exactly the shots meant to hurt. It's now sized to the real error it exists to cover: the sim samples the net plane once a frame, so a diving ball can test below the tape when the closed form clears it. Margin = descent rate × the dt clamp. A flat drive pays almost nothing, a lob pays what it must. Measured, target 20m out: 24.7 / 32.2 / 41 m/s for weak / mid / full draw. At the net it's 9.6 / 11.2 / 11.6 — still the slow floaty lob, as it should be. Ball-vs-shambler contact gets a crude sweep (radius + half a frame's travel), because a 41 m/s ball steps 0.7m per frame at 60fps and 1.6m at main.js's dt clamp, both wider than a shambler. Verified the full smash connects at both. Co-Authored-By: Claude Opus 5 --- config.js | 9 ++++++++- gameplay.js | 8 ++++++-- index.html | 2 +- render.js | 35 ++++++++++++++++++++--------------- zombies.js | 12 ++++++++++-- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/config.js b/config.js index 3446781..a0d71aa 100644 --- a/config.js +++ b/config.js @@ -11,7 +11,14 @@ export const TUNE = { swingWindup: 0.06, // s before contact window opens swingWindow: 0.16, // s contact window length baseShotSpeed: 16, qualityShotSpeed: 12, // horizontal m/s = base + q*quality; perfect timing = real pace - serveSpeed: 21, serveQualitySpeed: 7, + // the slingshot's pace range. Weighted hard toward QUALITY so a full draw is a smash you can + // see: 21 m/s floppy at the minimum pull, 41 m/s at full. (baseShotSpeed above is the rally + // volley and stays put — it's shared with tennis mode and the CPU.) + serveSpeed: 19, serveQualitySpeed: 22, + serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the + // net-clearance loop ate the whole smash: a flat fast ball can't get over the + // tape AND come down on a target, so it got throttled back to walking pace. + // Heavy topspin lets it leave flat and DIVE, which is what a smash actually is. aimError: 2.4, // m of scatter at quality 0 perfectMs: 45, // |offset from window center| for PERFECT aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09, diff --git a/gameplay.js b/gameplay.js index 8145dc8..b6aeb1f 100644 --- a/gameplay.js +++ b/gameplay.js @@ -151,17 +151,21 @@ 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 g = TUNE.gravity * (1 + quality * 0.35); // kick serves dip a little too + const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips // 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; + // The sim only samples the net plane once a frame, so a steeply DIVING ball can test below + // the tape even when the closed form clears it. Charge margin for exactly that sampling + // error (descent rate × the worst frame step) instead of doHit's flat quality cushion — + // that 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(ball.x, ball.y, ball.z, boxX, tzs, s, g); } diff --git a/index.html b/index.html index 38ad6a5..a01d87d 100644 --- a/index.html +++ b/index.html @@ -52,6 +52,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 5c51637..5117f58 100644 --- a/render.js +++ b/render.js @@ -417,24 +417,29 @@ export function render() { const a = proj(P.x, 1.4, P.z), b = proj(tx, 0.35, tz); const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len, px = -uy, py = ux; // unit along the lane, and its normal - // chevrons pointing at the target: they multiply and grow as the draw gets stronger, so - // the trail reads as pressure building up rather than as a static line - const n = Math.round(3 + ft.f * 5), size = 2 + ft.f * 2.5; + // (proj is a true projective map, so a straight world line stays straight on screen — one + // screen direction is correct for every chevron along it, only the SIZE has to change) + const chevron = (c, size, alpha) => { + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.moveTo(c.x + ux * size, c.y + uy * size); + ctx.lineTo(c.x - ux * size * 0.4 + px * size, c.y - uy * size * 0.4 + py * size); + ctx.lineTo(c.x - ux * size * 0.4 - px * size, c.y - uy * size * 0.4 - py * size); + ctx.fill(); + }; + // Chevrons stepped along the lane in WORLD space, each sized by its own projection scale, + // so they shrink into the distance like everything else on the court. They also fade IN + // toward the target: faint at your racket, solid on the body, which walks the eye up the + // lane instead of leaving a uniform stripe. More of them, and fatter, the harder you draw. + const n = Math.round(3 + ft.f * 5); ctx.fillStyle = col; for (let i = 1; i <= n; i++) { - const d = len * (i / (n + 1)), cx = a.x + ux * d, cy = a.y + uy * d; - ctx.beginPath(); - ctx.moveTo(cx + ux * size, cy + uy * size); - ctx.lineTo(cx - ux * size * 0.4 + px * size, cy - uy * size * 0.4 + py * size); - ctx.lineTo(cx - ux * size * 0.4 - px * size, cy - uy * size * 0.4 - py * size); - ctx.fill(); + const u = i / (n + 1); + const c = proj(P.x + (tx - P.x) * u, 1.4 + (0.35 - 1.4) * u, P.z + (tz - P.z) * u); + chevron(c, Math.max(1.5, c.s * (0.07 + ft.f * 0.09)), 0.3 + 0.7 * u); } - const head = 3 + ft.f * 3.5; // the arrowhead lands ON the target, bigger the harder you pull - ctx.beginPath(); - ctx.moveTo(b.x + ux * head, b.y + uy * head); - ctx.lineTo(b.x - ux * head + px * head * 0.8, b.y - uy * head + py * head * 0.8); - ctx.lineTo(b.x - ux * head - px * head * 0.8, b.y - uy * head - py * head * 0.8); - ctx.fill(); + chevron(b, Math.max(2.5, b.s * (0.10 + ft.f * 0.11)), 1); // the arrowhead lands ON the target + ctx.globalAlpha = 1; drawRing(tx, tz, col); } // the draw meter: it fills as you pull and the notch marks the kill line. Anchored on the diff --git a/zombies.js b/zombies.js index 8f6d534..9e37191 100644 --- a/zombies.js +++ b/zombies.js @@ -195,6 +195,13 @@ export function updateZombies(dt) { } } + // ponytail: crude swept collision — grow the contact window by half the distance the ball + // covers this frame. A full-draw smash moves ~0.7 m per frame (1.6 m on a 25 fps device, where + // main.js clamps dt), which is wider than a shambler, so a discrete point test tunnels straight + // through bodies. Upgrade to a real segment-vs-circle test if the fudge ever reads as a phantom + // hit; at these speeds and radii it doesn't. + const ballSweep = ball.active ? Math.hypot(ball.vx, ball.vz) * dt * 0.5 : 0; + for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us @@ -230,7 +237,7 @@ export function updateZombies(dt) { } if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); - if (d < (zb.variant === 'g' ? 0.75 : 0.55)) { // the brute is a bigger target + if (d < (zb.variant === 'g' ? 0.75 : 0.55) + ballSweep) { // the brute is a bigger target if (ball.bomb) { // detonate on first contact, whatever the pace explode(ball.x, ball.z); ball.bomb = false; spentBall(); break; // zombies mutated — stop this frame's loop @@ -311,7 +318,8 @@ export function updateZombies(dt) { if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) { const zb = zombies[j]; if (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop - if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)) { + if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55) + + Math.hypot(e.vx, e.vz) * dt * 0.5) { // same sweep: clones inherit the smash pace if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') { if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers