Stadium pass: real crowd, sponsor boards, umpire visible in portrait

- Umpire chair moved courtside (UMP_X 5.6, was 7.1 — which projected to
  x=272 on a 270px-wide portrait canvas: literally one pixel off-screen).
  Tap-cheat coords follow the constant.
- Generated pixel-art night crowd (x_crowd.png) replaces the tier stripes +
  random dots; drawn in 16 columns that bounce independently while the crowd
  cheers. Old procedural crowd kept as the loading fallback.
- Generated courtside sponsor boards (x_ads.png): ACEADE, TOPSPIN & CO,
  VOLLEX, NETTO GRIP, SLAMWICH — tiled at native aspect so lettering is
  never half-cut at any viewport width. Replaces the 'SPLATTER OPEN' text.
- One rot tint over crowd + boards driven by the shared paletteT() (new
  helper), so the stadium dims into splatter and brightens back for duels.

Verified: fresh origin; tennis + splatter screenshots (boards tile cleanly,
crowd reads, tint dims, umpire on-screen beside the court); umpire triple-tap
works at the new chair position; tennis serve benchmark 33/36 across two runs
(first 12 was flub noise); no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 17:48:39 +02:00
1 parent e948e8b36e
commit 1b97905217
5 files changed
+37 -23

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

+1
View File
@@ -58,6 +58,7 @@ COURT.svcNear = COURT.netZ + 6.4; // near service line
export const PLAYER_H = 1.88;
export const BOY_H = 1.2;
export const UMP_H = 3.0;
export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait
// contact rules: outside this height band the ball can't be hit (below = it's past you)
export const CONTACT = { minY: 0.24, maxY: 2.3 };
+2 -2
View File
@@ -3,7 +3,7 @@
// 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 } from './config.js';
import { TUNE, COURT, UMP_H, UMP_X } from './config.js';
import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js';
@@ -43,7 +43,7 @@ cv.addEventListener('pointerdown', e => {
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay
const u = proj(7.1, 0, COURT.netZ);
const u = proj(UMP_X, 0, COURT.netZ);
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
const now = performance.now();
umpTaps = umpTaps.filter(t => now - t < 1600);
+34 -21
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras } from './zombies.js';
@@ -19,9 +19,12 @@ const hexLerp = (a, b, t) => {
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)})`;
};
// how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home
function paletteT() {
return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
}
function palette() {
// splatter lerps back toward the tennis palette while a duel runs — order, restored
const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6);
const t = paletteT();
if (t === 0) return PAL.tennis;
if (t === 1) return PAL.splatter;
const m = {};
@@ -61,7 +64,7 @@ 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','x_crowd','x_ads',
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_s1','z_s2','z_s3']) {
const img = new Image();
@@ -254,24 +257,34 @@ export function render() {
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
}
const pal = palette();
// stands: tiered crowd from top of screen down to just above the far court
const pal = palette(), pt = paletteT();
// stands: the generated crowd, drawn in columns so it can BOUNCE when it cheers
const standsBottom = proj(0, 0, 0).y - 12;
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
for (let r = 0; r < 16; r++) {
ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB;
ctx.fillRect(0, 6 + r * 5.5, W, 5.5);
const adH = 14, crowdH = standsBottom - adH + 2;
const crowdImg = SPR['x_crowd'];
if (crowdImg) {
const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
for (let c = 0; c < cols; c++) {
const dy = crowd.cheer > 0 ? -Math.random() * 2 : 0;
ctx.drawImage(crowdImg, c * sw, 0, sw, crowdImg.height, c * dw, dy, dw + 0.5, crowdH + 2);
}
} else { // fallback until the image loads: the old tier stripes + dots
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); }
for (const d of crowd.dots) {
const jx = crowd.cheer > 0 ? (Math.random() * 2 - 1) : 0;
const jy = crowd.cheer > 0 ? -Math.random() * 1.5 : 0;
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
}
}
for (const d of crowd.dots) {
const jx = crowd.cheer > 0 ? (Math.random() * 2 - 1) : 0;
const jy = crowd.cheer > 0 ? -Math.random() * 1.5 : 0;
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
}
// courtside billboard wall
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';
// courtside advertising: tiled sponsor panels at native aspect (never half-cut text)
const ads = SPR['x_ads'];
if (ads) {
const tw = ads.width * (adH / ads.height);
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - adH, tw, adH);
} else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - adH, W, adH); }
// the rot dims the stadium: one tint over crowd + boards, fading home during duels
if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.5 * pt})`; ctx.fillRect(0, 0, W, standsBottom); }
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
const dw = COURT.dblW / 2;
@@ -314,7 +327,7 @@ export function render() {
// far-side drawables in z order (far → near), then the net, then the near side
const farSide = [
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, UMP_H) },
{ z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', UMP_X, 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) });