From 3a91d87b081f5dfb80c24e3c2a9a3afd4808f541 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 2 Aug 2026 17:16:21 +0200 Subject: [PATCH] Show the shot itself: one solver, one band, no meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reticle was a chevron trail on the ground and a draw meter across the bottom. The trail was a RAY — it pointed at the target but said nothing about the ball getting over the net — and the meter sat in the apron, which is exactly where your thumb is while you pull, so the one readout that mattered mid-gesture spent the whole gesture underneath your hand. Now the aim IS the flight. previewServe runs the same targeting and the same loft solve hitServe fires with (both extracted, neither duplicated), so the arc you see is bit-identical to the ball you get — verified: vx/vy/vz/g all match to zero at quality 1.0, where the aim error vanishes. A preview drawn from a second copy of the maths is worse than no preview; it lies with confidence. One object now carries all three readings, up-court where you are looking: the parabola is WHERE, the colour is WHETHER (cool blue still means it only staggers), and the band's thickness and opacity are HOW MUCH — which is the part a hue cannot carry on its own. Because the arc ends at the auto-picked target, a weak pull and a full one at the same shambler draw the same curve in different colours: "pulling further does not reach further" made visible. A shadow tracks the arc along the lawn. An arc hanging in the air is ambiguous about where it comes down, and that trail is what pins it. The apron is left to the gesture demo alone, drawn bigger now that it owns the space, with its chevrons lifted clear of the player's legs. Tennis and the duel still serve to the box, not at shamblers — checked both. Co-Authored-By: Claude Opus 5 --- gameplay.js | 93 ++++++++++++++++++++++++++------------ render.js | 127 +++++++++++++++++++++++++++------------------------- 2 files changed, 130 insertions(+), 90 deletions(-) diff --git a/gameplay.js b/gameplay.js index ac0c129..ad345d0 100644 --- a/gameplay.js +++ b/gameplay.js @@ -146,43 +146,78 @@ export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gr // sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35). const NET_LOFT_TRIES = 12; +// ---- ONE SOLVER for the slingshot serve. The reticle draws the arc the ball will actually fly, +// so the targeting and the ballistics below are shared with hitServe rather than reimplemented. +// The moment a preview and a shot come from two copies of this maths they drift, and then the +// preview is worse than nothing — it lies with confidence. + +// dragServe snaps the ball to a FIXED contact before striking, which is the whole reason the arc +// is previewable: the launch point is known while you are still pulling. Both live here. +export const dragContact = () => ({ x: P.x + 0.12, y: 2.2, z: P.z }); + +// Where a splatter serve is aimed. `ex` is the aim error: hitServe passes the real random one, +// the preview passes 0, so the arc you see is the shot you MEANT rather than the shot you got. +// Returns null when this is not a free-aim/lane serve at all (the tennis serve keeps its box). +function splatServeTarget(sv, aim, s, x0, z0, ex = 0) { + if (G.mode === 'tennis' || G.interlude) return null; // the duel serves like tennis + const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }; + if (ft.zt) { + const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s; + return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), ft }; // lead the walk + } + if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ft }; + return null; +} + +// The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared. +// A shambler just the other side of the tape is only reachable on a loft. +function serveArc(x0, y0, z0, tx, tz, quality) { + let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; + const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips + let v = solveShot(x0, y0, z0, tx, tz, s, g); + for (let i = 0; i < NET_LOFT_TRIES; i++) { + const tN = (COURT.netZ - z0) / v.vz; + const yN = y0 + 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) rather than a 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(x0, y0, z0, tx, tz, s, g); + } + return { v, g }; +} + +// What render draws while you pull: the whole shot, error-free, from the same two functions the +// release will use. null when there is nothing to show yet. +export function previewServe(aim) { + if (!aim || aim.mag < TUNE.pullMin) return null; + const q = pullQuality(aim.mag); + const c = dragContact(); + const s = TUNE.serveSpeed + q * TUNE.serveQualitySpeed; + const st = splatServeTarget(P, aim, s, c.x, c.z); + if (!st) return null; + const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q); + return { x0: c.x, y0: c.y, z0: c.z, v, g, tx: st.tx, tz: st.tz, zt: st.ft.zt, + q, f: pullFrac(aim.mag) }; +} + function hitServe(sv, quality, aim = null) { const toFar = sv.side === 'P'; - let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; + const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; + // splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb + // is the one the ball flies. Only the aim error is added here and left out of the preview. + const st = splatServeTarget(sv, aim, s, ball.x, ball.z, (Math.random() * 2 - 1) * (1 - quality) * 0.8); 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; - if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie - const zt = ft.zt; - boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8; - const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; - tzs = Math.min(23, zt.z + zt.v * T0); - } else if (ft && ft.tx !== undefined) { // free-aim, no magnet: serve to the flicked spot - boxX = ft.tx; tzs = ft.tz; - } else { + if (st) { boxX = st.tx; tzs = st.tz; } + else { // target: diagonal service box. Serving from deuce side → target box is across. boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality); 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 * 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); - 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); - } + const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality); ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = sv.side; ball.bounces = 0; diff --git a/render.js b/render.js index bd36cb8..e6ce27d 100644 --- a/render.js +++ b/render.js @@ -3,7 +3,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { P, O, ball, score, crowd, G, court } from './state.js'; -import { predictLanding, flickTarget } from './gameplay.js'; +import { predictLanding, previewServe } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; // ---------------------------------------------------------------- palette ---- @@ -617,7 +617,9 @@ function drawMgRacket() { // horde: the player braces the racket forward and up- const RACK_BOT = 0; // px below the rack: none — it sits ON the bottom edge. SAFE.bottom still lifts // it clear of the home indicator, which is the only gap that has to be there. const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge) -export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom; // the draw meter's fixed bottom slot +// The bottom text slot. Named for the draw meter that used to live here; the meter is gone (the +// arc band carries power now) and the apron below is left to the gesture demo. +export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom; function rackGeom() { const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n; return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT - SAFE.bottom, tw, h, n, rw }; @@ -678,25 +680,34 @@ function drawCard(title, sub1, sub2, color) { // Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides // BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats. -// Pulling is the whole game in splatter mode, and a line of text was carrying that alone. +// THE APRON IS THIS DEMO'S ROOM, and now it is the only thing in it. The draw meter used to share +// the space, which was backwards on both counts: a live readout down here is under your thumb the +// whole time it matters (the arc band carries power now, up-court where you can see it), while +// the demo only ever plays when your thumb is NOT there. So it gets the whole apron and is drawn +// at a size you can read at arm's length. function drawPullHint() { const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc; const draw = Math.min(1, t / 1.15); // the pull itself, then a beat held at full const gone = t > 1.45; // released — the thumb lifts off - const anchor = proj(P.x, 0, P.z + 0.6); - const ax = Math.round(anchor.x), ay = Math.round(Math.min(anchor.y + 6, METER_Y() - 26)); - const len = 30 * draw; + const feet = proj(P.x, 0, P.z).y; + const room = Math.max(30, METER_Y() - 10 - feet); // apron between his feet and the text slot + const travel = Math.min(46, room * 0.5); + const ax = Math.round(proj(P.x, 0, P.z).x), ay = Math.round(feet + 12); + const len = travel * draw; ctx.save(); ctx.globalAlpha = gone ? 0 : 0.85; // the dotted track the thumb travels, straight down-screen = "pull back" ctx.fillStyle = UI.dim; - for (let d = 6; d < 34; d += 6) ctx.fillRect(ax - 1, ay + d, 2, 2); - // chevrons growing UP-court as the pull grows — the inverse relationship is the whole lesson + for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax - 1, ay + d, 2, 2); + // chevrons growing UP-court as the pull grows — the inverse relationship is the whole lesson. + // They start above his HEAD, not at his feet: pinned to the apron anchor they were drawn across + // his own legs, which read as clutter on the sprite rather than as a shot leaving him. + const head = proj(P.x, 2.4, P.z).y; ctx.fillStyle = UI.warm; for (let i = 0; i < 3; i++) { const u = (i + 1) / 4; if (draw < u * 0.9) continue; - const cy = ay - 8 - i * 7, s = 4; + const cy = head - 6 - i * 9, s = 5; ctx.beginPath(); ctx.moveTo(ax, cy - s); ctx.lineTo(ax + s, cy + s * 0.7); ctx.lineTo(ax - s, cy + s * 0.7); ctx.fill(); @@ -704,9 +715,9 @@ function drawPullHint() { // the ghost thumb ctx.globalAlpha = gone ? 0 : 0.9; ctx.fillStyle = UI.cream; - ctx.beginPath(); ctx.arc(ax, ay + len, 5, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(ax, ay + len, 6, 0, 7); ctx.fill(); ctx.fillStyle = 'rgba(0,0,0,0.45)'; - ctx.beginPath(); ctx.arc(ax, ay + len, 2, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(ax, ay + len, 2.5, 0, 7); ctx.fill(); ctx.restore(); } @@ -865,59 +876,53 @@ export function render() { if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); } if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? UI.hot : UI.warm); - // slingshot aim. Three readings, all of them power: a chevron trail running down the lane, an - // arrowhead on the target, and a notched meter over your head. The notch is the point of the - // whole thing — it puts the kill threshold on screen instead of leaving it to be discovered. + // SLINGSHOT AIM: the shot's actual flight, drawn as a band through the air. + // It replaces a chevron trail on the ground plus a draw meter across the bottom. The trail was + // a RAY — it pointed at the target but said nothing about the ball going over the net — and the + // meter lived in the apron, which is precisely where your thumb is while you pull, so the one + // readout you needed mid-gesture was under your hand. + // Now one object carries everything, up-court where you are already looking: + // WHERE the parabola itself, from previewServe — the same solver the release fires with + // WHETHER the colour, flipping at the kill threshold (cool blue = this only staggers) + // HOW MUCH the band's thickness and opacity, which is what a hue cannot say on its own + // Because the arc terminates at the auto-picked target, a weak pull and a full one at the same + // shambler draw the SAME curve in different colours — which is the "distance is free" rule made + // visible instead of merely documented. if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) { - const ft = flickTarget(P, G.aim); - const tx = ft.zt ? ft.zt.x : ft.tx, tz = ft.zt ? ft.zt.z : ft.tz; - const kills = ft.q >= TUNE.zSplatQ; - // cool blue-white = this pull only staggers. It stays READABLE on the dark lawn: you still - // need to see where you're pointing, you just need to know it won't kill. - const col = !kills ? '#9db4d0' : ft.zt ? UI.hot : UI.warm; - if (tx !== undefined) { - 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 - // (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); + const pv = previewServe(G.aim); + if (pv) { + const kills = pv.q >= TUNE.zSplatQ; + // cool blue-white = this pull only staggers. It stays READABLE on the dark lawn: you still + // need to see where you're pointing, you just need to know it won't kill. + const col = !kills ? '#9db4d0' : pv.zt ? UI.hot : UI.warm; + const vh = Math.hypot(pv.v.vx, pv.v.vz) || 1; + const T = Math.hypot(pv.tx - pv.x0, pv.tz - pv.z0) / vh; // flight time to the target + const N = 20; + const at = t => ({ x: pv.x0 + pv.v.vx * t, y: pv.y0 + pv.v.vy * t - 0.5 * pv.g * t * t, + z: pv.z0 + pv.v.vz * t }); + // The shadow first, under everything: an arc hanging in the air is ambiguous about where it + // comes down until something pins it to the lawn. This is the whole 3D cue. + for (let i = 0; i <= N; i++) { + const w = at(T * i / N), p = proj(w.x, 0, w.z); + ctx.fillStyle = `rgba(0,0,0,${0.42 - 0.24 * (i / N)})`; + ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * 0.07), Math.max(0.5, p.s * 0.028), 0, 0, 7); 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 OUT - // toward the target: solid at your racket, thinning up the lane, so the trail reads as - // something leaving your hand rather than as a label on the zombie. 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 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)), 1 - 0.7 * u); } - 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. A fixed BAR ACROSS - // THE BOTTOM, ~90% of the width — it used to be a 26x4 px stub floating on the apron by your - // feet, which is nothing at arm's length with a thumb over it. Fixed position also means it - // never wanders behind the chevrons or off the top of the court. - const mw = Math.round(W * 0.9), mh = 10, mx = Math.round((W - mw) / 2); - const my = METER_Y(); - ctx.fillStyle = 'rgba(0,0,0,0.62)'; ctx.fillRect(mx - 2, my - 2, mw + 4, mh + 4); - ctx.fillStyle = 'rgba(255,255,255,0.16)'; ctx.fillRect(mx, my, mw, mh); // empty channel - ctx.fillStyle = col; ctx.fillRect(mx, my, Math.round(mw * ft.f), mh); - const notch = Math.round(mw * (TUNE.zSplatQ - TUNE.pullQMin) / (1 - TUNE.pullQMin)); - ctx.fillStyle = '#ffffff'; ctx.fillRect(mx + notch, my - 3, 2, mh + 6); // the kill line - if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the whole bar flashes - ctx.fillStyle = '#ffffff'; ctx.fillRect(mx, my, mw, mh); + // ...then the band. Width comes from each point's OWN projection scale, so it tapers into + // the distance like everything else on the court rather than being a flat screen-space + // stroke. It also fades along its length: solid at the racket, thinning toward the target, + // so it reads as something leaving your hand and not as a label pinned on the shambler. + ctx.strokeStyle = col; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + let prev = proj(pv.x0, pv.y0, pv.z0); + for (let i = 1; i <= N; i++) { + const u = i / N, w = at(T * u), p = proj(w.x, w.y, w.z); + ctx.globalAlpha = (0.30 + 0.55 * pv.f) * (1 - 0.55 * u); + ctx.lineWidth = Math.max(1, p.s * (0.04 + pv.f * 0.15)); + ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke(); + prev = p; + } + ctx.globalAlpha = 1; ctx.lineWidth = 1; + drawRing(pv.tx, pv.tz, col); } }