Let the player choose a court before the match

Three courts, picked on a second start-screen stage: CENTRE COURT (grass),
TERRE ROUGE (clay) and THE SLAB (hard). A court is a row in config.COURTS and
four PNGs — render() never learns their names.

Each row carries two palettes, because every court still rots: what "drained"
means on orange clay is not what it means on green lawn. Mow stripes became a
flag rather than a colour — recolouring the lawn and leaving the bands on still
read as grass. Bounce is two numbers copied into TUNE on selection, so the three
bounce sites in gameplay.js and the T panel stay untouched: grass skids low and
fast, clay sits up and drags, hard is the old baseline.

Per-court crowd sheets and ad strips. Each court's clap frame is generated from
its OWN seated sheet, not from x_crowd2 — pairing one court's crowd with
another's applause cross-fades between two different crowds.

The picker is a second panel in the existing #intro overlay, rows built from the
table. Last pick is remembered and comes up pre-highlighted; 1..N or Enter on a
keyboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-01 10:25:30 +02:00
1 parent 6378ff2a1b
commit fa30fb94c4
15 files changed
+166 -36

No files matched your search

+26 -19
View File
@@ -2,18 +2,14 @@
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js';
import { P, O, ball, score, crowd, G, court } from './state.js';
import { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } 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' },
};
// tennis palette -> drained splatter palette; the transition lerps between them.
// Both halves now come from the SELECTED COURT (config.COURTS) — see the court table for why each
// court carries a pair rather than one palette and a global rot filter.
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);
@@ -25,10 +21,10 @@ function paletteT() {
}
function palette() {
const t = paletteT();
if (t === 0) return PAL.tennis;
if (t === 1) return PAL.splatter;
if (t === 0) return court.tennis;
if (t === 1) return court.splatter;
const m = {};
for (const k in PAL.tennis) m[k] = hexLerp(PAL.tennis[k], PAL.splatter[k], t);
for (const k in court.tennis) m[k] = hexLerp(court.tennis[k], court.splatter[k], t);
return m;
}
@@ -155,12 +151,15 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 73; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 74; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
'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_crowd','x_crowd2','x_ads',
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set
'x_crowd_c','x_crowd_c2','x_ads_c', // TERRE ROUGE (clay)
'x_crowd_h','x_crowd_h2','x_ads_h', // THE SLAB (hard)
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
@@ -747,7 +746,10 @@ export function render() {
// (a fixed baseline offset over-/under-shoots because camF differs portrait vs landscape).
const standsBottom = proj(0, 0, -6).y;
const crowdH = standsBottom - AD_H + 2;
const crowdImg = SPR['x_crowd'];
// per-court crowd sheet, falling back to the grass one until it loads (or if a court ships
// without its own): a court with no crowd art must never drop to the dot fallback while the
// grass sheet is sitting right there.
const crowdImg = SPR[court.crowd] || SPR['x_crowd'];
if (crowdImg) {
// Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same
// stand with every arm up. Columns swap between them on their own phase, so the clap ripples
@@ -755,7 +757,9 @@ export function render() {
// each column by a FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a
// fractional y makes its pixel rows re-snap every frame. That boiling, right beside the
// scoreboard, is what read as the HUD flickering. Integer-aligned draws don't shimmer.
const clapImg = SPR['x_crowd2'];
// ...and its clap frame must come from the SAME sheet as the seated one, or the applause
// cross-fades between two different crowds.
const clapImg = crowdImg === SPR['x_crowd'] ? SPR['x_crowd2'] : SPR[court.crowd + '2'];
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
for (let c = 0; c < cols; c++) {
const clapping = crowd.cheer > 0 && clapImg && Math.floor(G.idleT * 9 + c * 0.7) % 2;
@@ -773,7 +777,7 @@ export function render() {
}
}
// courtside advertising: tiled sponsor panels at native aspect (never half-cut text)
const ads = SPR['x_ads'];
const ads = SPR[court.ads] || SPR['x_ads'];
if (ads) {
const tw = ads.width * (AD_H / ads.height);
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - AD_H, tw, AD_H);
@@ -789,9 +793,12 @@ export function render() {
// TUNE.camZ (48): proj() divides by (camZ - z), so at z >= camZ the scale blows up and then goes
// NEGATIVE, and the quad's far edge folds back above the screen as an inverted triangle. The
// full-frame clear above is what covers the bottom of the screen; this quad does not need to.
quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, '#e8e8dc'); // perimeter border line
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
quadXZ(-dw - 0.15, -0.15, dw + 0.15, COURT.L + 0.15, court.border); // perimeter border line
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // playing surface (doubles court)
// mow stripes: grass only. Clay and acrylic are laid flat, and banding them was the one thing
// that kept a recoloured court reading as grass however far the palette moved.
if (court.stripes)
for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), pal.stripe);
// 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);