// 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 { P, O, ball, score, crowd, G } from './state.js'; import { predictLanding } from './gameplay.js'; import { zombies, gore, parts } 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' }, }; 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); return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`; }; function palette() { const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 : Math.min(1, G.transT / 6); if (t === 0) return PAL.tennis; if (t === 1) return PAL.splatter; const m = {}; for (const k in PAL.tennis) m[k] = hexLerp(PAL.tennis[k], PAL.splatter[k], t); return m; } // ---------------------------------------------------------------- camera ---- export const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail export let W = 480, H = 270; export function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens const portrait = innerHeight > innerWidth; const [w, h] = portrait ? [270, 480] : [480, 270]; if (cv.width !== w * RES) { W = w; H = h; cv.width = w * RES; cv.height = h * RES; TUNE.camF = portrait ? 400 : 280; TUNE.horizon = portrait ? 90 : 40; buildCrowd(); } ctx.imageSmoothingEnabled = false; // resets on canvas resize const sc = Math.min(innerWidth / w, innerHeight / h); cv.style.width = w * sc + 'px'; cv.style.height = h * sc + 'px'; } addEventListener('resize', fitViewport); export function proj(x, y, z) { const d = TUNE.camZ - z, s = TUNE.camF / d; return { x: W / 2 + x * s, y: TUNE.horizon + (TUNE.camY - y) * s, s }; } // ---------------------------------------------------------------- sprites ---- // assets/.png; game falls back to code-drawn figures until sprites load. 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', 'z_a1','z_a2','z_b1','z_b2','z_c1','z_c2']) { const img = new Image(); img.onload = () => SPR[n] = img; img.src = 'assets/' + n + '.png'; } // ------------------------------------------------------------------ crowd ---- export function buildCrowd() { crowd.dots.length = 0; const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8); for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({ x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2, c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8] }); } // -------------------------------------------------------------- primitives ---- function line(x1, z1, x2, z2, w = 1) { const a = proj(x1, 0, z1), b = proj(x2, 0, z2); ctx.strokeStyle = '#e8e8dc'; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid) const a = proj(x1, 0, z1), b = proj(x2, 0, z1), c = proj(x2, 0, z2), d = proj(x1, 0, z2); ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill(); } function drawShadow(px, py, rx, ry, alpha = 0.28) { ctx.fillStyle = `rgba(0,0,0,${alpha})`; ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill(); } function drawBall(x, y, z, glint = false) { const sh = proj(x, 0, z); drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3); const bp = proj(x, y, z); const r = Math.max(1.5, bp.s * 0.09); ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); } } // ----------------------------------------------------------------- actors ---- function drawStatic(name, x, z, hMeters, bob = 0) { const img = SPR[name]; if (!img) return; const p = proj(x, 0, z); const h = hMeters * p.s, w = h * img.width / img.height; drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25); ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h); } function drawBoy(b) { const img = SPR['x_boyrun' + (Math.floor(b.runT * 14) % 4 + 1)]; if (!img) return; const p = proj(b.x, 0, b.z); const h = BOY_H * p.s, w = h * img.width / img.height; drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25); ctx.save(); ctx.translate(p.x, p.y - (Math.floor(b.runT * 20) % 2)); if (b.dir < 0) ctx.scale(-1, 1); ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } function drawZombie(zb) { const frame = Math.floor(zb.walkT * 2.6) % 2 + 1; drawStatic('z_' + zb.variant + frame, zb.x, zb.z, 1.8, Math.floor(zb.walkT * 5.2) % 2); } function drawRing(x, z, color) { const p = proj(x, 0, z); // integer-snapped so the moving ring steps whole pixels instead of shimmering const cx = Math.round(p.x), cy = Math.round(p.y); const rx = Math.max(2, Math.round(p.s * 0.3)), ry = Math.max(1, Math.round(p.s * 0.12)); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke(); } function drawChar(ch) { const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle'; const sway = receiverWaiting ? Math.sin(G.idleT * 3.2) : 0; const p = proj(ch.x + sway * 0.12, 0, ch.z); const hpx = PLAYER_H * p.s; // pick sprite const pre = ch.side === 'P' ? 'p_' : 'o_'; let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') { const w = G.idleT * BOUNCE_IDLE.tempo; // same phase as the bouncing ball const rising = Math.sin(w) * Math.cos(w) > 0; name = pre + (rising ? 'bounce2' : 'bounce'); // hand up while the ball rises, pat down exactly at the apex } if (ch.pose === 'run') name = pre + (Math.floor(ch.runT * 9) % 2 ? 'run1' : 'run2'); if (receiverWaiting) name = pre + (sway > 0 ? 'run1' : 'run2'); // split-step shuffle if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2'); if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash'); if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob const img = SPR[name]; drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06); if (img) { const k = hpx / SPRITE_H; // sprites share one scale; the idle pose defines it const w = img.width * k, h = img.height * k; ctx.save(); ctx.translate(p.x, p.y); if (ch.facing < 0) ctx.scale(-1, 1); ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } else drawFallbackChar(ch, p, hpx); } function drawFallbackChar(ch, p, h) { // shown only until sprites load const you = ch.side === 'P'; const u = h / 16, x = p.x, y = p.y; const skin = '#e8b88a', shirt = you ? '#e8e8e8' : '#d04848', shorts = you ? '#3858a8' : '#404040'; ctx.fillStyle = skin; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 4 * u); ctx.fillStyle = you ? '#804820' : '#282828'; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 1.2 * u); ctx.fillStyle = shirt; ctx.fillRect(x - 2.6 * u, y - 12 * u, 5.2 * u, 6 * u); ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u); ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u); } // ------------------------------------------------------------------ frame ---- export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density const pal = palette(); // stands: tiered crowd from top of screen down to just above the far court 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); } 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'; // ground + court (Super Tennis lawn); everything outside the doubles court is OUT const dw = COURT.dblW / 2; quadXZ(-14, -6, 14, 30, pal.out); // out area 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 // 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); line(-COURT.W / 2, 0, -COURT.W / 2, COURT.L, 1.5); line(COURT.W / 2, 0, COURT.W / 2, COURT.L, 1.5); line(-COURT.W / 2, COURT.svcFar, COURT.W / 2, COURT.svcFar, 1); line(-COURT.W / 2, COURT.svcNear, COURT.W / 2, COURT.svcNear, 1); line(0, COURT.svcFar, 0, COURT.svcNear, 1); line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks // gore decals: what remains of the visiting team for (const d of gore) { const p = proj(d.x, 0, d.z); ctx.fillStyle = `rgba(140,18,18,${d.a})`; ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill(); } // landing rings: white = incoming ball, yellow/red = your shot (red lands out) if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') { const l = predictLanding(); if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); } if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048'); // 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) }, ]; 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) }); for (const zb of zombies) if (zb.z <= COURT.netZ) farSide.push({ z: zb.z, f: () => drawZombie(zb) }); farSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); // net const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ); const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ); ctx.fillStyle = 'rgba(20,26,34,0.72)'; ctx.beginPath(); ctx.moveTo(nl.x, nl.y); ctx.lineTo(nr.x, nr.y); ctx.lineTo(nrt.x, nrt.y); ctx.lineTo(nlt.x, nlt.y); ctx.fill(); ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke(); ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y); if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy); zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie); // server idly bouncing the ball while waiting if (G.phase === 'pretoss') { const sv = score.server === 'P' ? P : O; const bx = sv.x - BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1); const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex; drawBall(bx, by, sv.z); } // the match ball if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true); // splatter particles for (const pt of parts) { const pp = proj(pt.x, pt.y, pt.z); ctx.fillStyle = 'rgba(170,22,22,0.9)'; ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); } if (G.mode !== 'eaten') drawChar(P); // there is no P anymore // HUD — scoreboard ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30); ctx.strokeStyle = '#5a6a85'; ctx.strokeRect(6.5, 6.5, 131, 29); ctx.font = 'bold 9px monospace'; ctx.textBaseline = 'top'; const ptTxt = i => score.pts[i] >= 3 && score.pts[0] >= 3 && score.pts[1] >= 3 ? (score.pts[i] === score.pts[1 - i] ? '40' : (score.pts[i] > score.pts[1 - i] ? 'AD' : '40')) : ['0', '15', '30', '40'][Math.min(score.pts[i], 3)]; ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc'; ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10); ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc'; ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + score.games[1] + ' ' + ptTxt(1), 12, 22); ctx.textAlign = 'center'; if (G.mode === 'eaten') { ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#c02020'; ctx.font = 'bold 22px monospace'; ctx.fillText('EATEN.', W / 2, H / 2 - 30); ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace'; ctx.fillText('GAMES YOU ' + score.games[0] + ' — THEM ' + score.games[1], W / 2, H / 2); if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 20); } } if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten') { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; const touch = matchMedia('(pointer: coarse)').matches; ctx.fillText(G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'), W / 2, H - 16); } if (G.flash) { ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1); } if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060'; ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); } ctx.textAlign = 'left'; }