diff --git a/gameplay.js b/gameplay.js index 880e638..97a2b93 100644 --- a/gameplay.js +++ b/gameplay.js @@ -24,7 +24,7 @@ export function predictLanding() { // first-bounce point, closed form — exact, // splatter FREE-AIM: the flick vector maps to a landing point from where you stand — a short // flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that // point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked. -function flickTarget(ch, aim) { +export function flickTarget(ch, aim) { // exported: render draws the live reticle with it const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R)); const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z) @@ -513,6 +513,16 @@ function racketAI(rz, dt) { } } +// release of the two-stage aim: an instant serve to the reticle (quality fixed — placement +// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) +export function dragServe(aim) { + if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return; + if (G.horde || G.mgT > 0 || G.changeover) return; + 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, 0.62, aim); +} + export function keyPressed(code) { if (code !== 'Space') return; audio(); // unlock on first gesture diff --git a/input.js b/input.js index 9536e00..4b1b3ee 100644 --- a/input.js +++ b/input.js @@ -4,7 +4,8 @@ import { cv } from './dom.js'; import { audio } from './audio.js'; -import { keyPressed } from './gameplay.js'; +import { G, score } from './state.js'; +import { keyPressed, dragServe } from './gameplay.js'; import { togglePanel } from './panel.js'; const keys = {}; @@ -36,10 +37,17 @@ export function takeFlickAim(maxAgeMs = 450) { return null; } +// 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. +const canAimServe = () => + G.mode === 'splatter' && G.phase === 'pretoss' && score.server === 'P' + && !G.horde && !(G.mgT > 0) && !G.changeover; + cv.addEventListener('touchstart', e => { e.preventDefault(); audio(); for (const t of e.changedTouches) { - if (!stick) stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), moved: false }; + if (!stick) stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), + moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve else keyPressed('Space'); } }, { passive: false }); @@ -49,8 +57,12 @@ cv.addEventListener('touchmove', e => { if (!stick) return; for (const t of e.changedTouches) { if (t.identifier !== stick.id) continue; - const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0; - if (Math.hypot(dx, dy) > DEAD) stick.moved = true; + const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); + if (dist > DEAD) stick.moved = true; + if (stick.aimable) { // aiming, not steering: the reticle tracks the thumb + G.aim = dist > DEAD ? { fx: dx / dist, fy: dy / dist, mag: dist } : null; + continue; + } touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; } @@ -62,7 +74,12 @@ const endTouch = e => { if (!stick || t.identifier !== stick.id) continue; const dt = performance.now() - stick.t0; const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); - if (!stick.moved && dt < 250) keyPressed('Space'); // tap + if (stick.aimable && stick.moved) { // two-stage release + const aim = G.aim; G.aim = null; + if (aim && dist > DEAD * 1.5) dragServe(aim); // on target — serve + // else: dragged back home — cancel, the tin stays in stock + } + else if (!stick.moved && dt < 250) keyPressed('Space'); // tap else if (dt < 300 && dist > 28) { // flick = aimed swing flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, diff --git a/render.js b/render.js index 0a97f6e..fdc7bdb 100644 --- a/render.js +++ b/render.js @@ -3,7 +3,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { P, O, ball, score, crowd, G } from './state.js'; -import { predictLanding } from './gameplay.js'; +import { predictLanding, flickTarget } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras } from './zombies.js'; // ---------------------------------------------------------------- palette ---- @@ -295,6 +295,15 @@ 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 ? '#ff5050' : '#f8e048'); + // two-stage serve aim: the reticle tracks the thumb; red = the magnet has a zombie locked + if (G.aim && G.mode === 'splatter' && G.phase === 'pretoss') { + const ft = flickTarget(P, G.aim); + if (ft.zt) { // locked: the ring rides the zombie itself, plus a marker over its head + drawRing(ft.zt.x, ft.zt.z, '#ff5050'); + const hp = proj(ft.zt.x, 2.2, ft.zt.z); + ctx.fillStyle = '#ff5050'; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3); + } else drawRing(ft.tx, ft.tz, '#f8e048'); + } // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ @@ -392,8 +401,8 @@ export function render() { const touch = matchMedia('(pointer: coarse)').matches; const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null; const txt = G.mode === 'splatter' - ? (stock ? `FLICK FIRES THE ${stock} · TAP KEEPS IT` - : touch ? 'TAP TO SERVE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE SERVES · ARROWS AIM') + ? (stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT` + : touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); ctx.fillText(txt, W / 2, H - 16); diff --git a/state.js b/state.js index bbe8648..a379242 100644 --- a/state.js +++ b/state.js @@ -40,6 +40,7 @@ export const G = { deliveries: 0, // tins delivered this run — indexes the weapon ladder tinDue: false, // the rules called for new balls — deliver at the next changeover pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms + aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) interlude: false, // sudden-death point vs the racket zombie: tennis rules apply, shamblers spectate horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving runGames: 0, // games finished this run (either side) — the difficulty director