The menu grows a second step, classic learns difficulty, and the rally feel unifies

Oliver's restructure: mode first, then what the mode asks — classic deals
EASY / MEDIUM / HARD chips, ZOMBIE TENNIS deals two sub-mode posters (ARCADE,
the exhibition ladder as was, and THE TOURNAMENT), and SPLATTER GORE stands
alone as the third top card. config.MENU is the grouping, MODES stays the four
run modes, and pickedMode stays the run-mode id — start(), stats and the
summary never learn the menu got taller. The sub-row sits inset under the
posters; sub-cards are the poster at 2/3 height centred on their scene.

DIFFICULTY is the one real gameplay addition: config.DIFFS scales the three
dials the classic CPU already reads (flub, quality cap, aiSpeed), MEDIUM
pinned by test to the shipped classicFlub/classicQualityMax so the middle chip
IS yesterday's game. Rides on G.diff next to G.classic, so restarts keep it.

AND THE FEEL SPLIT OLIVER REPORTED IS REAL: "in classic tennis I tend to miss
balls way more often" — the match's three contact helpers (matchReach 1.35,
the matchAssist floor, the short-ball chase) were all G.match-gated, so
classic played with a 35% smaller racket and no rescue on the dying drop
shot. rallyFeel() = match || classic now carries all three; gore's opener
keeps the old prologue feel on purpose.

The ARCADE poster took two rolls: the first came back a flat sticker line-up
("very constructed, not artsy"), the second anchors on the zombie poster
itself as a MOOD reference — one challenger lit at the net, the queue receding
into purple haze. Rolls in _refcheck/modebtn. The cup's stats mapping also
joins MODE_MATCH (it banks through endRun as MATCH and read back as GORE).
SPRV 117, main.js v233, 66 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 10:29:51 +02:00
1 parent d3bbd8e157
commit e4c917e23d
7 files changed
+141 -34

No files matched your search

+59 -16
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, MODES, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, 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, startBench, endBench, FALL, benchFall } from './beats.js';
@@ -72,27 +72,63 @@ 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).
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
// tennis deals its two sub-mode cards, gore just is gore. `pickedMode` stays the RUN mode id
// (classic/zombie/cup/gore) so start(), stats and the summary never learn about the grouping.
let pickedMode = remembered('mode') || MODES[0].id;
let pickedDiff = remembered('diff') || 'medium';
const modeOf = id => MODES.find(x => x.id === id);
const topOf = m => MENU.find(t => t.mode === m || (t.kids && t.kids.includes(m))) || MENU[0];
const modeList = document.getElementById('modeList');
for (const m of MODES) {
const modeSub = document.getElementById('modeSub');
const banner = (card, cls, on) => {
const b = document.createElement('button');
b.className = 'mode' + (m.id === pickedMode ? ' on' : '');
b.className = cls + (on ? ' on' : '');
// the scrim rides IN the background stack (gradient over art), so the caption needs no extra box
b.style.backgroundImage =
`linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${m.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${m.name}</b><i>${m.sub}</i></span>`;
`linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${card.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>`;
return b;
};
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
function buildSub() {
modeSub.innerHTML = '';
const t = topOf(pickedMode);
if (t.diffs) for (const d of DIFFS) {
const b = document.createElement('button');
b.className = 'chip' + (d.id === pickedDiff ? ' on' : '');
b.textContent = d.name;
b.addEventListener('click', () => {
pickedDiff = d.id;
try { localStorage.setItem('diff', d.id); } catch {}
for (const el of modeSub.children) el.classList.toggle('on', el === b);
summary();
});
modeSub.appendChild(b);
}
else if (t.kids) for (const id of t.kids) {
const b = banner(modeOf(id), 'mode sm', id === pickedMode);
b.addEventListener('click', () => {
pickMode(id);
for (const el of modeSub.children) el.classList.toggle('on', el === b);
summary();
});
modeSub.appendChild(b);
}
}
for (const t of MENU) {
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
b.addEventListener('click', () => {
pickedMode = m.id;
try { localStorage.setItem('mode', m.id); } catch {} // come back to what you were playing
// a group remembers which child you were on; entering it fresh lands on its first card
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
for (const el of modeList.children) el.classList.toggle('on', el === b);
buildSub();
summary();
});
modeList.appendChild(b);
}
buildSub();
// --- 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.
@@ -144,13 +180,17 @@ function summary() {
const c = COURTS.find(x => x.id === picked) || COURTS[0];
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;
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
// (see cupWon), so its summary reads the same book it writes.
const mode = m.id === 'zombie' || m.id === 'cup' ? 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')
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
: best === 1 ? ' GAME' : ' GAMES';
const name = m.id === 'classic'
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${m.name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
@@ -178,6 +218,8 @@ function start(modeId = pickedMode) {
// 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 = modeId === 'classic';
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
if (modeId === 'cup') { intoSplatter(); startMatch(true); }
}
@@ -185,7 +227,8 @@ let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
// came to look at, and it saves a tap on the way in every single time
const openStats = () => {
stMode = pickedMode === 'zombie' ? stats.MODE_MATCH : pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
stMode = pickedMode === 'zombie' || pickedMode === 'cup' ? stats.MODE_MATCH
: pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};