Thumb-scale draw meter, looser aim magnet, a bomb worth the tin

- The draw meter is a fixed bar across the bottom, ~90% of the width and 10px
  tall, with the kill notch on it. It was a 26x4 stub floating on the apron by
  the player's feet, which is nothing under a thumb at arm's length. The serve
  hint is suppressed while pulling since they share the slot. pullFull 95 -> 130:
  at 95 every pull maxed out by accident, so the meter never meant anything.
- The aim magnet took the nearest body ALONG the lane, so a close shambler a
  little off-axis stole shots clearly aimed past it. It now picks the best
  ALIGNED body, with TUNE.aimTie of slack in which a nearer one still wins — a
  straight-ahead lane still eats the nearest body first, which is the settled
  feel. aimTie: 1 restores the old behaviour exactly, for A/B.
- Bomb radius 2.5 -> 4.2 (2 kills -> 5 in a scattered ten-wave), a 120-particle
  eruption, a ring of thrown stains and a full-size pile. The blinding white
  frame is now a brief warm bloom — it was hiding the very gore it announced.
- Rack tiles 22 -> 30 tall, and the touch band is padded above and runs to the
  screen edge below. At 22 logical px they were ~30 CSS px on a phone, well under
  the ~44 a thumb expects; now ~62.

26-game soak clean with heavy bomb use, arrays stay bounded. Serve returns
136/150 = 90.7% +/-2.4%, 30 human-tapped points = 50 hits / 0 whiffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-28 09:51:24 +03:00
1 parent 55ddf5932e
commit 21b9dd53fa
7 files changed
+90 -35

No files matched your search

+19
View File
@@ -211,6 +211,25 @@ Oliver rejected the procedural fakes on sight. The rule this round: **animation
The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape); The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape);
serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma. serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma.
## Fourth feedback round — feel polish (2026-07-27)
- **The draw meter is a fixed bar across the bottom** (~90% of W, 10px tall, `METER_Y()` in
render.js), not a 26×4 stub floating on the apron by the player's feet. The serve hint is
suppressed while `G.aim` is live because they share that slot. `TUNE.pullFull` 95 → 130: at 95
every pull maxed out by accident and the meter meant nothing. Don't push much past ~150 — a pull
that STARTS low on the screen still has to be completable before the finger runs off the glass.
- **Aim magnet.** `flickTarget` used to take the nearest body *along* the lane, so a close shambler
a little off-axis stole shots clearly aimed past it. It now picks the best-ALIGNED body
(normalised offset within the cone), with `TUNE.aimTie` (0..1) of slack in which a nearer body
still wins — so a straight-ahead lane still eats the nearest one first, which is the settled feel.
`aimTie: 1` restores the old "nearest always wins" exactly, for A/B.
- **Bomb**: radius 2.5 → 4.2 (2 kills → 5 in a scattered 10-wave), 120-particle eruption, a ring of
thrown stains, size-4 pile. The white-out (`rgba(255,255,255,0.85)`) is now a brief warm bloom
(`rgba(255,150,70,0.3)`) — the flash was hiding the very gore it announced.
- **Rack touch target**: `rackH` 22 → 30 and `rackHit` pads the touch band by `RACK_GRAB` above and
runs to the screen edge below. 22 logical px is ~30 CSS px on a phone, well under the ~44 a thumb
expects; it's now ~62. Rack text is positioned proportionally (`g.h * 0.33` / `0.72`) so the tile
height stays a free knob.
## Gotchas learned the hard way ## Gotchas learned the hard way
- Browser-pane verification: python http.server sends no cache headers, so edited ES - Browser-pane verification: python http.server sends no cache headers, so edited ES
modules often load STALE even after location.reload(). Reliable fix: preview_stop + modules often load STALE even after location.reload(). Reliable fix: preview_stop +
+12 -3
View File
@@ -47,13 +47,20 @@ export const TUNE = {
// reaching a far shambler must not cost a longer drag than the screen has room for. The old // reaching a far shambler must not cost a longer drag than the screen has room for. The old
// mapping (pull length = reach) is why you had to drag off the bottom of the phone. // mapping (pull length = reach) is why you had to drag off the bottom of the phone.
pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent
pullFull: 95, // px of pull for a full-power shot. Keep this well inside one thumb's travel. pullFull: 130, // px of pull for a full-power shot. Keep this well inside one thumb's travel — but
// 95 was so short that EVERY pull maxed out by accident and the meter never
// meant anything. A full draw should cost a deliberate stretch of the thumb.
// Capped short of ~150 because a pull that STARTS low on the screen has to
// still be completable downward before the finger runs off the glass.
pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below
// zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob. // zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob.
aimCone: 0.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE aimCone: 0.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE
// mean something: at 0.45 the cone was ±10 m at the far baseline — the whole // mean something: at 0.45 the cone was ±10 m at the far baseline — the whole
// court — so every downward pull picked the same body and the angle felt inert. // court — so every downward pull picked the same body and the angle felt inert.
// 0.10 gives ±2.9 m out there: you have to actually point at a lane. // 0.10 gives ±2.9 m out there: you have to actually point at a lane.
aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the
// cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the
// lane always wins", which is what made the magnet grab the wrong shambler.
aimReach: 22, // m the aim ray runs when no shambler stands in the lane aimReach: 22, // m the aim ray runs when no shambler stands in the lane
// How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and // How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and
// pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more // pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more
@@ -76,8 +83,10 @@ export const TUNE = {
tinFlames: 3, // flame balls in a fresh tin tinFlames: 3, // flame balls in a fresh tin
tinGuns: 1, // machine-gun charges in a fresh tin tinGuns: 1, // machine-gun charges in a fresh tin
wCap: 9, // per-type cap on the courtside rack — tins accumulate, but only so many wCap: 9, // per-type cap on the courtside rack — tins accumulate, but only so many
rackH: 22, // px height of the bottom weapon rack (feel knob: how much court it covers) rackH: 30, // px height of the bottom weapon rack (feel knob: how much court it covers).
bombRadius: 2.5, // m blast radius of a bomb ball // 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which
// is why switching tins felt fiddly. rackHit also pads the touch band (render.js).
bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater
hordeLen: 12, // s a horde wave lasts hordeLen: 12, // s a horde wave lasts
hordeRate: 2.5, // zombies spawned per second during a horde hordeRate: 2.5, // zombies spawned per second during a horde
mgRate: 6, // machine-gun pellets fired per second during a horde mgRate: 6, // machine-gun pellets fired per second during a horde
+13 -3
View File
@@ -34,13 +34,23 @@ export function flickTarget(ch, aim) { // exported: render draws the live reticl
const q = pullQuality(aim.mag), f = pullFrac(aim.mag); const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z
let zt = null, near = 1e9; let zt = null, near = 1e9, bestErr = 1e9;
for (const zb of zombies) { for (const zb of zombies) {
const dx = zb.x - ch.x, dz = zb.z - ch.z; const dx = zb.x - ch.x, dz = zb.z - ch.z;
const along = dx * ax + dz * az; // how far down the lane it stands const along = dx * ax + dz * az; // how far down the lane it stands
if (along <= 0) continue; // behind the shot if (along <= 0) continue; // behind the shot
if (Math.abs(dx * az - dz * ax) > TUNE.zSnap + TUNE.aimCone * along) continue; // outside the cone const cone = TUNE.zSnap + TUNE.aimCone * along;
if (along < near) { near = along; zt = zb; } // the FIRST one in the lane eats it const err = Math.abs(dx * az - dz * ax) / cone; // 0 = dead on the ray, 1 = the cone's edge
if (err > 1) continue; // outside the cone entirely
// Pick the body you are actually POINTING AT, not merely the nearest one that happens to fall
// inside the cone. "First in the lane wins" let a close shambler off to one side steal a shot
// clearly aimed past it — the magnet Oliver felt. Range only breaks ties between equally
// well-aimed targets (TUNE.aimTie of slack), so a deliberate far shot still beats a sloppy
// near one while a genuine straight-ahead lane still eats the nearest body first.
const tie = TUNE.aimTie;
if (err < bestErr - tie || (Math.abs(err - bestErr) <= tie && along < near)) {
bestErr = Math.min(bestErr, err); near = along; zt = zb;
}
} }
const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it
const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R)); const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R));
+1 -1
View File
@@ -60,6 +60,6 @@
<p class="start">TAP TO START</p> <p class="start">TAP TO START</p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=31"></script> <script type="module" src="main.js?v=34"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -5,7 +5,7 @@
import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint } from './state.js'; import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude } from './gameplay.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js'; import { fitViewport, render, proj, RES } from './render.js';
import { voice, ambient } from './audio.js'; import { voice, ambient } from './audio.js';
import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js'; import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js';
@@ -58,6 +58,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget,
step: dt => update(dt), render, press: keyPressed, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+25 -18
View File
@@ -320,15 +320,20 @@ function drawMgRacket() { // horde: the player braces the racket forward and up-
// the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to // the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to
// pick which one your next serve throws; empty tins dim out. Geometry is shared with input // pick which one your next serve throws; empty tins dim out. Geometry is shared with input
// via rackHit so a tap lands on the tile you see. // via rackHit so a tap lands on the tile you see.
const RACK_BOT = 10; // px of breathing room below the rack (floats it up off the screen edge) const RACK_BOT = 8; // px of breathing room below the rack (floats it up off the screen edge)
const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge)
export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17; // the draw meter's fixed bottom slot
function rackGeom() { function rackGeom() {
const h = TUNE.rackH, n = 5, tw = Math.min(52, (W - 8) / n), rw = tw * n; const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n;
return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT, tw, h, n, rw }; return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT, tw, h, n, rw };
} }
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1 export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1
if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel
const g = rackGeom(), cx = nx * W, cy = ny * H; const g = rackGeom(), cx = nx * W, cy = ny * H;
if (cy < g.y0 || cy > g.y0 + g.h || cx < g.x0 || cx >= g.x0 + g.rw) return -1; // The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to
// the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a
// phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly.
if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1;
return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0)); return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0));
} }
function drawRack() { function drawRack() {
@@ -354,9 +359,9 @@ function drawRack() {
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.globalAlpha = dim ? 0.5 : 1; ctx.globalAlpha = dim ? 0.5 : 1;
ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc'; ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc';
ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + pad + 5); // ball = ∞, else ×count ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a'; ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a';
ctx.fillText(labels[i], tx, g.y0 + g.h - pad - 3); // weapon label, under its count ctx.fillText(labels[i], tx, g.y0 + g.h * 0.72); // weapon label, under its count
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
if (i === 4 && G.mgT > 0) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown if (i === 4 && G.mgT > 0) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown
} }
@@ -521,18 +526,18 @@ export function render() {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
drawRing(tx, tz, col); drawRing(tx, tz, col);
} }
// the draw meter: it fills as you pull and the notch marks the kill line. Anchored on the // The draw meter: it fills as you pull and the notch marks the kill line. A fixed BAR ACROSS
// apron JUST IN FRONT of your feet — over the head it sat right in the lane and the chevrons // THE BOTTOM, ~90% of the width — it used to be a 26x4 px stub floating on the apron by your
// ran straight through it, and down here there is never anything else to collide with. // feet, which is nothing at arm's length with a thumb over it. Fixed position also means it
const hd = proj(P.x, 0, P.z + 1.2); // never wanders behind the chevrons or off the top of the court.
const mw = 26, mh = 4, mx = Math.round(hd.x - mw / 2); const mw = Math.round(W * 0.9), mh = 10, mx = Math.round((W - mw) / 2);
// advanced up-court the apron runs out, so pin the meter clear of the rack and the hint line const my = METER_Y();
const my = Math.min(Math.round(hd.y), H - TUNE.rackH - RACK_BOT - 28); ctx.fillStyle = 'rgba(0,0,0,0.62)'; ctx.fillRect(mx - 2, my - 2, mw + 4, mh + 4);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(mx - 1, my - 1, mw + 2, mh + 2); ctx.fillStyle = 'rgba(255,255,255,0.16)'; ctx.fillRect(mx, my, mw, mh); // empty channel
ctx.fillStyle = col; ctx.fillRect(mx, my, Math.round(mw * ft.f), mh); ctx.fillStyle = col; ctx.fillRect(mx, my, Math.round(mw * ft.f), mh);
const notch = Math.round(mw * (TUNE.zSplatQ - TUNE.pullQMin) / (1 - TUNE.pullQMin)); const notch = Math.round(mw * (TUNE.zSplatQ - TUNE.pullQMin) / (1 - TUNE.pullQMin));
ctx.fillStyle = '#ffffff'; ctx.fillRect(mx + notch, my - 2, 1, mh + 4); // the kill line ctx.fillStyle = '#ffffff'; ctx.fillRect(mx + notch, my - 3, 2, mh + 6); // the kill line
if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the meter flashes if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the whole bar flashes
ctx.fillStyle = '#ffffff'; ctx.fillRect(mx, my, mw, mh); ctx.fillStyle = '#ffffff'; ctx.fillRect(mx, my, mw, mh);
} }
} }
@@ -597,7 +602,9 @@ export function render() {
if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player
ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,255,255,${Math.min(1, G.boomFlash / 0.08) * 0.85})`; ctx.fillRect(0, 0, W, H); } // bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone
// and hid the very gore eruption it was announcing.
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); }
// HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light // HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game // top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
@@ -665,7 +672,7 @@ export function render() {
if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); } if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
ctx.restore(); ctx.restore();
} }
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) { if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim) { // while pulling, the draw meter owns this slot
ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillStyle = UI.warm; ctx.font = F(8);
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH'; const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
@@ -676,7 +683,7 @@ export function render() {
: touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM') : touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
ctx.fillText(txt, W / 2, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses
} }
if (G.flash) { if (G.flash) {
ctx.font = F(12); ctx.fillStyle = '#ffffff'; ctx.font = F(12); ctx.fillStyle = '#ffffff';
+18 -8
View File
@@ -179,18 +179,28 @@ function tickHorde(dt) {
export function explode(x, z) { export function explode(x, z) {
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array
if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i); if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
gore.push({ x, z, r: 1.6, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent gore.push({ x, z, r: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift(); gore.push({ x, z, r: 2.0, a: 0.9 }); if (gore.length > 40) gore.shift();
// a ring of splatter thrown outward, so the blast paints the lawn instead of leaving one puddle
for (let k = 0; k < 6; k++) {
const a = Math.random() * 6.2832, r = TUNE.bombRadius * (0.35 + Math.random() * 0.6);
gore.push({ x: x + Math.cos(a) * r, z: z + Math.sin(a) * r, r: 0.7 + Math.random() * 0.7, a: 0.85 });
if (gore.length > 40) gore.shift();
}
const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6); const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6);
if (pl) pl.size = Math.min(4, pl.size + 3); if (pl) pl.size = Math.min(4, pl.size + 3);
else { piles.push({ x, z, size: 3, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); } else { piles.push({ x, z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); }
for (let k = 0; k < 40; k++) parts.push({ // the eruption itself: a fat, slow, high-arcing column of gore rather than a camera flash
x, y: 0.6, z, vx: (Math.random() * 2 - 1) * 6, vy: 1 + Math.random() * 5, vz: (Math.random() * 2 - 1) * 6, for (let k = 0; k < 120; k++) parts.push({
t: 0.5 + Math.random() * 0.7, dark: k % 2 === 0, x: x + (Math.random() * 2 - 1) * 0.4, y: 0.5, z: z + (Math.random() * 2 - 1) * 0.4,
vx: (Math.random() * 2 - 1) * 11, vy: 2 + Math.random() * 9, vz: (Math.random() * 2 - 1) * 11,
t: 0.7 + Math.random() * 1.1, dark: k % 3 === 0,
}); });
G.hitstop = 0.18; boom(); G.hitstop = 0.18; boom();
G.shake = { t: 0.35, mag: 6 }; // camera kick G.shake = { t: 0.45, mag: 9 }; // camera kick
G.boomFlash = 0.08; // one white frame // a warm, brief bloom instead of a white-out — the blinding frame was doing the work the gore
// should be doing, and on a phone it just hurt (see render.js for the colour)
G.boomFlash = 0.06;
if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure
} }