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

+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