diff --git a/assets/_sheet_machw.png b/assets/_sheet_machw.png new file mode 100644 index 0000000..e0ea2f9 Binary files /dev/null and b/assets/_sheet_machw.png differ diff --git a/assets/z_machw1.png b/assets/z_machw1.png new file mode 100644 index 0000000..ee4093f Binary files /dev/null and b/assets/z_machw1.png differ diff --git a/assets/z_machw2.png b/assets/z_machw2.png new file mode 100644 index 0000000..db15c9b Binary files /dev/null and b/assets/z_machw2.png differ diff --git a/config.js b/config.js index 69af11e..2e8219d 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/index.html b/index.html index ea3b06e..2ee4c9e 100644 --- a/index.html +++ b/index.html @@ -97,6 +97,6 @@

- + diff --git a/render.js b/render.js index 399a5a0..34d3540 100644 --- a/render.js +++ b/render.js @@ -155,7 +155,7 @@ export function proj(x, y, z) { // ---------------------------------------------------------------- sprites ---- // assets/.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); diff --git a/zombies.js b/zombies.js index df920e9..a80eaef 100644 --- a/zombies.js +++ b/zombies.js @@ -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);