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>
No files matched your search
@@ -409,6 +409,39 @@ Known generator quirks, all still true:
|
||||
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
|
||||
linear map onto the original's mean and sd, then verify with a mean-difference number.
|
||||
|
||||
## Session log: court types (2026-08-01)
|
||||
|
||||
Three courts, picked on a second start-screen stage. A court is pure DATA — `COURTS` in config.js
|
||||
— so adding one is a table row plus four PNGs, never a change to `render()`.
|
||||
|
||||
- **Each court carries TWO palettes, not one.** Every court still rots when the horde arrives, so
|
||||
the row holds a `tennis` palette and a drained `splatter` one and `palette()` lerps between them
|
||||
exactly as before. A single palette plus a global rot filter was the tempting shortcut and it is
|
||||
wrong: what "drained" means is different on orange clay than on green lawn.
|
||||
- **Mow stripes are a flag, not a colour.** `court.stripes`. Recolouring the lawn to terracotta and
|
||||
leaving the bands on still read as grass — the banding is the grass tell, not the hue.
|
||||
- **Physics is two numbers copied into TUNE on selection** (`selectCourt` in state.js). Grass skids
|
||||
low and fast (0.50/0.84), clay sits up and drags (0.66/0.70), hard is the old baseline
|
||||
(0.58/0.78). Doing it this way means the three bounce call sites in gameplay.js never learn that
|
||||
courts exist, and the T panel still edits the live values.
|
||||
- **A court's clap frame must come from its own sheet.** `x_crowd`/`x_crowd2` are a matched pair —
|
||||
same people, arms up. Pairing court A's seated sheet with court B's clap sheet cross-fades
|
||||
between two different crowds, which is the flicker class this codebase keeps re-learning. The
|
||||
clay/hard clap sheets were generated FROM their own seated sheet ("same image, arms raised"),
|
||||
not from `x_crowd2`, for exactly this reason.
|
||||
- **Ad-strip aspect is load-bearing.** `render()` draws the sheet at a fixed `AD_H` of 14 px and
|
||||
derives tile width from the sheet's own aspect, so a strip that comes back 6:1 instead of 9:1
|
||||
renders its lettering at half size and goes unreadable. The first clay roll was 6.34:1 and was
|
||||
thrown away; re-rolling with `x_ads_h.png` as the input (already 9.27:1) fixed it. **Check the
|
||||
aspect of a regenerated ad strip before installing it.**
|
||||
- **The picker is DOM, not canvas** — a second panel inside the existing `#intro` overlay, rows
|
||||
built from `COURTS` in main.js. Same Silkscreen card, no new `G.mode`, no canvas UI code. Last
|
||||
pick is remembered in `localStorage` and comes up pre-highlighted; keys 1..N pick, Enter takes
|
||||
the highlighted one.
|
||||
|
||||
Not done: clay ball-marks (the `gore` decal loop already draws flat ground ellipses, so a pale
|
||||
scuff per bounce is one call), per-court bounce SOUND, per-court crowd density.
|
||||
|
||||
## Session log: the serve is the gesture (2026-08-01)
|
||||
|
||||
Oliver's notes on the art pass: the zombies are good, the runner "flickers very hard", and the
|
||||
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 429 KiB |
@@ -268,6 +268,42 @@ export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs arou
|
||||
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
|
||||
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
|
||||
|
||||
// ------------------------------------------------------------------ courts ----
|
||||
// A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render().
|
||||
// Each carries TWO palettes because every court still rots — `tennis` is how it looks before the
|
||||
// horde arrives, `splatter` is the drained version render.js lerps to (see paletteT).
|
||||
// stripes mown lawn bands. Grass only: clay and acrylic are laid flat, and drawing stripes on
|
||||
// them was the single thing that made a recoloured court still read as grass.
|
||||
// line painted line colour. Chalk-white reads on green and blue; on clay it has to go warmer
|
||||
// or the lines glow like neon.
|
||||
// ads/crowd sprite BASE names; the clap frame is <crowd>2 (see the two-frame note in render()).
|
||||
// bounceY/XZ copied into TUNE on selection, so the T panel keeps editing the live values and the
|
||||
// three physics call sites never learn that courts exist. Grass skids low and fast, clay
|
||||
// sits up and drags, acrylic is the middle it always was.
|
||||
export const COURTS = [
|
||||
{ id: 'grass', name: 'CENTRE COURT', sub: 'GRASS · fast, low skid', tile: 'court_grass',
|
||||
ads: 'x_ads', crowd: 'x_crowd', stripes: true, line: '#e8e8dc', border: '#e8e8dc',
|
||||
bounceY: 0.50, bounceXZ: 0.84,
|
||||
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' } },
|
||||
{ id: 'clay', name: 'TERRE ROUGE', sub: 'CLAY · slow, high kick', tile: 'court_clay',
|
||||
ads: 'x_ads_c', crowd: 'x_crowd_c', stripes: false, line: '#efe3cf', border: '#efe3cf',
|
||||
bounceY: 0.66, bounceXZ: 0.70,
|
||||
tennis: { stands: '#4a3025', tierA: '#5a3a2c', tierB: '#513428', board: '#8a4426',
|
||||
out: '#7a3f26', lawn: '#c4652f', stripe: '#c4652f' },
|
||||
splatter: { stands: '#2a1a18', tierA: '#33201d', tierB: '#2d1c19', board: '#3a1212',
|
||||
out: '#4a2a1c', lawn: '#8a4a2a', stripe: '#8a4a2a' } },
|
||||
{ id: 'hard', name: 'THE SLAB', sub: 'HARD · true bounce', tile: 'court_hard',
|
||||
ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#f2f4f8', border: '#f2f4f8',
|
||||
bounceY: 0.58, bounceXZ: 0.78,
|
||||
tennis: { stands: '#1d2836', tierA: '#243141', tierB: '#1f2b3a', board: '#12525e',
|
||||
out: '#2c7a52', lawn: '#1f5c93', stripe: '#1f5c93' },
|
||||
splatter: { stands: '#141a26', tierA: '#1a2130', tierB: '#161d2a', board: '#3a1212',
|
||||
out: '#25452f', lawn: '#1c3e5c', stripe: '#1c3e5c' } },
|
||||
];
|
||||
|
||||
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
|
||||
// net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts
|
||||
// now stand exactly on the doubles sidelines.
|
||||
|
||||
@@ -76,6 +76,22 @@
|
||||
#build { margin: 10px 0 0; font-size: 9px; color: #5a6270; letter-spacing: 0.5px; }
|
||||
#intro .start { margin-top: 18px; color: #e0cc72; font-weight: bold; letter-spacing: 1px; animation: blink 1.1s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0.25; } }
|
||||
/* court select — the intro's second stage. One overlay, two panels, .picking swaps them, so the
|
||||
backdrop and the card frame never flash between screens. */
|
||||
#courts { display: none; }
|
||||
#intro.picking #splash { display: none; }
|
||||
#intro.picking #courts { display: block; animation: drop 0.3s ease-out; }
|
||||
#courts h2 { margin: 0 0 14px; font: 400 11px Silkscreen, monospace; color: #e0cc72; letter-spacing: 2px; }
|
||||
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
|
||||
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
|
||||
#courts button { display: flex; align-items: center; gap: 12px; width: min(340px, 84vw);
|
||||
margin: 0 auto 8px; padding: 6px; background: rgba(255,255,255,0.04);
|
||||
border: 2px solid #3b4354; color: inherit; font: inherit; text-align: left;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
#courts button.on { border-color: #e0cc72; background: rgba(224,204,114,0.10); }
|
||||
#courts img { width: 108px; height: 72px; display: block; image-rendering: pixelated; flex: none; }
|
||||
#courts b { display: block; font: 400 11px Silkscreen, monospace; color: #dfe6f2; letter-spacing: 0.5px; }
|
||||
#courts i { display: block; margin-top: 5px; font: 400 9px Silkscreen, monospace; color: #8a96ab; font-style: normal; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -84,19 +100,23 @@
|
||||
<div id="hint">move: WASD/arrows · serve/swing: SPACE · aim: hold a direction while swinging · T: tuning panel</div>
|
||||
<div id="intro">
|
||||
<div class="card">
|
||||
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
||||
<div id="splash">
|
||||
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
||||
|
||||
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
|
||||
<div class="how">
|
||||
<p><span>👆</span> tap — serve + swing</p>
|
||||
<p><span>👉</span> flick — aimed shot</p>
|
||||
<p><span>✋</span> drag — run <em>(auto-run is on)</em></p>
|
||||
<p class="kb"><span>⌨️</span> WASD / arrows + SPACE</p>
|
||||
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
|
||||
<div class="how">
|
||||
<p><span>👆</span> tap — serve + swing</p>
|
||||
<p><span>👉</span> flick — aimed shot</p>
|
||||
<p><span>✋</span> drag — run <em>(auto-run is on)</em></p>
|
||||
<p class="kb"><span>⌨️</span> WASD / arrows + SPACE</p>
|
||||
</div>
|
||||
<p class="start">TAP TO START</p>
|
||||
<p id="build"></p>
|
||||
</div>
|
||||
<p class="start">TAP TO START</p>
|
||||
<p id="build"></p>
|
||||
<!-- rows are built from config.COURTS in main.js: adding a court is a table row, never markup -->
|
||||
<div id="courts"><h2>CHOOSE YOUR COURT</h2></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=80"></script>
|
||||
<script type="module" src="main.js?v=81"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,8 +3,8 @@
|
||||
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
||||
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
||||
|
||||
import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint } from './state.js';
|
||||
import { TUNE, COURT, COURTS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint, selectCourt } from './state.js';
|
||||
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js';
|
||||
import { fitViewport, render, proj, RES } from './render.js';
|
||||
import { voice, ambient } from './audio.js';
|
||||
@@ -39,13 +39,28 @@ setupPoint();
|
||||
// warm the pixel HUD font so the canvas never paints a system-serif fallback frame
|
||||
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
|
||||
|
||||
// intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play
|
||||
// intro overlay, two stages: the first tap opens court select, picking a court starts play and
|
||||
// unlocks audio (iOS needs the gesture, and the SECOND tap is still a gesture — the pick is what
|
||||
// has to unlock it, not the splash).
|
||||
const intro = document.getElementById('intro');
|
||||
function dismissIntro() {
|
||||
const courts = document.getElementById('courts');
|
||||
const lastCourt = (() => { try { return localStorage.getItem('court'); } catch { return null; } })();
|
||||
for (const c of COURTS) {
|
||||
const b = document.createElement('button');
|
||||
b.className = c.id === (lastCourt || COURTS[0].id) ? 'on' : ''; // your last court comes up pre-picked
|
||||
b.innerHTML = `<img src="assets/${c.tile}.png?v=81" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
|
||||
b.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); start(c.id); });
|
||||
courts.appendChild(b);
|
||||
}
|
||||
function openPicker() { if (intro.parentNode) intro.classList.add('picking'); }
|
||||
function start(id) {
|
||||
if (!intro.parentNode) return;
|
||||
selectCourt(id);
|
||||
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
|
||||
}
|
||||
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
|
||||
// a tap anywhere on the splash opens the picker; on the picker itself only the buttons act, so a
|
||||
// stray tap beside a tile can't start a match on a court you didn't choose.
|
||||
intro.addEventListener('pointerdown', e => { e.preventDefault(); openPicker(); });
|
||||
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
|
||||
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
|
||||
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
|
||||
@@ -53,7 +68,15 @@ fetch('main.js', { method: 'HEAD' })
|
||||
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
|
||||
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
||||
.catch(() => {}); // no header, no line — never a broken start screen
|
||||
addEventListener('keydown', () => dismissIntro(), { once: true });
|
||||
// keyboard: any key opens the picker, then 1..N picks a court and Enter/Space takes the
|
||||
// highlighted one. Not `once` any more — it has two stages to get through.
|
||||
addEventListener('keydown', e => {
|
||||
if (!intro.parentNode) return;
|
||||
if (!intro.classList.contains('picking')) { openPicker(); return; }
|
||||
const n = parseInt(e.key, 10);
|
||||
if (n >= 1 && n <= COURTS.length) start(COURTS[n - 1].id);
|
||||
else if (e.key === 'Enter' || e.key === ' ') start(lastCourt || COURTS[0].id);
|
||||
});
|
||||
|
||||
// eaten: any tap or key restarts — but only once the death animation has played out, so a
|
||||
// panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// All mutable game state in one place, plus the point-reset that re-arms it.
|
||||
|
||||
import { HOME, BOY_ENTER, TUNE, COURT } from './config.js';
|
||||
import { HOME, BOY_ENTER, TUNE, COURT, COURTS } from './config.js';
|
||||
import { gore } from './zombies.js'; // runtime-only cycle (zombies imports state): sendBoy reads gore
|
||||
// to decide whether a mopping round is worth walking out for
|
||||
|
||||
@@ -11,6 +11,17 @@ export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
|
||||
export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
|
||||
export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd
|
||||
|
||||
// The court you picked on the start screen. Everything visual reads `court` directly; the two
|
||||
// bounce numbers are COPIED INTO TUNE instead, so the T panel still edits the live physics and
|
||||
// gameplay's three bounce sites stay untouched.
|
||||
export let court = COURTS[0];
|
||||
export function selectCourt(id) {
|
||||
court = COURTS.find(c => c.id === id) || COURTS[0];
|
||||
TUNE.bounceY = court.bounceY; TUNE.bounceXZ = court.bounceXZ;
|
||||
try { localStorage.setItem('court', court.id); } catch {} // private mode: a forgotten pick is fine
|
||||
return court;
|
||||
}
|
||||
|
||||
// everything that used to be loose top-level lets
|
||||
export const G = {
|
||||
mode: 'tennis', // tennis | transition | splatter
|
||||
|
||||