diff --git a/index.html b/index.html index 84c5e1c..0bf9c32 100644 --- a/index.html +++ b/index.html @@ -471,6 +471,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index 4b56389..68c212b 100644 --- a/src/config.js +++ b/src/config.js @@ -650,14 +650,17 @@ export const MENU = [ { 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. +// CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING — +// the quality distributions overlapped (0.45 floor either way), ±12% speed never binds because the +// CPU pre-plans its intercept, and the dials that actually read as skill were untouched. What a +// player FEELS is: how fast the CPU reads a shot (reaction — "well-aimed corners are winners"), +// how long its racket is (reach), the FLOOR of its shot quality (qMin — a raised floor means every +// ball comes back with pace), how often it just misses (flub), and its serve. MEDIUM repeats the +// shipped TUNE values dial for dial (pinned by test), so the middle chip IS the old game. 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 }, + { id: 'easy', name: 'EASY', flub: 0.30, quality: 0.62, qMin: 0.45, speed: 0.80, reaction: 0.62, reach: 1.45, serve: 0.75 }, + { id: 'medium', name: 'MEDIUM', flub: 0.10, quality: 0.86, qMin: 0.45, speed: 1.0, reaction: 0.45, reach: 1.6, serve: 1.0 }, + { id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.62, speed: 1.18, reaction: 0.28, reach: 1.75, serve: 1.1 }, ]; // THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's diff --git a/src/gameplay.js b/src/gameplay.js index b73e2b8..591e835 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1154,7 +1154,8 @@ 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) * (rallyFeel() ? TUNE.matchReach : 1); + const reach = isAI ? (G.diff ? G.diff.reach : 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; @@ -1176,7 +1177,11 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const aim = isAI ? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } : flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; - if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (qCap - TUNE.aiQualityMin)); + // G.diff (classic difficulty) moves the FLOOR too: a raised qMin is what makes HARD's every + // ball arrive with pace, where a cap alone just trims the best ones. Null outside classic, so + // the racket zombie and the gore opener keep the TUNE floor. + const qMin = G.diff ? G.diff.qMin : TUNE.aiQualityMin; + if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin)); if (!isAI && G.mode === 'tennis') { G.swingSnaps = null; G.rallyNear = null; logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 }); @@ -1711,14 +1716,15 @@ export function update(dt) { if (G.aiServeT <= 0) tossBall(O); } else if (oServes && G.phase === 'toss') { if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { - G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); + G.wasServe = true; hitServe(O, Math.min(1, (0.6 + Math.random() * 0.35) * (G.diff ? G.diff.serve : 1))); // ...and the body that just served holds the strike, so the swing reads as its own (beats) if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; } } } if (G.mode === 'tennis' && G.phase === 'rally') { G.aiReactT += dt; - const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction; + const incoming = ball.active && ball.lastHit === 'P' + && G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction); const tgt = incoming ? intercept('O') : HOME.O; 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); diff --git a/test.js b/test.js index f3314c6..2bd951a 100644 --- a/test.js +++ b/test.js @@ -1114,15 +1114,25 @@ test('classic wears the match feel, and the difficulty table is a ladder', () => 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. + // ...and DIFFS climbs monotonically on every dial, with MEDIUM pinned dial-for-dial to the + // shipped TUNE values so the middle chip can never drift from what "no difficulty" played like. + // (The first cut scaled only flub/cap/speed and Oliver felt no difference — reaction, reach, + // qMin and serve are the dials a player actually reads as skill.) 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.ok(e.reaction > m.reaction && m.reaction > h.reaction, 'harder reads the shot sooner'); + assert.ok(e.reach < m.reach && m.reach < h.reach, 'harder swings a longer racket'); + assert.ok(e.qMin <= m.qMin && m.qMin < h.qMin, 'harder raises the quality floor'); + assert.ok(e.serve < m.serve && m.serve < h.serve, 'harder serves better'); 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'); + assert.equal(m.qMin, TUNE.aiQualityMin, 'MEDIUM floor IS aiQualityMin'); + assert.equal(m.reaction, TUNE.aiReaction, 'MEDIUM reaction IS aiReaction'); + assert.equal(m.reach, TUNE.aiReach, 'MEDIUM reach IS aiReach'); + assert.equal(m.serve, 1.0, 'MEDIUM leaves the serve alone'); }); // stats.js ships its own fixture-based check — the period filter, the mode split and the name