Three modes, picked up front — and CLASSIC TENNIS is one of them

The setup screen picks WHAT before WHO and WHERE. Three rows out of a table,
in the court row's own markup:

CLASSIC TENNIS is not the opener with the turn switched off and left there —
it is the mode the other two are built on, so it gets real match scoring: six
games with a two-game lead is a set, best of TUNE.classicSets is the match,
the board shows the sets column (hidden until now outside splatter) and the
bar under it says which set this is. The CPU stops rubber-banding and flubs
less: aiFlubLoss exists to get a new player PAST the opener, and a mode whose
whole promise is a real match cannot be won by an opponent that falls apart
the moment he goes ahead. No tiebreak — 6-6 plays on to 8-6. Marked ponytail.

That needed a second ENDING, the one with no corpse in it: G.done, the same
summary and gated tap as the death screen, its own words (GAME, SET, MATCH. /
BEATEN.) and the man still standing. The three endings now share one block
rather than three copies of it.

Caught by the first classic test: stats banked only the FIRST classic run of a
session. `banked` was cleared by the next KILL, and a classic match has no
kills in it at all — every later match was swallowed by the double-bank guard.
beginRun() is the honest reset and restartRun calls it.

ZOMBIE TENNIS from the menu is one line; the back room keeps its door.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 09:27:49 +02:00
1 parent 66fdad3d74
commit c89d4a3ff9
31 files changed
+472 -123

No files matched your search

+39 -5
View File
@@ -3,7 +3,7 @@
// 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, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js';
import { TUNE, COURTS, TOURS, MODES, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } 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, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch } from './beats.js';
@@ -72,6 +72,26 @@ 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
// --- MODE tiles, built from config.MODES. The setup screen now picks WHAT, WHO and WHERE, and this
// is the first of the three because it is the one that changes the other two's meaning. Same
// "tap selects, SERVE UP starts" rule as everything else on the screen; the row markup is the court
// row's, reused wholesale (see the shared selectors in index.html).
let pickedMode = remembered('mode') || MODES[0].id;
const modeList = document.getElementById('modeList');
for (const m of MODES) {
const b = document.createElement('button');
b.className = 'court' + (m.id === pickedMode ? ' on' : '');
b.innerHTML = `<img src="assets/${m.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${m.name}</b><i>${m.sub}</i></span>`;
b.addEventListener('click', () => {
pickedMode = m.id;
try { localStorage.setItem('mode', m.id); } catch {} // come back to what you were playing
for (const el of modeList.children) el.classList.toggle('on', el === b);
summary();
});
modeList.appendChild(b);
}
// --- 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');
@@ -120,10 +140,16 @@ for (const c of COURTS) {
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;
const m = MODES.find(x => x.id === pickedMode) || MODES[0];
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
// thing — see the record-shape note in stats.endRun
const mode = m.id === 'zombie' ? stats.MODE_MATCH : m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
const best = stats.read('all', undefined, mode).longest;
const unit = m.id === 'zombie' ? (best === 1 ? ' FELLED' : ' FELLED')
: best === 1 ? ' GAME' : ' GAMES';
document.getElementById('sSum').innerHTML =
`<span>${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + (best === 1 ? ' GAME' : ' GAMES') + ' TO BEAT' : 'FIRST RUN'}</span>`;
`<span>${m.name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
@@ -141,6 +167,12 @@ function start() {
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
// ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes
// differ by at this point: gore is the game as it always was, classic is the same tennis with the
// turn switched off, and zombie tennis is the match taking the court. Everything else about a mode
// lives in the code that reads G.classic / G.match, never here.
G.classic = pickedMode === 'classic';
if (pickedMode === 'zombie') { intoSplatter(); startMatch(); }
}
let stPeriod = 'all';
const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); };
@@ -330,7 +362,9 @@ addEventListener('pagehide', () => {
// 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;
// ...and the classic ending taps out the same way, on its own shorter clock (G.done, no corpse)
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
if (G.modal || !ready) return;
restartRun();
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;