Trade the tutorial for a screen that counts the dead

The menus were placeholders next to the art. Three screens now: a title over
an empty, dimmed, blood-stained court; a MATCH SETUP screen where each court
wears meters read straight off its own bounce numbers; and BODY COUNT, which
needed the game to start measuring anything at all.

stats.js keeps one capped array of per-run records — all time, last match and
seven days are three filters over it rather than three counters that can drift
apart. Kills file under the body AND the weapon: killZombie only ever knew an
index, so every damage loop stamps the cause on its way past.

The tutorial is gone. Marvin explained what the in-game modals already teach,
and his stage is where the stats live now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-04 16:01:59 +02:00
1 parent 411bff8724
commit f3b0b791d6
21 files changed
+619 -210

No files matched your search

+74 -69
View File
@@ -6,10 +6,11 @@
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 { 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();
@@ -45,9 +46,9 @@ setupPoint();
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
// 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;
@@ -56,35 +57,70 @@ 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.
// --- 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=84" alt=""><b>${t.name}</b>`;
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
// --- 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=84" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
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 figures. Re-read every time the splash comes up, because a run has just ended and
// both numbers have moved.
function refreshSplash() {
const { kills, best } = stats.total();
document.getElementById('sBest').textContent = best ? best + (best === 1 ? ' GAME' : ' GAMES') : '—';
document.getElementById('sRank').textContent = '#' + stats.rank(kills).toLocaleString('en-GB');
document.getElementById('mKills').textContent = kills.toLocaleString('en-GB') + ' KILLS';
summary();
}
function start() {
if (!menuUp()) return;
selectTour(pickedTour);
@@ -92,62 +128,22 @@ function start() {
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'); });
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);
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');
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
@@ -161,24 +157,32 @@ 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.
// 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('courts');
else if (e.key === 't' || e.key === 'T') { tutPage = 0; drawTut(); go('tutorial'); }
if (e.key === 'Enter' || e.key === ' ') go('setup');
else if (e.key === 's' || e.key === 'S') openStats();
return;
}
if (stage === 'tutorial') { if (e.key === 'Enter' || e.key === ' ') tNext.click(); 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
@@ -187,6 +191,7 @@ addEventListener('keydown', e => {
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);
@@ -264,5 +269,5 @@ 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,
stats, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };