From d110e1232194227545bbaae2c9f85c66de687138 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Wed, 14 Oct 2026 11:41:05 +0200 Subject: [PATCH] Bring the swing log to every rally mode, and time the gold ring Oliver wants to hand over a log of why he missed balls. It existed behind a scoreboard tap but was gated on G.mode === 'tennis', so the Climb, the cup and training (which run as 'splatter') logged nothing. It now writes in every rally mode: hits with shot, tap age and any quality cap, misses with the new let-it-bounce reason, and a WON/LOST line naming why each point went. Tapping the open log copies all 200 entries with a header. Its first catch: the gold ring went gold at a distance, which is too early for the slow balls steps 1-3 brought in. It is now timed off the ball's real path to his reach (against where his run is taking him): gold means touch now. ringTime 0 restores the old ring. v365. Co-Authored-By: Claude Opus 5.5 --- PROJECT.md | 36 ++++++++++++++++ index.html | 2 +- src/beats.js | 3 +- src/config.js | 4 ++ src/gameplay.js | 107 ++++++++++++++++++++++++++++++++++++++---------- src/main.js | 26 +++++++++++- src/render.js | 36 +++++++++++----- test.js | 52 ++++++++++++++++++++--- 8 files changed, 225 insertions(+), 41 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 01a73e9..0bbab80 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5384,6 +5384,42 @@ rolling (seed 31337 found it). **Not built:** the champion LEARNING whether the net works for him (Free Tennis's idea), a style for classic's CPU, and a "DROP"/"LOB" word at contact. +### The swing log comes back for every rally mode, and the gold ring is TIMED (2026-10-14, v365) +Oliver: *"bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht bekommen habe +(ich glaube das war ein Tap aufs Scoreboard).. ich würde dir das gerne mal zum Debuggen geben."* +- **The log existed but was gated on `G.mode === 'tennis'`.** The match runs as `'splatter'` under its + interlude, so the Climb, the cup and training logged nothing at all. `swingLog()` (tennis or + `rallyFeel`) now gates every site: whiff analysis, swing snapshots, closest approach, the HIT line. +- What a line now carries: + - **HIT:** `q=0.82 drop left volley · tap 120ms · capped: …`, i.e. the shot asked for, the tap's + age (keyPressed's `lead`, stored as `P.tap`), and which cap took the quality down (volley below + the tape, dug-out drop, lobbed). + - **MISS:** the old early/late/wide/high/low reasons, plus "waited for the bounce" when the + let-it-bounce gate refused it. The snapshot reach now includes `matchReach`; without it a near + miss in the match read as wide. + - **WON/LOST:** every point names why (`logPoint`, called by both `pointTo` and `resolveInterlude`). + A point lost with no swing adds how close the ball came. The old PASSED entry folded into this + line; the ghost hand's pass streak keeps its old classic-only trigger. +- The ring holds 200 entries. **Tap the scoreboard** to open the last nine. **Tap the open log** to + copy all of it (`copySwingLog`, main.js): a header with build, mode, opponent and style, then one + line per entry with its measured fields. It uses the clipboard, with the share sheet as fallback. + `render.logGeom` is the one geometry for drawing and hit test. +- **The log's first catch: the gold ring was a DISTANCE** (2.7 m ± 0.5). That is right for the + ~20 m/s rally balls it was calibrated on, and too early for every slow ball steps 1-3 brought in: + moonballs, lobs, drops, deep balls braked by their bounce. A gold-ring tap on one missed "30 ms after + the window". + - `gameplay.ringCue` rolls the ball forward (the real bounce rule, reach, height band and + let-it-bounce gate) to the moment it enters the player's contact reach, measured against where + the auto-run is CARRYING him (`P.tgt`). Against where he stands, the cue jumped 50-180 ms a frame. + - Gold means a finger landing now centres that moment in the swing window (`ringBand` 0.35 of the + half-window, so the gold edge is q 0.65). The rings kiss at the sweet moment: a tap at the kiss + measured q 1.00 every time. + - Gold-tapping bot with a 100 ms lift: 92-98% returned at every style. The distance ring returned + 75% against THE BRUTE's topspin. + - `TUNE.ringTime 0` restores the distance ring for A/B. +- Note: contact still fires on the first frame the ball is in reach, which right after a bounce is + often 15-25 cm off the grass. A visual half-volley, the old contact model; untouched. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index fd85b7d..0b8a461 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index d1ce2ab..d06309a 100644 --- a/src/beats.js +++ b/src/beats.js @@ -13,7 +13,7 @@ import { award, prof, save as profSave, push as profPush, setPromo, levelAt, rec import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; -import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js'; +import { cheer, aside, endBurst, deliverTin, showModal, logSwing, logPoint, advancePoint, scoreKey } from './gameplay.js'; import { startDrillStream } from './drill.js'; import { recordEvent } from './recorder.js'; import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon @@ -997,6 +997,7 @@ export function resolveInterlude(side, reason = 'unspecified') { // G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs. if (G.match.decided && !G.matchEarly) { ball.active = false; return; } if (!G.match.ending) recordEvent('point', { winner: side, reason }); + if (!G.match.ending) logPoint(side, reason); // the swing log's WON/LOST line (scoreboard tap) // netplay (death match): this phone judged the live flight — the verdict crosses the wire // before it is applied, exactly as pointTo does for classic. netJudging() is false while a // remote verdict is being applied, which is what stops the echo. diff --git a/src/config.js b/src/config.js index 8f7e7fb..5409469 100644 --- a/src/config.js +++ b/src/config.js @@ -643,6 +643,10 @@ export const TUNE = { matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark // lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre) matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible + ringTime: 1, // 1 = the ring is TIMED (gameplay.ringCue): gold while a touch now centres the contact in + // the swing window, at any pace. 0 = the old distance ring (matchRingSweet/Band) + ringBand: 0.35, // share of the half-window either side of the sweet moment that draws gold (edge = q 0.65) + ringLead: 0.9, // s before contact the timed ring appears // ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely // make mistakes any more — we need a twist"). The dead staff LEARN YOUR PATTERNS: land the same // lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated diff --git a/src/gameplay.js b/src/gameplay.js index 5b251ad..f1c6973 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -749,12 +749,9 @@ export function pointTo(side, reason) { // exported for net.js: a remote verdict // applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE // verdict is being applied, which is what stops the two phones echoing a point back and forth. if (netJudging()) netSendPoint(side, reason); - if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it - const n = G.rallyNear; - logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`, - dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) }); - G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint) - } + if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) + G.passStreak++; // their ball died on your side untouched: two in a row and the ghost hand shows the tap (render.drawHitHint) + logPoint(side, reason); const gameOver = advancePoint(side); G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; sendBoy(); @@ -1505,23 +1502,79 @@ function intercept(side) { return rise || fallback || deepest || anyHit || home; // zone points first: meeting deep is still better } +// THE GOLD RING, TIMED (2026-10-14). The approach ring used to go gold at a DISTANCE (matchRingSweet +// 2.7 m) — right for the ~20 m/s rally balls it was calibrated on, and too early for every slow ball +// steps 1-3 brought in: a pusher's moonball, a lob, a drop, a deep ball braked by its bounce. A +// gold-ring tap on a slow ball missed "30 ms after the window" (the swing log's first catch). Now the +// cue rolls the ball forward to the moment it enters the player's real contact reach (the same reach, +// height band and let-it-bounce rule trySwing applies) and is gold while a finger landing NOW centres +// that moment in the swing window — whatever the pace. TUNE.ringTime 0 restores the distance ring. +export function ringCue() { + if (!ball.active || ball.lastHit !== 'O' || ball.vz <= 0) return null; + const g = ball.g || TUNE.gravity, dt = 1 / 120; + let { x, y, z, vx, vy, vz } = ball, b = ball.bounces; + // ...against where the player WILL be: the auto-run is still carrying him to its target at the pace + // he is running now, and measuring against where he stands made the cue jump 50-180 ms a frame + const sp = Math.hypot(P.vx || 0, P.vz || 0), tg = P.tgt; + const togo = tg ? Math.hypot(tg.x - P.x, tg.z - P.z) : 0; + for (let t = 0; t < 1.6; t += dt) { + const volley = b === 0; + const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1); + const legal = y <= CONTACT.maxY && (y >= CONTACT.minY || vy > 0) + && !(volley && y < COURT.netH && ball.rx && !ball.rx.net); + const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0; + const px = P.x + (tg ? tg.x - P.x : 0) * k, pz = P.z + (tg ? tg.z - P.z : 0) * k; + if (legal && Math.hypot(x - px, (z - pz) * 0.8) <= reach) { + const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel(); + const sweet = TUNE.swingWindup + win / 2, band = win / 2 * TUNE.ringBand; + return { eta: t, sweet, band, gold: Math.abs(t - sweet) < band }; + } + vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; + if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); b++; if (b > 1) return null; } + } + return null; +} + // ---------------------------------------------------------- miss diagnostics ---- -// Every player swing in tennis mode gets logged: HIT with quality, or MISS with the -// gate that rejected it and the ball's closest approach. Balls that pass without a -// swing log as PASSED when the point ends. Inspect live via T.G.missLog; the latest -// miss reason is drawn on the HUD (TUNE.missHud). +// Every player swing in a rally gets logged: HIT with quality, shot and how long the tap was, or +// MISS with the gate that rejected it and the ball's closest approach — and every point ends with +// a WON/LOST line naming why. Tap the scoreboard to see it; tap the log to COPY all of it (main.js). +// Inspect live via T.G.missLog; the latest miss reason can ride the HUD (TUNE.missHud). +// RALLY MODES since 2026-10-14 (Oliver: "bitte bau nochmal die Funktion ein die protokolliert warum +// ich einen Ball nicht bekommen habe"): it was gated on G.mode === 'tennis', and the match runs as +// 'splatter' under its interlude — so the Climb and the cup, the modes he plays, logged nothing. const r2 = x => Math.round(x * 100) / 100; +const swingLog = () => G.mode === 'tennis' || rallyFeel(); export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring) entry.at = r2(G.idleT); G.missLog.push(entry); - if (G.missLog.length > 50) G.missLog.shift(); - if (entry.result !== 'HIT') G.lastMiss = { text: entry.reason, at: G.idleT }; + if (G.missLog.length > 200) G.missLog.shift(); + if (entry.result !== 'HIT' && entry.result !== 'WON') G.lastMiss = { text: entry.reason, at: G.idleT }; +} + +// The line a POINT ends on, so a run of MISS/HIT lines reads as a story: who took it and why, and — +// when it was lost with no swing at all — how close the ball came. Called by pointTo (classic) and +// by resolveInterlude (the match), the two places every verdict passes. +export function logPoint(side, reason) { + if (!swingLog()) return; + const mine = ball.lastHit === 'P', n = G.rallyNear; + const why = reason === 'out' ? (mine ? 'your ball out' : 'their ball out') + : reason === 'double_bounce' ? (mine ? 'bounced twice on their side' : 'bounced twice on your side') + : reason === 'double_fault' ? 'double fault' + : reason === 'dead_roller' ? 'ball died' + : reason === 'passed_player' ? 'passed you' + : reason === 'unreachable_after_bounce' || reason === 'unreachable_in_flight' ? 'called early: out of his reach' + : reason === 'body_destroyed' ? 'body destroyed' : reason || 'unspecified'; + const unswung = side === 'O' && !mine && n && !G.swung; + logSwing({ result: side === 'P' ? 'WON' : 'LOST', + reason: why + (unswung ? ` — never swung, it came within ${r2(n.d)}m at y=${r2(n.y)}m` : ''), + ...(unswung ? { dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) } : {}) }); } function logWhiff() { const snaps = G.swingSnaps; G.swingSnaps = null; - if (G.mode !== 'tennis') return; + if (!swingLog()) return; if (!snaps || !snaps.length) { logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' }); return; @@ -1545,11 +1598,12 @@ function logWhiff() { reason = last.t < TUNE.swingWindup ? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened` : `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`; - } else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`; + } else if (inWin.every(s => s.lb)) reason = 'waited for the bounce — still in the air below the tape (from the back you let it bounce)'; + else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`; else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`; else reason = 'unclassified — in reach, in window, in height band'; // should not happen } - logSwing({ result: 'MISS', reason, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), + logSwing({ result: 'MISS', reason: reason + ` · tap ${P.tap || 0}ms`, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) }); } @@ -1614,6 +1668,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor. const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin; if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin)); + const q0 = q; // (the log names whichever cap below took it down) // a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what // the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after // its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met @@ -1622,9 +1677,15 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply. // That is the lob's real reward — rarely the winner itself, nearly always the point. if (rallyFeel() && ball.rx && ball.rx.lob) q = Math.min(q, TUNE.lowVolleyQ); - if (!isAI && G.mode === 'tennis') { + if (!isAI && swingLog()) { 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 }); + const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop') + : aim.az < 0 ? ' deep' : ''; + const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : ''; + const cap = q >= q0 ? '' : ball.rx && ball.rx.lob ? ' · capped: lobbed, on your heels' + : ball.bounces === 0 ? ' · capped: volley below the tape' : ' · capped: dug out a drop'; + logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''} · tap ${P.tap || 0}ms${cap}`, + d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) }); } doHit(ch, q, aim); ch.swingT = -1; @@ -1942,6 +2003,7 @@ export function keyPressed(code, lead = 0) { // clock starts that far along: the timing is judged from touch-down, the ball still has to be // there for the contact. P.swingT = rallyFeel() ? lead : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; + P.tap = Math.round(lead * 1000); // ...and the log says how long the tap was (0 = a key or a flick) } } @@ -2109,7 +2171,7 @@ export function update(dt) { // walking between points" rule is untouched — nothing arms a burst except the player. if (G.phase === 'rally' || G.mgT > 0) { const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps - if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes + if (swingLog() && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; } @@ -2155,6 +2217,7 @@ export function update(dt) { const tgt = chasing ? intercept('P') : G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z } : rallyFeel() ? rallyHome(P) : HOME.P; + P.tgt = tgt; // the timed gold ring (ringCue) predicts the contact from where this run ends const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it P.x += dx / d * Math.min(rate, d); @@ -2210,11 +2273,13 @@ export function update(dt) { // recovers you toward the baseline between shots on touch; the duel is plain tennis as ever. if (P.swingT >= 0) { P.swingT += dt; - if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame) + if (swingLog() && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame) const volley = ball.bounces === 0; (G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y, - bz: ball.z, bounces: ball.bounces, - r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() }); + bz: ball.z, bounces: ball.bounces, lb: letBounce(), + // the reach trySwing really tests — the match's longer racket included, or a near miss reads wide + r: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1), + w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() }); } trySwing(P, false); // a contact resets swingT to -1 inside trySwing // volley-length window while a hostile shot is in the air too, or the swing clock expires at diff --git a/src/main.js b/src/main.js index a0c1392..608076a 100644 --- a/src/main.js +++ b/src/main.js @@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, sel import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; import { netConnect, netHooks, netAgain } from './net.js'; -import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit } from './render.js'; +import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { cv } from './dom.js'; @@ -1078,7 +1078,31 @@ cv.addEventListener('pointerdown', e => { const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords const y = (e.clientY - r.top) / r.height * (cv.height / RES); if (x >= 6 && x <= 138 && y >= 6 && y <= 36) G.logShow = !G.logShow; // the scoreboard + else if (G.logShow) { // ...and a tap on the open log COPIES all of it, to paste into a debug session + const L = logGeom(); + if (x >= L.x && x <= L.x + L.w && y >= L.y && y <= L.y + L.h) copySwingLog(); + } }); +// The whole ring as text (Oliver, 2026-10-14: "ich würde dir das gerne mal zum Debuggen geben"): +// a header naming the build, the mode and the body across the net, then one line per entry with +// its measured fields. Clipboard first (a tap is the user gesture it needs); the share sheet where +// a webview refuses it. +function copySwingLog() { + const v = (document.querySelector('script[src*="main.js"]')?.src.match(/v=(\d+)/) || [])[1] || '?'; + const foe = G.match && !G.net ? matchFoe(G.match.opp) : null; + const head = `SPLATTER TENNIS v${v} · ${G.match ? (G.match.cup ? 'cup' : G.match.train ? 'training' : 'climb') : G.classic ? 'classic' : G.mode}` + + (foe ? ` · ${foe.short} (${foe.style})` : G.diff ? ` · ${G.diff.name}` : '') + ` · ${new Date().toISOString()}`; + const lines = G.missLog.map(e => { + const { at, result, reason, ...rest } = e; + const extra = Object.keys(rest).length ? ' {' + Object.entries(rest).map(([k, val]) => k + '=' + val).join(' ') + '}' : ''; + return `${at}s ${result} ${reason}${extra}`; + }); + const text = [head, ...lines].join('\n'); + const done = ok => { G.flash = { text: ok ? 'SWING LOG COPIED' : 'COPY FAILED', t: 1.2 }; }; + const share = () => navigator.share ? navigator.share({ text }).then(() => done(true), () => done(false)) : done(false); + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(() => done(true), share); + else share(); +} diff --git a/src/render.js b/src/render.js index cb03b9b..705ae0c 100644 --- a/src/render.js +++ b/src/render.js @@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js'; import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js'; import { RAISERS, FRAMES } from './crowd.js'; -import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; +import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf, ringCue } from './gameplay.js'; import { drillQueue, drillLegal, drillMult } from './drill.js'; import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js'; import { runDelta, progress, prof, cupBest, hasPromo } from './profile.js'; @@ -1070,13 +1070,21 @@ function drawApproachRing() { // core tennis legible. Reported three times before it was believed. The gore mode is left out on // purpose: there is no rally there, the ball comes back off bodies. if (!(G.match || G.mode === 'tennis') || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return; - const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric - if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back const p = proj(ball.x, ball.y, ball.z); if (p.s <= 0) return; - const inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand; const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring - const r = rSweet * Math.max(1, d / TUNE.matchRingSweet); // the closing ring — they kiss at sweet + let inBand, r; + if (TUNE.ringTime) { // TIMED (gameplay.ringCue): kiss = touch now + const c = ringCue(); + if (!c || c.eta > TUNE.ringLead || c.eta < c.sweet - c.band) return; // not yet, or the moment has passed + inBand = c.gold; + r = rSweet * (1 + Math.max(0, c.eta - c.sweet) * 4); // closes onto the target as the moment nears + } else { // the old DISTANCE ring, for A/B + const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); + if (d > TUNE.matchRingFar || ball.vz < 0) return; + inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand; + r = rSweet * Math.max(1, d / TUNE.matchRingSweet); + } ctx.lineWidth = 1.5; ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.55)'; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.stroke(); @@ -1117,6 +1125,12 @@ function drawApexBand() { // walking looks wrong on some of them" has to be checked. Geometry lives here, next to the drawing, // and input.js hit-tests the same function: one source, so a moved button cannot stop responding // (the rack learned this the hard way — see rackGeom). +// THE SWING LOG's panel — drawn here, hit-tested by main.js's copy tap (one source, the rackGeom rule) +export function logGeom() { + const rows = Math.min(9, G.missLog.length); + return { x: 6, y: 40, w: W - 12, h: rows * 10 + 16, rows }; +} + export function benchButtons() { // FOUR across now: FALL plays the real collapse-and-rise beat (beats.benchFall), which is a SEQUENCE // and not a pose, so it could not be one of the stepped stops. @@ -3296,15 +3310,15 @@ export function render() { ctx.font = F(8); ctx.fillStyle = '#ff9060'; ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); } - if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly - const list = G.missLog.slice(-9), lh = 10, top = 40; - ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16); + if (G.logShow) { // scoreboard tap: recent swing diagnostics; tap the panel to COPY the whole log + const L = logGeom(), list = G.missLog.slice(-L.rows), lh = 10; + ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(L.x, L.y, L.w, L.h); ctx.font = F(7, 0); ctx.textAlign = 'left'; - ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5); + ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — TAP HERE TO COPY · SCOREBOARD HIDES', 10, L.y + 5); const maxCh = Math.floor((W - 20) / 4.3); list.forEach((e, i) => { - ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060'; - ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh); + ctx.fillStyle = e.result === 'HIT' || e.result === 'WON' ? '#7fd07f' : '#ff9060'; + ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, L.y + 16 + i * lh); }); } if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it diff --git a/test.js b/test.js index 1491e42..fd2a8db 100644 --- a/test.js +++ b/test.js @@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; -const { predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); +const { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -1160,8 +1160,7 @@ test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) { - const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); - if (Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand) { armed = true; incoming++; fire = i + LAG; } + if (ringCue()?.gold) { armed = true; incoming++; fire = i + LAG; } // the finger LANDS on gold and lifts LAG later } if (i === fire) keyPressed('Space', LAG / 60); // the lift: the touch's age rides along const before = ball.lastHit; @@ -1326,16 +1325,19 @@ test('a NET RUSHER serves and volleys: the serve is his approach and he sprints score.server = 'O'; setupPoint(); Object.assign(zb, { x: 1.3, z: -0.5, serveHold: 0 }); // the serve's flight plus the return's: ~1.1 s — what a real serve-and-volleyer has to close in - let served = -1, z0 = null, appr = null, zMax = -99; + let served = -1, z0 = null, appr = null, zMax = -99, rushed = false, sprint = 0; for (let i = 0; i < 4 * 60 && !(served >= 0 && i - served > 66); i++) { G.modal = null; G.modalQ.length = 0; + const zPrev = zb.z; update(DT); if (served < 0 && ball.active && ball.lastHit === 'O' && G.phase === 'rally') { served = i; z0 = zb.z; appr = ball.approach; } - if (served >= 0) zMax = Math.max(zMax, zb.z); + if (served >= 0) { zMax = Math.max(zMax, zb.z); rushed ||= !!zb.rushing; sprint = Math.max(sprint, (zb.z - zPrev) / DT); } } assert.ok(served >= 0, 'he served'); assert.equal(appr, 'O', 'the serve is an approach'); - assert.ok(zMax > z0 + 2, `he charged the net behind it: ${z0.toFixed(1)} -> ${zMax.toFixed(1)}`); + assert.ok(rushed && zMax > z0 + 1.2, `he charged the net behind it: ${z0.toFixed(1)} -> ${zMax.toFixed(1)}`); + // ...at a SPRINT, not the half-pace walk an ordinary recovery uses + assert.ok(sprint > 0.8 * cfg.TUNE.racketSpeed * 1.25, 'he sprints in: ' + sprint.toFixed(2) + ' m/s'); }); test('LOBBED at the net, the player backpedals too, and only blocks it back', () => { @@ -1353,6 +1355,44 @@ test('LOBBED at the net, the player backpedals too, and only blocks it back', () } finally { TUNE.autoAssist = assist; } }); +test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and why the point went', () => { + // Oliver, 2026-10-14: "bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht + // bekommen habe". It was gated on G.mode === 'tennis' and the match runs as 'splatter' — the Climb + // and the cup logged nothing at all. + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + startMatch(); G.interlude.introT = 0; + for (let i = 0; i < 2.2 * 60; i++) update(DT); + G.missLog.length = 0; + const zb = oppOf(); zb.swingCool = 999; + const incoming = () => { + G.phase = 'rally'; G.wasServe = false; G.swung = false; G.rallyNear = null; + const v = solveShot(0, 1, 2, 0.5, 21, 20); + Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false, + x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); + }; + incoming(); + keyPressed('Space', 0.1); // far too early: the ball has barely left + for (let i = 0; i < 30; i++) update(DT); + const miss = G.missLog.find(e => e.result === 'MISS'); + assert.ok(miss && /tap too early/.test(miss.reason) && /tap 100ms/.test(miss.reason), 'the whiff is explained: ' + (miss && miss.reason)); + for (let i = 0; i < 4 * 60 && G.phase === 'rally'; i++) update(DT); + const lost = G.missLog.find(e => e.result === 'LOST'); + assert.ok(lost, 'the point it cost is logged: ' + JSON.stringify(G.missLog.map(e => e.result))); + // a clean hit carries its shot and its tap + for (let i = 0; i < 4 * 60 && G.phase !== 'pretoss'; i++) update(DT); + incoming(); G.missLog.length = 0; + let lift = -1; + for (let i = 0; i < 3 * 60 && ball.lastHit === 'O'; i++) { + if (lift < 0 && ringCue()?.gold) lift = i + 6; // the finger lands on gold... + if (i === lift) keyPressed('Space', 0.1); // ...and lifts 100 ms later + update(DT); + } + const hit = G.missLog.find(e => e.result === 'HIT'); + assert.ok(hit && /^q=/.test(hit.reason) && /tap 100ms/.test(hit.reason), 'the hit says how it was struck: ' + (hit && hit.reason)); + } finally { TUNE.autoAssist = assist; } +}); + test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => { // the only out a rally ball may find is wide — depth must never put one past the baseline startMatch(); G.interlude.introT = 0;