Burn them to ashes, and give the burst back its bar

A burnt body does not burst. Fire kills by cooking, so there is nothing left to
spray: a body that dies alight -- flamethrower or flame ball -- leaves dark grey
ash instead of blood. Same decal, mound and particles, recoloured and smaller,
with a puff of smoke and a dull thud instead of the wet splat and the headshot
crack. The flame BALL has to mark itself, because that kill lands before the body
ever catches.

The flamethrower is a lighter, not a gun: no impact damage at all, it pierces,
and it only IGNITES -- the burn does the killing, so a lit crowd walks a few
steps and goes down together. 26 m of reach against a 22.8 m court, so it
crosses to the far baseline. The guard matters: hp -= 0 fell straight through to
killZombie, which made the cone delete the crowd on contact.

Every burst weapon had no progress bar and the cause was the spend. Arming spent
the tin at once, so the rack entry vanished -- and the bar draws on the slot
holding G.gunId, which no longer existed. The tin is spent when the burst ENDS
now, so icon, count and bar are all there while it runs, and every path that
ends a burst routes through endBurst or it would hand out free ammo.

Auto-fire movement again: the previous fix read moving as mx||mz||assist, and
finger-steering zeroes mx/mz, so sliding left and right still counted as standing
still. And the oval on his back was the mg racket -- its head projected almost
exactly onto the sprite; offset by facing it reads as held out beside him.

The laser's far edge is pinned in SCREEN space to the middle of the ad strip
rather than running out to beamLen on the ground, which landed on their top line.
beamLen goes back to being the kill range instead of a number tuned to one camera.

Beacon thumps, rocket swooshes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 15:05:56 +02:00
1 parent 91053b4c35
commit 6e88fd5bb9
6 files changed
+126 -28

No files matched your search

+39
View File
@@ -358,6 +358,45 @@ trail only says where it has been.
`jet`/`trailR` were added to the WEAPONS rows without being added to that list — so the rocket flew `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.** with no plume at all and nothing errored. **Any new pellet field has to be added in both places.**
## Session log: fire, ash, and the third laser pass (2026-08-03)
**A BURNT BODY DOES NOT BURST.** Fire kills by cooking, so there is nothing left to spray: a body
that dies while alight — flamethrower or flame ball — leaves **dark grey ash** instead of blood. Same
decal, same mound, same particles, all recoloured and a little smaller, plus a puff of smoke and a
dull thud instead of the wet splat and the headshot crack. `killZombie` reads `zb.burnT > 0 ||
zb.ashes`; **the flame BALL has to set `zb.ashes` itself**, because that kill happens before the body
ever catches. `gore`/`piles`/`parts` all carry an `ash` flag and render off `ASH_COLS`.
**The flamethrower is a lighter, not a gun.** `dmg: 0, hp: 0, pierce: true` — it does NO impact
damage at all and only IGNITES; the burn does the killing, so a lit crowd walks a few steps and goes
down together. Reach `life` 1.15 s x `speed` 23 = **26 m against a 22.8 m court**, so it crosses to
the far baseline. Watch the guard in the pellet loop: `hp -= 0` fell straight through to
`killZombie`, which made the flame cone delete the crowd on contact — a machine gun that happens to
be orange.
**Every burst weapon had no progress bar, and the cause was the spend.** Arming spent the tin
immediately, so the rack entry vanished — and the countdown bar draws on the slot holding `G.gunId`,
which no longer existed. The tin is spent when the burst ENDS now (`endBurst`), so the icon, the
count and the bar are all there while it runs. **Every path that ends a burst must route through
`endBurst`** — the duel, the boss fight and death all force `G.mgT = 0` on their own, and any one of
them missed would hand out free ammo. The soak asserts `gunId` is never set with `mgT` at zero.
**Auto-fire movement, again.** The previous fix set the pose from `moving`, but `moving` was
`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.
**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
a racket held out beside him.
**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
x's keep their perspective, the y is pinned. That holds in both orientations and leaves `beamLen`
free to be the KILL range instead of a number tuned to one camera.
**Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket,
`whoosh`), otherwise the gun's every-other-round tick.
## TEST MODE — triple-tap the scoreboard ## TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the A sandbox for trying new content without playing twenty games to reach it. Three taps on the
+7 -3
View File
@@ -465,12 +465,16 @@ export const WEAPONS = {
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first. // 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, 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: 1, trailR: 0.035, glow: 3.2, speed: 19, r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] }, 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 // "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. // glowing rounds that throw their own embers, and every one of them lights what it touches.
// It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact
// damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the
// killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon.
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30, flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
r: 1.15, dmg: 2, hp: 9, speed: 15, life: 0.7, ignite: true, igniteR: 2.6, r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
trail: 2, col: '#ff8420', glow: 2, spread: 3.1, // life 0.7s x 15 m/s = a ~10 m cone trail: 2, col: '#ff8420', glow: 2, spread: 3.1, sfx: 'flame',
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] }, desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a // The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
// blue plane that widens down the court and cuts through everything standing in it. // blue plane that widens down the court and cuts through everything standing in it.
@@ -483,7 +487,7 @@ export const WEAPONS = {
// Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup // 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". // (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: 1.1, 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, r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38, sfx: 'swoosh',
col: '#ffd050', glow: 1.8, col: '#ffd050', glow: 1.8,
desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] }, desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
}; };
+21 -7
View File
@@ -592,7 +592,7 @@ export function startDuel() {
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins G.weapon = 0; // tennis rules for the duel: plain balls, no tins
G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order. G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration) G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
ball.active = false; setupPoint(); ball.active = false; setupPoint();
@@ -614,7 +614,7 @@ export function startBossFight(chapter) {
if (G.bossFight) return; if (G.bossFight) return;
G.bossFight = chapter; G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; G.clearing = false; G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
ball.active = false; setupPoint(); ball.active = false; setupPoint();
spawnChapterBoss(chapter); spawnChapterBoss(chapter);
zombieDuck(true); // the music drops out for it zombieDuck(true); // the music drops out for it
@@ -934,7 +934,10 @@ function tryArmGun() {
if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false; if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false;
G.gunId = id; G.gunId = id;
G.mgT = w.len ?? TUNE.mgPickupLen; G.mgT = w.len ?? TUNE.mgPickupLen;
spendSlot(G.weapon); // The tin is spent when the burst ENDS, not when it starts. Spending on commit removed the rack
// entry immediately, so the countdown bar — which draws on the slot holding G.gunId — had no slot
// to draw on and every burst weapon ran with no progress bar at all. Oliver spotted it on the
// flamethrower; it was true of all four.
G.mgCool = 0; G.mgCool = 0;
ball.active = false; G.phase = 'rally'; ball.active = false; G.phase = 'rally';
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
@@ -953,6 +956,15 @@ export function serveToss() {
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P); if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
} }
// The burst is over: NOW the tin is spent. Called from updateZombies when mgT runs out.
export function endBurst() {
const id = G.gunId;
G.gunId = null;
if (!id) return;
const slot = [0, 1, 2, 3, 4].find(i => slotId(i) === id);
if (slot !== undefined) spendSlot(slot);
}
// The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction // The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction
// and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim // and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim
// if there is one and otherwise fires straight up-court. // if there is one and otherwise fires straight up-court.
@@ -1168,12 +1180,11 @@ export function update(dt) {
// to what the camera can see (portrait's world bounds are wider than its viewport) // to what the camera can see (portrait's world bounds are wider than its viewport)
const mgMode = G.horde || G.mgT > 0; const mgMode = G.horde || G.mgT > 0;
const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
let mgSteer = false; let mgSteer = false, mgMoving = false;
if (mgMode && touchPX() !== null) { if (mgMode && touchPX() !== null) {
mgSteer = true; mgSteer = true;
const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis)); const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis));
if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; P.runT += dt; P.pose = 'run'; } if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; mgMoving = true; }
else P.pose = 'idle';
P.x = tx; P.x = tx;
} }
const slow = P.swingT >= 0 ? 0.35 : 1; const slow = P.swingT >= 0 ? 0.35 : 1;
@@ -1202,7 +1213,10 @@ export function update(dt) {
const xLim = Math.max(xVis, COURT.W / 2); const xLim = Math.max(xVis, COURT.W / 2);
P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.x = Math.max(-xLim, Math.min(xLim, P.x));
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
const moving = mx || mz || assist !== 0; // mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a
// player sliding left and right under auto-fire counted as standing still and never ran —
// "when he's moving left and right the run animation is not applied".
const moving = mgMoving || mx || mz || assist !== 0;
P.runT += moving ? dt : 0; P.runT += moving ? dt : 0;
// Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then // 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 // faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic
+1 -1
View File
@@ -179,6 +179,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=101"></script> <script type="module" src="main.js?v=102"></script>
</body> </body>
</html> </html>
+26 -4
View File
@@ -599,6 +599,11 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
oz + uz * (u * TUNE.beamLen) + pz * w); oz + uz * (u * TUNE.beamLen) + pz * w);
const u0 = Math.max(0, from); const u0 = Math.max(0, from);
const rgb = RGB(WEAPONS.laser.col); const rgb = RGB(WEAPONS.laser.col);
// WHERE THE FAR EDGE SITS is a screen-space decision, not a world one. Run out to beamLen on the
// ground and it lands on the ads' TOP line; the beam should die at their MIDDLE. Pinning the far
// corners' y here keeps that exact in both orientations and leaves beamLen free to be the KILL
// range rather than a number tuned to one camera.
const farY = proj(0, 0, -6).y - AD_H / 2; // proj(0,0,-6) is the same line the ad strip sits on
// THREE NESTED WEDGES: a wide violet-blue bloom, a cyan body, and a white-hot core down the // 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 // 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. // an axis and get brighter toward the centre are what read as sci-fi light.
@@ -613,6 +618,7 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
for (const L of layers) { for (const L of layers) {
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
// ...and it fades ALONG its length too, so the far end dissolves into the ads instead of // ...and it fades ALONG its length too, so the far end dissolves into the ads instead of
// stopping at a hard edge // stopping at a hard edge
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,
@@ -676,13 +682,18 @@ function mulberry32(a) {
return ((t ^ t >>> 14) >>> 0) / 4294967296; }; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
} }
const PILE_COLS = ['#6e1414', '#8c1212', '#4e0e0e', '#7a2a18']; const PILE_COLS = ['#6e1414', '#8c1212', '#4e0e0e', '#7a2a18'];
// ...and the same mound for something the fire got: cold, dry, grey. A body that burns has nothing
// left to spray, so it should not leave a wet red heap behind it.
const ASH_COLS = ['#4a4744', '#3a3835', '#5c5854', '#2e2c2a'];
function buildBlobs(pl) { function buildBlobs(pl) {
const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5); const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5);
pl.blobs = []; pl.blobs = [];
for (let k = 0; k < n; k++) { for (let k = 0; k < n; k++) {
const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk
pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad, pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad,
lift: rnd(), pr: 1 + rnd() * 2, color: rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0] }); lift: rnd(), pr: 1 + rnd() * 2,
color: pl.ash ? (rnd() < 0.12 ? '#8a8580' : ASH_COLS[(rnd() * 4) | 0]) // a few pale flecks of bone
: (rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0]) });
} }
pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top
pl.builtSize = pl.size; pl.builtSize = pl.size;
@@ -799,8 +810,12 @@ function drawFallbackChar(ch, p, h) { // shown only until sprites load
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 function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style
const hip = proj(P.x + 0.15, 1.05, P.z); // HELD OUT TO ONE SIDE. Straight up-court (P.x, P.z - 1.35) projects to almost exactly where the
const tip = proj(P.x, 0.95, P.z - 1.35); // pointing up-court, where the pellets stream out // 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.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke(); 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.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward
@@ -1072,7 +1087,9 @@ export function render() {
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds) // gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
for (const d of gore) { for (const d of gore) {
const p = proj(d.x, 0, d.z); const p = proj(d.x, 0, d.z);
ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : `rgba(140,18,18,${d.a})`; // craters burn black-brown ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})`
: d.ash ? `rgba(58,56,54,${d.a})` // burnt out: dark grey ash, not blood
: `rgba(140,18,18,${d.a})`; // craters burn black-brown
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill(); ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill();
} }
drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
@@ -1197,6 +1214,11 @@ export function render() {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
continue; continue;
} }
if (pt.ash) { // settling ash from a burnt body
ctx.fillStyle = pt.dark ? 'rgba(46,44,42,0.9)' : 'rgba(92,88,84,0.9)';
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
continue;
}
ctx.fillStyle = pt.spark ? pt.spark // a thrown round dragging its colour 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.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore
: pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010') : pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010')
+32 -13
View File
@@ -5,8 +5,8 @@
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal , dragContact } from './gameplay.js'; import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal , dragContact , endBurst } from './gameplay.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -232,7 +232,7 @@ export function killPlayer() {
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after // the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z }; G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; // everything stops G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops
try { // the run is over — settle it against the best (games survived, sets as tiebreak) try { // the run is over — settle it against the best (games survived, sets as tiebreak)
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 }; const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
G.newBest = G.runGames > b.games; // beat it → the summary crows about it G.newBest = G.runGames > b.games; // beat it → the summary crows about it
@@ -396,24 +396,33 @@ function killZombie(i, quiet) {
} }
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
if (zb.variant === 'B') endBossFight(); // the chapter is over if (zb.variant === 'B') endBossFight(); // the chapter is over
// A BURNT BODY DOES NOT BURST. Fire kills by cooking, so there is nothing left to spray — it goes
// to dark grey ash instead of blood: same decal, same mound, same particles, all recoloured and a
// little smaller, plus a puff of smoke. Marked either by still burning or by `ashes`, which the
// flame ball sets before it kills (that kill happens before the body ever catches).
const ash = zb.burnT > 0 || zb.ashes;
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
for (let k = 0; k < stains; k++) { for (let k = 0; k < stains; k++) {
gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0), gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0),
r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul), a: 0.85 }); r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul) * (ash ? 0.8 : 1), a: ash ? 0.7 : 0.85, ash });
if (gore.length > 40) gore.shift(); if (gore.length > 40) gore.shift();
} }
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
if (pl) pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5)); if (pl) { pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5)); if (!ash) pl.ash = false; }
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0, ash });
if (piles.length > 30) piles.shift(); } if (piles.length > 30) piles.shift(); }
const n = Math.round(30 * mul); // base bumped 24 -> 30, then the multiplier goes to work const n = Math.round((ash ? 18 : 30) * mul); // ash settles rather than sprays: fewer, slower motes
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
x: zb.x, y: 0.9, z: zb.z, x: zb.x, y: 0.9, z: zb.z,
vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5, vx: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), vy: (ash ? 0.6 : 1) + Math.random() * (ash ? 1.6 : 3.5),
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark, vz: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5),
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark, ash,
}); } }); }
if (ash) { for (let k = 0; k < 5; k++) smokePuff(zb.x, 0.4 + Math.random() * 1.1, zb.z, 0.45); pock(0.35, 150); }
else {
splat(); splat();
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
}
sendBoy(zb.x, zb.z, true); // a boy comes out with the mop sendBoy(zb.x, zb.z, true); // a boy comes out with the mop
// The chapter boss deliberately scores NOTHING: its reward is the chapter card and the parked game // The chapter boss deliberately scores NOTHING: its reward is the chapter card and the parked game
// beat resuming. Letting it bank a point meant splatPoint's game-over branch and endBossFight both // beat resuming. Letting it bank a point meant splatPoint's game-over branch and endBossFight both
@@ -591,7 +600,11 @@ export function firePellet(w = WEAPONS.gun) {
// it is running from the corner of your eye. // it is running from the corner of your eye.
if (w.hue) pe.col = HUES[mgTick % HUES.length]; if (w.hue) pe.col = HUES[mgTick % HUES.length];
pellets.push(pe); pellets.push(pe);
if (++mgTick % 2 === 0 || w.arc) pock(0.25, w.boom ? 420 : w.arc ? 900 : w.ignite ? 300 : 1400); // each weapon announces itself: a low THUMP as the beacon lobs, a SWOOSH as a rocket leaves,
// and the gun's rapid tick every other round (at 12+/s every round buzzes the mixer)
if (w.sfx === 'thump') boom(0.3);
else if (w.sfx === 'swoosh') whoosh(0.3);
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
} }
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5']; const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
// the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past // the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past
@@ -730,7 +743,7 @@ export function updateZombies(dt) {
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A // whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
const gw = WEAPONS[G.gunId] || WEAPONS.gun; const gw = WEAPONS[G.gunId] || WEAPONS.gun;
if (G.phase === 'rally' && gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; } if (G.phase === 'rally' && gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes if (G.mgT <= 0) { endBurst(); setupPoint(); } // window over: spend the tin, resume the serve loop
} }
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns) if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns)
spawnT -= dt; spawnT -= dt;
@@ -892,6 +905,7 @@ export function updateZombies(dt) {
continue; continue;
} }
const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z; const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z;
zb.ashes = true; // burnt, not burst — see killZombie
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
continue; continue;
@@ -985,8 +999,13 @@ export function updateZombies(dt) {
// EVERY new weapon comes through here, which is why none of them can silently no-op on a // EVERY new weapon comes through here, which is why none of them can silently no-op on a
// boss the way three of the five once did. // boss the way three of the five once did.
if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself
if (isBoss(zb)) { if (wound(zb, pe.dmg ?? TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); } // A round with no impact damage (the flamethrower) only LIGHTS what it touches — the burn
else if ((zb.hp -= (pe.hp ?? TUNE.dmgPelletHp)) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round // does the killing. Without this guard `hp -= 0` fell straight through to killZombie and the
// flame cone deleted the crowd on contact, which is a machine gun that happens to be orange.
const dmgHp = pe.hp ?? TUNE.dmgPelletHp, dmgBoss = pe.dmg ?? TUNE.dmgPellet;
if (isBoss(zb)) { if (dmgBoss > 0 && wound(zb, dmgBoss, false)) killZombie(j, true); else pock(0.45, 240); }
else if (dmgHp <= 0) { /* fire only: it is already alight from the ignite above */ }
else if ((zb.hp -= dmgHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
else killZombie(j, true); else killZombie(j, true);
if (pe.boom) { explode(pe.x, pe.z); hit = true; break; } // the rocket takes the crowd, not the body if (pe.boom) { explode(pe.x, pe.z); hit = true; break; } // the rocket takes the crowd, not the body
hit = !pe.pierce; // a laser keeps going down the whole lane hit = !pe.pierce; // a laser keeps going down the whole lane