Files
splatter-tennis/input.js
T
wuwiandClaude Fable 5 8cfe9fdbcd 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>
2026-07-12 13:00:15 +02:00

95 lines
4.2 KiB
JavaScript

// Keyboard + one-handed touch. Exposes movement axes and the flick-aim for gameplay.
// Note: input <-> gameplay is an intentional import cycle; both only call each other's
// functions at runtime, never during module evaluation.
import { cv } from './dom.js';
import { audio } from './audio.js';
import { G, score } from './state.js';
import { keyPressed, dragServe } from './gameplay.js';
import { togglePanel } from './panel.js';
const keys = {};
addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
if (e.key === 't' || e.key === 'T') togglePanel();
if (!keys[e.code]) keyPressed(e.code);
keys[e.code] = true;
});
addEventListener('keyup', e => keys[e.code] = false);
const touchAxis = { x: 0, z: 0 };
export const axisX = () => touchAxis.x || (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0);
export const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0);
// One-handed touch: hold-drag = run, quick FLICK = swing aimed in the flick direction,
// plain tap = neutral swing / toss / serve. Extra finger tap still swings (two-thumb play).
let stick = null; // {id, x0, y0, t0, moved}
let flickAim = null; // {ax, az, t} aim override for the swing just triggered
const DEAD = 14;
// gameplay consumes the flick at contact; returns null if none or expired
export function takeFlickAim(maxAgeMs = 450) {
if (flickAim && performance.now() - flickAim.t < maxAgeMs) {
const aim = flickAim; // carries ax/az (lanes, tennis) AND fx/fy/mag (splatter free-aim)
flickAim = null;
return aim;
}
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, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve
else keyPressed('Space');
}
}, { passive: false });
cv.addEventListener('touchmove', e => {
e.preventDefault();
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, 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;
}
}, { passive: false });
const endTouch = e => {
e.preventDefault();
for (const t of e.changedTouches) {
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.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,
fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim
t: performance.now() };
keyPressed('Space');
}
stick = null; touchAxis.x = 0; touchAxis.z = 0;
}
};
cv.addEventListener('touchend', endTouch, { passive: false });
cv.addEventListener('touchcancel', endTouch, { passive: false });