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:
1 parent
f221ff84df
commit
69e3a0c310
20 files changed
+247
-25
No files matched your search
+58
-10
@@ -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
|
||||
|
||||
Reference in new issue
Block a user