Splatter: chevrons in perspective, and a smash that actually flies

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-26 16:36:06 +03:00
1 parent f32cac6a70
commit f3dcf0b16f
5 files changed
+45 -21

No files matched your search

+8 -1
View File
@@ -11,7 +11,14 @@ export const TUNE = {
swingWindup: 0.06, // s before contact window opens swingWindup: 0.06, // s before contact window opens
swingWindow: 0.16, // s contact window length swingWindow: 0.16, // s contact window length
baseShotSpeed: 16, qualityShotSpeed: 12, // horizontal m/s = base + q*quality; perfect timing = real pace 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 aimError: 2.4, // m of scatter at quality 0
perfectMs: 45, // |offset from window center| for PERFECT perfectMs: 45, // |offset from window center| for PERFECT
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09, aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
+6 -2
View File
@@ -151,17 +151,21 @@ function hitServe(sv, quality, aim = null) {
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6; const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
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 * 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 // 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 // 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. // 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 // 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. // 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); 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++) { 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;
// 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; if (tN <= 0 || yN >= COURT.netH + margin) break;
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g); s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
} }
+1 -1
View File
@@ -52,6 +52,6 @@
<p class="start">TAP TO START</p> <p class="start">TAP TO START</p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=17"></script> <script type="module" src="main.js?v=19"></script>
</body> </body>
</html> </html>
+20 -15
View File
@@ -417,24 +417,29 @@ export function render() {
const a = proj(P.x, 1.4, P.z), b = proj(tx, 0.35, tz); 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 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 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 // (proj is a true projective map, so a straight world line stays straight on screen — one
// the trail reads as pressure building up rather than as a static line // screen direction is correct for every chevron along it, only the SIZE has to change)
const n = Math.round(3 + ft.f * 5), size = 2 + ft.f * 2.5; 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; ctx.fillStyle = col;
for (let i = 1; i <= n; i++) { for (let i = 1; i <= n; i++) {
const d = len * (i / (n + 1)), cx = a.x + ux * d, cy = a.y + uy * d; const u = i / (n + 1);
ctx.beginPath(); const c = proj(P.x + (tx - P.x) * u, 1.4 + (0.35 - 1.4) * u, P.z + (tz - P.z) * u);
ctx.moveTo(cx + ux * size, cy + uy * size); chevron(c, Math.max(1.5, c.s * (0.07 + ft.f * 0.09)), 0.3 + 0.7 * u);
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 head = 3 + ft.f * 3.5; // the arrowhead lands ON the target, bigger the harder you pull chevron(b, Math.max(2.5, b.s * (0.10 + ft.f * 0.11)), 1); // the arrowhead lands ON the target
ctx.beginPath(); ctx.globalAlpha = 1;
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();
drawRing(tx, tz, col); drawRing(tx, tz, col);
} }
// the draw meter: it fills as you pull and the notch marks the kill line. Anchored on the // the draw meter: it fills as you pull and the notch marks the kill line. Anchored on the
+10 -2
View File
@@ -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--) { for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i]; const zb = zombies[i];
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us 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) { if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); 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 if (ball.bomb) { // detonate on first contact, whatever the pace
explode(ball.x, ball.z); ball.bomb = false; explode(ball.x, ball.z); ball.bomb = false;
spentBall(); break; // zombies mutated — stop this frame's loop 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--) { if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j]; const zb = zombies[j];
if (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop 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 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') { else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') {
if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers