Let the ball machine roll, dent, and blow up

It was furniture in the most literal sense: bolted to one spot, three hits from
scrap, and when it died it bled and a ball boy came out to mop it.

- It trundles to a new spot every seven seconds or so, and CANNOT fire while it
  rolls. That is honest (a rolling cart cannot aim) and it is the window you use
  to close on it — but it also means it stops being a landmark you can ignore.
- Six hits, not three, and it wears them: dented at two thirds, split open and
  sparking at one third, smoking where it stands once it is that far gone.
- It comes apart as MACHINERY. No blood decal, no gore mound, no mop boy — a
  flash, sparks, torn metal and a column of dark smoke over a scorch mark. Smoke
  is a new particle flavour that rises and spreads instead of falling.

Damaged sheets keep the healthy cart's footprint: the generator drew the smoke
plume into the sprite, which would have shrunk the cart as it took damage, so
the plume is cropped off and the smoke is drawn procedurally where it can move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-31 20:05:44 +02:00
1 parent 1168e9ee5b
commit 661ad90be5
7 files changed
+70 -11

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+6 -1
View File
@@ -62,7 +62,12 @@ export const TUNE = {
// deal with, which is the target-priority decision the game was missing.
machEvery: 4, // games before the first machine can appear, and games between them after
machMax: 2, // machines allowed on court at once
machHp: 3, // splat-quality hits to wreck one
machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now
// shows the damage on the way down (z_machw1 dented, z_machw2 split open)
machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is
// both honest (a rolling cart cannot aim) and the window you use to close on it
machRepo: 7, // s between repositions, jittered. "Furniture that occasionally wheels itself
// somewhere else" — it stops being a landmark you can ignore once it is aimed away
machCycle: 3.4, // s between its shots
machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek()
machSpeed: 13, // m/s it fires at
+1 -1
View File
@@ -97,6 +97,6 @@
<p id="build"></p>
</div>
</div>
<script type="module" src="main.js?v=60"></script>
<script type="module" src="main.js?v=61"></script>
</body>
</html>
+15 -4
View File
@@ -155,7 +155,7 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 55; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 56; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2',
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
@@ -165,7 +165,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
'z_mach1','z_mach2', // the ball machine: idle, and firing with a muzzle flash
'z_mach1','z_mach2','z_machw1','z_machw2', // the ball machine: idle, firing, dented, split open
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 5, ...)
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...)
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
@@ -271,7 +271,12 @@ const ZFRAMES = { a: 4, b: 4, f: 4, d: 4, g: 4 }; // 4-frame walk cycles where s
// player has to recognise and prioritise.
function drawMachine(zb) {
const firing = zb.windT > 0;
const name = firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1';
// It wears its damage like every other multi-hit body on court: dented at two thirds, split open
// and sparking at one. The healthy sheet keeps the muzzle-flash frame; a damaged machine flashes
// procedurally instead of needing a firing frame per stage.
const f = zb.hp / (zb.hpMax || TUNE.machHp);
const dmg = f <= 0.34 ? 'z_machw2' : f <= 0.67 ? 'z_machw1' : null;
const name = dmg && SPR[dmg] ? dmg : firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1';
const img = SPR[name];
if (!img) return; // sheet not loaded yet: draw nothing, not a box
const h = MACH_H * (img.height / SPRITE_H);
@@ -846,9 +851,15 @@ export function render() {
ctx.fillStyle = UI.ball;
ctx.beginPath(); ctx.arc(Math.round(sp.x), Math.round(sp.y), r, 0, 7); ctx.fill();
}
// splatter particles (fire embers ride the same array)
// splatter particles (fire embers and machine smoke ride the same array)
for (const pt of parts) {
const pp = proj(pt.x, pt.y, pt.z);
if (pt.smoke) { // a broken machine's smoke: dark, soft-edged, growing and thinning as it climbs
const age = Math.min(1, pt.t / 1.8), r = Math.max(2, pp.s * 0.09 * (2 - age));
ctx.fillStyle = `rgba(46,44,48,${0.62 * age})`;
ctx.beginPath(); ctx.arc(pp.x | 0, pp.y | 0, r, 0, 7); ctx.fill();
continue;
}
ctx.fillStyle = 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);
+48 -5
View File
@@ -40,8 +40,9 @@ export function spawnZombie(x, z, variant) {
if (zb.hp) zb.hpMax = zb.hp;
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
if ('fgsd'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It does not walk at all.
zb.v = 0; zb.hp = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES...
zb.v = 0; zb.hp = zb.hpMax = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
zb.repoT = TUNE.machRepo * (0.4 + Math.random()); // ...but it wheels itself somewhere else now and then
}
zombies.push(zb);
}
@@ -159,10 +160,36 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3, m: 1.5, B: 6 }; // a chapter boss comes apart like nothing else on the court
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
function smokePuff(x, y, z, spread = 0.5) {
parts.push({ x: x + (Math.random() * 2 - 1) * spread, y, z: z + (Math.random() * 2 - 1) * spread * 0.5,
vx: (Math.random() * 2 - 1) * 0.35, vy: 0.5 + Math.random() * 0.5, vz: (Math.random() * 2 - 1) * 0.35,
t: 0.9 + Math.random() * 0.9, smoke: true });
}
// The machine is the one thing on court that is not made of meat, and it used to burst like a body:
// blood decal, gore mound, the wet splat, a ball boy sent out to mop it. It comes apart as MACHINERY
// now — a flash, sparks, torn metal, and a column of dark smoke over a scorch mark.
function wreckMachine(zb) {
boom(); G.shake = { t: 0.3, mag: 5 }; G.boomFlash = 0.09;
gore.push({ x: zb.x, z: zb.z, r: 1.1, a: 0.95, scorch: true }); // burnt clay, not blood
if (gore.length > 40) gore.shift();
for (let k = 0; k < 26; k++) parts.push({ // sparks and burning debris
x: zb.x, y: 0.8, z: zb.z,
vx: (Math.random() * 2 - 1) * 4, vy: 1.5 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4,
t: 0.3 + Math.random() * 0.5, fire: true });
for (let k = 0; k < 26; k++) smokePuff(zb.x, 0.5 + Math.random() * 1.6, zb.z, 0.7); // a column, not a puff
}
function killZombie(i, quiet) {
const zb = zombies[i];
const mul = GORE_MUL[zb.variant] || 1;
zombies.splice(i, 1);
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
wreckMachine(zb);
if (!G.horde && !G.clearing) splatPoint('P', quiet);
return;
}
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
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
@@ -487,7 +514,8 @@ export function updateZombies(dt) {
if (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool)
// ---- the two RANGED attackers. Both wind up visibly before firing (TUNE.machWind / drawZombie's
// charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha.
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter') {
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter'
&& zb.rollTo === undefined) { // a cart on the roll cannot aim — that is the window to close on it
const boss = zb.variant === 'B';
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
if ((zb.salvoT -= dt) <= 0) {
@@ -506,7 +534,20 @@ export function updateZombies(dt) {
pock(0.3, 1500); // the tell: a mechanical click as it loads
}
}
if (held || zb.variant === 'r' || zb.variant === 'm') { /* boss AI owns its x; the machine is bolted down */ }
if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
if (!held && G.mode === 'splatter' && !G.interlude) {
if (zb.rollTo !== undefined) {
const d = zb.rollTo - zb.x;
if (Math.abs(d) < 0.06) { zb.rollTo = undefined; zb.repoT = TUNE.machRepo * (0.7 + Math.random() * 0.6); }
else {
zb.x += Math.sign(d) * Math.min(Math.abs(d), TUNE.machRoll * dt);
if ((zb.rollT -= dt) <= 0) { pock(0.16, 150); zb.rollT = 0.4; } // castors grinding on the clay
}
} else if ((zb.repoT -= dt) <= 0) { zb.rollTo = (Math.random() * 2 - 1) * 3.6; zb.rollT = 0; }
}
if (zb.hp < zb.hpMax * 0.5 && Math.random() < dt * 6) smokePuff(zb.x, 1.1, zb.z, 0.25); // wrecked: it smokes where it stands
}
else if (held || zb.variant === 'r') { /* boss AI owns its x; a duelling opponent owns his */ }
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
const tx = P.x + zb.homeOff;
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
@@ -667,7 +708,9 @@ export function updateZombies(dt) {
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.t -= dt; p.vy -= 14 * dt;
p.t -= dt;
if (p.smoke) { p.vy = 0.55; p.vx *= 0.97; p.vz *= 0.97; } // smoke rises and spreads; it does not fall
else p.vy -= 14 * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
if (p.y < 0) { p.y = 0; p.vy = 0; p.vx *= 0.5; p.vz *= 0.5; }
if (p.t <= 0) parts.splice(i, 1);