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
- +