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
+149 -60

No files matched your search

+38
View File
@@ -320,6 +320,44 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so
every legal ball stays catchable.
## Session log: the second pass on the barrel weapons (2026-08-03)
**The auto-fire jog is gone, and it was one guard.** `gameplay` skipped the pose update in `mgSteer`
mode (`if (!mgSteer && ...) P.pose = moving ? 'run' : 'idle'`), 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 Oliver saw: *"this hectic run animation... looked odd all the time."* The pose
follows MOVEMENT in every mode now and the fake is deleted, so auto-fire (and the horde, and the
melee racket) uses the ordinary run animation and standing still is standing still. The comment that
justified the fake — "the idle sprite has a racket at his hip AND drawMgRacket puts one in his hands,
two rackets" — was solving a smaller problem than it caused; at sprite scale the overlap does not read.
**The laser, second pass.** Four things, all of them "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 precisely why it read as lying on the floor and being *under*
the net. Light in the air goes last.
- **`beamLen` 26 -> 34.** At 26 it stopped a metre past the far baseline and read as being CUT OFF in
mid-court. It now carries into the courtside ads, which is where light should stop.
- **`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 rather than as something emitted.
- **It DELETES ordinary bodies**, hp irrelevant (`killZombie`, not a wound) — 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: "they also must suffer severely" but they survive.
- **The look is four nested wedges composited `lighter`** — violet bloom, the weapon's blue, cyan,
white core — each fading along its length. One quad with a gradient was a coloured shape; layers
sharing an axis and brightening toward the centre is what reads as sci-fi.
**Rocket:** speed 17 -> 38 and a real JET plume. The plume is laid down tight ON the flight line
(`jet`, spread along this frame's step, near-zero velocity, additive hot-white -> orange -> grey)
instead of scattered sparks with their own velocities, which looked like a firework.
**Beacon:** a finer trail (`trail` 1 at `trailR` 0.035, one-pixel motes) and a much brighter ball
(`glow` 3.2, a two-ring additive bloom and a fat white core), so the BALL is the bright thing and the
trail only says where it has been.
**The bug in the middle of all that:** `firePellet` stamps a whitelist of fields onto the round, and
`jet`/`trailR` were added to the WEAPONS rows without being added to that list — so the rocket flew
with no plume at all and nothing errored. **Any new pellet field has to be added in both places.**
## TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the
+12 -8
View File
@@ -240,11 +240,15 @@ export const TUNE = {
testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
testBarrelEvery: 14,// s between barrels, so the pickup itself is testable too
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear
beamW0: 0.35, // m half-width at the racket...
beamW1: 4.2, // ...and at the far end. It is a CONE: the spread is the point of it
beamLen: 34, // m it reaches. Was 26, which stopped at the far baseline and read as the beam
// being CUT OFF in mid-court; the player stands at z~24, so this carries it past
// the baseline and up into the courtside ads, which is where light should stop.
beamW0: 0.06, // m half-width at the racket — a POINT. At 0.35 it left the racket as a 0.7 m
// bar hanging in the air rather than as something emitted from the strings.
beamW1: 5.0, // ...and at the far end. It is a CONE: the spread is the point of it
beamT: 0.42, // s the wedge stays drawn
dmgBeam: 3, // hp the beam takes off anything it catches (dmgBomb is 3, and it reads the same)
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
@@ -460,7 +464,7 @@ export const WEAPONS = {
// A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6,
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 3, glow: 1.6, speed: 19,
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
// "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat
// glowing rounds that throw their own embers, and every one of them lights what it touches.
@@ -478,9 +482,9 @@ export const WEAPONS = {
desc: ['AIM AND RELEASE', 'A WIDENING WALL OF LIGHT'] },
// Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup
// (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on".
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 0.85,
r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, trail: 2, speed: 17,
col: '#ffd050', glow: 1.4,
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 1.1,
r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38,
col: '#ffd050', glow: 1.8,
desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
};
// What a pink barrel can be carrying — the four above, which you cannot win any other way. NOT a
+5 -1
View File
@@ -1204,7 +1204,11 @@ export function update(dt) {
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
const moving = mx || mz || assist !== 0;
P.runT += moving ? dt : 0;
if (!mgSteer && P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
// Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then
// faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic
// run animation" whether he was moving or not. The pose follows MOVEMENT in every mode now, so
// auto-fire uses the ordinary run animation and standing still is standing still.
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
if (mx) P.facing = mx > 0 ? 1 : -1;
else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing
}
+1 -1
View File
@@ -179,6 +179,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=100"></script>
<script type="module" src="main.js?v=101"></script>
</body>
</html>
+67 -38
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})`);
// 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 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();
}
// 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) {
+26 -12
View File
@@ -314,10 +314,11 @@ export function fireBeam(dirX, dirZ) {
const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane
const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen);
if (off > halfW + hitR(zb)) continue;
// THE BEAM DELETES ORDINARY BODIES. hp does not enter into it: a plane of light that left a
// roller man standing was the thing that made it feel like a worse ball rather than a weapon.
// A BOSS is the exception and takes a heavy wound instead — "they also must suffer severely".
if (isBoss(zb)) { if (wound(zb, TUNE.dmgBeam)) killZombie(i, true); }
else if (zb.variant === 'o') killZombie(i, true); // it pops a barrel too, which is fair
else if ((zb.hpMax || 0) > 1 && (zb.hp -= TUNE.dmgBeam) > 0) woundBurst(zb);
else splatter(i);
else killZombie(i, !!G.horde);
}
}
@@ -573,7 +574,7 @@ export function firePellet(w = WEAPONS.gun) {
const pe = { x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s,
t: w.life ?? 1.2, r: w.r ?? TUNE.mgPelletR, dmg: w.dmg ?? TUNE.dmgPellet, hp: w.hp ?? TUNE.dmgPelletHp,
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0,
trail: w.trail || 0, col: w.col, glow: w.glow };
trail: w.trail || 0, trailR: w.trailR, jet: w.jet || 0, col: w.col, glow: w.glow };
// A LOBBED round: it is thrown, not sprayed. Solved so it comes down about where the crowd is, so
// "it throws itself" reads as aiming rather than as a flat stream that happens to be fat.
if (w.arc) {
@@ -952,15 +953,28 @@ export function updateZombies(dt) {
pe.vy -= pe.g * dt; pe.y += pe.vy * dt;
if (pe.y <= 0.05) { pe.y = 0.05; landed = true; }
}
// a colour dragged behind it — the beacon and the rocket are supposed to be seen from across
// the court, and this is what makes a lobbed round read as a THROWN object rather than a dot
if (pe.trail && parts.length < 220)
// A JET PLUME, not a shower of sparks: the exhaust is laid down tight on the flight line,
// barely moving, so it reads as a rocket motor drawing a line through the air. Scattered
// sparks with their own velocities looked like a firework, which is not what a rocket does.
if (pe.jet && parts.length < 240)
for (let k = 0; k < pe.jet; k++) {
const b = (k + Math.random()) / pe.jet; // spread ALONG this frame's step
parts.push({
x: pe.x - pe.vx * dt * b + (Math.random() * 2 - 1) * 0.05,
y: pe.y - (pe.vy || 0) * dt * b + (Math.random() * 2 - 1) * 0.05,
z: pe.z - pe.vz * dt * b + (Math.random() * 2 - 1) * 0.05,
vx: 0, vy: 0.25, vz: 0, // it hangs and lifts, it does not fly off
t: 0.30 + Math.random() * 0.30, jet: 1, spark: '#fff2c0' });
}
// ...and a FINE thread for the beacon: one small mote a frame, tight to the ball, so the ball
// itself is the bright thing and the trail only says where it has been.
if (pe.trail && parts.length < 240)
for (let k = 0; k < pe.trail; k++) parts.push({
x: pe.x + (Math.random() * 2 - 1) * 0.12, y: pe.y + (Math.random() * 2 - 1) * 0.12,
z: pe.z + (Math.random() * 2 - 1) * 0.12,
vx: (Math.random() * 2 - 1) * 0.5, vy: (Math.random() * 2 - 1) * 0.5 + 0.3,
vz: (Math.random() * 2 - 1) * 0.5,
t: 0.18 + Math.random() * 0.22, spark: pe.col || '#fff' });
x: pe.x + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
y: pe.y + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
z: pe.z + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
vx: 0, vy: 0.12, vz: 0,
t: 0.16 + Math.random() * 0.16, fine: 1, spark: pe.col || '#fff' });
for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j];
if (!zb) continue;