diff --git a/config.js b/config.js index 591ee4e..c872ac1 100644 --- a/config.js +++ b/config.js @@ -30,6 +30,7 @@ export const TUNE = { hordeLen: 12, // s a horde wave lasts hordeRate: 2.5, // zombies spawned per second during a horde mgRate: 6, // machine-gun pellets fired per second during a horde + meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you missHud: 1, // show the last miss reason on screen (full log: T.G.missLog) }; diff --git a/gameplay.js b/gameplay.js index 7a7f36e..f7d78eb 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 { audio, pock, swish, applause, voice } from './audio.js'; import { axisX, axisZ, takeFlickAim } from './input.js'; -import { updateZombies, spawnZombie, zombies, explode } from './zombies.js'; +import { updateZombies, spawnZombie, zombies, explode, meleeSwipe, stashExtraBall } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -375,22 +375,20 @@ function trySwing(ch, isAI) { export function keyPressed(code) { if (code !== 'Space') return; audio(); // unlock on first gesture - if (G.horde) return; // horde: the racket auto-fires, taps do nothing — the thumb drag steers you - // splatter: a tap/flick during the brief 'over' beat arms the next serve immediately — - // reset inline, then fall through to pretoss so the finger is the only cadence limit - if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } - if (G.phase === 'pretoss' && score.server === 'P') { - if (G.mode === 'splatter') { - const flick = takeFlickAim(); - if (flick) { // quick serve: no toss ritual, modest pace, aimed by the flick - 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; + if (G.mode === 'splatter') { + // splatter is a shooter: every swish cleaves adjacent shamblers, and — outside a horde — + // fires a ball immediately. Serving while one still flies stashes it, so you can barrage. + meleeSwipe(P.x, P.z, TUNE.meleeRange); + if (G.horde) return; // horde: MG auto-fires, the tap only swishes (no serve) + if (ball.active) stashExtraBall(); // keep the in-flight ball alive + const flick = takeFlickAim(); // flick aims; a plain tap auto-targets the nearest zombie + 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; } + // tennis: the toss ritual + rally swing (unchanged) + if (G.phase === 'pretoss' && score.server === 'P') { tossBall(P); return; } if (G.phase === 'toss' && score.server === 'P') { // serve timing: distance from apex = quality const dy = Math.abs(ball.y - serveApexY()); diff --git a/main.js b/main.js index d35d239..4988f84 100644 --- a/main.js +++ b/main.js @@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint } from './state.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js'; import { fitViewport, render, proj } from './render.js'; import { voice } from './audio.js'; -import { zombies, spawnZombie, piles, gore, parts, pellets } from './zombies.js'; +import { zombies, spawnZombie, piles, gore, parts, pellets, extraBalls } from './zombies.js'; import { cv } from './dom.js'; import './input.js'; @@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extraBalls, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index c2f1777..cb23c7b 100644 --- a/render.js +++ b/render.js @@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { P, O, ball, score, crowd, G } from './state.js'; import { predictLanding } from './gameplay.js'; -import { zombies, gore, piles, parts, pellets } from './zombies.js'; +import { zombies, gore, piles, parts, pellets, extraBalls } from './zombies.js'; // ---------------------------------------------------------------- palette ---- // tennis palette -> drained splatter palette; the transition lerps between them @@ -211,6 +211,17 @@ function drawFallbackChar(ch, p, h) { // shown only until sprites load ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u); ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u); } +function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style + const hip = proj(P.x + 0.15, 1.05, P.z); + const tip = proj(P.x, 0.95, P.z - 1.35); // pointing up-court, where the pellets stream out + ctx.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke(); + ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward + ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke(); + if (G.horde && G.horde.mgT > 1 / TUNE.mgRate - 0.06) { // muzzle flash right after a shot + ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill(); + } +} // ------------------------------------------------------------------ frame ---- export function render() { @@ -293,6 +304,8 @@ export function render() { const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex; drawBall(bx, by, sv.z); } + // earlier serves still in flight (multi-ball barrage) + for (const b of extraBalls) drawBall(b.x, b.y, b.z, false, b.bomb); // the match ball if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb); // splatter particles @@ -308,6 +321,7 @@ export function render() { ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2); } if (G.mode !== 'eaten') drawChar(P); // there is no P anymore + if (G.horde && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player // HUD — scoreboard ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30); @@ -336,8 +350,11 @@ export function render() { if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; const touch = matchMedia('(pointer: coarse)').matches; - ctx.fillText(G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') - : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'), W / 2, H - 16); + const txt = G.mode === 'splatter' + ? (touch ? 'TAP TO FIRE · FLICK TO AIM · SWISH TO MELEE' : 'SPACE FIRES · ARROWS AIM') + : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') + : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); + ctx.fillText(txt, W / 2, H - 16); } if (G.flash) { ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; diff --git a/zombies.js b/zombies.js index 9c2fd30..5b2556a 100644 --- a/zombies.js +++ b/zombies.js @@ -5,7 +5,7 @@ import { TUNE, COURT } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; -import { splat, moan, pock, voice, boom } from './audio.js'; +import { splat, moan, pock, voice, boom, swish } from './audio.js'; import { splatPoint, cheer } from './gameplay.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} @@ -13,6 +13,7 @@ export const gore = []; // lawn decals {x, z, r, a} — the flat ground laye export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark} export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t} +export const extraBalls = []; // earlier serves still in flight while you fire the next {x,y,z,vx,vy,vz,g,pow,bomb,bounces} let spawnT = 1.5, moanT = 3; @@ -21,7 +22,9 @@ export function spawnZombie(x, z, variant) { zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant }); } -function splatter(i, fanfare = true) { +// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling — +// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls. +function killZombie(i, quiet) { const zb = zombies[i]; zombies.splice(i, 1); gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 }); @@ -36,9 +39,32 @@ function splatter(i, fanfare = true) { t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark, }); } splat(); - ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next sendBoy(zb.x, zb.z); // a boy sprints out to mop - splatPoint('P', !!G.horde); // horde kills rate-limit the umpire so he doesn't machine-gun scores + splatPoint('P', quiet); // horde kills rate-limit the umpire so he doesn't machine-gun scores +} + +// swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off) +export function meleeSwipe(x, z, range) { + let hit = 0; + for (let i = zombies.length - 1; i >= 0; i--) { + if (zombies[i].variant === 'r') continue; + if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < range) { killZombie(i, !!G.horde); hit++; } + } + if (hit) swish(0.35); + return hit; +} + +// serving again while a ball still flies: stash the current one so it keeps killing (a barrage) +export function stashExtraBall() { + if (!ball.active) return; + extraBalls.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, + g: ball.g, pow: ball.pow, bomb: ball.bomb, bounces: ball.bounces }); + if (extraBalls.length > 6) extraBalls.shift(); // cap the barrage +} + +function splatter(i, fanfare = true) { // the MAIN ball hit a zombie + killZombie(i, !!G.horde); + ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter ball.kills++; // this shot's running streak // escalating multi-kill fanfare — one shot, ≥2 zombies @@ -111,7 +137,10 @@ export function updateZombies(dt) { const zb = zombies[i]; zb.walkT += dt; zb.z += zb.v * dt; - zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // shamblers don't walk straight lines + if (G.mode === 'splatter' && zb.variant !== 'r' && zb.z > COURT.netZ) // on YOUR half they home in on you + zb.x += Math.sign(P.x - zb.x) * Math.min(Math.abs(P.x - zb.x), zb.v * 0.7 * dt); + else + zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half / racket boss: aimless shamble if (G.mode === 'splatter' && Math.hypot(zb.x - P.x, zb.z - P.z) < 0.7) { // it got you gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); for (let k = 0; k < 30; k++) parts.push({ @@ -122,7 +151,7 @@ export function updateZombies(dt) { splat(1); voice('game_set_massacre', 0.8); G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience - G.horde = null; pellets.length = 0; // the swarm stops; the eaten screen takes over + G.horde = null; pellets.length = 0; extraBalls.length = 0; // the swarm stops; the eaten screen takes over } if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); @@ -145,10 +174,26 @@ export function updateZombies(dt) { pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt; let hit = false; for (let j = zombies.length - 1; j >= 0; j--) - if (Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { splatter(j, false); hit = true; break; } + if (zombies[j].variant !== 'r' && Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { killZombie(j, true); hit = true; break; } if (hit || pe.t <= 0) pellets.splice(i, 1); } + for (let i = extraBalls.length - 1; i >= 0; i--) { // earlier serves keep arcing and killing + const b = extraBalls[i]; + b.vy -= (b.g || TUNE.gravity) * dt; + b.x += b.vx * dt; b.y += b.vy * dt; b.z += b.vz * dt; + let done = false; + if (b.y <= 0 && b.vy < 0) { b.y = 0; b.vy = -b.vy * TUNE.bounceY; b.vx *= TUNE.bounceXZ; b.vz *= TUNE.bounceXZ; b.bounces++; + if (b.bomb) { explode(b.x, b.z); done = true; } } + if (!done && b.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) { + if (zombies[j].variant === 'r' || Math.hypot(zombies[j].x - b.x, zombies[j].z - b.z) >= 0.55) continue; + if (b.bomb) { explode(b.x, b.z); done = true; } + else if ((b.pow || 0) >= TUNE.zSplatQ) { killZombie(j, !!G.horde); b.vx *= TUNE.zPierce; b.vz *= TUNE.zPierce; } + break; + } + if (done || b.bounces >= 2 || b.z < -2 || b.z > 25.5 || Math.abs(b.x) > 7.5) extraBalls.splice(i, 1); + } + for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; p.t -= dt; p.vy -= 14 * dt;