Fire the laser as light, and stop him jogging on the spot

The auto-fire jog was one guard. gameplay skipped the pose update in mgSteer mode,
so the pose stuck on 'idle' -- and render covered for it by faking a run cycle off
G.idleT, which animated whether he was moving or not. That fake is what read as
"this hectic run animation... odd all the time". The pose follows MOVEMENT in every
mode now and the fake is deleted, so auto-fire, the horde and the melee racket all
use the ordinary run animation and standing still is standing still. The comment
that justified the fake -- two rackets when idle -- was solving a smaller problem
than it caused; at sprite scale the overlap does not read.

The laser, second pass, all of it "it must read as light he fired":
 - it draws AFTER the net and every body. It was drawn before the far-side pass,
   so the net and the crowd painted over it, which is exactly why it looked like
   it was lying on the floor and sitting under the net.
 - beamLen 26 -> 34: at 26 it stopped just past the far baseline and read as cut
   off in mid-court. It carries into the courtside ads now.
 - beamW0 0.35 -> 0.06, a POINT at the strings. At 0.35 it left the racket as a
   0.7 m bar hanging in mid-air instead of something emitted from it.
 - it DELETES ordinary bodies, hp irrelevant. A plane of light that left a roller
   man standing made it a worse ball, not a weapon. A boss is the exception and
   takes dmgBeam 8 -- severe, survivable.
 - four nested wedges composited 'lighter' (violet bloom, blue, cyan, white core),
   each fading along its length. One quad with a gradient was a coloured shape.

Rocket: speed 17 -> 38, and a real jet plume laid tight ON the flight line rather
than scattered sparks, which looked like a firework.

Beacon: a finer one-pixel thread and a much brighter ball, so the BALL is the
bright thing and the trail only says where it has been.

And the bug in the middle of that: firePellet stamps a whitelist of fields onto
the round, and jet/trailR went onto the WEAPONS rows without being added to the
list -- so the rocket flew with no plume and nothing errored. Any new pellet field
has to be added in both places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 14:44:57 +02:00
1 parent 46e268973d
commit 91053b4c35
6 files changed
+151 -62

No files matched your search

+69 -40
View File
@@ -591,33 +591,46 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
if (from >= 1) return;
const px = -uz, pz = ux; // the lane's perpendicular, on the ground
const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u;
// It LEAVES THE RACKET. Drawn flat on the lawn it read as a shape lying at his feet rather than as
// something he fired; the plane starts at racket height and settles onto the court as it goes out,
// which is what makes the near end look attached to him.
const yAt = u => oy + (0.05 - oy) * Math.min(1, u * 2.2);
// It LEAVES THE RACKET as a POINT and opens out. Drawn flat on the lawn it read as a shape lying
// at his feet; starting wide it read as a bar hanging in mid-air. Narrow at the strings, settling
// onto the court as it goes, is what makes the near end look attached to him.
const yAt = u => oy + (0.05 - oy) * Math.min(1, u * 1.7);
const corner = (u, w) => proj(ox + ux * (u * TUNE.beamLen) + px * w, yAt(u),
oz + uz * (u * TUNE.beamLen) + pz * w);
const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1);
const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
const u0 = Math.max(0, from);
const rgb = RGB(WEAPONS.laser.col);
// White only right at the near edge; BLUE for the length of it. A gradient that ran white -> faint
// blue read as a pale grey smear on the lawn — the colour is the identity of the shot.
const g = ctx.createLinearGradient(c1.x, c1.y, c4.x, c4.y);
g.addColorStop(0, `rgba(255,255,255,${0.92 * alpha})`);
g.addColorStop(0.14, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.88 * alpha})`);
g.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.46 * alpha})`);
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();
// the two burning edges: HAIRLINES. At 2.5px they were heavier than anything else on the court and
// read as a drawn outline rather than as light — "the borders of the laser are quite big".
ctx.strokeStyle = `rgba(210,232,255,${0.42 * alpha})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y);
ctx.moveTo(c1.x, c1.y); ctx.lineTo(c4.x, c4.y); ctx.stroke();
// ...and the erase front itself: a bright bar racing away down the lane
if (from > 0) {
ctx.strokeStyle = `rgba(255,255,255,${0.85 * alpha})`; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke();
// THREE NESTED WEDGES: a wide violet-blue bloom, a cyan body, and a white-hot core down the
// middle. One flat quad with a gradient along its length was a coloured shape; layers that share
// an axis and get brighter toward the centre are what read as sci-fi light.
const layers = [
{ k: 1.00, col: [90, 60, 255], a: 0.30 }, // outer bloom, violet
{ k: 0.62, col: rgb, a: 0.50 }, // the body, in the weapon's blue
{ k: 0.28, col: [130, 235, 255], a: 0.62 }, // inner, cyan
{ k: 0.09, col: [255, 255, 255], a: 0.92 }, // the core
];
ctx.save();
ctx.globalCompositeOperation = 'lighter'; // light ADDS — this is the whole sci-fi trick
for (const L of layers) {
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);
// ...and it fades ALONG its length too, so the far end dissolves into the ads instead of
// stopping at a hard edge
const g = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2,
(c3.x + c4.x) / 2, (c3.y + c4.y) / 2);
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(1, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},0)`);
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();
}
// the erase front: a bright bar racing away down the lane
if (from > 0) {
const wf = wAt(u0);
const f1 = corner(u0, -wf), f2 = corner(u0, wf);
ctx.strokeStyle = `rgba(255,255,255,${0.9 * alpha})`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(f1.x, f1.y); ctx.lineTo(f2.x, f2.y); ctx.stroke();
}
ctx.restore();
ctx.lineWidth = 1;
}
@@ -731,10 +744,6 @@ function drawChar(ch) {
if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash');
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
// Auto-fire: the idle sprite carries a racket at his hip AND drawMgRacket puts one in his hands —
// two rackets. The run frames are the racket-less art, so he jogs on the spot instead.
if (ch.side === 'P' && ch.pose === 'idle' && G.mode === 'splatter' && (G.horde || G.mgT > 0))
name = pre + 'run' + (Math.floor(G.idleT * 3 * runN) % runN + 1);
const img = SPR[name];
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
if (img) {
@@ -1104,15 +1113,6 @@ export function render() {
if (laserAim) { const c = dragContact(); drawBeamWedge(laserAim.v.vx, laserAim.v.vz, c.x, c.y, c.z, 0.34, 0); }
else if (band) drawShotBand(band, bandU0, flying);
// THE LASER's fired wedge, ERASING ALONG ITS OWN PATH: the whole plane is there at the flash and
// then it is wiped away from the racket outward, so the shot reads as the energy travelling down
// the lane rather than as a decal fading out where it lay. Oliver: "the release could erase the
// laserplane along the path."
if (G.beam) {
const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies
drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1, 1 - k);
}
// far-side drawables in z order (far → near), then the net, then the near side
const farSide = [
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, COURT.netZ, UMP_H) },
@@ -1136,6 +1136,13 @@ export function render() {
if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue
nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying });
nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
// THE LASER's fired wedge, ERASING ALONG ITS OWN PATH. Drawn HERE — after the net and after every
// body — because it is light in the air, not a decal on the grass. Drawn before them it went
// UNDER the net and behind the crowd, which is exactly why it read as lying on the floor.
if (G.beam) {
const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies
drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1, 1 - k);
}
if (band && !laserAim) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark
// server idly bouncing the ball while waiting
@@ -1174,6 +1181,22 @@ export function render() {
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
continue;
}
// a ROCKET's exhaust: a soft round puff that fades hot-white -> orange -> grey, drawn additively
// so the plume glows instead of stippling. This is the difference between a motor and a firework.
if (pt.jet) {
const age = Math.min(1, pt.t / 0.6), r = Math.max(1.2, pp.s * 0.028 * (1.8 - age));
ctx.save(); ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = age > 0.72 ? `rgba(255,246,214,${age})`
: age > 0.38 ? `rgba(255,170,60,${age * 0.8})` : `rgba(120,110,110,${age * 0.5})`;
ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill(); ctx.restore();
continue;
}
if (pt.fine) { // the beacon's thread: ONE pixel, so the ball stays the bright thing
ctx.globalAlpha = Math.min(1, pt.t * 4);
ctx.fillStyle = pt.spark; ctx.fillRect(pp.x | 0, pp.y | 0, 1, 1);
ctx.globalAlpha = 1;
continue;
}
ctx.fillStyle = pt.spark ? pt.spark // a thrown round dragging its colour
: pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore
: pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
@@ -1192,12 +1215,18 @@ export function render() {
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04 * sc), Math.max(1, sh.s * 0.02 * sc), 0.25);
const pp = proj(pe.x, pe.y, pe.z), r = Math.max(1, pp.s * 0.05 * sc);
if (pe.glow) { // the exotic rounds carry their own light, the plain pellet does not
ctx.globalAlpha = 0.35; ctx.fillStyle = pe.col || UI.ball;
ctx.beginPath(); ctx.arc(pp.x, pp.y, r * 2.1, 0, 7); ctx.fill();
ctx.globalAlpha = 1;
ctx.save(); ctx.globalCompositeOperation = 'lighter';
for (const [rf, af] of [[2.6, 0.20], [1.6, 0.34]]) { // a real bloom, not one flat disc
ctx.globalAlpha = af * Math.min(1.6, pe.glow);
ctx.fillStyle = pe.col || UI.ball;
ctx.beginPath(); ctx.arc(pp.x, pp.y, r * rf, 0, 7); ctx.fill();
}
ctx.restore(); ctx.globalAlpha = 1;
}
ctx.fillStyle = pe.col || UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill();
ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.5, r * 0.45), 0, 7); ctx.fill(); // hot core: it reads sharp, not soft
// a WHITE-HOT centre, bigger on a glowing round: the ball itself should be the brightest thing
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.6, r * (pe.glow ? 0.62 : 0.45)), 0, 7); ctx.fill();
}
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
if (G.bossBurst) {