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 <noreply@anthropic.com>
This commit is contained in:
1 parent
180288ef8f
commit
d110e12321
8 files changed
+225
-41
No files matched your search
+86
-21
@@ -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
|
||||
|
||||
Reference in new issue
Block a user