Files
splatter-tennis/main.js
T
wuwiandClaude Opus 5 0ca43bf68f Let the ball follow the mirror, and stop the serve shrinking her
Four things Oliver caught on the phone.

The screen title is the ANNOUNCEMENT face now. A menu heading is a thing that
shouts, the same register the canvas gives SET and CHAPTER — it was wearing the
HUD font, which is the voice for scores and key hints. Bangers carries its own
weight, so it takes size and air instead of Silkscreen's letter-spacing, plus
the padding under it he asked for.

Her toss and smash were shrinking the whole woman. The slice anchors frame 0 to
224 px, and frame 0 was the toss — whose arm is straight up, so the BODY got
squeezed to make room for it. This is the trap already written down for the
raised racket; it just moved to a different sheet. Re-sliced the retained sheet
at the men's toss height (250) instead, so toss/smash/pull come out 250/293/191
against his 250/320/213 and her body matches her own idle again. No re-roll.

The pre-serve ball did not follow the MIRROR. drawChar flips the whole sprite
when a server faces left, so dodging during the bounce swapped his hands over
and left the ball hanging out by the racket — one `* sv.facing` on the offset.
It was wrong in the men's world the whole time, it just took a second body
standing next to it before anyone saw it.

And she reaches further out and higher than he does, so BOUNCE_IDLE's pair is
overridable per tour now (0.46/0.78 against his 0.36/0.68). Measured off the
drawn hand by eye, which is the only way those two numbers have ever been set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:37:59 +02:00

269 lines
15 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, COURTS, TOURS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js';
import './input.js';
let last = performance.now();
// T.pause(true) freezes the WORLD but keeps drawing, so a value changed in devtools shows up on the
// next frame without the scene moving on underneath you. Halting rAF instead (the old test recipe)
// freezes the picture too, which is useless for looking at a shot.
let paused = false;
function frame(now) {
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
if (paused) { last = now; render(); return; }
let dt = Math.min(0.04, (now - last) / 1000); last = now;
// A chapter boss dies in slow motion. The clock that ENDS it runs at real speed, so the effect is
// always the same length in seconds however far the world has been slowed.
if (G.slowT > 0) { G.slowT -= dt; dt *= TUNE.bossSlowMo; }
update(dt); render();
}
fitViewport();
// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env()
// safe-area inset once, against a provisional geometry, and never revisits them until the geometry
// is "exercised" — which is why the game only filled the screen after a rotate and back, and why
// waiting for an event could never work: nothing fires. Toggling viewport-fit off and on is the
// known WebKit workaround: it forces that recalculation without the user rotating anything.
// Standalone only, because that is the only mode with the bug.
if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) {
const meta = document.querySelector('meta[name=viewport]'), real = meta.content;
meta.content = real.replace('viewport-fit=cover', 'viewport-fit=auto');
requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); });
}
setupPoint();
// warm both faces so the canvas never paints a system-serif fallback frame
if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers'])
document.fonts.load(f);
// The overlay is a three-stage machine: splash -> {courts, tutorial}. The title screen IS the menu —
// a separate menu stage behind it showed the same logo on the same backdrop and read as the title
// screen failing to advance. One overlay throughout, swapped by data-stage, so the court never
// flashes between screens; HIDDEN during play rather than removed, so a restart puts it back.
const intro = document.getElementById('intro');
const menuUp = () => !intro.hidden;
const remembered = k => { try { return localStorage.getItem(k); } catch { return null; } };
let picked = remembered('court') || COURTS[0].id;
let pickedTour = remembered('tour') || TOURS[0].id;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// --- tour tiles, built from the table. Same "tap selects, START starts" rule as the court rows —
// the court screen picks WHO and WHERE, and nothing on it commits you.
const tourList = document.getElementById('tours');
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.innerHTML = `<img src="assets/${t.tile}.png?v=84" alt=""><b>${t.name}</b>`;
b.addEventListener('click', () => {
pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b);
});
tourList.appendChild(b);
}
// --- court rows, built from the table
const courtList = document.getElementById('courtList');
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.innerHTML = `<img src="assets/${c.tile}.png?v=84" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
});
courtList.appendChild(b);
}
function start() {
if (!menuUp()) return;
selectTour(pickedTour);
selectCourt(picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
}
document.getElementById('mStart').addEventListener('click', () => go('courts'));
document.getElementById('mTut').addEventListener('click', () => { tutPage = 0; drawTut(); go('tutorial'); });
document.getElementById('cStart').addEventListener('click', start);
document.getElementById('cBack').addEventListener('click', () => go('splash'));
// --- the tutorial, narrated by Marvin the mop boy. Pure data: a page is a Marvin pose, an optional
// picture already in assets/, and his lines.
const TUT = [
{ pose: 1, say: ["I'm <em>MARVIN</em>. I mop the court.",
"Lately there is a great deal more to mop.",
"Let me show you how this goes."] },
{ pose: 2, shot: 'tut_serve', say: ["First it is just <em>TENNIS</em>.",
"<em>TAP</em> once to toss, tap again to serve.",
"<em>FLICK</em> toward where you want it.",
"He runs down the rest himself, mostly."] },
{ pose: 4, say: ["Then, at some point, the other fellow...",
"stops being the other fellow.",
"Nobody stops the match. Nobody ever does."] },
{ pose: 2, shot: 'tut_aim', say: ["Now you do not rally. You <em>AIM</em>.",
"<em>PULL BACK</em> from your man and let go.",
"The further you pull, the harder it lands.",
"A soft one only makes them cross."] },
{ pose: 4, say: ["You will not outrun them. Don't try.",
"<em>SWIPE SIDEWAYS</em> and he plants a foot",
"and is simply somewhere else.",
"Nothing touches him on the way. Nothing."] },
{ pose: 2, shot: 'tut_rack', say: ["I bring tins out to the rack down there.",
"<em>BOMBS</em>, <em>TRIPLES</em>, <em>FIRE</em>, and the <em>GUN</em>.",
"Tap the rack to load one, then serve it.",
"Do try not to hit me with them."] },
{ pose: 3, shot: 'tut_duel', say: ["Sometimes the umpire calls order back.",
"One point, tennis rules, against one of them.",
"The lines are live again. Do not miss."] },
{ pose: 1, say: ["That is the lot. Mind the deep ones.",
"And if it all goes wrong —",
"I will be along with the mop."] },
];
let tutPage = 0;
const tSay = document.getElementById('tSay'), tMarvin = document.getElementById('tMarvin');
const tShot = document.getElementById('tShot');
const tDots = document.getElementById('tDots'), tNext = document.getElementById('tNext');
function drawTut() {
const p = TUT[tutPage];
tMarvin.src = `assets/marvin${p.pose}.png?v=92`;
tShot.style.display = p.shot ? 'block' : 'none';
if (p.shot) tShot.src = `assets/${p.shot}.png?v=93`;
tSay.innerHTML = p.say.map(l => `<p>${l}</p>`).join('');
tDots.textContent = TUT.map((_, i) => i === tutPage ? '●' : '·').join('');
tNext.textContent = tutPage === TUT.length - 1 ? 'DONE' : 'NEXT';
}
tNext.addEventListener('click', () => {
if (tutPage < TUT.length - 1) { tutPage++; drawTut(); } else go('splash');
});
document.getElementById('tBack').addEventListener('click', () => {
if (tutPage > 0) { tutPage--; drawTut(); } else go('splash');
});
// THE FIRST TAP ANYWHERE ON THE OVERLAY STARTS THE THEME, and does nothing else — the screens have
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
// is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has
// guard), so coming back from a run picks up the track already playing rather than restarting it.
intro.addEventListener('pointerdown', () => menuMusic());
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
fetch('main.js', { method: 'HEAD' })
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
.catch(() => {}); // no header, no line — never a broken start screen
// Keyboard, per stage. On the splash Enter/Space starts, T opens the tutorial; on the court screen
// 1..N picks and Enter starts; Escape backs out to the splash.
addEventListener('keydown', e => {
if (!menuUp()) return;
menuMusic(); // a key is a gesture too — unlock the theme on a desktop that never taps
const stage = intro.dataset.stage;
if (e.key === 'Escape') return go('splash');
if (stage === 'splash') {
if (e.key === 'Enter' || e.key === ' ') go('courts');
else if (e.key === 't' || e.key === 'T') { tutPage = 0; drawTut(); go('tutorial'); }
return;
}
if (stage === 'tutorial') { if (e.key === 'Enter' || e.key === ' ') tNext.click(); return; }
const n = parseInt(e.key, 10);
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
else if (e.key === 'Enter' || e.key === ' ') start();
});
// eaten: any tap or key returns to the title — but only once the death animation has played out, so
// a panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
// NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so
// the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the
// bed that has been playing under the death summary simply carries on into the title screen.
const maybeRestart = () => {
if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return;
restartRun();
intro.hidden = false; go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
// hidden shortcuts: three taps on the UMPIRE summons zombie mode early, three on the SCOREBOARD
// opens test mode (a medium wave plus every barrel weapon — see gameplay.toggleTestMode)
let umpTaps = [], boardTaps = [];
cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { // the scoreboard
const now = performance.now();
boardTaps = boardTaps.filter(t => now - t < 1600);
boardTaps.push(now);
if (boardTaps.length >= 3) {
boardTaps = [];
// taps one and two already toggled the miss-log twice, so it is back where it started —
// this third one must NOT toggle it again, or every trip through test mode leaves the
// overlay on behind you
toggleTestMode();
} else G.logShow = !G.logShow; // a single tap is still the miss-log overlay
return;
}
const u = proj(UMP_X, 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(); }
});
// ---- A POSED SCENE for tuning a shot: `T.laserScene()`, or load with #laser. Six bodies of
// different types at known depths, the laser armed, a pull held.
// IT DOES NOT PAUSE, deliberately. The first version froze the world so the picture would hold
// still — and then every TUNE value you changed did nothing visible, because nothing redrew. The
// bodies are held still individually (v = 0) and spawning is off instead, so the picture is stable
// while the render loop keeps running and an edit shows on the very next frame.
function laserScene() {
const intro = document.getElementById('intro');
if (intro) intro.hidden = true;
paused = false;
if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter
G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false;
G.mgT = 0; G.gunId = null; G.beam = null; G.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp;
zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0;
score.server = 'P'; setupPoint();
P.x = 0;
TUNE.zMax = 0; // nothing new wanders into the shot
for (const [x, z, v] of [[0, 20, 'a'], [0.75, 16, 'b'], [-0.85, 12, 'k'],
[0.85, 8.5, 'g'], [-0.75, 5.5, 'h'], [0, 3, 'e']]) {
spawnZombie(x, z, v);
zombies[zombies.length - 1].v = 0; // held still: the scene is a photograph
}
G.rack.length = 0; G.temp = null;
G.weapon = grantWeapon('laser');
G.modal = null;
G.aim = { fx: 0, fy: -1, mag: 100 }; // a full pull straight up-court (fy is NEGATIVE)
return 'posed and LIVE — change TUNE (T panel or console) and it redraws. T.laserFire() to shoot it.';
}
// fire the posed shot, then pose it again so you can keep tuning
function laserFire() {
G.aim = null; score.server = 'P'; setupPoint();
keyPressed('Space');
return 'fired — T.laserScene() to re-pose';
}
// a plain timer, not the load event: this module runs as a module, so `load` may already have gone
if (location.hash === '#laser') setTimeout(laserScene, 600);
requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };