diff --git a/assets/_sheet_awound.png b/assets/_sheet_awound.png new file mode 100644 index 0000000..b93b106 Binary files /dev/null and b/assets/_sheet_awound.png differ diff --git a/assets/_sheet_bwound.png b/assets/_sheet_bwound.png new file mode 100644 index 0000000..1e97141 Binary files /dev/null and b/assets/_sheet_bwound.png differ diff --git a/assets/z_aw.png b/assets/z_aw.png new file mode 100644 index 0000000..091ffb6 Binary files /dev/null and b/assets/z_aw.png differ diff --git a/assets/z_bw.png b/assets/z_bw.png new file mode 100644 index 0000000..b60cc56 Binary files /dev/null and b/assets/z_bw.png differ diff --git a/config.js b/config.js index 380cc45..6c4fd27 100644 --- a/config.js +++ b/config.js @@ -45,6 +45,8 @@ export const TUNE = { fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable + shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass + setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde) bruteHp: 2, // splat-quality hits (or pellets) a brute ('g') soaks before it dies shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you diff --git a/gameplay.js b/gameplay.js index ee3bad7..ccae1fd 100644 --- a/gameplay.js +++ b/gameplay.js @@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } fr import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; import { axisX, axisZ, takeFlickAim, touchPX } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies -import { updateZombies, spawnZombie, spawnRacketZombie, sweepCourt, zombies, explode, meleeSwipe, extras, parts } from './zombies.js'; +import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -236,23 +236,47 @@ export function splatPoint(side, quiet = false) { G.tinDue = true; voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan - sweepCourt(); // the wave is OVER: survivors collapse where they stand — a game ends visibly - ball.active = false; // the beat owns the court - G.pendingGame = true; // 'GAME N' announces the next wave when play resumes const i = side === 'P' ? 0 : 1; - if (score.games[i] >= 6) { // set to the winner — and the court answers with a horde - G.sets[i]++; score.games = [0, 0]; - startHorde(); - } else if ((score.games[0] + score.games[1]) % 2 === 1) startChangeover(); // real rule: after odd games - else { G.phase = 'over'; G.overT = TUNE.gameBeatT; } // even games get a real beat too, not a 0.1s blink + if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card + else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games + else G.afterClear = 'game'; // even game: a beat, then the next wave + G.clearWinner = side; if (side === 'P' && !zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it) + // The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before + // play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear() + // fires (see update()). If the winning shot already emptied the court, proceed at once. + if (zombies.some(z => z.variant !== 'r')) { + G.clearing = true; ball.active = false; + G.flash = { text: 'CLEAR THE COURT', t: 1.8 }; + G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up + } else proceedAfterClear(); } else if (!quiet || G.idleT - G.lastScoreT > 2) { voice(scoreKey(), 0.45); G.lastScoreT = G.idleT; if (side === 'P' && !quiet) cheer(0.7, 0.25); } } +// the court is mopped up (or was already clear): play advances to whatever the won game earned — +// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde. +export function proceedAfterClear() { + G.clearing = false; + const mode = G.afterClear, side = G.clearWinner; + G.afterClear = null; + if (mode === 'set') { + G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over) + sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM', + sub2: 'SETS ' + G.sets[0] + ' – ' + G.sets[1], + color: side === 'P' ? '#e6ee3a' : '#ff5050', t: TUNE.setCardT }; + G.pendingHorde = true; // the horde begins when the card's pause ends + ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT; + } else if (mode === 'changeover') { + G.pendingGame = true; startChangeover(); // 'GAME N' after the breather + } else { // 'game': even-game beat, then GAME N announces the next wave + G.pendingGame = true; ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT; + } +} + // changeover (after odd games, as the rules demand): a forced breather — no spawns, // no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe. function startChangeover() { @@ -326,6 +350,7 @@ function startHorde() { G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 }; ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose + P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) voice('remain_seated', 0.3); G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; } @@ -540,6 +565,7 @@ function tryGunBurst() { G.mgT = TUNE.mgPickupLen; G.mgCool = 0; ball.active = false; G.phase = 'rally'; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; + P.z = HOME.P.z; // brace at the baseline for the burst (finger steers x only) G.flash = { text: 'GUN', t: 1.0 }; return true; } @@ -614,6 +640,7 @@ export function update(dt) { return; } if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; } + if (G.card) { G.card.t -= dt; if (G.card.t <= 0) G.card = null; } if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.boomFlash > 0) G.boomFlash -= dt; crowd.cheer = Math.max(0, crowd.cheer - dt); @@ -666,9 +693,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; + // splatter: you START at the baseline but you're free to advance up-court (bounded at the net by + // P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still + // recovers you toward the baseline between shots on touch; the duel is plain tennis as ever. 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) @@ -685,6 +712,7 @@ export function update(dt) { // --- zombies + transition timeline if (G.mode !== 'tennis') updateZombies(dt); + if (G.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance if (G.mode === 'transition') transition(dt); // --- interlude trigger: the court is clear, an opponent holds the far court, you're about @@ -778,7 +806,7 @@ export function update(dt) { if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { ball.active = false; if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel - else { if (!G.horde) splatPoint('O'); spentBall(); } + else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way } // splatter: retire spent or hopeless balls fast so the next serve comes right up. // z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26) @@ -813,6 +841,7 @@ export function update(dt) { if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { + if (G.pendingHorde) { G.pendingHorde = false; startHorde(); return; } // the set card's pause is over — release the horde if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes // auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into // setupPoint() -> pretoss left the player locked in the serve posture while the gun diff --git a/index.html b/index.html index 5b2637d..38ad6a5 100644 --- a/index.html +++ b/index.html @@ -52,6 +52,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 2d950d1..168d8da 100644 --- a/render.js +++ b/render.js @@ -68,7 +68,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' '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_gw','z_s1','z_s2','z_s3', - 'z_cw1','z_cw2', // wounded skins for multi-hit enemies (brute, racket boss) + 'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers) 'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons 'scoreboard']) { const img = new Image(); @@ -153,6 +153,7 @@ function drawZombie(zb) { // 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 ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler 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); @@ -315,6 +316,17 @@ function drawRack() { ctx.restore(); } +// the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel +// and the SET announcement so both read as the same interruption. Assumes textAlign is already set. +function drawCard(title, sub1, sub2, color) { + ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64); + ctx.textAlign = 'center'; + ctx.fillStyle = color; ctx.font = 'bold 18px monospace'; ctx.fillText(title, W / 2, H / 2 - 26); + ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace'; + if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2); + if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12); +} + // ------------------------------------------------------------------ frame ---- export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density @@ -523,14 +535,11 @@ export function render() { ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1); } - if (G.interlude && G.interlude.introT > 0) { // the duel card: a real pause, front and center - ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64); - ctx.fillStyle = '#ff5050'; ctx.font = 'bold 18px monospace'; - ctx.fillText('SUDDEN DEATH', W / 2, H / 2 - 26); - ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace'; - ctx.fillText('ONE POINT · THE LINES ARE LIVE', W / 2, H / 2 - 2); - ctx.fillText('WIN IT FOR THE NEXT TIN', W / 2, H / 2 + 12); - } + // the sudden-death duel card, then (below it in priority) the SET milestone card — same block + if (G.interlude && G.interlude.introT > 0) + drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050'); + else if (G.card && G.card.t > 0) + drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color); if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060'; ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); diff --git a/state.js b/state.js index 9702cde..e32fed9 100644 --- a/state.js +++ b/state.js @@ -52,6 +52,11 @@ export const G = { runGames: 0, // games finished this run (either side) — the difficulty director sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde changeover: false, // the odd-game breather: no spawns, no serving, the boys mop + clearing: false, // a game just ended: the surviving wave stays and must be killed by HAND before play advances (no auto-sweep). No new spawns, kills don't score. + afterClear: null, // what happens once the court is clear: 'changeover' | 'game' | 'set' + clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card) + pendingHorde: false,// a set was won; the horde starts after the set card's pause + card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block' shameGame: false, // they took a game — the next new-balls delivery is withheld best: null, // best run {games, sets}, loaded from localStorage when you get eaten newBest: false, // this run beat the stored best — the eaten summary celebrates it diff --git a/zombies.js b/zombies.js index 71fdf33..025b943 100644 --- a/zombies.js +++ b/zombies.js @@ -22,6 +22,7 @@ export function spawnZombie(x, z, variant) { const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable + if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left zombies.push(zb); @@ -67,22 +68,16 @@ function killZombie(i, quiet) { splat(); sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst sendBoy(zb.x, zb.z); // a boy sprints out to mop - if (!G.horde) splatPoint('P', quiet); // a horde is survival — it doesn't score, so it can't chain into itself + if (!G.horde && !G.clearing) splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores } -// game over: the surviving wave collapses where it stands — no points, no piles, just bodies. -// This is what makes a game END visibly instead of bleeding into the next one. -export function sweepCourt() { - let n = 0; - for (let i = zombies.length - 1; i >= 0; i--) { - const zb = zombies[i]; - if (zb.variant === 'r') continue; // the opponent keeps its residence - zombies.splice(i, 1); n++; - for (let k = 0; k < 6; k++) parts.push({ - x: zb.x, y: 0.5, z: zb.z, t: 0.4, dark: k % 2 === 0, - vx: (Math.random() * 2 - 1) * 1.5, vy: 0.4 + Math.random(), vz: (Math.random() * 2 - 1) * 1.5 }); - } - if (n) splat(0.4); +// a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the +// kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel. +function woundBurst(zb) { + for (let k = 0; k < 5; k++) parts.push({ + x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z, + vx: (Math.random() * 2 - 1) * 1.6, vy: 0.6 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.6, + t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 }); } // swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off) @@ -164,6 +159,7 @@ function bleed(zb) { if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) wounds = [[-0.3, 1.95, 1.1]]; // brute: torn-off arm else if (zb.variant === 'r' && zb.hp <= 1) wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm else if (zb.variant === 'r' && zb.hp === 2) wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm + else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder else return; for (const w of wounds) { if (Math.random() > 0.4) continue; // sparse: most frames this wound emits nothing @@ -186,7 +182,7 @@ export function updateZombies(dt) { if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes } - if (!G.horde && !G.changeover && !G.interlude) { // breathers and duels get a quiet court + if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns) spawnT -= dt; // the spawn director: the match's age (games finished) sets both crowd size and tempo const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames); @@ -199,7 +195,7 @@ export function updateZombies(dt) { for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; - if (!zb) continue; // a kill can cascade into sweepCourt (game won mid-loop) and gut the array under us + if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us zb.walkT += dt; zb.z += zb.v * dt; bleed(zb); // open wounds bleed every frame they're standing @@ -270,6 +266,14 @@ export function updateZombies(dt) { continue; } if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts + if (zb.variant === 'a' || zb.variant === 'b') { // classic shamblers soak a splat-quality wound before they drop + if ((ball.pow || 0) >= TUNE.zSplatQ) { + if (--zb.hp <= 0) { splatter(i); continue; } // the second splat is the killing blow + woundBurst(zb); splat(0.5); pock(0.6, 280); // first splat: a bloody wound, not a death + zb.z -= TUNE.zPushback * 0.5; ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound + } + zb.z -= TUNE.zPushback; pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; // soft ball just staggers + } // chair-down shield zombies (and anything hit from behind) obey the normal rules: if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; } zb.z -= TUNE.zPushback; // weak sitters only stagger them @@ -277,7 +281,7 @@ export function updateZombies(dt) { ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball } } - if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde) splatPoint('O'); } // it reached your baseline + if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) } for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills @@ -288,7 +292,7 @@ export function updateZombies(dt) { const zb = zombies[j]; if (!zb || zb.variant === 'r') continue; if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) { - if (zb.variant === 'g' && --zb.hp > 0) pock(0.5, 260); // the brute soaks pellets too + if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill else killZombie(j, true); hit = true; break; } @@ -308,7 +312,7 @@ export function updateZombies(dt) { if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)) { if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') { - if (zb.variant === 'g' && --zb.hp > 0) splat(0.4); // wounds the brute + if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers else killZombie(j, !!G.horde); } else { zb.z -= TUNE.zPushback; pock(0.5, 300); } dead = true; break; // a side ball is spent on its first touch