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
+36
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user