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

+36
View File
@@ -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.
+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=364"></script>
<script type="module" src="src/main.js?v=365"></script>
</body>
</html>
+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
+46 -6
View File
@@ -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;