Time flicks from touch-down, log the opponent's shots, save early half-volleys

From Oliver's first swing log (cup vs the Brute): 13 of 22 hits were
perfect, 9 badly early or late — mostly forward-right flicks — and the
weak balls became seven "passed you". Every hit was a flick, and flicks
still swung only at the flickMin crossing while the timed ring says "touch
now": they are now timed from touch-down too (capped at flickLead 0.1 s).

The log gains its other half: a THEM line per opponent shot (kind, pace,
where it lands relative to you, coming in) and "never swung" judged per
incoming ball instead of once a point. And an early swing on a low ball
digs it out as a capped half-volley instead of whiffing. v366.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 11:48:51 +02:00
1 parent d110e12321
commit 5bbde720ad
7 files changed
+66 -9

No files matched your search

+25
View File
@@ -5420,6 +5420,31 @@ Oliver: *"bitte bau nochmal die Funktion ein die protokolliert warum ich einen B
- Note: contact still fires on the first frame the ball is in reach, which right after a bounce is - 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. often 15-25 cm off the grass. A visual half-volley, the old contact model; untouched.
### Oliver's first swing log (v365, cup vs THE BRUTE) and what it changed (2026-10-14, v366)
**His question: "am I just bad or is something off".** Read off the log:
- 22 hits: 13 at q = 1, 9 under 0.5. Six of the nine bad ones were "deep right" (the forward-right
flick), which failed 6 of its 9 tries; the left flick failed 3 of 10. The bad hits split between
EARLY (the ball met at the edge of reach, d 1.55-1.73) and LATE (the ball already on top of him,
d 0.55-0.73). It is timing, with no single offset.
- Weak balls are what the Brute (power, league-5 quality) punishes. Seven LOST "passed you", and
several of them follow a weak hit of his within a second.
- Every hit said `tap 0ms`: he flicks everything. Hypothesis to check in the next log: the gore
slingshot habit. A pull-back before the forward flick crosses `flickMin` DOWNWARD, which fires the
swing early, and the live aim turns to "deep right" by contact.
**Changes:**
- **Flicks are timed from touch-down too** (`keyPressed(code, lead, flick)`; input.js passes the
crossing's age, capped at `flickLead` 0.1 s so a slow drag keeps a window). The timed ring means
"touch now" for both gestures. The log says `flick 46ms` / `tap 120ms`. Verified with real touch
events.
- **The log's missing half:** a THEM line per opponent shot (kind, spin, q, pace, where it lands
relative to the player, OUT, "coming in"), and `whiffed it` for a CPU flub. "Never swung" is now judged
per incoming ball: `G.swung` and `G.rallyNear` reset at the opponent's strike. It used to be judged
once a point, so none of his "passed you" lines could say whether he swung or how close the ball came.
- **An early swing on a low skidding ball no longer whiffs.** The let-it-bounce gate waits only while
the bounce will land inside the swing window. Otherwise it digs the ball out as a half-volley, capped
below the tape. His 48.14 s MISS was exactly this.
# OPEN — leftovers, in rough priority order # OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks. 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>
</div> </div>
<span id="recLamp" hidden>REC</span> <span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=365"></script> <script type="module" src="src/main.js?v=366"></script>
</body> </body>
</html> </html>
+1
View File
@@ -647,6 +647,7 @@ export const TUNE = {
// the swing window, at any pace. 0 = the old distance ring (matchRingSweet/Band) // 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) 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 ringLead: 0.9, // s before contact the timed ring appears
flickLead: 0.1, // s: a flick is timed from touch-down too, up to this much (a slow drag keeps a window)
// ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely // ---- 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 // 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 // lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated
+27 -5
View File
@@ -366,6 +366,20 @@ function doHit(ch, quality, aim) {
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null; ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
ball.from = Math.abs(ch.z - COURT.netZ); ball.from = Math.abs(ch.z - COURT.netZ);
ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh
// THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it —
// was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point.
if (ch.side !== 'P' && swingLog()) {
G.swung = false; G.rallyNear = null;
const land = predictLanding(), mark = !land ? '' : (() => {
const dx = land.x - P.x, inside = r2(COURT.L - land.z);
const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ;
return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`;
})();
const kind = soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
: soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball';
logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}`
+ (ball.approach === ch.side ? ' · coming in' : '') });
}
G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts ball.kills = 0; // fresh shot: multi-kill streak restarts
@@ -1603,7 +1617,7 @@ function logWhiff() {
else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`; 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 else reason = 'unclassified — in reach, in window, in height band'; // should not happen
} }
logSwing({ result: 'MISS', reason: reason + ` · tap ${P.tap || 0}ms`, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), logSwing({ result: 'MISS', reason: reason + ` · ${P.flick ? 'flick' : '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) }); ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) });
} }
@@ -1629,7 +1643,14 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const dx = ball.x - ch.x, dz = ball.z - ch.z; const dx = ball.x - ch.x, dz = ball.z - ch.z;
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return; if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
if (letBounce()) return; // planned from the back: the swing waits for the bounce instead of a weak pick-up // planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while
// the bounce will still land inside this window. If it will not, this is the last chance and the
// low ball is dug out as a half-volley (the below-the-tape cap prices it): a swing a touch early
// used to WHIFF here, which read as the game eating a good swing (Oliver's log, 2026-10-14).
if (letBounce()) {
const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg;
if (t + tb < TUNE.swingWindup + win) return;
}
// quality: how centered in the window the contact lands // quality: how centered in the window the contact lands
const center = TUNE.swingWindup + win / 2; const center = TUNE.swingWindup + win / 2;
const off = Math.abs(t - center) / (win / 2); const off = Math.abs(t - center) / (win / 2);
@@ -1684,7 +1705,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : ''; 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' 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'; : 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}`, logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`,
d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) }); d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) });
} }
doHit(ch, q, aim); doHit(ch, q, aim);
@@ -1802,6 +1823,7 @@ function racketAI(rz, dt) {
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0); + (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
if (Math.random() < flub) { if (Math.random() < flub) {
recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach }); recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach });
if (swingLog()) logSwing({ result: 'THEM', reason: 'whiffed it' });
rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
} }
else { else {
@@ -1904,7 +1926,7 @@ function meleeCooldown() {
TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd));
} }
export function keyPressed(code, lead = 0) { export function keyPressed(code, lead = 0, flick = false) {
// THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no // THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no
// ball and no scoring, so every other meaning a key has here is meaningless inside it. // ball and no scoring, so every other meaning a key has here is meaningless inside it.
if (G.bench) return benchKey(code); if (G.bench) return benchKey(code);
@@ -2003,7 +2025,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 // clock starts that far along: the timing is judged from touch-down, the ball still has to be
// there for the contact. // there for the contact.
P.swingT = rallyFeel() ? lead : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; 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) P.tap = Math.round(lead * 1000); P.flick = flick; // ...and the log says which gesture, and how long it took to fire
} }
} }
+7 -2
View File
@@ -207,7 +207,12 @@ cv.addEventListener('touchmove', e => {
? { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, ? { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
fx: dx / dist, fy: dy / dist, mag: dist } : null; fx: dx / dist, fy: dy / dist, mag: dist } : null;
if (G.swipe && !stick.swung && G.phase === 'rally') { stick.swung = true; keyPressed('Space'); } // ...timed, like the tap, from the moment the finger LANDED (gameplay.keyPressed's `lead`): the
// timed gold ring means "touch now", and a flick only swings once it has travelled flickMin.
// Capped at flickLead, so a slow drag still has a window to swing into (the left-flick lesson).
if (G.swipe && !stick.swung && G.phase === 'rally') {
stick.swung = true; keyPressed('Space', Math.min(TUNE.flickLead, (performance.now() - stick.t0) / 1000), true);
}
} }
}, { passive: false }); }, { passive: false });
@@ -235,7 +240,7 @@ const endTouch = e => {
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim
t: performance.now() }; t: performance.now() };
if (!stick.swung) keyPressed('Space'); // the crossing already swung — never twice per gesture if (!stick.swung) keyPressed('Space', Math.min(TUNE.flickLead, dt / 1000), true); // the crossing already swung — never twice per gesture
} }
stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null; stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null;
} }
+1 -1
View File
@@ -3317,7 +3317,7 @@ export function render() {
ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — TAP HERE TO COPY · SCOREBOARD HIDES', 10, L.y + 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); const maxCh = Math.floor((W - 20) / 4.3);
list.forEach((e, i) => { list.forEach((e, i) => {
ctx.fillStyle = e.result === 'HIT' || e.result === 'WON' ? '#7fd07f' : '#ff9060'; ctx.fillStyle = e.result === 'HIT' || e.result === 'WON' ? '#7fd07f' : e.result === 'THEM' ? UI.dim : '#ff9060';
ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, L.y + 16 + i * lh); ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, L.y + 16 + i * lh);
}); });
} }
+4
View File
@@ -1390,6 +1390,10 @@ test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and wh
} }
const hit = G.missLog.find(e => e.result === 'HIT'); 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)); assert.ok(hit && /^q=/.test(hit.reason) && /tap 100ms/.test(hit.reason), 'the hit says how it was struck: ' + (hit && hit.reason));
// ...and the opponent's half is there: what it hit back, and where it lands relative to you
for (let i = 0; i < 2 * 60 && ball.lastHit === 'P'; i++) { zb.swingCool = 0; update(DT); }
const them = G.missLog.find(e => e.result === 'THEM');
assert.ok(them && (/lands/.test(them.reason) || /whiffed/.test(them.reason)), 'the reply is logged: ' + (them && them.reason));
} finally { TUNE.autoAssist = assist; } } finally { TUNE.autoAssist = assist; }
}); });