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:
1 parent
411bff8724
commit
f3b0b791d6
21 files changed
+619
-210
No files matched your search
@@ -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; } };
|
||||
Reference in new issue
Block a user