Hide the racket, and light the beam all the way to its edge

The mg racket is gone, not moved. It was a procedural racket drawn over a sprite
that already holds one: straight up-court it projected onto the player's back as
a floating oval, and offset to the side it just stuck out.

The laser's far edge was pinned to the middle of the ad strip two commits ago and
nothing appeared to change, twice. The geometry was always right -- each wedge
layer faded to alpha 0 along its length, so the beam went invisible before it
reached its own far corners and the VISIBLE end sat well short of the pinned one.
The far stop is lit now, so the two are the same line. Verified by recomputing
the projection: far corners at y 113.8, the ads' middle, against 110.2 unpinned.

It also falls off with distance now, brightest at the strings and thinning down
the court -- but never to zero, which is the trap above.

Fixes a third instance of the empties-under-the-iterator bug: a rocket blast can
bank the last kill of a game, and the duel or chapter boss that follows clears
pellets[] mid-loop. shots, pellets, extras and the body loop all carry the guard.

Also records that two consecutive render() calls do not produce the same frame --
render mutates camera smoothing as a side effect -- so frame-diffing to find where
something was drawn measures the camera drift instead. It claimed the beam reached
16 px above where its own geometry could go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 15:20:33 +02:00
1 parent 6e88fd5bb9
commit da9cca2f23
4 files changed
+48 -24

No files matched your search

+28 -1
View File
@@ -385,10 +385,31 @@ them missed would hand out free ammo. The soak asserts `gunId` is never set with
`mx || mz || assist`, and finger-steering zeroes `mx`/`mz` — so a player sliding left and right under `mx || mz || assist`, and finger-steering zeroes `mx`/`mz` — so a player sliding left and right under
auto-fire still counted as standing still and never ran. `mgMoving` is in that expression now. auto-fire still counted as standing still and never ran. `mgMoving` is in that expression now.
**The oval on his back was the mg racket.** `drawMgRacket` put its head at `(P.x, 0.95, P.z - 1.35)`, **The mg racket is GONE.** It was a procedural racket drawn over a sprite that already holds one:
straight up-court it projected onto the player's back as a floating oval, and offset to the side it
just stuck out. Oliver: "can we please just hide it?" The pellets streaming up-court are the
feedback. `drawMgRacket` is deleted, not commented out.
**(Previously) the oval on his back was the mg racket.** `drawMgRacket` put its head at `(P.x, 0.95, P.z - 1.35)`,
which at this camera projects almost exactly onto the player sprite. Offset by `P.facing` it reads as which at this camera projects almost exactly onto the player sprite. Offset by `P.facing` it reads as
a racket held out beside him. a racket held out beside him.
**A third array had the empties-under-the-iterator bug.** A rocket blast can bank the last kill of a
game, and the beat that follows (a duel, a chapter boss) does `pellets.length = 0` — so the pellet
loop's next index read undefined and the frame threw. `shots`, `pellets`, `extras` and the body loop
all carry the same `if (!x) continue` guard now. **Any array iterated in `updateZombies` that can
contain something whose death advances the game needs it.**
**A gradient that ends at alpha 0 hides the thing you are aligning.** The laser's far corners were
pinned correctly from the first attempt, but each wedge layer faded to fully transparent along its
length — so the beam became invisible before it reached the pinned edge, the VISIBLE end sat well
short of it, and moving the geometry appeared to do nothing twice in a row. The far stop is lit
(0.62 of the layer's alpha) now, so the visible edge and the pinned edge are the same line.
**The beam falls off with distance** — brightest at the strings, thinning down the court, because
that is what a beam travelling away from you does. The far stop is 0.32 of the layer's alpha: enough
depth to read, never zero, so the visible end stays the pinned end.
**The laser's far edge is pinned in SCREEN space.** Running out to `beamLen` on the ground landed it **The laser's far edge is pinned in SCREEN space.** Running out to `beamLen` on the ground landed it
on the ads' TOP line; it should die at their middle. `c3.y = c4.y = proj(0,0,-6).y - AD_H / 2` — the on the ads' TOP line; it should die at their middle. `c3.y = c4.y = proj(0,0,-6).y - AD_H / 2` — the
x's keep their perspective, the y is pinned. That holds in both orientations and leaves `beamLen` x's keep their perspective, the y is pinned. That holds in both orientations and leaves `beamLen`
@@ -420,6 +441,12 @@ Drive the game deterministically via `window.T` with `javascript_tool`. The Brow
context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with
`window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap. `window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap.
Clear the intro first: `document.getElementById('intro')?.remove()`. Clear the intro first: `document.getElementById('intro')?.remove()`.
- **Two consecutive `render()` calls do NOT produce the same frame**, so "draw it, change one thing,
draw again, diff the pixels" silently measures nothing. `render()` mutates camera state as a side
effect (`aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15`), so the whole scene shifts slightly between
grabs and the diff lights up everywhere — it reported a laser beam extending 16 px above where its
own geometry could possibly reach. To check WHERE something lands, recompute the projection
directly (`T.proj(...)`) and compare numbers; keep pixel-diffing for "did anything at all change".
- **The module-refresh list MUST include the VERSIONED entry point.** `index.html` loads - **The module-refresh list MUST include the VERSIONED entry point.** `index.html` loads
`main.js?v=N`, and `fetch('main.js', {cache:'reload'})` does not bust that URL — so main.js alone `main.js?v=N`, and `fetch('main.js', {cache:'reload'})` does not bust that URL — so main.js alone
stays stale through a reload while every other module is fresh. Read the src off the page: stays stale through a reload while every other module is fresh. Read the src off the page:
+1 -1
View File
@@ -179,6 +179,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=102"></script> <script type="module" src="main.js?v=103"></script>
</body> </body>
</html> </html>
+14 -22
View File
@@ -619,13 +619,19 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
const w0 = wAt(u0) * L.k, w1 = wAt(1) * L.k; const w0 = wAt(u0) * L.k, w1 = wAt(1) * L.k;
const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1); const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
c3.y = c4.y = farY; // ...the x's keep their perspective, the y is pinned c3.y = c4.y = farY; // ...the x's keep their perspective, the y is pinned
// ...and it fades ALONG its length too, so the far end dissolves into the ads instead of // It must NOT fade to nothing along its length. The far corners are pinned to the ads' middle,
// stopping at a hard edge // but a gradient ending at alpha 0 made the beam INVISIBLE for its last stretch — so what you saw
// ended well short of where it was drawn, and no amount of moving the geometry changed it. The
// far end stays lit, so the visible edge and the pinned edge are the same line.
const g = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2, const g = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2,
(c3.x + c4.x) / 2, (c3.y + c4.y) / 2); (c3.x + c4.x) / 2, (c3.y + c4.y) / 2);
// DEPTH: brightest at the strings, thinning away down the court, because that is what a beam
// travelling away from you does. It must NOT reach zero though — a stop at alpha 0 made the beam
// invisible before its far edge, so the visible end sat short of the pinned one and moving the
// geometry appeared to change nothing at all.
g.addColorStop(0, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * alpha})`); g.addColorStop(0, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * alpha})`);
g.addColorStop(0.55, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * 0.75 * alpha})`); g.addColorStop(0.45, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * 0.58 * alpha})`);
g.addColorStop(1, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},0)`); g.addColorStop(1, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * 0.32 * alpha})`);
ctx.fillStyle = g; ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y); ctx.fill(); ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y); ctx.fill();
} }
@@ -809,23 +815,6 @@ function drawFallbackChar(ch, p, h) { // shown only until sprites load
ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u); ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u);
ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u);
} }
function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style
// HELD OUT TO ONE SIDE. Straight up-court (P.x, P.z - 1.35) projects to almost exactly where the
// player sprite already is, so the racket head landed as an oval painted on his back — Oliver saw
// it every time. Offset by facing, it reads as a racket held out beside him.
const f = P.facing || 1;
const hip = proj(P.x + 0.34 * f, 1.15, P.z);
const tip = proj(P.x + 0.86 * f, 1.02, P.z - 1.25); // pointing up-court, where the pellets stream out
ctx.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke();
ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward
ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke();
const cool = G.horde ? G.horde.mgT : G.mgCool; // horde or GUN-tin pickup — same racket
const rate = G.horde ? G.horde.mgRate || TUNE.mgRate : TUNE.mgRate;
if (cool > 1 / rate - 0.03) { // muzzle flash right after a shot
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill();
}
}
// the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to // the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to
// pick which one your next serve throws; empty tins dim out. Geometry is shared with input // pick which one your next serve throws; empty tins dim out. Geometry is shared with input
@@ -1260,7 +1249,10 @@ export function render() {
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke();
} }
if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying) if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying)
if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player // The braced "rambo racket" is NOT drawn any more. It was a procedural racket laid over a sprite
// that already holds one: straight up-court it projected onto his back as a floating oval, and
// offset to the side it just stuck out. The pellets streaming up-court are the feedback; the man
// holding his own racket is the picture.
ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady
// bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone // bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
+5
View File
@@ -961,6 +961,10 @@ export function updateZombies(dt) {
for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc
const pe = pellets[i]; const pe = pellets[i];
// A rocket blast can bank the last kill of a game, and the beat that follows (a duel, a chapter
// boss) empties this array — so every remaining index reads undefined and the frame throws. Same
// guard, and the same reason, as the one in the shots loop and the body loop.
if (!pe) continue;
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt; pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
let hit = false, landed = false; let hit = false, landed = false;
if (pe.arc) { // a thrown round: it falls, and landing is a hit if (pe.arc) { // a thrown round: it falls, and landing is a hit
@@ -1062,6 +1066,7 @@ export function updateZombies(dt) {
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
const e = extras[i]; const e = extras[i];
if (!e) continue; // same as pellets/shots: a kill here can start a beat that empties the array
e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from
e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt; e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt;
if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; } if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; }