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:
1 parent
2f1419b1f2
commit
8cfe9fdbcd
4 files changed
+46
-9
No files matched your search
+11
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user