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; return reverbNode;
} }
export function voice(name, delay = 0) { export function voice(name, delay = 0, rate = 1) {
const ac = audio(); const ac = audio();
const play = buf => { const play = buf => {
const src = ac.createBufferSource(); src.buffer = 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.connect(ac.destination); src.connect(reverb(ac)); // dry + center-court echo
src.start(ac.currentTime + delay); src.start(ac.currentTime + delay);
}; };
@@ -83,6 +84,38 @@ export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
} catch (e) {} } 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 export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized claps; swap for a sample if it reads flat
try { try {
const ac = audio(), t0 = ac.currentTime, n = 90 * seconds; 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, aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners 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) 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 }; 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 { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice } from './audio.js'; import { audio, pock, swish, applause, voice } from './audio.js';
import { axisX, axisZ, takeFlickAim } from './input.js'; import { axisX, axisZ, takeFlickAim } from './input.js';
import { updateZombies, spawnZombie } from './zombies.js';
// ------------------------------------------------------------- ballistics ---- // ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. // 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.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; 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 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.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
G.aiReactT = 0; // CPU hasn't read the shot yet G.aiReactT = 0; // CPU hasn't read the shot yet
} }
@@ -79,7 +81,8 @@ function hitServe(sv, quality) {
ball.g = g; ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0; 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); G.phase = 'rally'; pock(1, 1100);
sv.pose = 'serve'; sv.poseT = 0; sv.pose = 'serve'; sv.poseT = 0;
if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; } 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); 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; const i = side === 'P' ? 0 : 1, j = 1 - i;
score.pts[i]++; score.pts[i]++;
let gameOver = false;
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) { 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; G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
sendBoy(); sendBoy();
if (reason) voice(reason); if (reason) voice(reason);
if (gameOver) { if (gameOver) {
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned) return startTransition();
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
cheer(2.8, 0.6); cheer(2.8, 0.6);
score.server = other(score.server); score.server = other(score.server);
@@ -123,6 +132,37 @@ function pointTo(side, reason) {
G.serveNum = 1; 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() { function fault() {
if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); } if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); }
else pointTo(other(score.server), 'double_fault'); else pointTo(other(score.server), 'double_fault');
@@ -139,6 +179,7 @@ export function inServeBox(x, z) {
function onBounce() { function onBounce() {
ball.bounces++; ball.bounces++;
pock(0.35, 500); pock(0.35, 500);
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
if (ball.bounces === 1) { if (ball.bounces === 1) {
ball.firstLand = { x: ball.x, z: ball.z }; ball.firstLand = { x: ball.x, z: ball.z };
const wasServe = G.wasServe; G.wasServe = false; const wasServe = G.wasServe; G.wasServe = false;
@@ -271,14 +312,18 @@ export function update(dt) {
} }
expirePose(P, dt); expirePose(P, dt);
// --- AI // --- zombies + transition timeline
if (score.server === 'O' && G.phase === 'pretoss') { 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; G.aiServeT -= dt;
if (G.aiServeT <= 0) tossBall(O); 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 (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; G.aiReactT += dt;
const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction; const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction;
const tgt = incoming ? intercept('O') : HOME.O; const tgt = incoming ? intercept('O') : HOME.O;
@@ -328,7 +373,10 @@ export function update(dt) {
onBounce(); onBounce();
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
ball.active = false; 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 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 { update, keyPressed, solveShot, inServeBox, inCourt } from './gameplay.js';
import { fitViewport, render, proj } from './render.js'; import { fitViewport, render, proj } from './render.js';
import { voice } from './audio.js'; import { voice } from './audio.js';
import { zombies, spawnZombie } from './zombies.js';
import './input.js'; import './input.js';
let last = performance.now(); let last = performance.now();
@@ -32,6 +33,6 @@ addEventListener('keydown', () => dismissIntro(), { once: true });
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // 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, step: dt => update(dt), press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; 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 { 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 { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding } from './gameplay.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 ---- // ---------------------------------------------------------------- camera ----
export const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail 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 = {}; 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', 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', '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(); const img = new Image();
img.onload = () => SPR[n] = img; img.onload = () => SPR[n] = img;
img.src = 'assets/' + n + '.png'; img.src = 'assets/' + n + '.png';
@@ -93,6 +117,10 @@ function drawBoy(b) {
ctx.drawImage(img, -w / 2, -h, w, h); ctx.drawImage(img, -w / 2, -h, w, h);
ctx.restore(); 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) { function drawRing(x, z, color) {
const p = proj(x, 0, z); const p = proj(x, 0, z);
// integer-snapped so the moving ring steps whole pixels instead of shimmering // 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() { export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density 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 // stands: tiered crowd from top of screen down to just above the far court
const standsBottom = proj(0, 0, 0).y - 12; 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++) { 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); ctx.fillRect(0, 6 + r * 5.5, W, 5.5);
} }
for (const d of crowd.dots) { 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); ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
} }
// courtside billboard wall // 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.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.fillText(W > 400 ? '· SPLATTER OPEN · NEW BALLS PLEASE · SPLATTER OPEN ·' : '· SPLATTER OPEN ·', W / 2, standsBottom - 9);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT // ground + court (Super Tennis lawn); everything outside the doubles court is OUT
const dw = COURT.dblW / 2; 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.15, -0.15, dw + 0.15, COURT.L + 0.15, '#e8e8dc'); // perimeter border line
quadXZ(-dw, 0, dw, COURT.L, '#4cae52'); // lawn (doubles court) 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), '#46a54c'); // mow stripes 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 // 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, 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); 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, 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 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) // landing rings: white = incoming ball, yellow/red = your shot (red lands out)
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') { if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
const l = predictLanding(); 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 // far-side drawables in z order (far → near), then the net, then the near side
const farSide = [ const farSide = [
{ z: O.z, f: () => drawChar(O) },
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, UMP_H) }, { 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) }); 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()); farSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
// net // 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.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); 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); 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 // server idly bouncing the ball while waiting
if (G.phase === 'pretoss') { if (G.phase === 'pretoss') {
@@ -214,6 +252,12 @@ export function render() {
} }
// the match ball // the match ball
if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true); 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); drawChar(P);
// HUD — scoreboard // HUD — scoreboard
@@ -226,7 +270,7 @@ export function render() {
ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc'; ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc';
ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10); ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10);
ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc'; 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'; ctx.textAlign = 'center';
if (G.phase === 'pretoss' && score.server === 'P') { if (G.phase === 'pretoss' && score.server === 'P') {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; 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 // everything that used to be loose top-level lets
export const G = { 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 phase: 'pretoss', // pretoss | toss | rally | over
serveNum: 1, serveNum: 1,
overT: 0, overT: 0,
@@ -30,6 +35,12 @@ export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even
export function setupPoint() { export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null; 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 sv = score.server === 'P' ? P : O, rc = score.server === 'P' ? O : P;
const sx = deuceSide() ? 1.3 : -1.3; const sx = deuceSide() ? 1.3 : -1.3;
sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net 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; G.aiServeT = score.server === 'O' ? 1.4 : 0;
} }
export function sendBoy() { export function sendBoy(tx = ball.x, tz = ball.z) { // fetch a dead ball — or mop a splat
if (ball.z < -3 || ball.z > 27) return; // ball left the field lengthwise, let it go if (tz < -3 || tz > 27) return; // target 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 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: ball.z, dir, runT: 0 }; 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);
}
}