diff --git a/HANDOFF.md b/HANDOFF.md index ffffecd..dfeb6aa 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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, diff --git a/gameplay.js b/gameplay.js index 840583d..ac0c129 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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; diff --git a/index.html b/index.html index c76e859..83fe3f1 100644 --- a/index.html +++ b/index.html @@ -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 @@
move: WASD/arrows  ·  serve/swing: SPACE  ·  aim: hold a direction while swinging  ·  T: tuning panel
+
-

Fifteen. Thirty. Forty. Dead.

-
-

👆 tap — serve + swing

-

👉 flick — aimed shot

-

✋ drag — run (auto-run is on)

-

⌨️ WASD / arrows + SPACE

-
-

TAP TO START

-

-
-
@@ -183,6 +168,6 @@
- + diff --git a/input.js b/input.js index da9fecb..077d91b 100644 --- a/input.js +++ b/input.js @@ -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(); diff --git a/main.js b/main.js index 12d9d44..3327dd2 100644 --- a/main.js +++ b/main.js @@ -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); diff --git a/state.js b/state.js index 1c4904c..70bb977 100644 --- a/state.js +++ b/state.js @@ -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 diff --git a/zombies.js b/zombies.js index 9427721..e8f191f 100644 --- a/zombies.js +++ b/zombies.js @@ -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