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>
No files matched your search
|
After Width: | Height: | Size: 919 KiB |
|
After Width: | Height: | Size: 933 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; } };
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||