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

+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';