diff --git a/PROJECT.md b/PROJECT.md index ccbcf51..a78665d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5515,6 +5515,22 @@ taken earlier, on the rise, while it is still inside. The final target is clampe ball still gets a run to the nearest legal spot. Test: his ball (hard cross-court, lands at 3.3, kicks out). Without the fix the target is 14.8 m; with it the run stays in bounds and he returns it. +### The ring stops twitching, and the praise word gets out of the ball's way (2026-10-14, v371) +Oliver: *"der Treffer-/Zielkreis zittert jetzt manchmal, außerdem ... die Nachrichten wie shot of the +day, textbook ... verdecken manchmal den Ball."* +- **The timed ring re-solved every frame** against the auto-run's target, and a target shifting a few + centimetres moved the predicted moment. Probe: p99 100 ms, max 158 ms per frame, nearly half the + ring's size in one frame. The drawn cue is now `G.ring`, smoothed in `update`: it counts down on its + own clock and eases toward each fresh solve (`ringSmooth` 8/s), and a frame with no solve holds it + for up to 0.12 s. Measured jump: p99 17 ms, max 21 ms. Gold toggles are unchanged (two per ball). + The bots and tests keep reading the raw `ringCue`. +- **The praise word sat ON the ball.** On a rally contact it rose straight into the path of the ball + just struck; a serve's word sat on the far bounce, where the return comes back through. + `praisePop`: in tennis modes it lands `praiseBehind` (1 m) behind the player's feet, on the dark + apron under him, and stays put (`popText`'s new `still`). The MOMENT is unchanged (contact / the + serve's landing). Gore keeps its words on the bodies they belong to. Checked in the pane: PERFECT + under him, ball clear. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index 2341b4b..4a4bf03 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 3f9d135..e811dd3 100644 --- a/src/config.js +++ b/src/config.js @@ -648,6 +648,8 @@ export const TUNE = { // 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 + ringSmooth: 8, // 1/s: how fast the drawn ring eases toward each fresh solve (it counts down on its own) + praiseBehind: 1.0, // m behind the player's feet a rally's praise word is drawn (under him, off the ball's path) flickLead: 0, // s a flick's swing is back-dated toward touch-down (v366 used 0.1 — his next log showed // every 62-64 ms flick landing ~85 ms EARLY: a flick is timed by its swipe, not its touch) // ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely diff --git a/src/gameplay.js b/src/gameplay.js index bdc07ad..0914f9e 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -87,6 +87,16 @@ let praiseN = 0; // the word must land at the IMPACT, not at the release (Oliver: "the reactions are playing after // shooting the ball, not when the ball hits"). A serve arms it on ball.praise and the contact/ // bounce pops it; a rally hit pops it at the racket, where contact and praise are the same moment. +// ...and in a rally it lands BELOW THE PLAYER, just behind his feet, and stays put (Oliver, 2026-10-14: +// "die Nachrichten wie shot of the day, textbook verdecken manchmal den Ball"). On the contact it rose +// straight into the path of the ball he had just struck; a serve's word sat on the far bounce, which +// is exactly where the return comes back through. The MOMENT is unchanged — only where it is drawn. +// Gore keeps its words on the bodies they belong to. +function praisePop(w, x, y, z) { + if (rallyFeel() || G.mode === 'tennis') popText(P.x, 0, P.z + TUNE.praiseBehind, w, undefined, true); + else popText(x, y, z, w); +} + function praiseWord() { if (G.praiseT > 0) return null; G.praiseT = TUNE.praiseEvery; @@ -410,7 +420,7 @@ function doHit(ch, quality, aim) { if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs) G.hitstop = 0.05; pock(0.5, 1600); const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact - if (w) popText(ball.x, ball.y, ball.z, w); + if (w) praisePop(w, ball.x, ball.y, ball.z); } ch.pose = 'swing'; ch.poseT = 0; if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit @@ -1320,7 +1330,7 @@ function onBounce() { pock(0.35, 500); if (ball.scored) return; // a called ball still bounces and still sounds; it just cannot score twice // a deferred praise word that met no body lands with the ball — the DRAW was still perfect - if (ball.praise) { popText(ball.x, 0.3, ball.z, ball.praise); ball.praise = null; } + if (ball.praise) { praisePop(ball.praise, ball.x, 0.3, ball.z); ball.praise = null; } if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up explode(ball.x, ball.z); ball.bomb = false; spentBall(); return; @@ -2306,6 +2316,19 @@ export function update(dt) { // mid-stride left him welded to a single run frame — the "stuck mid-animation" report. // vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead. else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } } + // THE GOLD RING, STEADY (Oliver, 2026-10-14: "der Treffer-/Zielkreis zittert jetzt manchmal"). + // ringCue re-solves every frame against where the auto-run is taking him, and a target that shifts + // a few centimetres moved the predicted moment by a frame or two — the ring's size twitched and its + // gold blinked. The drawn cue COUNTS DOWN on its own clock and only eases toward each fresh solve + // (TUNE.ringSmooth), and a frame with no solve holds it instead of dropping it. + if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && (rallyFeel() || G.mode === 'tennis')) { + const c = ringCue(); + if (c && !G.ring) G.ring = { ...c }; + else if (c) { const e = G.ring.eta - dt; G.ring = { ...c, eta: e + (c.eta - e) * Math.min(1, dt * TUNE.ringSmooth) }; } + else if (G.ring && (G.ring.hold = (G.ring.hold || 0) + dt) < 0.12) G.ring.eta -= dt; + else G.ring = null; + if (G.ring) G.ring.gold = Math.abs(G.ring.eta - G.ring.sweet) < G.ring.band; + } else G.ring = null; // The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent // in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him // off the edge of the screen either. diff --git a/src/render.js b/src/render.js index f777fa2..6dc7500 100644 --- a/src/render.js +++ b/src/render.js @@ -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, ringCue } from './gameplay.js'; +import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } 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'; @@ -1075,7 +1075,7 @@ function drawApproachRing() { const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring let inBand, r; if (TUNE.ringTime) { // TIMED (gameplay.ringCue): kiss = touch now - const c = ringCue(); + const c = G.ring; // gameplay's STEADY copy of 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 @@ -2883,7 +2883,7 @@ export function render() { ctx.save(); ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (1 - f) * 14), p.col || '#ffffff'); + shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (p.still ? 0 : (1 - f) * 14)), p.col || '#ffffff'); ctx.restore(); } // (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The diff --git a/src/state.js b/src/state.js index fab6d43..61efb4f 100644 --- a/src/state.js +++ b/src/state.js @@ -184,6 +184,7 @@ export const G = { rallyNear: null, // closest the incoming ball got this rally (for never-swung passes) swung: false, // player swung at least once this rally — whiff log supersedes PASSED lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly + ring: null, // the drawn gold ring's steady cue (gameplay update smooths ringCue) logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump }; diff --git a/src/zombies.js b/src/zombies.js index 6724dc8..1f3e2af 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -29,8 +29,8 @@ export const shots = []; // {x, y, z, vx, vy, vz, g, t} // the impact — the answer to "what did I just do" appears where you are already looking, not in // the announcement band. Render draws them, gameplay's update ticks them, killed in resetZombies. export const pops = []; // {x, y, z, text, col, t, T} -export function popText(x, y, z, text, col) { - pops.push({ x, y, z, text, col, t: 0.85, T: 0.85 }); +export function popText(x, y, z, text, col, still = false) { + pops.push({ x, y, z, text, col, still, t: 0.85, T: 0.85 }); // still: does not rise (the rally's praise, under the player) if (pops.length > 10) pops.shift(); }