Fold the menu into the title screen, and stop the theme restarting

The menu stage was the same logo on the same backdrop with two buttons, so it
read as the title screen failing to advance. The buttons move onto the title
screen in place of the control list, which is the tutorial's job now.

Death no longer reloads the page. The reload was an honest full reset, but it
also tore down the AudioContext — so the theme restarted from silence every
time you died, when it should carry straight on from the death summary into the
title screen. restartRun() puts G, P, O, ball and score back from a
structuredClone taken at module load (all plain data, so the clone is the whole
job and a field added later is covered for free), and the two modules with
timers of their own reset themselves: every zombies array plus spawnT/moanT/
mgTick, and input's held keys and half-finished drags, which would otherwise be
inherited by the next run's first frame.

The overlay is hidden rather than removed so it can come straight back, which
needs its own [hidden] rule to beat the display:grid on #intro.

The theme starts on the first tap and never restarts: loopBed's beds.has guard
already makes every later menuMusic() a no-op.

Verified: one background-music fetch across launch -> run -> death -> title ->
second run, and a full second run through the transition after the in-place
reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-01 16:25:20 +02:00
1 parent 2d4fa79258
commit d3aabc4bd6
7 files changed
+114 -74

No files matched your search

+27 -12
View File
@@ -454,11 +454,29 @@ death summary is the end menu, and the zombie bed ducks out as it comes in. Two
bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several
seconds later, over the match.
**The overlay is a four-stage machine**, not a screen per DOM node: `#intro[data-stage]` swaps
`splash -> menu -> {courts, tutorial}` while the backdrop and card frame stay mounted, because a
screen that unmounts the overlay flashes the live court through between menus. Court tiles SELECT
only; `START GAME` and `BACK` are their own buttons. The tutorial is a data table (`TUT` in main.js)
of Marvin poses, an optional picture already in `assets/`, and his lines — adding a page is a row.
**The overlay is a three-stage machine**, not a screen per DOM node: `#intro[data-stage]` swaps
`splash -> {courts, tutorial}` while the backdrop and card frame stay mounted, because a screen that
unmounts the overlay flashes the live court through between menus. **The title screen IS the menu** —
a separate menu stage behind it was the same logo on the same backdrop with two buttons, and read as
the title screen failing to advance. Court tiles SELECT only; `START GAME` and `BACK` are their own
buttons. The tutorial is a data table (`TUT` in main.js) of Marvin poses, an optional picture in
`assets/`, and his lines — adding a page is a row.
**DEATH NO LONGER RELOADS THE PAGE.** `location.reload()` was an honest full reset and for a long
time the right call — but it also tore down the AudioContext, so the menu theme restarted from
silence every time you died, when it is supposed to carry straight on from the death summary into
the title screen. `gameplay.restartRun()` replaces it:
- `state.resetState()` restores `G`, `P`, `O`, `ball` and `score` from a `structuredClone` taken at
module load. Everything in those bags is plain data, so the clone is the whole job — nothing has
to enumerate fields, and a field added later is covered for free.
- The two modules with timers of their own put them back themselves: `zombies.resetZombies()` (every
array plus `spawnT`/`moanT`/`mgTick`) and `input.resetInput()` — a key still held or a
half-finished drag would otherwise be inherited by the next run's first frame.
- The overlay is **hidden, not removed**, so it can come straight back. `#intro[hidden]` needs its
own rule because the `display: grid` on `#intro` would otherwise beat the hidden attribute.
**If you add module-level mutable state anywhere, it belongs in one of those reset functions.**
**Menu and tutorial art is generated DIRECTLY, not through `_regen.py`.** That pipeline keys magenta
and slices figures out of a sheet; these are whole illustrated panels with backgrounds, so they go
@@ -478,13 +496,10 @@ Generated type is a lottery and would not match the rest of the HUD. `btn_start`
a taller plate: by there the drips have run out to the ends and the centre is clear enough to print
over, and the plate's own bottom border comes with it.
**The menu theme starts on the FIRST TAP, never at boot** (`go()` calls `menuMusic`). A title screen
that plays at you before you touch it is rude, and on a phone the autoplay policy would have held it
silent until that tap regardless.
**Death returns to the MENU, not the splash.** `maybeRestart` still reloads — that is the honest
full reset and nothing has to unwind a run by hand — but it sets `sessionStorage.splatterStage` on
the way out, and boot consumes it to open at the menu.
**The menu theme starts on the FIRST TAP, never at boot.** A title screen that plays at you before
you touch it is rude, and on a phone the autoplay policy would have held it silent until that tap
regardless. `loopBed`'s `beds.has` guard makes every later `menuMusic()` a no-op, which is what lets
the title screen pick up the track already playing instead of restarting it.
**The theme is AAC** (`background-music.m4a`, 768 KB — the WAV was 8.3 MB, and this one downloads on
every page load before the player has decided to play). `loopBed` takes the FULL filename now,
+12 -3
View File
@@ -1,11 +1,11 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed } from './zombies.js';
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -551,6 +551,15 @@ function startHorde() {
}
// hidden shortcut: tapping the umpire three times summons the horror early
// Back to a fresh run WITHOUT reloading the page. The reload was the honest reset, but it also tore
// down the AudioContext, so the menu theme restarted from silence every time you died — and the menu
// is supposed to pick up the track that was already playing over the death summary. Every mutable
// bag has a pristine copy (state.resetState) and the two modules with timers of their own put them
// back themselves, so nothing here has to know the shape of any of it.
export function restartRun() {
resetZombies(); resetInput(); resetState(); setupPoint();
}
export function forceZombies() {
if (G.mode === 'splatter') { startHorde(); return; } // in splatter the cheat forces a horde wave
if (G.mode !== 'tennis') return;
+12 -27
View File
@@ -57,7 +57,7 @@
/* desktop key hint — up in the empty top-right corner now that the rack owns the bottom edge */
#hint { position: fixed; top: 6px; right: 10px; text-align: right;
font: 400 10px Silkscreen, monospace; color: #6b7688; }
@media (pointer: coarse) { #hint, #intro .kb { display: none; } }
@media (pointer: coarse) { #hint { display: none; } }
#intro { position: fixed; inset: 0; z-index: 10; touch-action: none; display: grid; place-items: center;
background: radial-gradient(circle at 50% 34%, rgba(34,16,20,0.92), rgba(8,10,16,0.96)); }
#intro .card { font: 400 11px Silkscreen, monospace; color: #dfe6f2; text-align: center; line-height: 1.9;
@@ -66,24 +66,21 @@
/* the logo is generated pixel art now (assets/logo.png), not stacked CSS text */
#intro .logo { display: block; width: min(320px, 78vw); height: auto; margin: 0 auto 8px;
image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
#intro .slogan { margin: 8px 0 16px; font-size: 12px; color: #b6a0a4; letter-spacing: 0.5px; }
/* the slogan carries most of the gap between the logo's blood and the first button */
#intro .slogan { margin: 10px 0 26px; font-size: 12px; color: #b6a0a4; letter-spacing: 0.5px; }
#intro .slogan b { color: #c9564a; }
#intro .how { display: inline-block; text-align: left; color: #cbd4e2; line-height: 2; }
#intro .how p { margin: 0; }
#intro .how span { display: inline-block; width: 30px; }
#intro em { color: #8a96ab; font-style: normal; font-size: 12px; }
/* which build is served — filled in by main.js, empty (and invisible) if the header is missing */
#build { margin: 10px 0 0; font-size: 9px; color: #5a6270; letter-spacing: 0.5px; }
#intro .start { margin-top: 18px; color: #e0cc72; font-weight: bold; letter-spacing: 1px; animation: blink 1.1s step-end infinite; }
@keyframes blink { 50% { opacity: 0.25; } }
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #menu, #courts, #tutorial { display: none; }
#splash, #courts, #tutorial { display: none; }
#intro[data-stage="splash"] #splash { display: block; }
#intro[data-stage="menu"] #menu { display: block; animation: drop 0.3s ease-out; }
#intro[data-stage="courts"] #courts { display: block; animation: drop 0.3s ease-out; }
#intro[data-stage="tutorial"] #tutorial { display: block; animation: drop 0.3s ease-out; }
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
display:grid above would otherwise win over the hidden attribute */
#intro[hidden] { display: none; }
/* headline: taller than the body text, so a screen reads as a screen and not as a list */
#intro h2 { margin: 0 0 20px; font: 400 15px Silkscreen, monospace; color: #e0cc72;
letter-spacing: 3px; line-height: 1.5; }
@@ -102,9 +99,6 @@
image-rendering: pixelated; text-shadow: 0 2px 0 rgba(0,0,0,0.9); }
.btn.plate { background-image: url('assets/btn_start.png?v=94'); color: #f7ecb8; }
.btn.plate2 { background-image: url('assets/btn_tut.png?v=94'); color: #d6dfec; }
/* the logo sits exactly as it does on the title screen — same width, and enough room under it that
the blood running off its bottom edge never touches the first button */
#menu .logo { margin-bottom: 30px; }
.btn.back { font-size: 10px; padding: 9px 10px; color: #8a96ab; }
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
@@ -139,24 +133,15 @@
<div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div>
<div id="intro" data-stage="splash">
<div class="card">
<!-- The title screen IS the menu. A separate menu stage behind it was the same logo over the
same backdrop with two buttons, so it read as the title screen failing to advance. The
control list that used to sit here is the tutorial's job now. -->
<div id="splash">
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
<div class="how">
<p><span>👆</span> tap — serve + swing</p>
<p><span>👉</span> flick — aimed shot</p>
<p><span>✋</span> drag — run <em>(auto-run is on)</em></p>
<p class="kb"><span>⌨️</span> WASD / arrows + SPACE</p>
</div>
<p class="start">TAP TO START</p>
<p id="build"></p>
</div>
<div id="menu">
<!-- the logo IS the menu's headline; the title screen's own art, not a word set in Silkscreen -->
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<button class="btn go plate" id="mStart">START GAME</button>
<button class="btn plate2" id="mTut">TUTORIAL</button>
<p id="build"></p>
</div>
<!-- court rows are built from config.COURTS in main.js: adding a court is a table row, never markup -->
<div id="courts">
@@ -183,6 +168,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=94"></script>
<script type="module" src="main.js?v=95"></script>
</body>
</html>
+9
View File
@@ -54,6 +54,15 @@ const canAimServe = () =>
let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it
export const touchPX = () => steerPX;
// Restarting a run no longer reloads the page (see state.resetState), so this module's own state has
// to be put back by hand: a key still held, or a half-finished drag, would otherwise be inherited by
// the next run's very first frame.
export function resetInput() {
for (const k in keys) delete keys[k];
touchAxis.x = touchAxis.z = 0;
stick = flickAim = steerPX = null;
}
cv.addEventListener('touchstart', e => {
e.preventDefault(); audio();
const r = cv.getBoundingClientRect();
+33 -32
View File
@@ -5,7 +5,7 @@
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, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } 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 } from './zombies.js';
@@ -39,9 +39,12 @@ 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 four-stage machine: splash -> menu -> {courts, tutorial}. One overlay throughout,
// swapped by data-stage, so the court never flashes between screens.
// 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
@@ -62,15 +65,15 @@ for (const c of COURTS) {
}
function start() {
if (!intro.parentNode) return;
if (!menuUp()) return;
selectCourt(picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
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('menu'));
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.
@@ -114,24 +117,17 @@ function drawTut() {
tNext.textContent = tutPage === TUT.length - 1 ? 'DONE' : 'NEXT';
}
tNext.addEventListener('click', () => {
if (tutPage < TUT.length - 1) { tutPage++; drawTut(); } else go('menu');
if (tutPage < TUT.length - 1) { tutPage++; drawTut(); } else go('splash');
});
document.getElementById('tBack').addEventListener('click', () => {
if (tutPage > 0) { tutPage--; drawTut(); } else go('menu');
if (tutPage > 0) { tutPage--; drawTut(); } else go('splash');
});
// A tap anywhere on the SPLASH moves to the menu — and that tap is the gesture that unlocks audio,
// which is why the theme is not started at boot any more. Every later screen has real buttons, so
// the overlay itself must not swallow taps beside them.
intro.addEventListener('pointerdown', e => {
if (intro.dataset.stage !== 'splash') return;
e.preventDefault(); go('menu');
});
// Coming back from a run: the death screen reloads (an honest full reset) and asks, through
// sessionStorage, to land on the MENU rather than the splash — you have already seen the title.
try {
if (sessionStorage.getItem('splatterStage') === 'menu') { sessionStorage.removeItem('splatterStage'); go('menu'); }
} catch {}
// 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.
@@ -139,28 +135,33 @@ 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: a key leaves the splash; on the court screen 1..N picks and Enter starts;
// Escape backs out. Not `once` any more — there are four stages to get through.
// 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 (!intro.parentNode) return;
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 (stage === 'splash') return go('menu');
if (e.key === 'Escape') return go(stage === 'menu' ? 'splash' : 'menu');
if (stage === 'menu') { if (e.key === 'Enter' || e.key === ' ') go('courts'); return; }
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 restarts — 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).
// The reload IS the reset (nothing here has to unwind a run by hand); the flag is what makes it
// come back to the menu instead of the title screen.
// 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;
try { sessionStorage.setItem('splatterStage', 'menu'); } catch {}
location.reload(); // ponytail: reload = honest full reset
restartRun();
intro.hidden = false; go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
+14
View File
@@ -103,6 +103,20 @@ export const G = {
logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump
};
// A pristine deep copy of every mutable bag, taken at module load before anything can touch them.
// This is what replaced `location.reload()` on death: the reload was an honest full reset, but it
// also tore down the AudioContext, so the menu theme restarted from silence every time you died.
// Everything above is plain data — primitives, nulls, small arrays and {} — so a structured clone
// is the whole job.
const PRISTINE = structuredClone({ G, P, O, ball, score });
export function resetState() {
const p = structuredClone(PRISTINE);
Object.assign(G, p.G); Object.assign(P, p.P); Object.assign(O, p.O);
Object.assign(ball, p.ball); Object.assign(score, p.score);
G.hp = G.hpMax = TUNE.playerHp; // re-read: the T panel may have moved it since boot
crowd.cheer = 0;
}
export const other = s => s === 'P' ? 'O' : 'P';
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
+7
View File
@@ -23,6 +23,13 @@ export const shots = []; // {x, y, z, vx, vy, vz, g, t}
let spawnT = 1.5, moanT = 3;
// Every body, decal and projectile on the court, plus this module's own timers. Called by
// gameplay.restartRun — a restart is in-place now, not a page reload.
export function resetZombies() {
for (const a of [zombies, gore, piles, parts, pellets, extras, shots]) a.length = 0;
spawnT = 1.5; moanT = 3; mgTick = 0;
}
export function spawnZombie(x, z, variant) {
// Keyed on GAMES FINISHED, not on splatT: the old `1 + min(1.5, splatT / 70)` maxed out after
// 105 seconds of splatter, so every shambler from roughly game 3 onward walked at the same top