diff --git a/HANDOFF.md b/HANDOFF.md index 616b713..cdf6c48 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/config.js b/config.js index c908605..07d2aa0 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index 0b4d893..e924b19 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 } diff --git a/index.html b/index.html index aff33c0..6e25eee 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/render.js b/render.js index 5ec8cb9..00c42c0 100644 --- a/render.js +++ b/render.js @@ -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) { diff --git a/zombies.js b/zombies.js index cebe13d..071f425 100644 --- a/zombies.js +++ b/zombies.js @@ -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;