Zombie town: the match continues

Win your first game and the applause cuts off mid-clap. The palette drains,
the umpire lowers his voice ('Quiet... please'), your opponent turns — torn
red polo, dragging his racket — and shamblers come over the billboard wall.
The scoreboard rewrites CPU to THEM. 'Play.'

- zombies.js: shamblers walk your way; quality shots splatter them (gore
  decal + particle burst + wet synth splat), weak sitters only stagger them
  back; one crossing your baseline is a point for THEM
- tennis scoring machinery keeps running untouched: fifteen, thirty, deuce,
  and 'Game, set... massacre' on a game win, all in recorded umpire English
- ball boys mop the gore they pass (their destiny fulfilled)
- transition: palette lerp, scripted spawns incl. the turned opponent,
  new recorded lines (quiet_dark, remain_seated, play, new_balls)
- moan ambience, drained splatter palette, all zombie params in TUNE

Verified: transition triggers on first game win, splat/stagger/baseline
scoring all correct, dead-ball flow feeds fresh serves, tennis mode
regression clean (6/6 returns, 6/6 points resolved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 16:26:49 +02:00
1 parent f221ff84df
commit 69e3a0c310
20 files changed
+247 -25

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+34 -1
View File
@@ -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;
+5
View File
@@ -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 };
+58 -10
View File
@@ -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
+2 -1
View File
@@ -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; } };
+53 -9
View File
@@ -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';
+15 -4
View File
@@ -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 };
}
+80
View File
@@ -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);
}
}