diff --git a/PROJECT.md b/PROJECT.md index de916ec..a13a727 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -986,6 +986,30 @@ himself in live play. trails. It ended a three-round guessing match in one session — his reports and my autopilot measurements kept disagreeing because the autopilot had legs he did not. +**TWENTY-FIRST PASS — the short ball, the home-bound intercept, and the portrait vignette.** Second +recorded session: 5 wins and a deuce war to 5-6 (the mode is a contest now), but all seven losses +shared one signature — the ball DIED IN COURT 1.4-2 m short of him while he ran 0.2-0.5 m. Plus his +note *"there seems to be a problem when the ball arrives right from the player"* and *"still no +vignette. just a slight red pulsing from below"*. Three distinct defects: +- **The pace cut created DYING ROLLERS**: slow returns bounce and die at z 18-20 — outside + `intercept`'s near zone, so nothing ever chased them, and the rolled-dead rule gives the point to + the last hitter (the zombie). They also arrive at his feet below legal contact height, which is + the "ball arrives right at the player" feel. `intercept` now keeps an `anyHit` candidate — the + earliest REACHABLE point on his half where the ball is still at hitting height — so the assist + runs FORWARD onto the short ball, which is the tennis answer. (Zone points still win: deep is + better.) Verified: the chase test shows him sprinting z 23 -> 16.4 and meeting the ball at gap ~0. +- **`intercept` computed reachability with `autoAssist`** — 0 on any fine pointer — so a desktop + match's intercept thought nothing was reachable and answered "home" every time: he had legs + (matchAssist) but his destination was always the baseline centre. `assistK()` is the shared + helper now; both the mover and the planner use it. +- **THE VIGNETTE WAS RADIAL IN A PORTRAIT CANVAS.** The circle reaches the corners but arrives at + the left/right screen edges at fraction ZERO — "red pulsing from below" was the bottom edge being + the only place the circle got close. It is FOUR LINEAR EDGE FADES now, hugging the actual + rectangle on any aspect, sqrt(dmg) curve kept, inset deepening with damage. Posed at 6/8 hp: all + four edges carry visible red. +- **Test-measurement trap for the log**: reading `P.z` after the chase loop proves nothing — the + point reset teleports him home. Measure the minimum DURING the chase. + **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this diff --git a/index.html b/index.html index 353a30f..0e15987 100644 --- a/index.html +++ b/index.html @@ -400,6 +400,6 @@ - + diff --git a/src/gameplay.js b/src/gameplay.js index 0a8269b..15b1dbe 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -863,17 +863,33 @@ function onBounce() { // Where should a player run to? Simulate the ball forward and take the first point in // their half at hitting height that they can actually REACH IN TIME; otherwise chase the // deepest hittable point. This is the "every ball is catchable, timing is the skill" contract. +// The assist's leg speed — the match floors it (matchAssist) because autoAssist is 0 wherever the +// pointer is fine. Shared by the movement block AND intercept below: intercept uses it to decide +// which points are reachable, and computing that with autoAssist alone meant a desktop match's +// intercept thought NOTHING was reachable and sent the player home every time — he had legs but +// his destination was always the baseline centre. +export const assistK = () => G.match ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist; + function intercept(side) { const ch = side === 'O' ? O : P; - const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist; + const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * assistK(); let { x, y, z, vx, vy, vz } = ball; const g = ball.g || TUNE.gravity, dt = 0.01; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; const home = HOME[side]; - let fallback = null, deepest = null; + let fallback = null, deepest = null, anyHit = null; for (let i = 0; i < 300; i++) { vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; } + // THE SHORT BALL (match): a slow return can bounce and DIE at z 18-20, which never enters the + // near zone below — so nothing ever chased it, it rolled dead at the player's feet (below the + // legal contact height by then) and the point went to the zombie. Seven of seven recorded + // losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting + // height: running forward onto the short ball is the tennis answer, and now it is the assist's. + if (side === 'P' && G.match && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) { + const pt = { x, z: Math.min(24.5, z) }; + if (Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt) anyHit = pt; + } if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) { const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) }; const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt; @@ -882,7 +898,7 @@ function intercept(side) { deepest = pt; // last resort: run at the deepest hittable point } } - return fallback || deepest || home; + return fallback || deepest || anyHit || home; // zone points first: meeting deep is still better } // ---------------------------------------------------------- miss diagnostics ---- @@ -1316,13 +1332,13 @@ export function update(dt) { // desktop Browser pane (pointer: fine) had NO auto-run at all — the flight recorder showed // Oliver swinging in every lost point while his x never moved: a statue with a racket. The // match is a tap-timing game; footwork is the game's job. Held keys still override (mx/mz). - const assistK = G.match ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist; - if (!mx && !mz && assistK > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist + const aK = assistK(); + if (!mx && !mz && aK > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist // splatter: anything travelling back toward you is yours to run down — your own rebound // included. The assist puts you there; the volley timing stays honest. const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0); // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it - const rate = TUNE.playerSpeed * assistK * dt * (chasing ? 1 : 0.55); + const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55); // Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists // for a reason: // GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid diff --git a/src/render.js b/src/render.js index 2cdc1b0..5053b8f 100644 --- a/src/render.js +++ b/src/render.js @@ -1971,16 +1971,26 @@ export function render() { const dmg = 1 - Math.max(0, G.hp) / (G.hpMax || 1); const pulse = G.hurtFlash > 0 ? Math.min(1, G.hurtFlash / 0.3) * 0.3 : 0; if (dmg > 0 || pulse > 0) { - // SQRT of the damage, not the damage: the match pool is 8, so a linear curve made the first - // hit a 10% alpha nobody can see — "the vignette is basically invisible". sqrt front-loads it: - // hit one on 8 hp reads at ~0.3 alpha, and it still deepens all the way to the cap. The inner - // radius tightens with damage too, so deep wounds shrink the clean centre. + // FOUR EDGE FADES, not a radial. A radial gradient in a tall portrait canvas reaches the + // corners but arrives at the side edges at fraction ZERO — Oliver saw "a slight red pulsing + // from below" because the bottom edge was the only place the circle got close. Linear fades + // hug the actual rectangle on any aspect. sqrt(dmg) front-loads the curve (the match pool is + // 8, so linear made hit one an invisible 10%), and the inset DEEPENS with damage so wounds + // shrink the clean centre. const a = Math.min(0.85, Math.sqrt(dmg) * 0.8 + pulse); - const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * (0.5 - dmg * 0.22), - W / 2, H / 2, Math.max(W, H) * 0.7); - g.addColorStop(0, 'rgba(120,8,12,0)'); - g.addColorStop(1, `rgba(120,8,12,${a.toFixed(3)})`); - ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); + const col = k => `rgba(120,8,12,${(a * k).toFixed(3)})`; + const D = Math.round(Math.min(W, H) * (0.2 + 0.32 * dmg)); + for (const [x0, y0, x1, y1, w, h, gx, gy] of [ + [0, 0, 0, D, W, D, 0, 1], // top + [0, H, 0, H - D, W, D, 0, -1], // bottom + [0, 0, D, 0, D, H, 1, 0], // left + [W, 0, W - D, 0, D, H, -1, 0], // right + ]) { + const g = ctx.createLinearGradient(x0, y0, x1, y1); + g.addColorStop(0, col(1)); g.addColorStop(1, col(0)); + ctx.fillStyle = g; + ctx.fillRect(gx < 0 ? W - D : 0, gy < 0 ? H - D : 0, w, h); + } } } diff --git a/test.js b/test.js index c3b24d5..232045d 100644 --- a/test.js +++ b/test.js @@ -588,6 +588,33 @@ test('the match brings its own legs: auto-run works even where autoAssist is 0', } finally { TUNE.autoAssist = assist; } }); +test('THE SHORT BALL: the assist runs FORWARD onto a dying return, desktop conditions included', () => { + // Seven of seven recorded losses: a slow return bounced and died at z 18-20 — outside the + // intercept zone, so nothing chased it and it rolled dead at his feet (below legal contact + // height). Doubly broken on desktop: intercept computed reachability with autoAssist (0 there), + // so its answer was always "run home". Both fixed via assistK() + the anyHit candidate. + const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop pane condition + try { + startMatch(); + for (let i = 0; i < 2.2 * 60; i++) update(DT); + G.phase = 'rally'; + P.x = 0; P.z = 23; + // a slow short return: incoming, will bounce and die around z ~19 + ball.active = true; ball.lastHit = 'O'; ball.x = 1.5; ball.z = 10; ball.y = 1.6; + ball.vx = 0.4; ball.vy = 2.2; ball.vz = 5.5; ball.bounces = 0; ball.g = TUNE.gravity; + const z0 = P.z; + let minGap = 99, minPz = P.z; // deepest he RAN, read during the chase — the point reset + // teleports him home after, so the final position proves nothing + for (let i = 0; i < 2.5 * 60 && ball.active && ball.lastHit === 'O'; i++) { + update(DT); + minPz = Math.min(minPz, P.z); + minGap = Math.min(minGap, Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8)); + } + assert.ok(minPz < z0 - 3.0, 'he ran FORWARD to meet it: z ' + z0 + ' -> ' + minPz.toFixed(1)); + assert.ok(minGap < 1.6, 'and got the ball into reach (closest ' + minGap.toFixed(1) + ' m)'); + } finally { TUNE.autoAssist = assist; } +}); + test('the match recovers to CENTRE between shots — a wide return must not strand you', () => { // The match inherited gore-splatter's depth-only recovery (a sidestep-protection rule) by being // G.mode 'splatter' — so one wide return pulled you off centre and you STAYED there, and the next