diff --git a/assets/x_ads.png b/assets/x_ads.png new file mode 100644 index 0000000..913a3e7 Binary files /dev/null and b/assets/x_ads.png differ diff --git a/assets/x_crowd.png b/assets/x_crowd.png new file mode 100644 index 0000000..910b3e8 Binary files /dev/null and b/assets/x_crowd.png differ diff --git a/config.js b/config.js index 840a67b..9e7785c 100644 --- a/config.js +++ b/config.js @@ -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 }; diff --git a/main.js b/main.js index 0a7dc2d..d68308f 100644 --- a/main.js +++ b/main.js @@ -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); diff --git a/render.js b/render.js index f05679d..4d8df19 100644 --- a/render.js +++ b/render.js @@ -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) });