Make the pink weapons throw themselves, and let the big bodies soak

Oliver's playtest of the barrel weapons. Four notes, all four real defects.

"The bigger guys were way too easy to kill at the beginning." A bug, and an old
one. The ball-vs-body chain handled boss, g/m, s, f and a/b, and everything NOT
named in it fell through to "splat-quality kills outright" -- so the STEWARD has
died to one serve since the day he shipped with stewardHp 5 and a comment calling
it a brute's worth of hp, and the three new bodies inherited that the moment they
existed. Pellets and melee always read hp; only the ball skipped it. One generic
hpMax > 1 branch now, and measured after: shambler 2, steward 5, brute 5, ball
boy 3, bouncer 5, roller man 7 -- each exactly its hp.

"I have seen all of them by round 10." The new three move 3/8/15 -> 5/12/20. The
original four keep their gates: that curve was already tuned through game 12.

"The laser fired only when I tapped, that was confusing." The whole tap-per-round
kind is gone. A barrel weapon is now either the normal ball gesture made
spectacular or something that fires itself -- nothing waits for a tap.
 - laser is a serve, and the RAY is the weapon: no ball anywhere in it. The
   preview IS the wedge, it leaves the RACKET rather than lying on the grass, the
   release erases the plane along its own path behind a bright front, and the
   edges are hairlines instead of the heaviest lines on the court.
 - beacon and rocket are LOBBED bursts -- `arc` turns a flat pellet into a thrown
   one, solved onto the nearest body ahead, and landing is itself a hit so a
   rocket that misses still goes off where it lands. The beacon changes colour
   every ball and drags that colour behind it.
 - flamer got "way more pronounced": 30 rounds/s, a ~10 m cone, embers.

"I got the laser almost every time." A flat 1-in-4 over four items does not feel
uniform. BARREL_WEAPONS carries weight and a `from` gate now, never repeats twice
running, and the beacon is what you meet first. Measured: laser 16-28%, rocket
unlocked at game 12 so it is genuinely a late find.

And the barrel keeps its pink halo -- that was always procedural, painted by
drawBarrel, and never went near the chroma key. Three rings and a pool on the
lawn now, because one flat disc read as a smudge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 14:13:05 +02:00
1 parent 25f915f236
commit 282f5145a9
8 files changed
+363 -98

No files matched your search

+86 -11
View File
@@ -3,7 +3,7 @@
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS } from './config.js';
import { P, O, ball, score, crowd, G, court } from './state.js';
import { predictLanding, previewServe , slotId, slotStock } from './gameplay.js';
import { predictLanding, previewServe , slotId, slotStock , dragContact } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
// ---------------------------------------------------------------- palette ----
@@ -336,9 +336,20 @@ function drawBarrel(zb) {
const p = proj(zb.x, h * 0.5, zb.z);
const k = 0.55 + 0.45 * Math.sin((zb.roll || 0) * 3.1); // the pulse rides the roll, not a clock
ctx.save();
ctx.globalAlpha = 0.20 + k * 0.30; // the halo
ctx.fillStyle = UI.pink;
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(3, p.s * 0.30 * (0.8 + k * 0.4)), 0, 7); ctx.fill();
// THE PINK IS ALL HERE, and it can be as pink as it likes: this is light painted at draw time and
// it never goes near the chroma key that eats magenta out of PNGs. Three rings instead of one —
// a wide soft bloom, a bright body, and a white-hot core — because one flat disc at 20 px tall
// read as a smudge on the grass rather than as something GLOWING on a dark court.
const rgb = RGB(UI.pink);
for (const [rf, af] of [[0.62, 0.16], [0.34, 0.34], [0.17, 0.55]]) {
ctx.globalAlpha = af * (0.55 + k * 0.65);
ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), Math.max(2, p.s * rf * (0.85 + k * 0.35)), 0, 7); ctx.fill();
}
// ...and a pink pool on the LAWN under it, which is what actually sells "this thing is lit up"
const gp = proj(zb.x, 0, zb.z);
ctx.globalAlpha = 0.16 + k * 0.20;
ctx.beginPath(); ctx.ellipse(gp.x, gp.y, Math.max(3, gp.s * 0.55), Math.max(1.5, gp.s * 0.20), 0, 0, 7); ctx.fill();
ctx.globalAlpha = 1;
ctx.restore();
if (img) {
@@ -519,7 +530,12 @@ function drawShotBand(a, u0, flying) {
if (left <= 0.01) return;
// cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to
// see where you're pointing, you just need to know it won't kill.
const hex = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
// The LASER re-paints the band it is about to become: blue, and widening down the court instead
// of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the
// band itself to communicate the shape of the shot.
const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0;
const hex = beamW ? WEAPONS[slotId(G.weapon)].col
: a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
// the slice of arc to draw: everything while aiming, a short streak behind the ball in flight
const lo = flying ? Math.max(0, u0 - TAIL) : 0;
const hi = flying ? u0 : 1;
@@ -554,14 +570,57 @@ function drawShotBand(a, u0, flying) {
let prev = proj(head.x, head.y, head.z);
for (let i = 1; i <= N; i++) {
const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z);
ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay;
ctx.lineWidth = Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1));
ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay * (beamW ? 1.25 : 1);
// a normal band is brightest at the racket and thins away; the beam does the OPPOSITE, opening
// out toward the far end into the plane it is going to cut
ctx.lineWidth = beamW
? Math.max(1.5, p.s * (0.05 + 0.42 * (uAt(i))))
: Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1));
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
prev = p;
}
ctx.globalAlpha = 1; ctx.lineWidth = 1;
}
// The laser's plane on the ground, shared by the aim PREVIEW and the fired shot. `alpha` dims the
// whole thing (the preview is faint, the shot is not) and `from` is the erase front: 0 draws the
// whole wedge, 0.5 draws only its far half, 1 draws nothing. Drawn under the bodies, so the ones
// standing in it are lit from beneath instead of being covered by it.
function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
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);
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 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();
}
ctx.lineWidth = 1;
}
// The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the
// rest of the band (which happens before the sprite pass, so a nearer body correctly occludes the
// arc) hid it completely behind the very thing it was marking. That is why it was never once seen.
@@ -1037,7 +1096,22 @@ export function render() {
? (G.aim ? previewServe(G.aim) : G.trail) : null;
const flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent
const bandU0 = flying ? band.t / band.T : 0;
if (band) drawShotBand(band, bandU0, flying);
// THE LASER PREVIEWS ITSELF. The ray IS the weapon, so an arcing ball-band (and the little ring it
// puts on a body) is describing a projectile that never exists. While the laser is armed and you
// are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just
// not yet lit.
const laserAim = G.aim && WEAPONS[slotId(G.weapon)]?.beam && !flying ? band : null;
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 = [
@@ -1062,7 +1136,7 @@ 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());
if (band) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark
if (band && !laserAim) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark
// server idly bouncing the ball while waiting
if (G.phase === 'pretoss') {
@@ -1100,7 +1174,8 @@ export function render() {
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
continue;
}
ctx.fillStyle = pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore
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')
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
@@ -1251,7 +1326,7 @@ export function render() {
const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN';
// "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`,
// which is meaningless once the slots hold whatever you gathered
const gunLike = selW && (selW.kind === 'burst' || selW.kind === 'shot');
const gunLike = selW && selW.kind === 'burst'; // the only kind you UNLOAD; a laser is served
// The DUEL is played under tennis rules — no slingshot, no rack, serve to the box (see the
// !G.interlude guards in solveShot and the aim reticle). It was still being told to pull back
// to aim, which is the one thing that does nothing there.