Splatter: clear the wave by hand, tougher 2-hit shamblers, set cards, free depth
Feedback batch from a phone playtest: - Winning a game no longer auto-sweeps the court. The survivors stay and must be killed by hand (no new spawns, no scoring during the mop-up); only once the court is clear does play advance to the changeover / next game / set. Replaces sweepCourt() with a G.clearing gate + proceedAfterClear(). "CLEAR THE COURT". - Classic shamblers (a/b) now soak one splat-quality wound before they drop (shamblerHp=2) — swapping to new z_aw/z_bw wound skins that bleed the thin dynamic trickle plus a small impact pop. Only the runner stays one-shot glass; brute/boss unchanged. Ball, pellets and triple side-balls all respect hp. - Winning a SET shows a centered milestone card (a real pause) before the horde; one reusable drawCard() now backs both it and the reworded sudden-death card. - Player is no longer pinned to the baseline: free to advance up-court on keyboard (bounded at the net); touch keeps the baseline (drag is the serve-aim) and the horde/GUN burst re-brace there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
13416ee749
commit
6df0ddb00f
10 files changed
+91
-42
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 997 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -45,6 +45,8 @@ export const TUNE = {
|
|||||||
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
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
|
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
|
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
|
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
|
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
|
meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
|
||||||
|
|||||||
+42
-13
@@ -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 { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
|
||||||
import { axisX, axisZ, takeFlickAim, touchPX } from './input.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 { 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 ----
|
// ------------------------------------------------------------- ballistics ----
|
||||||
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
// 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;
|
G.tinDue = true;
|
||||||
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
|
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
|
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;
|
const i = side === 'P' ? 0 : 1;
|
||||||
if (score.games[i] >= 6) { // set to the winner — and the court answers with a horde
|
if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card
|
||||||
G.sets[i]++; score.games = [0, 0];
|
else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games
|
||||||
startHorde();
|
else G.afterClear = 'game'; // even game: a beat, then the next wave
|
||||||
} else if ((score.games[0] + score.games[1]) % 2 === 1) startChangeover(); // real rule: after odd games
|
G.clearWinner = side;
|
||||||
else { G.phase = 'over'; G.overT = TUNE.gameBeatT; } // even games get a real beat too, not a 0.1s blink
|
|
||||||
if (side === 'P' && !zombies.some(z => z.variant === 'r'))
|
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)
|
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) {
|
} else if (!quiet || G.idleT - G.lastScoreT > 2) {
|
||||||
voice(scoreKey(), 0.45); G.lastScoreT = G.idleT;
|
voice(scoreKey(), 0.45); G.lastScoreT = G.idleT;
|
||||||
if (side === 'P' && !quiet) cheer(0.7, 0.25);
|
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,
|
// 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.
|
// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
|
||||||
function startChangeover() {
|
function startChangeover() {
|
||||||
@@ -326,6 +350,7 @@ function startHorde() {
|
|||||||
G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 };
|
G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 };
|
||||||
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
|
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.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);
|
voice('remain_seated', 0.3);
|
||||||
G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
|
G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
|
||||||
}
|
}
|
||||||
@@ -540,6 +565,7 @@ function tryGunBurst() {
|
|||||||
G.mgT = TUNE.mgPickupLen; G.mgCool = 0;
|
G.mgT = TUNE.mgPickupLen; G.mgCool = 0;
|
||||||
ball.active = false; G.phase = 'rally';
|
ball.active = false; G.phase = 'rally';
|
||||||
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
|
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 };
|
G.flash = { text: 'GUN', t: 1.0 };
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -614,6 +640,7 @@ export function update(dt) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; }
|
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.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||||||
if (G.boomFlash > 0) G.boomFlash -= dt;
|
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||||||
crowd.cheer = Math.max(0, crowd.cheer - 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;
|
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
|
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.
|
// splatter: you START at the baseline but you're free to advance up-court (bounded at the net by
|
||||||
// The duel is exempt: it's tennis again, so depth is yours to chase the rally.
|
// P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still
|
||||||
if (G.mode === 'splatter' && !G.interlude) P.z = HOME.P.z;
|
// recovers you toward the baseline between shots on touch; the duel is plain tennis as ever.
|
||||||
if (P.swingT >= 0) {
|
if (P.swingT >= 0) {
|
||||||
P.swingT += dt;
|
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)
|
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
|
// --- zombies + transition timeline
|
||||||
if (G.mode !== 'tennis') updateZombies(dt);
|
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);
|
if (G.mode === 'transition') transition(dt);
|
||||||
|
|
||||||
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about
|
// --- 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) {
|
if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
|
||||||
ball.active = false;
|
ball.active = false;
|
||||||
if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel
|
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.
|
// 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)
|
// 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') {
|
if (G.phase === 'over') {
|
||||||
G.overT -= dt;
|
G.overT -= dt;
|
||||||
if (G.overT <= 0) {
|
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
|
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
|
// 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
|
// setupPoint() -> pretoss left the player locked in the serve posture while the gun
|
||||||
|
|||||||
+1
-1
@@ -52,6 +52,6 @@
|
|||||||
<p class="start">TAP TO START</p>
|
<p class="start">TAP TO START</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=16"></script>
|
<script type="module" src="main.js?v=17"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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',
|
'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_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_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
|
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
|
||||||
'scoreboard']) {
|
'scoreboard']) {
|
||||||
const img = new Image();
|
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.
|
// zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded.
|
||||||
let name = 'z_' + v + frame;
|
let name = 'z_' + v + frame;
|
||||||
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw';
|
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 <= 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';
|
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);
|
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
|
||||||
@@ -315,6 +316,17 @@ function drawRack() {
|
|||||||
ctx.restore();
|
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 ----
|
// ------------------------------------------------------------------ frame ----
|
||||||
export function render() {
|
export function render() {
|
||||||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
|
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.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
|
||||||
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
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
|
// the sudden-death duel card, then (below it in priority) the SET milestone card — same block
|
||||||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
|
if (G.interlude && G.interlude.introT > 0)
|
||||||
ctx.fillStyle = '#ff5050'; ctx.font = 'bold 18px monospace';
|
drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050');
|
||||||
ctx.fillText('SUDDEN DEATH', W / 2, H / 2 - 26);
|
else if (G.card && G.card.t > 0)
|
||||||
ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace';
|
drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color);
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live
|
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.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060';
|
||||||
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
|
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export const G = {
|
|||||||
runGames: 0, // games finished this run (either side) — the difficulty director
|
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
|
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
|
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
|
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
|
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
|
newBest: false, // this run beat the stored best — the eaten summary celebrates it
|
||||||
|
|||||||
+23
-19
@@ -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,
|
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
|
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 === '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 === '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
|
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
|
||||||
zombies.push(zb);
|
zombies.push(zb);
|
||||||
@@ -67,22 +68,16 @@ function killZombie(i, quiet) {
|
|||||||
splat();
|
splat();
|
||||||
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
|
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
|
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.
|
// a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the
|
||||||
// This is what makes a game END visibly instead of bleeding into the next one.
|
// kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel.
|
||||||
export function sweepCourt() {
|
function woundBurst(zb) {
|
||||||
let n = 0;
|
for (let k = 0; k < 5; k++) parts.push({
|
||||||
for (let i = zombies.length - 1; i >= 0; i--) {
|
x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z,
|
||||||
const zb = zombies[i];
|
vx: (Math.random() * 2 - 1) * 1.6, vy: 0.6 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.6,
|
||||||
if (zb.variant === 'r') continue; // the opponent keeps its residence
|
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off)
|
// 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
|
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 <= 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 === '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;
|
else return;
|
||||||
for (const w of wounds) {
|
for (const w of wounds) {
|
||||||
if (Math.random() > 0.4) continue; // sparse: most frames this wound emits nothing
|
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.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
||||||
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
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;
|
spawnT -= dt;
|
||||||
// the spawn director: the match's age (games finished) sets both crowd size and tempo
|
// 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);
|
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--) {
|
for (let i = zombies.length - 1; i >= 0; i--) {
|
||||||
const zb = zombies[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.walkT += dt;
|
||||||
zb.z += zb.v * dt;
|
zb.z += zb.v * dt;
|
||||||
bleed(zb); // open wounds bleed every frame they're standing
|
bleed(zb); // open wounds bleed every frame they're standing
|
||||||
@@ -270,6 +266,14 @@ export function updateZombies(dt) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts
|
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:
|
// chair-down shield zombies (and anything hit from behind) obey the normal rules:
|
||||||
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
|
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
|
||||||
zb.z -= TUNE.zPushback; // weak sitters only stagger them
|
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
|
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
|
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];
|
const zb = zombies[j];
|
||||||
if (!zb || zb.variant === 'r') continue;
|
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 (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);
|
else killZombie(j, true);
|
||||||
hit = true; break;
|
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 (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
|
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') {
|
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 killZombie(j, !!G.horde);
|
||||||
} else { zb.z -= TUNE.zPushback; pock(0.5, 300); }
|
} else { zb.z -= TUNE.zPushback; pock(0.5, 300); }
|
||||||
dead = true; break; // a side ball is spent on its first touch
|
dead = true; break; // a side ball is spent on its first touch
|
||||||
|
|||||||
Reference in new issue
Block a user