diff --git a/assets/mode_arcade.png b/assets/mode_arcade.png new file mode 100644 index 0000000..efdd97b Binary files /dev/null and b/assets/mode_arcade.png differ diff --git a/index.html b/index.html index 8485216..62c74a0 100644 --- a/index.html +++ b/index.html @@ -223,18 +223,30 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { the art + a scrim gradient as the background stack) with the caption written over the bottom. Unpicked buttons sit dimmed and desaturated so the chosen mode is the one that glows — the same "one bright thing means selected" rule the menu palette already follows. */ - #modeList button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px; + #modeList button.mode, #modeSub button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px; margin: 0 0 8px; padding: 7px 9px; background-size: cover; background-position: center bottom; image-rendering: pixelated; border: 2px solid var(--line); color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; filter: saturate(0.45) brightness(0.62); transition: filter 0.15s; } - #modeList button.mode.on { border-color: var(--acid); filter: none; } - #modeList b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); } + #modeList button.mode.on, #modeSub button.mode.on { border-color: var(--acid); filter: none; } + #modeList b, #modeSub b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); } /* the subtitle carries the mode's one-line rules over busy art, so it gets WHITE at full opacity, a size up from the court rows' 9px, and a solid black underlay — "barely readable" at cream-on-crowd was the report from the first live look */ - #modeList i { color: #fff; font-size: 10px; margin-bottom: 0; + #modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0; text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; } + /* THE SECOND STEP (config.MENU): the picked poster's own choices, inset so they read as its + children. Sub-mode cards are the same poster at ~2/3 height, centred on their scene rather + than bottom-anchored (a 64px window off the bottom of these scenes is all court and shins); + the difficulty chips are plain acid-outline buttons — art for EASY/MEDIUM/HARD would be + forced, and the chip row reads faster anyway. */ + #modeSub { display: flex; flex-wrap: wrap; gap: 8px; padding-left: 14px; margin: 0 0 8px; } + #modeSub:empty { display: none; } + #modeSub button.mode.sm { flex: 1 1 100%; min-height: 64px; margin: 0; background-position: center; } + #modeSub button.chip { flex: 1; padding: 12px 0; background: var(--panel); border: 2px solid var(--line); + color: var(--cream); font: 400 11px Silkscreen, monospace; letter-spacing: 1px; + cursor: pointer; -webkit-tap-highlight-color: transparent; } + #modeSub button.chip.on { border-color: var(--acid); color: var(--acid); background: rgba(188,212,85,0.09); } #courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%; margin: 0 0 8px; padding: 7px; background: var(--panel); border: 2px solid var(--line); color: inherit; font: inherit; text-align: left; @@ -242,9 +254,9 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { #courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); } #courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none; object-fit: cover; } - #courtList .cb, #modeList .cb { flex: 1; min-width: 0; } - #courtList b, #modeList b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; } - #courtList i, #modeList i { display: block; margin: 3px 0 6px; font: 400 9px Silkscreen, monospace; color: var(--dim); font-style: normal; } + #courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; } + #courtList b, #modeList b, #modeSub b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; } + #courtList i, #modeList i, #modeSub i { display: block; margin: 3px 0 6px; font: 400 9px Silkscreen, monospace; color: var(--dim); font-style: normal; } /* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot drift away from what the ball actually does on it. */ .meter { display: flex; align-items: center; gap: 6px; margin-top: 4px; @@ -370,6 +382,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {

MATCH SETUP

01 · MODE

+

02 · PLAYER

03 · COURT

@@ -443,6 +456,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index 6f16fea..4b56389 100644 --- a/src/config.js +++ b/src/config.js @@ -635,11 +635,31 @@ export const COURTS = [ // baked in — the button's own name/sub render over a scrim, so the caption stays crisp. export const MODES = [ { id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' }, - { id: 'zombie', name: 'ZOMBIE TENNIS', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_zombie' }, + { id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' }, { id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' }, { id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', art: 'mode_gore' }, ]; +// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one +// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two +// sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES +// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`). +export const MENU = [ + { mode: 'classic', diffs: true }, + { name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] }, + { mode: 'gore' }, +]; + +// CLASSIC's difficulty, the one thing the modes screen adds to gameplay: a scaling on the three +// dials the classic CPU already reads (flub = error rate, quality = shot cap, speed = aiSpeed +// multiplier). MEDIUM is the game exactly as it shipped — classicFlub/classicQualityMax repeated +// here so the middle chip can never drift from what "no difficulty" used to play like. +export const DIFFS = [ + { id: 'easy', name: 'EASY', flub: 0.22, quality: 0.72, speed: 0.88 }, + { id: 'medium', name: 'MEDIUM', flub: 0.10, quality: 0.86, speed: 1.0 }, + { id: 'hard', name: 'HARD', flub: 0.04, quality: 0.95, speed: 1.12 }, +]; + // THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's // dead hierarchy, each round DRAWING one rung from its band so runs differ, the final always THE // CHAMPION. `need` is games to take the round — round one is a single game so a run opens fast — diff --git a/src/gameplay.js b/src/gameplay.js index b984cdc..b73e2b8 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1056,11 +1056,19 @@ function onBounce() { // which points are reachable, and computing that with autoAssist alone meant a desktop match's // intercept thought NOTHING was reachable and sent the player home every time — he had legs but // his destination was always the baseline centre. -export const assistK = () => G.match ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist; +// THE RALLY MODES SHARE ONE PLAYER FEEL (Oliver, 2026-08-14: "in classic tennis I tend to miss +// balls way more often — zombie tennis is the good one"). The match's three contact helpers — +// assist floor, matchReach, the short-ball chase — were tuned in the match and classic never got +// them, so the same swing whiffed centimetres short there. Gore's opener keeps the old feel: its +// tennis is a prologue, not the mode. +export const rallyFeel = () => !!(G.match || G.classic); +export const assistK = () => rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist; function intercept(side) { const ch = side === 'O' ? O : P; - const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * assistK(); + // G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the + // classic CPU speeds up or slows down. Same guard on every dial it scales. + const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK(); let { x, y, z, vx, vy, vz } = ball; const g = ball.g || TUNE.gravity, dt = 0.01; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; @@ -1074,7 +1082,7 @@ function intercept(side) { // legal contact height by then) and the point went to the zombie. Seven of seven recorded // losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting // height: running forward onto the short ball is the tennis answer, and now it is the assist's. - if (side === 'P' && G.match && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) { + if (side === 'P' && rallyFeel() && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) { const pt = { x, z: Math.min(24.5, z) }; if (Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt) anyHit = pt; } @@ -1146,7 +1154,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // sampled 1.3-1.6 m — centimetres outside the 1.3 m post-bounce reach, point after point. The // assist can deliver you NEAR the ball but not INSIDE a phone-tuned reach; a rally mode lives or // dies on contact feeling fair, and the gore modes are untouched. - const reach = isAI ? TUNE.aiReach : TUNE.reach * (volley ? VOLLEY.reach : 1) * (G.match ? TUNE.matchReach : 1); + const reach = isAI ? TUNE.aiReach : TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1); const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1); const t = ch.swingT; if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return; @@ -1712,7 +1720,7 @@ export function update(dt) { G.aiReactT += dt; const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction; const tgt = incoming ? intercept('O') : HOME.O; - const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points + const spd = TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz); if (d > 0.1) { O.x += dx / d * Math.min(spd * dt, d); @@ -1731,11 +1739,13 @@ export function update(dt) { // CLASSIC TENNIS turns the band OFF and flubs less: the band 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. - const flub = G.classic ? TUNE.classicFlub : TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss; + const flub = G.classic ? (G.diff ? G.diff.flub : TUNE.classicFlub) + : TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss; if (Math.random() < flub) G.aiSwingCool = 0.5; // whiff: hesitates and misses else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; - trySwing(O, true, G.classic ? TUNE.classicQualityMax : TUNE.aiQualityMax); O.swingT = -1; + trySwing(O, true, G.classic ? (G.diff ? G.diff.quality : TUNE.classicQualityMax) + : TUNE.aiQualityMax); O.swingT = -1; G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12; } } diff --git a/src/main.js b/src/main.js index 6461c63..c7d8d7f 100644 --- a/src/main.js +++ b/src/main.js @@ -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 = `${m.name}${m.sub}`; + `linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${card.art}.png?v=${SPRV})`; + b.innerHTML = `${card.name}${card.sub}`; + 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 = - `${m.name} · ${t.name.replace(' SINGLES', '')} · ${c.name}` + `${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}` + `${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}`; } @@ -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'); }; diff --git a/src/render.js b/src/render.js index 7420d8a..c1320f4 100644 --- a/src/render.js +++ b/src/render.js @@ -221,7 +221,7 @@ function farB(z) { // Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // menu screens hang tags on the same files, and two copies of this number is one copy too // many: the one that does not get bumped is the one that serves a cached 404. -export const SPRV = 116; +export const SPRV = 117; // The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was // already requested at import — it just makes the wait visible and holds the menu back until the // last sheet has landed. See the onerror note below for the other half of "sometimes missing". diff --git a/test.js b/test.js index 7156663..f3314c6 100644 --- a/test.js +++ b/test.js @@ -83,7 +83,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const { G, P, O, score, ball, setupPoint } = await import('./src/state.js'); -const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY } = await import('./src/gameplay.js'); +const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // player would, so the tests step a live court @@ -1104,6 +1104,27 @@ test('classic tennis: losing the match ends it the same way, and counts as a los assert.equal(runs[runs.length - 1].dead, 1, 'a match lost IS the run ending badly'); }); +test('classic wears the match feel, and the difficulty table is a ladder', () => { + // THE RALLY MODES SHARE ONE PLAYER FEEL: classic gets the match's assist floor (with matchReach + // and the short-ball chase riding the same rallyFeel() switch) — "in classic tennis I tend to + // miss balls way more often" was these dials being match-only. + G.classic = false; G.match = null; + assert.ok(!rallyFeel(), 'gore alone keeps the old feel'); + G.classic = true; + assert.ok(rallyFeel(), 'classic is a rally mode'); + assert.ok(assistK() >= TUNE.matchAssist, 'classic gets the match assist floor'); + G.classic = false; + // ...and DIFFS climbs monotonically, with MEDIUM pinned to the shipped classic dials so the + // middle chip can never drift from what "no difficulty" used to play like. + const [e, m, h] = cfg.DIFFS; + assert.ok(e.flub > m.flub && m.flub > h.flub, 'easier flubs more'); + assert.ok(e.quality < m.quality && m.quality < h.quality, 'harder hits better'); + assert.ok(e.speed < m.speed && m.speed < h.speed, 'harder runs faster'); + assert.equal(m.flub, TUNE.classicFlub, 'MEDIUM flub IS classicFlub'); + assert.equal(m.quality, TUNE.classicQualityMax, 'MEDIUM quality IS classicQualityMax'); + assert.equal(m.speed, 1.0, 'MEDIUM leaves aiSpeed alone'); +}); + // stats.js ships its own fixture-based check — the period filter, the mode split and the name // normalisation that has to agree with server.py. It was exported and never called by anything. test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); });