Two-stage serve aim: hold + drag shows the reticle, release fires

Flick-to-aim was aim-by-faith — you learned where you aimed after the ball
was gone. Now, at a serveable pretoss, touching and dragging shows a LIVE
reticle (same mapping: direction + length = landing point); release serves
there. The magnet is finally visible: within zSnap the ring turns red and
rides the locked zombie (plus a marker over its head) — you see the lock
BEFORE you commit the tin.

- Context locked at touchstart: a movement drag started mid-rally never
  morphs into a serve when the ball dies (sub-300ms releases still count
  as the rapid flick re-serve — the Phase 1 cadence survives)
- Drag back home = cancel: no serve, the tin stays in stock
- Aimed (drag) serves commit specials at fixed 0.62 quality; the tap-toss
  ritual keeps the PERFECT-timing power ceiling and the stock — placement
  vs power is now a real choice
- Rally / horde / machine gun untouched: drag steers, flick swings
- Serve prompt: 'TAP TO SERVE - HOLD + DRAG TO AIM' / 'AIMED SERVES FIRE
  THE BOMB - TAP KEEPS IT'

Verified with synthetic touch sequences: drag arms the reticle without
serving and it tracks the thumb; release serves at the marker with the bomb
committed; cancel keeps the tin; rally-started drags never morph; locked
reticle renders red on the zombie (screenshot); tennis 12/12; no console
errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 13:00:15 +02:00
1 parent 2f1419b1f2
commit 8cfe9fdbcd
4 files changed
+46 -9

No files matched your search

+11 -1
View File
@@ -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 // 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 // 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. // 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 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 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) 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) { export function keyPressed(code) {
if (code !== 'Space') return; if (code !== 'Space') return;
audio(); // unlock on first gesture audio(); // unlock on first gesture
+22 -5
View File
@@ -4,7 +4,8 @@
import { cv } from './dom.js'; import { cv } from './dom.js';
import { audio } from './audio.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'; import { togglePanel } from './panel.js';
const keys = {}; const keys = {};
@@ -36,10 +37,17 @@ export function takeFlickAim(maxAgeMs = 450) {
return null; 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 => { cv.addEventListener('touchstart', e => {
e.preventDefault(); audio(); e.preventDefault(); audio();
for (const t of e.changedTouches) { 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'); else keyPressed('Space');
} }
}, { passive: false }); }, { passive: false });
@@ -49,8 +57,12 @@ cv.addEventListener('touchmove', e => {
if (!stick) return; if (!stick) return;
for (const t of e.changedTouches) { for (const t of e.changedTouches) {
if (t.identifier !== stick.id) continue; if (t.identifier !== stick.id) continue;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0; const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
if (Math.hypot(dx, dy) > DEAD) stick.moved = true; 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.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 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; if (!stick || t.identifier !== stick.id) continue;
const dt = performance.now() - stick.t0; const dt = performance.now() - stick.t0;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); 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 else if (dt < 300 && dist > 28) { // flick = aimed swing
flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
+12 -3
View File
@@ -3,7 +3,7 @@
import { cv, ctx } from './dom.js'; import { cv, ctx } from './dom.js';
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.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 { 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'; import { zombies, gore, piles, parts, pellets, extras } from './zombies.js';
// ---------------------------------------------------------------- palette ---- // ---------------------------------------------------------------- palette ----
@@ -295,6 +295,15 @@ export function render() {
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); 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'); 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 // far-side drawables in z order (far → near), then the net, then the near side
const farSide = [ const farSide = [
@@ -392,8 +401,8 @@ export function render() {
const touch = matchMedia('(pointer: coarse)').matches; const touch = matchMedia('(pointer: coarse)').matches;
const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null; const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null;
const txt = G.mode === 'splatter' const txt = G.mode === 'splatter'
? (stock ? `FLICK FIRES THE ${stock} · TAP KEEPS IT` ? (stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT`
: touch ? 'TAP TO SERVE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE SERVES · ARROWS AIM') : touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
ctx.fillText(txt, W / 2, H - 16); ctx.fillText(txt, W / 2, H - 16);
+1
View File
@@ -40,6 +40,7 @@ export const G = {
deliveries: 0, // tins delivered this run — indexes the weapon ladder deliveries: 0, // tins delivered this run — indexes the weapon ladder
tinDue: false, // the rules called for new balls — deliver at the next changeover 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 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 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 horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
runGames: 0, // games finished this run (either side) — the difficulty director runGames: 0, // games finished this run (either side) — the difficulty director