The court art had three faults: a hard-edged pale panel sitting on the near half, mown stripes that stopped and restarted where the court began, and the blood pool stranded behind the baseline where a portrait phone cropped it off. Redrawn with the stripes running unbroken the full depth and the stain out on the court where it can be seen. The picture also read as zoomed in, which was not the art but the crop: at 4:6 `cover` was throwing away a third of the width on a phone. The sky is extended to 1:2 instead, so the whole arena survives the crop. Menu palette rotated off olive towards a cool desaturated blue-green with much quieter borders, and the boxed BEST RUN / WORLD RANK bar is gone from the splash — it sat on the best part of the art to say what the two buttons under it already said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
16 KiB
JavaScript
273 lines
16 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, SPRV } 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 * as stats from './stats.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 four-stage machine: splash -> {setup, stats, ranks}. 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, SERVE UP starts" rule as the court rows —
|
|
// the setup 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=${SPRV}" alt=""><b>${t.name}</b>`;
|
|
b.addEventListener('click', () => {
|
|
pickedTour = t.id;
|
|
for (const el of tourList.children) el.classList.toggle('on', el === b);
|
|
summary();
|
|
});
|
|
tourList.appendChild(b);
|
|
}
|
|
|
|
// --- court rows, built from the table.
|
|
// The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written
|
|
// ratings, so a physics change moves the bars with it and the screen can never advertise a court
|
|
// that plays differently from what it says.
|
|
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
|
|
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
|
|
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
|
|
const meter = (label, v, cls = '') =>
|
|
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
|
|
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
|
|
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=${SPRV}" alt=""><span class="cb">`
|
|
+ `<b>${c.name}</b><i>${c.sub}</i>`
|
|
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
|
|
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
|
|
+ '</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);
|
|
summary();
|
|
});
|
|
courtList.appendChild(b);
|
|
}
|
|
|
|
// what the setup footer says you are about to play, and what it is worth beating
|
|
function summary() {
|
|
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
|
|
const c = COURTS.find(x => x.id === picked) || COURTS[0];
|
|
const best = stats.total().best;
|
|
document.getElementById('sSum').innerHTML =
|
|
`<span>${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
|
|
+ `<span>${best ? best + (best === 1 ? ' GAME' : ' GAMES') + ' TO BEAT' : 'FIRST RUN'}</span>`;
|
|
}
|
|
|
|
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
|
|
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
|
|
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
|
|
// has just ended and the count has moved.
|
|
function refreshSplash() {
|
|
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
|
|
summary();
|
|
}
|
|
|
|
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
|
|
}
|
|
let stPeriod = 'all';
|
|
const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); };
|
|
document.getElementById('mStart').addEventListener('click', () => go('setup'));
|
|
document.getElementById('mStats').addEventListener('click', openStats);
|
|
document.getElementById('mRanks').addEventListener('click', () => { stats.drawRanks(); go('ranks'); });
|
|
document.getElementById('cStart').addEventListener('click', start);
|
|
for (const id of ['sBack', 'stBack', 'rkBack'])
|
|
document.getElementById(id).addEventListener('click', () => go('splash'));
|
|
// the three period tabs are one listener on the strip — the button carries the period in data-p
|
|
document.getElementById('stTabs').addEventListener('click', e => {
|
|
const b = e.target.closest('button'); if (!b) return;
|
|
stPeriod = b.dataset.p;
|
|
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
|
|
stats.drawStats(stPeriod, SPRV);
|
|
});
|
|
refreshSplash();
|
|
|
|
// 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 goes to setup and S opens the stats; on the setup
|
|
// screen 1..N picks a court and Enter starts; Escape backs out to the splash from anywhere.
|
|
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('setup');
|
|
else if (e.key === 's' || e.key === 'S') openStats();
|
|
return;
|
|
}
|
|
if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface
|
|
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();
|
|
});
|
|
|
|
// A run the player walks out on is still a run. killPlayer banks a death; this banks everything
|
|
// else — the tab closed mid-match, the phone's browser killed in the background. `pagehide` is the
|
|
// one that actually fires on iOS, where beforeunload/unload do not. endRun is idempotent and
|
|
// no-ops on an empty run, so closing the tab at the menu writes nothing.
|
|
addEventListener('pagehide', () => {
|
|
if (G.mode === 'splatter') stats.endRun(G.runGames, G.sets[0], G.splatT, false);
|
|
});
|
|
|
|
// 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();
|
|
refreshSplash(); // the run just ended: both splash figures have moved
|
|
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,
|
|
stats, step: dt => update(dt), render, press: keyPressed,
|
|
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|