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); } }