diff --git a/HANDOFF.md b/HANDOFF.md index 8f88227..46d2d6a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/assets/court_clay.png b/assets/court_clay.png new file mode 100644 index 0000000..a0e7c10 Binary files /dev/null and b/assets/court_clay.png differ diff --git a/assets/court_grass.png b/assets/court_grass.png new file mode 100644 index 0000000..6ee82fa Binary files /dev/null and b/assets/court_grass.png differ diff --git a/assets/court_hard.png b/assets/court_hard.png new file mode 100644 index 0000000..27f0481 Binary files /dev/null and b/assets/court_hard.png differ diff --git a/assets/x_ads_c.png b/assets/x_ads_c.png new file mode 100644 index 0000000..ef59f23 Binary files /dev/null and b/assets/x_ads_c.png differ diff --git a/assets/x_ads_h.png b/assets/x_ads_h.png new file mode 100644 index 0000000..17dbafb Binary files /dev/null and b/assets/x_ads_h.png differ diff --git a/assets/x_crowd_c.png b/assets/x_crowd_c.png new file mode 100644 index 0000000..ccd2e2c Binary files /dev/null and b/assets/x_crowd_c.png differ diff --git a/assets/x_crowd_c2.png b/assets/x_crowd_c2.png new file mode 100644 index 0000000..76f783d Binary files /dev/null and b/assets/x_crowd_c2.png differ diff --git a/assets/x_crowd_h.png b/assets/x_crowd_h.png new file mode 100644 index 0000000..038b5ed Binary files /dev/null and b/assets/x_crowd_h.png differ diff --git a/assets/x_crowd_h2.png b/assets/x_crowd_h2.png new file mode 100644 index 0000000..fb32d81 Binary files /dev/null and b/assets/x_crowd_h2.png differ diff --git a/config.js b/config.js index 40d8e8f..6e26cd4 100644 --- a/config.js +++ b/config.js @@ -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 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. diff --git a/index.html b/index.html index b8f1f97..87a3c53 100644 --- a/index.html +++ b/index.html @@ -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; } @@ -84,19 +100,23 @@
move: WASD/arrows  ·  serve/swing: SPACE  ·  aim: hold a direction while swinging  ·  T: tuning panel
- +
+ -

Fifteen. Thirty. Forty. Dead.

-
-

👆 tap — serve + swing

-

👉 flick — aimed shot

-

✋ drag — run (auto-run is on)

-

⌨️ WASD / arrows + SPACE

+

Fifteen. Thirty. Forty. Dead.

+
+

👆 tap — serve + swing

+

👉 flick — aimed shot

+

✋ drag — run (auto-run is on)

+

⌨️ WASD / arrows + SPACE

+
+

TAP TO START

+

-

TAP TO START

-

+ +

CHOOSE YOUR COURT

- + diff --git a/main.js b/main.js index 3bae771..1c425fb 100644 --- a/main.js +++ b/main.js @@ -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 = `${c.name}${c.sub}`; + 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). diff --git a/render.js b/render.js index d71a8f1..71b2175 100644 --- a/render.js +++ b/render.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/.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); diff --git a/state.js b/state.js index b17fd9b..1c4904c 100644 --- a/state.js +++ b/state.js @@ -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