commit 7bedb2d215fd824ae8d8855a7989d90b83b5ea55 Author: Oliver Rieger Date: Sat Jul 11 09:23:01 2026 +0200 initial commit diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c53a3a2 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "splatter-tennis", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "8641"], + "port": 8641 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/assets/_sheet_opp.png b/assets/_sheet_opp.png new file mode 100644 index 0000000..5189875 Binary files /dev/null and b/assets/_sheet_opp.png differ diff --git a/assets/_sheet_player.png b/assets/_sheet_player.png new file mode 100644 index 0000000..1c4a09a Binary files /dev/null and b/assets/_sheet_player.png differ diff --git a/assets/o_idle.png b/assets/o_idle.png new file mode 100644 index 0000000..ff30479 Binary files /dev/null and b/assets/o_idle.png differ diff --git a/assets/o_run1.png b/assets/o_run1.png new file mode 100644 index 0000000..c66d291 Binary files /dev/null and b/assets/o_run1.png differ diff --git a/assets/o_run2.png b/assets/o_run2.png new file mode 100644 index 0000000..1e605d5 Binary files /dev/null and b/assets/o_run2.png differ diff --git a/assets/o_serve.png b/assets/o_serve.png new file mode 100644 index 0000000..e9d723d Binary files /dev/null and b/assets/o_serve.png differ diff --git a/assets/o_swing1.png b/assets/o_swing1.png new file mode 100644 index 0000000..86b5a68 Binary files /dev/null and b/assets/o_swing1.png differ diff --git a/assets/o_swing2.png b/assets/o_swing2.png new file mode 100644 index 0000000..1cd98a6 Binary files /dev/null and b/assets/o_swing2.png differ diff --git a/assets/p_idle.png b/assets/p_idle.png new file mode 100644 index 0000000..a543620 Binary files /dev/null and b/assets/p_idle.png differ diff --git a/assets/p_run1.png b/assets/p_run1.png new file mode 100644 index 0000000..9262021 Binary files /dev/null and b/assets/p_run1.png differ diff --git a/assets/p_run2.png b/assets/p_run2.png new file mode 100644 index 0000000..1798b9a Binary files /dev/null and b/assets/p_run2.png differ diff --git a/assets/p_serve.png b/assets/p_serve.png new file mode 100644 index 0000000..ecad35d Binary files /dev/null and b/assets/p_serve.png differ diff --git a/assets/p_swing1.png b/assets/p_swing1.png new file mode 100644 index 0000000..9cfc2c3 Binary files /dev/null and b/assets/p_swing1.png differ diff --git a/assets/p_swing2.png b/assets/p_swing2.png new file mode 100644 index 0000000..36a3d68 Binary files /dev/null and b/assets/p_swing2.png differ diff --git a/game.js b/game.js new file mode 100644 index 0000000..70f2ba8 --- /dev/null +++ b/game.js @@ -0,0 +1,530 @@ +'use strict'; +// Splatter Tennis — phase 1: a rally that feels right. Super Tennis (SNES) reference. +// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline. +// The near player is you. Pseudo-3D: ball has court pos (x,z) + height y, drawn with a shadow. + +// ---------------------------------------------------------------- tuning ---- +// Every feel-relevant number lives here; press T in game for live sliders. +const TUNE = { + camZ: 36, camY: 9, camF: 280, horizon: 40, + gravity: 9.2, // slightly floaty vs 9.8 for readability + bounceY: 0.58, bounceXZ: 0.78, + playerSpeed: 5.4, aiSpeed: 4.4, + reach: 1.5, aiReach: 1.6, + swingWindup: 0.06, // s before contact window opens + swingWindow: 0.16, // s contact window length + baseShotSpeed: 15, qualityShotSpeed: 9, // horizontal m/s = base + q*quality + serveSpeed: 21, serveQualitySpeed: 7, + aimError: 2.4, // m of scatter at quality 0 + perfectMs: 45, // |offset from window center| for PERFECT + aiQualityMin: 0.5, aiQualityMax: 0.95, aiFlub: 0.06, +}; + +const COURT = { L: 23.77, W: 8.23, netZ: 11.885, netH: 0.914, netW: 10.06, serviceD: 6.4 }; +// far service line z = netZ - serviceD... service boxes: far side z in [5.485, netZ], near [netZ, 18.285] +COURT.svcFar = COURT.netZ - 6.4; // 5.485 +COURT.svcNear = COURT.netZ + 6.4; // 18.285 + +// ---------------------------------------------------------------- canvas ---- +const cv = document.getElementById('c'), ctx = cv.getContext('2d'); +const W = cv.width, H = cv.height; +ctx.imageSmoothingEnabled = false; + +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 }; +} + +// ----------------------------------------------------------------- input ---- +const keys = {}; +addEventListener('keydown', e => { + if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault(); + if (e.key === 't' || e.key === 'T') togglePanel(); + if (!keys[e.code]) keyPressed(e.code); + keys[e.code] = true; +}); +addEventListener('keyup', e => keys[e.code] = false); +const axisX = () => (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0); +const axisZ = () => (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0); + +// ----------------------------------------------------------------- audio ---- +let AC = null; +const audio = () => AC || (AC = new (window.AudioContext || window.webkitAudioContext)()); +function noiseBuf(ac, len) { + const b = ac.createBuffer(1, ac.sampleRate * len, ac.sampleRate), d = b.getChannelData(0); + for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; + return b; +} +function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock + try { + const ac = audio(), t = ac.currentTime; + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.06); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 1.2; + const g = ac.createGain(); + g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.07); + src.connect(bp).connect(g).connect(ac.destination); src.start(); + const o = ac.createOscillator(), og = ac.createGain(); // low thump body + o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.08); + og.gain.setValueAtTime(vol * 0.5, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.09); + o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.1); + } catch (e) {} +} +function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized claps; swap for a sample if it reads flat + try { + const ac = audio(), t0 = ac.currentTime, n = 90 * seconds; + crowd.cheer = Math.max(crowd.cheer, seconds); + for (let i = 0; i < n; i++) { + const t = t0 + Math.random() * seconds; + const env = Math.min(1, 4 * (1 - Math.abs(2 * (t - t0) / seconds - 1))); // fade in/out + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.025); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; + bp.frequency.value = 1000 + Math.random() * 1600; bp.Q.value = 2.5; + const g = ac.createGain(); + g.gain.setValueAtTime(vol * env * (0.3 + Math.random() * 0.7), t); + g.gain.exponentialRampToValueAtTime(0.001, t + 0.03); + src.connect(bp).connect(g).connect(ac.destination); src.start(t); + } + } catch (e) {} +} +let voice = null; +function say(text, delay = 0) { // ponytail: speechSynthesis umpire; swap for recorded lines if it reads flat + try { + if (!voice) voice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null; + const u = new SpeechSynthesisUtterance(text); + if (voice) u.voice = voice; u.rate = 0.88; u.pitch = 0.75; u.volume = 0.9; + setTimeout(() => speechSynthesis.speak(u), delay * 1000); + } catch (e) {} +} + +// ---------------------------------------------------------------- sprites ---- +// assets/.png; game falls back to code-drawn figures until sprites load. +const SPR = {}; +for (const n of ['p_idle','p_run1','p_run2','p_swing1','p_swing2','p_serve', + 'o_idle','o_run1','o_run2','o_swing1','o_swing2','o_serve']) { + const img = new Image(); + img.onload = () => SPR[n] = img; + img.src = 'assets/' + n + '.png'; +} + +// ---------------------------------------------------------------- state ---- +const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; +const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; +const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, active: false, lastHit: 'P', bounces: 0, firstLand: null }; +const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O +const PTS = ['Love', 'Fifteen', 'Thirty', 'Forty']; +let phase = 'pretoss'; // pretoss | toss | rally | over +let serveNum = 1, overT = 0, hitstop = 0, flash = null; // flash = {text,x,y,t} +let aiServeT = 0, aiSwingCool = 0; +const crowd = { cheer: 0, dots: [] }; +for (let row = 0; row < 16; row++) for (let i = 0; i < 60; 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] +}); + +const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side +function setupPoint() { + phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null; + const sv = score.server === 'P' ? P : O, rc = score.server === 'P' ? O : P; + const sx = deuceSide() ? 1.3 : -1.3; + sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net + sv.z = sv.side === 'P' ? 24.3 : -0.5; + rc.x = -sv.x; rc.z = rc.side === 'P' ? 22.5 : 1.2; + P.pose = O.pose = 'idle'; P.swingT = O.swingT = -1; + aiServeT = score.server === 'O' ? 1.4 : 0; +} + +// ------------------------------------------------------------- ballistics ---- +// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s. Returns velocity. +function solveShot(x0, y0, z0, tx, tz, s) { + const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s; + return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * TUNE.gravity * T * T) / T }; +} + +function doHit(ch, quality, aim) { + // aim: {ax: -1|0|1, az: -1|0|1} from held keys at contact (az -1 = deep, 1 = short/drop) + const toFar = ch.side === 'P'; + const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); + let tx = aim.ax * 2.7 + err(); + let tzBase = aim.az < 0 ? 3.0 : aim.az > 0 ? 8.6 : 5.4; // depth from target baseline + let tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; + tx = Math.max(-5, Math.min(5, tx)); + const s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; + const v = solveShot(ball.x, ball.y, ball.z, tx, tz, s); + ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; + ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; + pock(0.9, 950); + if (quality > 0.999) { hitstop = 0.05; flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); } + ch.pose = 'swing'; ch.poseT = 0; +} + +// ----------------------------------------------------------------- serve ---- +function tossBall(sv) { + ball.active = true; ball.bounces = 0; ball.firstLand = null; + ball.x = sv.x + 0.35 * (sv.side === 'P' ? 1 : -1); ball.z = sv.z; ball.y = 1.8; + ball.vx = 0; ball.vz = 0; ball.vy = 4.6; + phase = 'toss'; +} +function serveApexY() { return 1.8 + 4.6 * 4.6 / (2 * TUNE.gravity); } // ~2.95 +function hitServe(sv, quality) { + const toFar = sv.side === 'P'; + // target: diagonal service box. Serving from deuce side → target box is across. + const boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality); + const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6; + const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; + const v = solveShot(ball.x, ball.y, ball.z, boxX, boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5, s); + ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; + ball.lastHit = sv.side; ball.bounces = 0; + phase = 'rally'; pock(1, 1100); + sv.pose = 'serve'; sv.poseT = 0; + if (quality > 0.999) { flash = { text: 'PERFECT', t: 0.7 }; hitstop = 0.05; } +} + +// ---------------------------------------------------------------- scoring ---- +function callScore() { + const s = score.server === 'P' ? [score.pts[0], score.pts[1]] : [score.pts[1], score.pts[0]]; + if (s[0] >= 3 && s[1] >= 3) { + if (s[0] === s[1]) return 'Deuce'; + return 'Advantage ' + ((s[0] > s[1]) === (score.server === 'P') ? 'player' : 'receiver'); + } + if (s[0] === s[1]) return PTS[s[0]] + ' all'; + return PTS[s[0]] + ', ' + PTS[s[1]]; +} +function pointTo(side, reason) { + const i = side === 'P' ? 0 : 1, j = 1 - i; + score.pts[i]++; + let gameOver = false; + if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) { + score.games[i]++; score.pts = [0, 0]; gameOver = true; + } + phase = 'over'; overT = gameOver ? 3.2 : 2.2; + if (reason) say(reason); + if (gameOver) { + say('Game. ' + (side === 'P' ? 'Player' : 'Opponent') + ' leads ' + Math.max(score.games[0], score.games[1]) + ' games to ' + Math.min(score.games[0], score.games[1]) + '.', 0.6); + applause(2.8, 0.6); + score.server = score.server === 'P' ? 'O' : 'P'; + } else { + say(callScore(), 0.7); + if (side === 'P') applause(1.2, 0.3); + } + serveNum = 1; +} +function fault() { + if (serveNum === 1) { serveNum = 2; say('Fault'); phase = 'over'; overT = 1.4; } + else pointTo(other(score.server), 'Double fault'); +} +const other = s => s === 'P' ? 'O' : 'P'; + +function inCourt(x, z) { return Math.abs(x) <= COURT.W / 2 + 0.02 && z >= -0.02 && z <= COURT.L + 0.02; } +function inServeBox(x, z) { + const toFar = score.server === 'P'; + const xOK = deuceSide() ? (toFar ? x <= 0.02 : x >= -0.02) : (toFar ? x >= -0.02 : x <= 0.02); + const zOK = toFar ? (z >= COURT.svcFar - 0.02 && z <= COURT.netZ) : (z >= COURT.netZ && z <= COURT.svcNear + 0.02); + return xOK && Math.abs(x) <= COURT.W / 2 + 0.02 && zOK; +} + +function onBounce() { + ball.bounces++; + pock(0.35, 500); + if (ball.bounces === 1) { + ball.firstLand = { x: ball.x, z: ball.z }; + const wasServe = phaseWasServe; phaseWasServe = false; + if (wasServe) { + if (!inServeBox(ball.x, ball.z)) { ball.active = false; fault(); } + return; + } + // must land on the side opposite the hitter + const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; + if (!inCourt(ball.x, ball.z) || !okSide) { + ball.active = false; pointTo(other(ball.lastHit), 'Out'); + } + } else if (ball.bounces === 2) { + ball.active = false; pointTo(ball.lastHit, null); + } +} +let phaseWasServe = false; + +// ---------------------------------------------------------------- update ---- +function trySwing(ch, isAI) { + // Contact happens if the ball is inside reach while the swing window is open. + const reach = isAI ? TUNE.aiReach : TUNE.reach; + const t = ch.swingT; + if (t < TUNE.swingWindup || t > TUNE.swingWindup + TUNE.swingWindow) return; + if (!ball.active || phase !== 'rally') return; + if (ball.lastHit === ch.side) return; + const dx = ball.x - ch.x, dz = ball.z - ch.z; + if (Math.hypot(dx, dz * 0.8) > reach || ball.y > 2.3) return; + // quality: how centered in the window the contact lands + const center = TUNE.swingWindup + TUNE.swingWindow / 2; + const off = Math.abs(t - center) / (TUNE.swingWindow / 2); + let q = Math.max(0.05, 1 - off); + if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; + ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite + const aim = isAI + ? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } + : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; + if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (TUNE.aiQualityMax - TUNE.aiQualityMin)); + doHit(ch, q, aim); + ch.swingT = -1; +} + +function keyPressed(code) { + if (code !== 'Space') return; + audio(); // unlock on first gesture + if (phase === 'pretoss' && score.server === 'P') { tossBall(P); return; } + if (phase === 'toss' && score.server === 'P') { + // serve timing: distance from apex = quality + const dy = Math.abs(ball.y - serveApexY()); + if (ball.y < 2.0) return; // too low, let it drop (re-toss) + let q = Math.max(0.05, 1 - dy / 0.9); + if (dy < 0.12) q = 1; + phaseWasServe = true; + hitServe(P, q); if (q >= 1) flash = { text: 'PERFECT', t: 0.7 }; + return; + } + if (phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; } +} + +function update(dt) { + if (hitstop > 0) { hitstop -= dt; return; } + if (flash) { flash.t -= dt; if (flash.t <= 0) flash = null; } + crowd.cheer = Math.max(0, crowd.cheer - dt); + + // --- player movement (near half only, generous margins) + if (phase === 'rally' || phase === 'toss' || phase === 'pretoss') { + const slow = P.swingT >= 0 ? 0.35 : 1; + const mx = axisX(), mz = axisZ(); + if (phase === 'rally') { + P.x += mx * TUNE.playerSpeed * slow * dt; + P.z += mz * TUNE.playerSpeed * slow * dt; + P.x = Math.max(-6.5, Math.min(6.5, P.x)); + P.z = Math.max(COURT.netZ + 1.2, Math.min(26, P.z)); + P.runT += (Math.abs(mx) + Math.abs(mz)) * dt; + if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = (mx || mz) ? 'run' : 'idle'; + if (mx) P.facing = mx > 0 ? 1 : -1; + } + } + if (P.swingT >= 0) { P.swingT += dt; trySwing(P, false); if (P.swingT > 0.4) P.swingT = -1; } + if (P.pose === 'swing' || P.pose === 'serve') { P.poseT += dt; if (P.poseT > 0.38) P.pose = 'idle'; } + + // --- AI + if (score.server === 'O' && phase === 'pretoss') { + aiServeT -= dt; + if (aiServeT <= 0) tossBall(O); + } else if (score.server === 'O' && phase === 'toss') { + if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { phaseWasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); } + } + if (phase === 'rally') { + const incoming = ball.active && ball.lastHit === 'P'; + const tx = incoming ? ball.x : 0; + const tz = incoming ? Math.max(0.5, Math.min(9.5, ball.z)) : 2.5; + const dx = tx - O.x, dz = tz - O.z, d = Math.hypot(dx, dz); + if (d > 0.1) { + O.x += dx / d * Math.min(TUNE.aiSpeed * dt, d); + O.z += dz / d * Math.min(TUNE.aiSpeed * dt, d) * 0.7; + O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt; + if (dx) O.facing = dx > 0 ? 1 : -1; + } else if (O.pose === 'run') O.pose = 'idle'; + aiSwingCool -= dt; + if (incoming && aiSwingCool <= 0 && O.swingT < 0) { + const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach + 0.4 && ball.y < 2.3 && ball.vz < 0; + if (near) { + if (Math.random() < TUNE.aiFlub) aiSwingCool = 0.5; // whiff: hesitates and misses + else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(O, true); O.swingT = -1; aiSwingCool = 0.4; } + } + } + } + if (O.pose === 'swing' || O.pose === 'serve') { O.poseT += dt; if (O.poseT > 0.38) O.pose = 'idle'; } + + // --- ball physics + if (ball.active) { + ball.vy -= TUNE.gravity * dt; + const oldZ = ball.z; + ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt; + // net: plane at netZ, only during rally flight + if (phase === 'rally' && (oldZ - COURT.netZ) * (ball.z - COURT.netZ) < 0 && Math.abs(ball.x) < COURT.netW / 2) { + const tCross = (COURT.netZ - oldZ) / (ball.z - oldZ); + const yAt = ball.y - ball.vy * dt * (1 - tCross); // approx height at crossing + if (yAt < COURT.netH) { + ball.z = COURT.netZ + (oldZ < COURT.netZ ? -0.05 : 0.05); + ball.vz *= -0.08; ball.vx *= 0.3; ball.vy = Math.min(ball.vy, 0); + pock(0.4, 300); + } + } + if (ball.y <= 0 && ball.vy < 0) { + ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY; + ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ; + onBounce(); + if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball + ball.active = false; + if (ball.bounces === 1) pointTo(ball.lastHit, null); + } + } + if (phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss + ball.active = false; phase = 'pretoss'; + if (score.server === 'O') aiServeT = 0.8; + } + } + + if (phase === 'over') { overT -= dt; if (overT <= 0) setupPoint(); } +} + +// ---------------------------------------------------------------- render ---- +function courtXZ(x, z) { return proj(x, 0, z); } +function line(x1, z1, x2, z2, w = 1) { + const a = courtXZ(x1, z1), b = courtXZ(x2, 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 = courtXZ(x1, z1), b = courtXZ(x2, z1), c = courtXZ(x2, z2), d = courtXZ(x1, 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 drawChar(ch) { + const p = proj(ch.x, 0, ch.z); + const hpx = 1.88 * p.s; + // pick sprite + const pre = ch.side === 'P' ? 'p_' : 'o_'; + let name = pre + 'idle'; + if (ch.pose === 'run') name = pre + (Math.floor(ch.runT * 8) % 2 ? 'run1' : 'run2'); + if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2'); + if (ch.pose === 'serve') name = pre + 'serve'; + const img = SPR[name]; + // shadow + ctx.fillStyle = 'rgba(0,0,0,0.28)'; + ctx.beginPath(); ctx.ellipse(p.x, p.y, hpx * 0.22, hpx * 0.06, 0, 0, 7); ctx.fill(); + if (img) { + const k = hpx / 56; // sprites share one scale; idle pose is 56px tall + 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) { // ponytail: replaced by generated sprites; kept as loading fallback + const you = ch.side === 'P'; + const u = h / 16, x = p.x, y = p.y; // 16-unit tall blocky figure + 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); // head + ctx.fillStyle = you ? '#804820' : '#282828'; ctx.fillRect(x - 2 * u, y - 16 * u, 4 * u, 1.2 * u); // hair + ctx.fillStyle = shirt; ctx.fillRect(x - 2.6 * u, y - 12 * u, 5.2 * u, 6 * u); // torso + ctx.fillStyle = shorts; ctx.fillRect(x - 2.4 * u, y - 6 * u, 4.8 * u, 3 * u); // shorts + 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); // legs + const f = ch.facing, swing = ch.pose === 'swing'; + ctx.strokeStyle = skin; ctx.lineWidth = u; + ctx.beginPath(); ctx.moveTo(x + f * 2.6 * u, y - 11 * u); + ctx.lineTo(x + f * (swing ? 5 : 3.6) * u, y - (swing ? 13 : 8) * u); ctx.stroke(); // arm + ctx.strokeStyle = '#c02020'; ctx.lineWidth = 1; + const rx = x + f * (swing ? 5.5 : 4) * u, ry = y - (swing ? 14 : 7.5) * u; + ctx.beginPath(); ctx.ellipse(rx, ry, 1.6 * u, 2.2 * u, f * 0.5, 0, 7); ctx.stroke(); // racket +} + +function render() { + // stands: tiered crowd from top of screen down to just above the far court + const standsBottom = proj(0, 0, 0).y - 12; + ctx.fillStyle = '#232e42'; ctx.fillRect(0, 0, W, standsBottom); + for (let r = 0; r < 16; r++) { // tier shading + ctx.fillStyle = r % 2 ? '#2a3750' : '#26324a'; + 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 = '#0e5c2e'; ctx.fillRect(0, standsBottom - 10, W, 10); + ctx.fillStyle = '#dfe8d0'; ctx.font = 'bold 7px monospace'; ctx.textBaseline = 'top'; + ctx.fillText('· SPLATTER OPEN · NEW BALLS PLEASE · SPLATTER OPEN ·', W / 2 - 128, standsBottom - 9); + + // ground + court (Super Tennis lawn) + quadXZ(-14, -6, 14, 30, '#2f7a35'); // surround + quadXZ(-COURT.W / 2 - 1.6, -2.5, COURT.W / 2 + 1.6, COURT.L + 2.5, '#3f9a45'); // apron + quadXZ(-COURT.W / 2, 0, COURT.W / 2, COURT.L, '#4cae52'); // court + // mow stripes + for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-COURT.W / 2, z, COURT.W / 2, z + 1.49, '#46a54c'); + // lines + line(-COURT.W / 2, 0, COURT.W / 2, 0, 2); line(-COURT.W / 2, COURT.L, COURT.W / 2, COURT.L, 2); + 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 + + // entities far→near, net between + drawChar(O); + // 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); + + // ball shadow + ball + if (ball.active || phase === 'over') { + const sh = proj(ball.x, 0, ball.z); + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.beginPath(); ctx.ellipse(sh.x, sh.y, Math.max(1.5, sh.s * 0.1), Math.max(1, sh.s * 0.04), 0, 0, 7); ctx.fill(); + const bp = proj(ball.x, ball.y, ball.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(); + 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)); + } + drawChar(P); + + // 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('CPU ' + score.games[1] + ' ' + ptTxt(1), 12, 22); + if (phase === 'pretoss' && score.server === 'P') { + ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; + ctx.fillText(serveNum === 2 ? 'SECOND SERVE — SPACE' : 'SPACE TO TOSS, SPACE TO SERVE', W / 2 - 80, H - 16); + } + if (flash) { + ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; + ctx.fillText(flash.text, W / 2 - 30, 60 + Math.sin(flash.t * 20) * 1); + } +} + +// ------------------------------------------------------------- debug panel ---- +let panel = null; +function togglePanel() { + if (panel) { panel.remove(); panel = null; return; } + panel = document.createElement('div'); + panel.style.cssText = 'position:fixed;top:8px;right:8px;background:#141a26;color:#cdd6e4;font:11px monospace;padding:8px;border:1px solid #3a4a66;max-height:90vh;overflow:auto'; + for (const k of Object.keys(TUNE)) { + const row = document.createElement('div'); + const v = TUNE[k]; + row.innerHTML = ``; + row.querySelector('input').oninput = e => TUNE[k] = parseFloat(e.target.value) || 0; + panel.appendChild(row); + } + document.body.appendChild(panel); +} + +// ------------------------------------------------------------------ loop ---- +let last = performance.now(); +function frame(now) { + const dt = Math.min(0.033, (now - last) / 1000); last = now; + update(dt); render(); + requestAnimationFrame(frame); +} +setupPoint(); +say('Quiet please. Ready? Play.'); +requestAnimationFrame(frame); + +// exposed for in-browser sanity checks +window.T = { TUNE, ball, P, O, score, solveShot, inServeBox, inCourt, proj, setupPoint, + get phase() { return phase; }, set phase(v) { phase = v; } }; diff --git a/index.html b/index.html new file mode 100644 index 0000000..4a2df10 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + +Splatter Tennis + + + + + +
move: WASD/arrows  ·  serve/swing: SPACE  ·  aim: hold a direction while swinging  ·  T: tuning panel
+ + +