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
|
- **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.
|
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)
|
## 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
|
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',
|
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
|
||||||
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
|
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
|
// 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
|
// 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.
|
// 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; }
|
#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; }
|
#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; } }
|
@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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -84,6 +100,7 @@
|
|||||||
<div id="hint">move: WASD/arrows · serve/swing: SPACE · aim: hold a direction while swinging · T: tuning panel</div>
|
<div id="hint">move: WASD/arrows · serve/swing: SPACE · aim: hold a direction while swinging · T: tuning panel</div>
|
||||||
<div id="intro">
|
<div id="intro">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div id="splash">
|
||||||
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
||||||
|
|
||||||
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
|
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
|
||||||
@@ -96,7 +113,10 @@
|
|||||||
<p class="start">TAP TO START</p>
|
<p class="start">TAP TO START</p>
|
||||||
<p id="build"></p>
|
<p id="build"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
<script type="module" src="main.js?v=81"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
// 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)
|
// 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 { TUNE, COURT, COURTS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
|
||||||
import { P, O, ball, score, G, setupPoint } from './state.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 { 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 { fitViewport, render, proj, RES } from './render.js';
|
||||||
import { voice, ambient } from './audio.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
|
// 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'); }
|
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');
|
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;
|
if (!intro.parentNode) return;
|
||||||
|
selectCourt(id);
|
||||||
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
|
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
|
// 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.
|
// 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.
|
// 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;
|
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
|
||||||
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
||||||
.catch(() => {}); // no header, no line — never a broken start screen
|
.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
|
// 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).
|
// 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 { 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 { 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 { predictLanding, flickTarget } from './gameplay.js';
|
||||||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------- palette ----
|
// ---------------------------------------------------------------- palette ----
|
||||||
// tennis palette -> drained splatter palette; the transition lerps between them
|
// tennis palette -> drained splatter palette; the transition lerps between them.
|
||||||
const PAL = {
|
// Both halves now come from the SELECTED COURT (config.COURTS) — see the court table for why each
|
||||||
tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e',
|
// court carries a pair rather than one palette and a global rot filter.
|
||||||
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 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 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);
|
const [ar, ag, ab] = c(a), [br, bg, bb] = c(b);
|
||||||
@@ -25,10 +21,10 @@ function paletteT() {
|
|||||||
}
|
}
|
||||||
function palette() {
|
function palette() {
|
||||||
const t = paletteT();
|
const t = paletteT();
|
||||||
if (t === 0) return PAL.tennis;
|
if (t === 0) return court.tennis;
|
||||||
if (t === 1) return PAL.splatter;
|
if (t === 1) return court.splatter;
|
||||||
const m = {};
|
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;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +151,15 @@ export function proj(x, y, z) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- sprites ----
|
// ---------------------------------------------------------------- sprites ----
|
||||||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
// 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 = {};
|
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',
|
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
|
'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',
|
'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_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
|
'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)
|
'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).
|
// (a fixed baseline offset over-/under-shoots because camF differs portrait vs landscape).
|
||||||
const standsBottom = proj(0, 0, -6).y;
|
const standsBottom = proj(0, 0, -6).y;
|
||||||
const crowdH = standsBottom - AD_H + 2;
|
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) {
|
if (crowdImg) {
|
||||||
// Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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;
|
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
|
||||||
for (let c = 0; c < cols; c++) {
|
for (let c = 0; c < cols; c++) {
|
||||||
const clapping = crowd.cheer > 0 && clapImg && Math.floor(G.idleT * 9 + c * 0.7) % 2;
|
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)
|
// 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) {
|
if (ads) {
|
||||||
const tw = ads.width * (AD_H / ads.height);
|
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);
|
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
|
// 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
|
// 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.
|
// 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.15, -0.15, dw + 0.15, COURT.L + 0.15, court.border); // perimeter border line
|
||||||
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // lawn (doubles court)
|
quadXZ(-dw, 0, dw, COURT.L, pal.lawn); // playing surface (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
|
// 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
|
// 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, 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);
|
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.
|
// 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
|
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
|
// 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 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
|
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
|
// everything that used to be loose top-level lets
|
||||||
export const G = {
|
export const G = {
|
||||||
mode: 'tennis', // tennis | transition | splatter
|
mode: 'tennis', // tennis | transition | splatter
|
||||||
|
|||||||