Phase 4: bomb balls
Ball boys deliver a bomb ball every TUNE.bombEvery (8) splatter kills, up to TUNE.bombMax (3) in stock (G.bombs / G.killMeter in state). The next serve auto-consumes one (hitServe, splatter-only) — no extra input, keeping one-handed play. A bomb detonates on first zombie contact (regardless of pace) or, if it misses everyone, on its first bounce: explode() splatters everything within TUNE.bombRadius (2.5m), each kill feeding ball.kills so the Phase-2 fanfare escalates naturally, and leaves a crater — big stain, size-3 pile, 40 particles, 0.1s hitstop, boom(). New synth boom() in audio.js. Bomb ball renders black with a flickering orange fuse; the HUD shows stocked bombs as black/orange pips by the YOU line. Both explode sites set phase='over' so the serve loop doesn't stall on the killed ball. Alternative delivery ideas Oliver floated but not built: umpire throws it, or court pickup. Verified via window.T: 8th kill earns a bomb (meter resets); a serve consumes one and renders #111 black + orange fuse; a pow-0 bomb amid 4 clustered zombies kills all 4 with 'MASSACRE ×4', boom, hitstop 0.1, crater stain (r1.4) + size-4 pile; a bomb that misses still craters on first bounce; bombs never leak into tennis (mode guard); 3 HUD pips; no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad4c8041b9
commit
06025bdccf
6 files changed
+67
-5
No files matched your search
@@ -100,6 +100,22 @@ export function splat(vol = 0.7) { // wet burst — a zombie meets a first serve
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filtered noise blast
|
||||||
|
try {
|
||||||
|
const ac = audio(), t = ac.currentTime;
|
||||||
|
const o = ac.createOscillator(), og = ac.createGain(); // 80Hz sine diving to 30Hz
|
||||||
|
o.frequency.setValueAtTime(80, t); o.frequency.exponentialRampToValueAtTime(30, t + 0.35);
|
||||||
|
og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
||||||
|
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.42);
|
||||||
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.4); // the blast
|
||||||
|
const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
|
||||||
|
lp.frequency.setValueAtTime(2200, t); lp.frequency.exponentialRampToValueAtTime(200, t + 0.35);
|
||||||
|
const g = ac.createGain();
|
||||||
|
g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
||||||
|
src.connect(lp).connect(g).connect(ac.destination); src.start(t);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
export function moan(vol = 0.1) { // distant undead ambience
|
export function moan(vol = 0.1) { // distant undead ambience
|
||||||
try {
|
try {
|
||||||
const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8;
|
const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8;
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export const TUNE = {
|
|||||||
zPushback: 1.4, // m a staggered zombie is knocked back
|
zPushback: 1.4, // m a staggered zombie is knocked back
|
||||||
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
|
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
|
||||||
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
|
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
|
||||||
|
bombEvery: 8, // splatter kills between bomb-ball rewards
|
||||||
|
bombMax: 3, // bomb balls you can stock at once
|
||||||
|
bombRadius: 2.5, // m blast radius of a bomb ball
|
||||||
missHud: 1, // show the last miss reason on screen (full log: T.G.missLog)
|
missHud: 1, // show the last miss reason on screen (full log: T.G.missLog)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DE
|
|||||||
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
|
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
|
||||||
import { audio, pock, swish, applause, voice } from './audio.js';
|
import { audio, pock, swish, applause, voice } from './audio.js';
|
||||||
import { axisX, axisZ, takeFlickAim } from './input.js';
|
import { axisX, axisZ, takeFlickAim } from './input.js';
|
||||||
import { updateZombies, spawnZombie, zombies } from './zombies.js';
|
import { updateZombies, spawnZombie, zombies, explode } 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.
|
||||||
@@ -79,6 +79,7 @@ function doHit(ch, quality, aim) {
|
|||||||
G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return
|
G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return
|
||||||
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
|
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
|
||||||
ball.kills = 0; // fresh shot: multi-kill streak restarts
|
ball.kills = 0; // fresh shot: multi-kill streak restarts
|
||||||
|
ball.bomb = false; // only a bomb-consuming serve is a bomb; any other hit clears it
|
||||||
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
|
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
|
||||||
const ok = G.mode !== 'tennis' || (Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ);
|
const ok = G.mode !== 'tennis' || (Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ);
|
||||||
G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
|
G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
|
||||||
@@ -120,6 +121,8 @@ function hitServe(sv, quality, aim = null) {
|
|||||||
ball.lastHit = sv.side; ball.bounces = 0;
|
ball.lastHit = sv.side; ball.bounces = 0;
|
||||||
ball.pow = quality;
|
ball.pow = quality;
|
||||||
ball.kills = 0; // fresh serve: multi-kill streak restarts
|
ball.kills = 0; // fresh serve: multi-kill streak restarts
|
||||||
|
ball.bomb = G.mode === 'splatter' && G.bombs > 0; // auto-consume a stocked bomb ball
|
||||||
|
if (ball.bomb) G.bombs--;
|
||||||
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 };
|
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 };
|
||||||
G.phase = 'rally'; pock(1, 1100);
|
G.phase = 'rally'; pock(1, 1100);
|
||||||
sv.pose = 'serve'; sv.poseT = 0;
|
sv.pose = 'serve'; sv.poseT = 0;
|
||||||
@@ -229,6 +232,10 @@ export function inServeBox(x, z) {
|
|||||||
function onBounce() {
|
function onBounce() {
|
||||||
ball.bounces++;
|
ball.bounces++;
|
||||||
pock(0.35, 500);
|
pock(0.35, 500);
|
||||||
|
if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up
|
||||||
|
explode(ball.x, ball.z); ball.active = false; ball.bomb = false;
|
||||||
|
G.phase = 'over'; G.overT = TUNE.splatterOverT; return;
|
||||||
|
}
|
||||||
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
|
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
|
||||||
if (ball.bounces === 1) {
|
if (ball.bounces === 1) {
|
||||||
ball.firstLand = { x: ball.x, z: ball.z };
|
ball.firstLand = { x: ball.x, z: ball.z };
|
||||||
|
|||||||
@@ -90,11 +90,16 @@ function drawShadow(px, py, rx, ry, alpha = 0.28) {
|
|||||||
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
|
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
|
||||||
ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill();
|
ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill();
|
||||||
}
|
}
|
||||||
function drawBall(x, y, z, glint = false) {
|
function drawBall(x, y, z, glint = false, bomb = false) {
|
||||||
const sh = proj(x, 0, z);
|
const sh = proj(x, 0, z);
|
||||||
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3);
|
drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3);
|
||||||
const bp = proj(x, y, z);
|
const bp = proj(x, y, z);
|
||||||
const r = Math.max(1.5, bp.s * 0.09);
|
const r = Math.max(1.5, bp.s * 0.09);
|
||||||
|
if (bomb) { // black ball with a flickering orange fuse pixel on top
|
||||||
|
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||||||
|
if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
|
||||||
if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); }
|
if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); }
|
||||||
}
|
}
|
||||||
@@ -289,7 +294,7 @@ export function render() {
|
|||||||
drawBall(bx, by, sv.z);
|
drawBall(bx, by, sv.z);
|
||||||
}
|
}
|
||||||
// the match ball
|
// the match ball
|
||||||
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true);
|
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb);
|
||||||
// splatter particles
|
// splatter particles
|
||||||
for (const pt of parts) {
|
for (const pt of parts) {
|
||||||
const pp = proj(pt.x, pt.y, pt.z);
|
const pp = proj(pt.x, pt.y, pt.z);
|
||||||
@@ -307,6 +312,10 @@ export function render() {
|
|||||||
: ['0', '15', '30', '40'][Math.min(score.pts[i], 3)];
|
: ['0', '15', '30', '40'][Math.min(score.pts[i], 3)];
|
||||||
ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc';
|
ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc';
|
||||||
ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10);
|
ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10);
|
||||||
|
for (let b = 0; b < G.bombs; b++) { // stocked bomb balls: black pips with an orange fuse rim
|
||||||
|
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#ff8010'; ctx.lineWidth = 0.75; ctx.stroke();
|
||||||
|
}
|
||||||
ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc';
|
ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc';
|
||||||
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22);
|
ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22);
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HOME } from './config.js';
|
|||||||
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
|
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
|
||||||
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
|
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
|
||||||
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
|
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
|
||||||
active: false, lastHit: 'P', bounces: 0, firstLand: null, collected: false, kills: 0 };
|
active: false, lastHit: 'P', bounces: 0, firstLand: null, collected: false, kills: 0, bomb: false };
|
||||||
export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
|
export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
|
||||||
export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd
|
export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ export const G = {
|
|||||||
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
|
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
|
||||||
wasServe: false, // the ball in flight is a serve (first bounce must hit the box)
|
wasServe: false, // the ball in flight is a serve (first bounce must hit the box)
|
||||||
splatT: 0, // time survived in splatter mode — drives the difficulty ramp
|
splatT: 0, // time survived in splatter mode — drives the difficulty ramp
|
||||||
|
bombs: 0, // bomb-ball stock (max TUNE.bombMax); the next serve auto-consumes one
|
||||||
|
killMeter: 0, // splatter kills toward the next bomb reward (every TUNE.bombEvery)
|
||||||
eatenT: 0, // time since you were eaten — gates the restart tap
|
eatenT: 0, // time since you were eaten — gates the restart tap
|
||||||
missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog
|
missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog
|
||||||
swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log
|
swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log
|
||||||
|
|||||||
+26
-1
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { TUNE, COURT } from './config.js';
|
import { TUNE, COURT } from './config.js';
|
||||||
import { G, P, ball, sendBoy } from './state.js';
|
import { G, P, ball, sendBoy } from './state.js';
|
||||||
import { splat, moan, pock, voice } from './audio.js';
|
import { splat, moan, pock, voice, boom } from './audio.js';
|
||||||
import { splatPoint, cheer } from './gameplay.js';
|
import { splatPoint, cheer } from './gameplay.js';
|
||||||
|
|
||||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||||
@@ -43,6 +43,27 @@ function splatter(i) {
|
|||||||
if (ball.kills === 2) { G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); }
|
if (ball.kills === 2) { G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); }
|
||||||
else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); }
|
else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); }
|
||||||
else if (ball.kills >= 4) { G.flash = { text: `MASSACRE ×${ball.kills}`, t: 0.9 }; cheer(2.8, 0.7); voice('unbelievable', 0.4); }
|
else if (ball.kills >= 4) { G.flash = { text: `MASSACRE ×${ball.kills}`, t: 0.9 }; cheer(2.8, 0.7); voice('unbelievable', 0.4); }
|
||||||
|
// bomb-ball reward: every bombEvery kills a boy delivers one (up to bombMax in stock)
|
||||||
|
if (++G.killMeter >= TUNE.bombEvery) {
|
||||||
|
G.killMeter = 0;
|
||||||
|
if (G.bombs < TUNE.bombMax) { G.bombs++; sendBoy(); voice('new_balls', 0.2); G.flash = { text: 'BOMB BALL', t: 0.9 }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so
|
||||||
|
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
|
||||||
|
export function explode(x, z) {
|
||||||
|
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices the array
|
||||||
|
if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
|
||||||
|
gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift();
|
||||||
|
const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6);
|
||||||
|
if (pl) pl.size = Math.min(4, pl.size + 3);
|
||||||
|
else { piles.push({ x, z, size: 3, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); }
|
||||||
|
for (let k = 0; k < 40; k++) parts.push({
|
||||||
|
x, y: 0.6, z, vx: (Math.random() * 2 - 1) * 6, vy: 1 + Math.random() * 5, vz: (Math.random() * 2 - 1) * 6,
|
||||||
|
t: 0.5 + Math.random() * 0.7, dark: k % 2 === 0,
|
||||||
|
});
|
||||||
|
G.hitstop = 0.1; boom();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateZombies(dt) {
|
export function updateZombies(dt) {
|
||||||
@@ -77,6 +98,10 @@ export function updateZombies(dt) {
|
|||||||
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
|
||||||
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
|
||||||
if (d < 0.55) {
|
if (d < 0.55) {
|
||||||
|
if (ball.bomb) { // detonate on first contact, whatever the pace
|
||||||
|
explode(ball.x, ball.z); ball.active = false; ball.bomb = false;
|
||||||
|
G.phase = 'over'; G.overT = TUNE.splatterOverT; break; // zombies mutated — stop this frame's loop
|
||||||
|
}
|
||||||
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
|
||||||
pock(0.6, 300);
|
pock(0.6, 300);
|
||||||
|
|||||||
Reference in new issue
Block a user