Files
splatter-tennis/main.js
T
wuwiandClaude Fable 5 8fd5055e5c Half-volleys: a rising ball is always scoopable; scoreboard tap shows the swing log
Field data (miss log) showed the dominant unfair miss: the assist parks
you on the landing spot, the ball spends the whole swing window below
minY rising off the bounce at your feet, and becomes hittable one frame
after the window closes. Root fix: the below-ankles rejection now only
applies to a DROPPING ball — rising post-bounce balls can be scooped at
any height (a half-volley). Feet-bounce taps go from ~0 to 19/30 winning
frames; 30 simulated real points now log 50 hits, 0 whiffs.

Also: swing-log snapshots record the same swing-timer value trySwing
tests (removes the one-frame misclassification behind 'unclassified'),
and tapping the scoreboard toggles an on-screen dump of the last 9 log
entries so live situations can be screenshotted from the phone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:14:34 +02:00

60 lines
2.7 KiB
JavaScript

// Splatter Tennis — boot, main loop, intro overlay, and the window.T test hooks.
// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline.
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURT, UMP_H } from './config.js';
import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js';
import { fitViewport, render, proj } from './render.js';
import { voice } from './audio.js';
import { zombies, spawnZombie } from './zombies.js';
import { cv } from './dom.js';
import './input.js';
let last = performance.now();
function frame(now) {
const dt = Math.min(0.033, (now - last) / 1000); last = now;
update(dt); render();
requestAnimationFrame(frame);
}
fitViewport();
setupPoint();
// intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play
const intro = document.getElementById('intro');
function dismissIntro() {
if (!intro.parentNode) return;
intro.remove(); voice('quiet_please');
}
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
addEventListener('keydown', () => dismissIntro(), { once: true });
// eaten: any tap or key (after a beat, so death-spam doesn't skip the screen) restarts
const maybeRestart = () => { if (G.mode === 'eaten' && G.eatenT > 0.7) location.reload(); }; // ponytail: reload = honest full reset
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
// hidden shortcut: tap the umpire three times to summon zombie mode early
let umpTaps = [];
cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * (cv.width / 2); // logical coords (RES = 2)
const y = (e.clientY - r.top) / r.height * (cv.height / 2);
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay
const u = proj(7.1, 0, COURT.netZ);
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
const now = performance.now();
umpTaps = umpTaps.filter(t => now - t < 1600);
umpTaps.push(now);
if (umpTaps.length >= 3) { umpTaps = []; forceZombies(); }
});
requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, solveShot, inServeBox, inCourt, proj, setupPoint,
step: dt => update(dt), press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };