diff --git a/assets/_sheet_cwound1.png b/assets/_sheet_cwound1.png new file mode 100644 index 0000000..d215102 Binary files /dev/null and b/assets/_sheet_cwound1.png differ diff --git a/assets/_sheet_cwound2.png b/assets/_sheet_cwound2.png new file mode 100644 index 0000000..6c9bfa3 Binary files /dev/null and b/assets/_sheet_cwound2.png differ diff --git a/assets/_sheet_gwound.png b/assets/_sheet_gwound.png new file mode 100644 index 0000000..89a0486 Binary files /dev/null and b/assets/_sheet_gwound.png differ diff --git a/assets/z_cw1.png b/assets/z_cw1.png new file mode 100644 index 0000000..5ed470e Binary files /dev/null and b/assets/z_cw1.png differ diff --git a/assets/z_cw2.png b/assets/z_cw2.png new file mode 100644 index 0000000..2f73b56 Binary files /dev/null and b/assets/z_cw2.png differ diff --git a/assets/z_gw.png b/assets/z_gw.png new file mode 100644 index 0000000..d485333 Binary files /dev/null and b/assets/z_gw.png differ diff --git a/gameplay.js b/gameplay.js index c58f031..58969c0 100644 --- a/gameplay.js +++ b/gameplay.js @@ -66,7 +66,7 @@ function doHit(ch, quality, aim) { const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); let tx, tz; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; - const ft = G.mode !== 'tennis' && ch.side === 'P' + const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null; if (ft && ft.zt) { const zt = ft.zt; @@ -124,7 +124,7 @@ function hitServe(sv, quality, aim = null) { const toFar = sv.side === 'P'; const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; let boxX, tzs; - const ft = G.mode !== 'tennis' + const ft = G.mode !== 'tennis' && !G.interlude // the duel serves like tennis: to the box, not free-aim ? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null; if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie const zt = ft.zt; @@ -547,9 +547,12 @@ function tryGunBurst() { // release of the two-stage aim: an instant serve to the reticle (quality fixed — placement // is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) export function dragServe(aim) { - if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return; - if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) return; + if (G.mode !== 'splatter' || score.server !== 'P') return; + if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle + if (G.phase !== 'pretoss' && G.phase !== 'rally') return; // rally too: fire again while a ball's still up if (tryGunBurst()) return; + if (ball.active) // rapid fire: the ball in flight keeps going as a ballistic clone so the next serve can launch now + extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5 }); ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; hitServe(P, 0.62, aim); @@ -564,18 +567,16 @@ export function keyPressed(code) { if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } + // splatter rapid serve: a flick fires an aimed quick serve — in pretoss OR mid-rally (dragServe + // banishes any live ball so the next launches now). The duel is exempt: it serves like tennis. + if (G.mode === 'splatter' && !G.interlude && score.server === 'P' + && (G.phase === 'pretoss' || G.phase === 'rally')) { + const flick = takeFlickAim(); + if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the live-ball banish + } if (G.phase === 'pretoss' && score.server === 'P') { if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball - if (G.mode === 'splatter') { - const flick = takeFlickAim(); - if (flick) { // flick = a quick aimed serve; a plain tap still does the full toss ritual - ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; - ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; - hitServe(P, 0.62, flick); - return; - } - } - tossBall(P); return; + tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel) } if (G.phase === 'toss' && score.server === 'P') { // serve timing: distance from apex = quality — a real serve, not a free tap @@ -585,7 +586,7 @@ export function keyPressed(code) { if (dy < 0.12) q = 1; G.wasServe = true; // a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special - const flick = G.mode === 'splatter' ? takeFlickAim() : null; + const flick = G.mode === 'splatter' && !G.interlude ? takeFlickAim() : null; const kb = { ax: axisX(), az: axisZ() }; hitServe(P, q, flick || (kb.ax || kb.az ? kb : null)); return; @@ -658,6 +659,9 @@ export function update(dt) { if (mx) P.facing = mx > 0 ? 1 : -1; else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing } + // splatter: you hold the baseline (a turret) — only side-to-side, no drifting up-court. + // The duel is exempt: it's tennis again, so depth is yours to chase the rally. + if (G.mode === 'splatter' && !G.interlude) P.z = HOME.P.z; if (P.swingT >= 0) { P.swingT += dt; if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame) diff --git a/input.js b/input.js index 22639ac..4d908f4 100644 --- a/input.js +++ b/input.js @@ -42,8 +42,9 @@ export function takeFlickAim(maxAgeMs = 450) { // the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle // (G.aim, drawn by render); release fires the serve there, dragging back home cancels. const canAimServe = () => - G.mode === 'splatter' && G.phase === 'pretoss' && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle - && !G.horde && !(G.mgT > 0) && !G.changeover && !(G.interlude && G.interlude.introT > 0); + G.mode === 'splatter' && (G.phase === 'pretoss' || G.phase === 'rally') // rally too: rapid serves mid-flight + && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle + && !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it export const touchPX = () => steerPX; diff --git a/render.js b/render.js index 31e1598..2d950d1 100644 --- a/render.js +++ b/render.js @@ -67,7 +67,8 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_ads', 'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2', - 'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_s1','z_s2','z_s3', + 'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3', + 'z_cw1','z_cw2', // wounded skins for multi-hit enemies (brute, racket boss) 'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons 'scoreboard']) { const img = new Image(); @@ -146,8 +147,15 @@ function drawZombie(zb) { // same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up let frame = rk && zb.pose === 'swing' ? 2 : Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1; if (zb.variant === 's' && zb.shieldT > 0) frame = 3; // chair dropped — hit it NOW - const h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd - drawStatic('z_' + v + frame, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2); + let h = zb.variant === 'g' ? 2.7 : rk ? 1.85 : 1.8; // the brute towers half again over the crowd + // wounded skins: multi-hit enemies show their damage as hp drops (arm off → head gone, still + // shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in + // zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded. + let name = 'z_' + v + frame; + if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw'; + else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do + else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1'; + drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2); if (rk) { // HP bar floating above the mini-boss const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2; ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3); @@ -377,7 +385,7 @@ export function render() { } if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048'); // two-stage serve aim: the reticle tracks the thumb; red = the magnet has a zombie locked - if (G.aim && G.mode === 'splatter' && G.phase === 'pretoss') { + if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'rally')) { const ft = flickTarget(P, G.aim); if (ft.zt) { // locked: the ring rides the zombie itself, plus a marker over its head drawRing(ft.zt.x, ft.zt.z, '#ff5050'); diff --git a/zombies.js b/zombies.js index efb5612..e1e07ba 100644 --- a/zombies.js +++ b/zombies.js @@ -155,6 +155,23 @@ export function explode(x, z) { if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure } +// wounded zombies gush blood — continuously and absurdly — from every open wound. Anchors are +// approximate body-height offsets (drawStatic never mirrors, so a fixed side stays put); the +// parts loop's gravity arcs each spurt into a spray. Bounded so the geyser self-regulates. +function bleed(zb) { + if (parts.length > 520) return; // absurd, but not unbounded + let wounds; + if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) wounds = [[-0.3, 1.95, 3.2]]; // brute: torn-off arm + else if (zb.variant === 'r' && zb.hp <= 1) wounds = [[0.05, 1.5, 6.5], [0.4, 1.15, 2.6]]; // headless: neck geyser + torn arm + else if (zb.variant === 'r' && zb.hp === 2) wounds = [[0.4, 1.2, 3]]; // racket: torn-off arm + else return; + for (const w of wounds) + for (let k = 0; k < 4; k++) parts.push({ + x: zb.x + w[0] + (Math.random() * 2 - 1) * 0.12, y: w[1] + (Math.random() * 2 - 1) * 0.1, z: zb.z + (Math.random() * 2 - 1) * 0.12, + vx: (Math.random() * 2 - 1) * 1.9, vy: w[2] * (0.5 + Math.random() * 0.7), vz: (Math.random() * 2 - 1) * 1.4, + t: 0.5 + Math.random() * 0.6, dark: Math.random() < 0.45 }); +} + export function updateZombies(dt) { moanT -= dt; if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; } @@ -183,6 +200,8 @@ export function updateZombies(dt) { if (!zb) continue; // a kill can cascade into sweepCourt (game won mid-loop) and gut the array under us zb.walkT += dt; zb.z += zb.v * dt; + bleed(zb); // open wounds bleed every frame they're standing + if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out if (zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ } else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column