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:
wuwiandClaude Opus 5.5 committed 2026-10-14 11:41:05 +02:00
1 parent 180288ef8f
commit d110e12321
8 files changed
+225 -41

No files matched your search

+2 -1
View File
@@ -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.
+4
View File
@@ -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
+86 -21
View File
@@ -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
+25 -1
View File
@@ -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();
}
+25 -11
View File
@@ -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