diff --git a/assets/_sheet_zcpu.png b/assets/_sheet_zcpu.png new file mode 100644 index 0000000..d27ab29 Binary files /dev/null and b/assets/_sheet_zcpu.png differ diff --git a/assets/_sheet_zombies.png b/assets/_sheet_zombies.png new file mode 100644 index 0000000..118e68e Binary files /dev/null and b/assets/_sheet_zombies.png differ diff --git a/assets/audio/game_set_massacre.m4a b/assets/audio/game_set_massacre.m4a new file mode 100644 index 0000000..c00490d Binary files /dev/null and b/assets/audio/game_set_massacre.m4a differ diff --git a/assets/audio/new_balls.m4a b/assets/audio/new_balls.m4a new file mode 100644 index 0000000..567262f Binary files /dev/null and b/assets/audio/new_balls.m4a differ diff --git a/assets/audio/play.m4a b/assets/audio/play.m4a new file mode 100644 index 0000000..a648b09 Binary files /dev/null and b/assets/audio/play.m4a differ diff --git a/assets/audio/quiet_dark.m4a b/assets/audio/quiet_dark.m4a new file mode 100644 index 0000000..9202091 Binary files /dev/null and b/assets/audio/quiet_dark.m4a differ diff --git a/assets/audio/remain_seated.m4a b/assets/audio/remain_seated.m4a new file mode 100644 index 0000000..409596a Binary files /dev/null and b/assets/audio/remain_seated.m4a differ diff --git a/assets/z_a1.png b/assets/z_a1.png new file mode 100644 index 0000000..75de561 Binary files /dev/null and b/assets/z_a1.png differ diff --git a/assets/z_a2.png b/assets/z_a2.png new file mode 100644 index 0000000..2ddb352 Binary files /dev/null and b/assets/z_a2.png differ diff --git a/assets/z_b1.png b/assets/z_b1.png new file mode 100644 index 0000000..1258f31 Binary files /dev/null and b/assets/z_b1.png differ diff --git a/assets/z_b2.png b/assets/z_b2.png new file mode 100644 index 0000000..74ddc7c Binary files /dev/null and b/assets/z_b2.png differ diff --git a/assets/z_c1.png b/assets/z_c1.png new file mode 100644 index 0000000..5bcf710 Binary files /dev/null and b/assets/z_c1.png differ diff --git a/assets/z_c2.png b/assets/z_c2.png new file mode 100644 index 0000000..2b33c02 Binary files /dev/null and b/assets/z_c2.png differ diff --git a/audio.js b/audio.js index 160ba57..0241329 100644 --- a/audio.js +++ b/audio.js @@ -24,10 +24,11 @@ function reverb(ac) { // stadium PA tail: exponentially decaying noise as impuls return reverbNode; } -export function voice(name, delay = 0) { +export function voice(name, delay = 0, rate = 1) { const ac = audio(); const play = buf => { const src = ac.createBufferSource(); src.buffer = buf; + src.playbackRate.value = rate; // < 1 = slower and lower — the umpire's dark register src.connect(ac.destination); src.connect(reverb(ac)); // dry + center-court echo src.start(ac.currentTime + delay); }; @@ -83,6 +84,38 @@ export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock } catch (e) {} } +export function splat(vol = 0.7) { // wet burst — a zombie meets a first serve + try { + const ac = audio(), t = ac.currentTime; + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.22); + const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; + lp.frequency.setValueAtTime(1400, t); lp.frequency.exponentialRampToValueAtTime(180, t + 0.2); + const g = ac.createGain(); + g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.22); + src.connect(lp).connect(g).connect(ac.destination); src.start(); + const o = ac.createOscillator(), og = ac.createGain(); // body thud + o.frequency.setValueAtTime(120, t); o.frequency.exponentialRampToValueAtTime(45, t + 0.15); + og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16); + o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18); + } catch (e) {} +} + +export function moan(vol = 0.1) { // distant undead ambience + try { + const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8; + const o = ac.createOscillator(); o.type = 'sawtooth'; + const f0 = 65 + Math.random() * 45; + o.frequency.setValueAtTime(f0, t); + o.frequency.linearRampToValueAtTime(f0 * 1.25, t + dur * 0.4); + o.frequency.linearRampToValueAtTime(f0 * 0.9, t + dur); + const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 320; + const g = ac.createGain(); + g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + o.connect(lp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05); + } catch (e) {} +} + export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized claps; swap for a sample if it reads flat try { const ac = audio(), t0 = ac.currentTime, n = 90 * seconds; diff --git a/config.js b/config.js index 8b3c6a2..1b307f4 100644 --- a/config.js +++ b/config.js @@ -17,6 +17,11 @@ export const TUNE = { aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09, aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile) + zSpeed: 0.7, // zombie shamble speed m/s + zSpawnEvery: 2.6, // s between spawns + zMax: 6, // shamblers on court at once + zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback + zPushback: 1.4, // m a staggered zombie is knocked back }; export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 }; diff --git a/gameplay.js b/gameplay.js index 8817e5d..d076b02 100644 --- a/gameplay.js +++ b/gameplay.js @@ -4,6 +4,7 @@ import { TUNE, COURT, CONTACT, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POS 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 } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -48,8 +49,9 @@ function doHit(ch, quality, aim) { ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; + ball.pow = quality; // carried pace: zombies splatter only above zSplatQ if (ch.side === 'P') { // shot marker: where this will land — red means it's going out - const ok = 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.aiReactT = 0; // CPU hasn't read the shot yet } @@ -79,7 +81,8 @@ function hitServe(sv, quality) { ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = sv.side; ball.bounces = 0; - if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 }; + ball.pow = quality; + 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); sv.pose = 'serve'; sv.poseT = 0; if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; } @@ -102,17 +105,23 @@ function cheer(seconds, vol) { crowd.cheer = Math.max(crowd.cheer, seconds); } -function pointTo(side, reason) { +function advancePoint(side) { // shared scoring math; returns whether a game was won const i = side === 'P' ? 0 : 1, j = 1 - i; score.pts[i]++; - let gameOver = false; if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) { - score.games[i]++; score.pts = [0, 0]; gameOver = true; + score.games[i]++; score.pts = [0, 0]; + return true; } + return false; +} + +function pointTo(side, reason) { + const gameOver = advancePoint(side); G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; sendBoy(); if (reason) voice(reason); if (gameOver) { + if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned) return startTransition(); voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); cheer(2.8, 0.6); score.server = other(score.server); @@ -123,6 +132,37 @@ function pointTo(side, reason) { G.serveNum = 1; } +// splatter-mode scoring: points land mid-flight without interrupting play +export function splatPoint(side) { + const gameOver = advancePoint(side); + if (gameOver) { + voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); + cheer(2.2, 0.5); + } else { + voice(scoreKey(), 0.45); + if (side === 'P') cheer(0.7, 0.25); + } +} + +// -------------------------------------------------------------- transition ---- +// You won your first game. The applause dies. The match... continues. +function startTransition() { + G.mode = 'transition'; G.transT = 0; G.tFlags = {}; G.everTransitioned = true; + G.overT = 9999; // hold the point flow; the timeline below releases it + applause(0.7, 0.5); // the clap that gets cut off + G.serveNum = 1; +} + +function transition(dt) { + const t = (G.transT += dt); + const once = (key, at, fn) => { if (t >= at && !G.tFlags[key]) { G.tFlags[key] = true; fn(); } }; + once('quiet', 1.8, () => voice('quiet_dark', 0, 0.82)); + once('turn', 3.4, () => { spawnZombie(O.x, O.z, 'c'); G.hideO = true; }); // the opponent... turns + once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); }); + once('seated', 5.4, () => voice('remain_seated', 0, 0.95)); + once('go', 8.2, () => { G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); }); +} + function fault() { if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); } else pointTo(other(score.server), 'double_fault'); @@ -139,6 +179,7 @@ export function inServeBox(x, z) { function onBounce() { ball.bounces++; pock(0.35, 500); + if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns if (ball.bounces === 1) { ball.firstLand = { x: ball.x, z: ball.z }; const wasServe = G.wasServe; G.wasServe = false; @@ -271,14 +312,18 @@ export function update(dt) { } expirePose(P, dt); - // --- AI - if (score.server === 'O' && G.phase === 'pretoss') { + // --- zombies + transition timeline + if (G.mode !== 'tennis') updateZombies(dt); + if (G.mode === 'transition') transition(dt); + + // --- AI (the opponent only exists in tennis mode) + if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') { G.aiServeT -= dt; if (G.aiServeT <= 0) tossBall(O); - } else if (score.server === 'O' && G.phase === 'toss') { + } else if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'toss') { if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); } } - if (G.phase === 'rally') { + if (G.mode === 'tennis' && G.phase === 'rally') { G.aiReactT += dt; const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction; const tgt = incoming ? intercept('O') : HOME.O; @@ -328,7 +373,10 @@ export function update(dt) { onBounce(); if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball ball.active = false; - if (ball.bounces === 1) pointTo(ball.lastHit, null); + if (G.mode === 'splatter') { // hand the server a fresh one + G.phase = 'over'; G.overT = 0.7; sendBoy(); + if (Math.random() < 0.2) voice('new_balls', 0.3); + } else if (ball.bounces === 1) pointTo(ball.lastHit, null); } } if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss diff --git a/main.js b/main.js index 368dd44..f3f32f9 100644 --- a/main.js +++ b/main.js @@ -8,6 +8,7 @@ import { P, O, ball, score, G, setupPoint } from './state.js'; import { update, keyPressed, solveShot, inServeBox, inCourt } from './gameplay.js'; import { fitViewport, render, proj } from './render.js'; import { voice } from './audio.js'; +import { zombies, spawnZombie } from './zombies.js'; import './input.js'; let last = performance.now(); @@ -32,6 +33,6 @@ addEventListener('keydown', () => dismissIntro(), { once: true }); requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, solveShot, inServeBox, inCourt, proj, setupPoint, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, solveShot, inServeBox, inCourt, proj, setupPoint, step: dt => update(dt), press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 75d5c0d..1507748 100644 --- a/render.js +++ b/render.js @@ -4,6 +4,29 @@ 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, parts } from './zombies.js'; + +// ---------------------------------------------------------------- palette ---- +// tennis palette -> drained splatter palette; the transition lerps between them +const PAL = { + tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e', + out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' }, + splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212', + out: '#20331c', lawn: '#5e7747', stripe: '#576f42' }, +}; +const hexLerp = (a, b, t) => { + const c = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; + const [ar, ag, ab] = c(a), [br, bg, bb] = c(b); + return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`; +}; +function palette() { + const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 : Math.min(1, G.transT / 6); + if (t === 0) return PAL.tennis; + if (t === 1) return PAL.splatter; + const m = {}; + for (const k in PAL.tennis) m[k] = hexLerp(PAL.tennis[k], PAL.splatter[k], t); + return m; +} // ---------------------------------------------------------------- camera ---- export const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail @@ -35,7 +58,8 @@ export function proj(x, y, z) { const SPR = {}; for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2', 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', - 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4']) { + 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4', + 'z_a1','z_a2','z_b1','z_b2','z_c1','z_c2']) { const img = new Image(); img.onload = () => SPR[n] = img; img.src = 'assets/' + n + '.png'; @@ -93,6 +117,10 @@ function drawBoy(b) { ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } +function drawZombie(zb) { + const frame = Math.floor(zb.walkT * 2.6) % 2 + 1; + drawStatic('z_' + zb.variant + frame, zb.x, zb.z, 1.8, Math.floor(zb.walkT * 5.2) % 2); +} function drawRing(x, z, color) { const p = proj(x, 0, z); // integer-snapped so the moving ring steps whole pixels instead of shimmering @@ -147,11 +175,12 @@ function drawFallbackChar(ch, p, h) { // shown only until sprites load export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density + const pal = palette(); // stands: tiered crowd from top of screen down to just above the far court const standsBottom = proj(0, 0, 0).y - 12; - ctx.fillStyle = '#232e42'; ctx.fillRect(0, 0, W, standsBottom); + ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom); for (let r = 0; r < 16; r++) { - ctx.fillStyle = r % 2 ? '#2a3750' : '#26324a'; + ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); } for (const d of crowd.dots) { @@ -160,17 +189,17 @@ export function render() { ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2); } // courtside billboard wall - ctx.fillStyle = '#0e5c2e'; ctx.fillRect(0, standsBottom - 10, W, 10); + ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - 10, W, 10); ctx.fillStyle = '#dfe8d0'; ctx.font = 'bold 7px monospace'; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; ctx.fillText(W > 400 ? '· SPLATTER OPEN · NEW BALLS PLEASE · SPLATTER OPEN ·' : '· SPLATTER OPEN ·', W / 2, standsBottom - 9); ctx.textAlign = 'left'; // ground + court (Super Tennis lawn); everything outside the doubles court is OUT const dw = COURT.dblW / 2; - quadXZ(-14, -6, 14, 30, '#295c2d'); // out area + quadXZ(-14, -6, 14, 30, pal.out); // out area quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, '#e8e8dc'); // perimeter border line - quadXZ(-dw, 0, dw, COURT.L, '#4cae52'); // lawn (doubles court) - for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), '#46a54c'); // mow stripes + quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // lawn (doubles court) + for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe); // mow stripes // lines: baselines, doubles + singles sidelines, service box line(-dw, 0, dw, 0, 2); line(-dw, COURT.L, dw, COURT.L, 2); line(-dw, 0, -dw, COURT.L, 1.5); line(dw, 0, dw, COURT.L, 1.5); @@ -180,6 +209,13 @@ export function render() { line(0, COURT.svcFar, 0, COURT.svcNear, 1); line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks + // gore decals: what remains of the visiting team + for (const d of gore) { + const p = proj(d.x, 0, d.z); + ctx.fillStyle = `rgba(140,18,18,${d.a})`; + ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill(); + } + // landing rings: white = incoming ball, yellow/red = your shot (red lands out) if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') { const l = predictLanding(); @@ -189,10 +225,11 @@ export function render() { // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ - { z: O.z, f: () => drawChar(O) }, { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, UMP_H) }, ]; + if (!G.hideO) farSide.push({ z: O.z, f: () => drawChar(O) }); if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) }); + for (const zb of zombies) if (zb.z <= COURT.netZ) farSide.push({ z: zb.z, f: () => drawZombie(zb) }); farSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); // net @@ -204,6 +241,7 @@ export function render() { ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke(); ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y); if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy); + zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie); // server idly bouncing the ball while waiting if (G.phase === 'pretoss') { @@ -214,6 +252,12 @@ export function render() { } // the match ball if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true); + // splatter particles + for (const pt of parts) { + const pp = proj(pt.x, pt.y, pt.z); + ctx.fillStyle = 'rgba(170,22,22,0.9)'; + ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); + } drawChar(P); // HUD — scoreboard @@ -226,7 +270,7 @@ export function render() { ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc'; ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10); ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc'; - ctx.fillText('CPU ' + score.games[1] + ' ' + ptTxt(1), 12, 22); + ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22); ctx.textAlign = 'center'; if (G.phase === 'pretoss' && score.server === 'P') { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; diff --git a/state.js b/state.js index 517ad8a..0ac8389 100644 --- a/state.js +++ b/state.js @@ -11,6 +11,11 @@ export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.bui // everything that used to be loose top-level lets export const G = { + mode: 'tennis', // tennis | transition | splatter + transT: 0, // transition timeline clock + tFlags: null, // one-shot markers within the transition + hideO: false, // the opponent has... left the match + everTransitioned: false, phase: 'pretoss', // pretoss | toss | rally | over serveNum: 1, overT: 0, @@ -30,6 +35,12 @@ export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even export function setupPoint() { G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null; + if (G.mode === 'splatter') { // always your serve; the receiver is... plural now + score.server = 'P'; + P.x = deuceSide() ? 1.3 : -1.3; P.z = 24.3; + P.pose = 'idle'; P.swingT = -1; P.facing = 1; + return; + } const sv = score.server === 'P' ? P : O, rc = score.server === 'P' ? O : P; const sx = deuceSide() ? 1.3 : -1.3; sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net @@ -40,8 +51,8 @@ export function setupPoint() { G.aiServeT = score.server === 'O' ? 1.4 : 0; } -export function sendBoy() { - if (ball.z < -3 || ball.z > 27) return; // ball left the field lengthwise, let it go - const dir = ball.x > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run - G.boy = { x: dir > 0 ? -13 : 13, z: ball.z, dir, runT: 0 }; +export function sendBoy(tx = ball.x, tz = ball.z) { // fetch a dead ball — or mop a splat + if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go + const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run + G.boy = { x: dir > 0 ? -13 : 13, z: tz, dir, runT: 0 }; } diff --git a/zombies.js b/zombies.js new file mode 100644 index 0000000..4815330 --- /dev/null +++ b/zombies.js @@ -0,0 +1,80 @@ +// The splatter phase: shamblers, gore decals, particles. The ball is the weapon; +// tennis scoring keeps running via gameplay.splatPoint. Zombies advance toward +// your baseline — one crossing it is a point for THEM. +// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) + +import { TUNE, COURT } from './config.js'; +import { G, ball, sendBoy } from './state.js'; +import { splat, moan, pock } from './audio.js'; +import { splatPoint } from './gameplay.js'; + +export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} +export const gore = []; // lawn decals {x, z, r, a} +export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t} + +let spawnT = 1.5, moanT = 3; + +export function spawnZombie(x, z, variant) { + zombies.push({ x, z, v: TUNE.zSpeed * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant }); +} + +function splatter(i) { + 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 }); + if (gore.length > 40) gore.shift(); + for (let k = 0; k < 14; k++) parts.push({ + x: zb.x, y: 0.9, z: zb.z, + vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5, + t: 0.4 + Math.random() * 0.4, + }); + splat(); + ball.vx *= 0.5; ball.vz *= 0.5; // the kill takes pace off the ball + sendBoy(zb.x, zb.z); // a boy sprints out to mop + splatPoint('P'); +} + +export function updateZombies(dt) { + moanT -= dt; + if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; } + + if (G.mode === 'splatter') { // transition spawns are scripted, not timed + spawnT -= dt; + if (spawnT <= 0 && zombies.length < TUNE.zMax) { + spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b'); + spawnT = TUNE.zSpawnEvery; + } + } + + for (let i = zombies.length - 1; i >= 0; i--) { + 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 (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { + const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); + if (d < 0.55) { + if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; } + zb.z -= TUNE.zPushback; // weak sitters only stagger them + pock(0.6, 300); + 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); splatPoint('O'); } // it reached your baseline + } + + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]; + p.t -= dt; p.vy -= 14 * dt; + p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; + if (p.y < 0) { p.y = 0; p.vy = 0; p.vx *= 0.5; p.vz *= 0.5; } + if (p.t <= 0) parts.splice(i, 1); + } + + const boy = G.boy; // the boy mops any gore he passes over + for (let i = gore.length - 1; i >= 0; i--) { + const d = gore[i]; + if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * 1.6; + if (d.a <= 0) gore.splice(i, 1); + } +}