Files
splatter-tennis/main.js
T
wuwiandClaude Opus 5 44419d9ce1 Pose the laser scene and hold it still, so it can be poked at
T.laserScene() (or #laser on the URL) drops into splatter with six bodies of
different types at known depths, the laser armed, a pull held, and the world
paused.

T.pause(true) freezes the WORLD but keeps drawing, which is the whole point: a
value changed in devtools shows on the next frame while the scene stands still.
The old recipe halted rAF, which freezes the picture too and is no use for
judging how something looks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:16:10 +02:00

243 lines
14 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, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt } 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 the pixel HUD font so the canvas never paints a system-serif fallback frame
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
// 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 lastCourt = (() => { try { return localStorage.getItem('court'); } catch { return null; } })();
let picked = lastCourt || COURTS[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
// --- 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=81" 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;
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.",
"<em>DRAG</em> to run — though he mostly runs himself."] },
{ 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: 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 looking at the laser. `T.laserScene()` in the console, or load the page
// with #laser. It drops you into splatter with a spread of bodies at known depths, the laser armed
// and a pull held, renders it, and PAUSES the world so devtools edits (TUNE.beamY, beamW1, the
// colours in drawBeamWedge) can be judged against a still picture.
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; // no new spawns wandering into the shot
// one of everything, at four depths, straddling the lane so the cut lands at different heights
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; // hold still: this 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)
update(1 / 60); P.x = 0;
render();
paused = true;
return 'laser scene posed and PAUSED. T.pause(false) to run again, T.render() to redraw by hand.';
}
// 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, 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; } };