diff --git a/Dockerfile b/Dockerfile index c0ee27f..7027d21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,5 +2,5 @@ FROM nginx:alpine # no-cache => browsers revalidate every request (ETag 304s are cheap), so a new # deploy is picked up on plain reload. ponytail: switch to hashed filenames if traffic ever matters. RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n etag on;\n location / { add_header Cache-Control "no-cache, must-revalidate"; }\n}\n' > /etc/nginx/conf.d/default.conf -COPY index.html game.js /usr/share/nginx/html/ +COPY index.html *.js /usr/share/nginx/html/ COPY assets /usr/share/nginx/html/assets diff --git a/audio.js b/audio.js new file mode 100644 index 0000000..160ba57 --- /dev/null +++ b/audio.js @@ -0,0 +1,101 @@ +// All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback. + +let AC = null; +export const audio = () => { + if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)(); + if (AC.state === 'suspended') AC.resume(); + return AC; +}; + +// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio — +// works on mobile where speechSynthesis is unreliable. say() remains the fallback. +const VOICE = {}; +let reverbNode = null; +function reverb(ac) { // stadium PA tail: exponentially decaying noise as impulse response + if (reverbNode) return reverbNode; + const len = ac.sampleRate * 1.3, ir = ac.createBuffer(2, len, ac.sampleRate); + for (let c = 0; c < 2; c++) { + const d = ir.getChannelData(c); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.8); + } + reverbNode = ac.createConvolver(); reverbNode.buffer = ir; + const wet = ac.createGain(); wet.gain.value = 0.35; + reverbNode.connect(wet).connect(ac.destination); + return reverbNode; +} + +export function voice(name, delay = 0) { + const ac = audio(); + const play = buf => { + const src = ac.createBufferSource(); src.buffer = buf; + src.connect(ac.destination); src.connect(reverb(ac)); // dry + center-court echo + src.start(ac.currentTime + delay); + }; + if (VOICE[name]) return play(VOICE[name]); + fetch('assets/audio/' + name + '.m4a') + .then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); }) + .then(ab => ac.decodeAudioData(ab)) + .then(buf => { VOICE[name] = buf; play(buf); }) + .catch(() => say(name.replace(/_/g, ' '), delay)); +} + +let sysVoice = null; +export function say(text, delay = 0) { // fallback when a recorded umpire line is missing + try { + if (!sysVoice) sysVoice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null; + const u = new SpeechSynthesisUtterance(text); + if (sysVoice) u.voice = sysVoice; u.rate = 0.88; u.pitch = 0.75; u.volume = 0.9; + setTimeout(() => speechSynthesis.speak(u), delay * 1000); + } catch (e) {} +} + +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; +} + +export function swish(vol = 0.3) { // racket cutting air — the sound of a whiff + try { + const ac = audio(), t = ac.currentTime; + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.16); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, t); + bp.frequency.exponentialRampToValueAtTime(3200, t + 0.12); bp.Q.value = 1.5; + const g = ac.createGain(); + g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05); + g.gain.exponentialRampToValueAtTime(0.001, t + 0.16); + src.connect(bp).connect(g).connect(ac.destination); src.start(); + } catch (e) {} +} + +export 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) {} +} + +export 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; + 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) {} +} diff --git a/config.js b/config.js new file mode 100644 index 0000000..72360e2 --- /dev/null +++ b/config.js @@ -0,0 +1,57 @@ +// All tuning and constants. TUNE is live-editable in game via the T panel; +// the exported consts below are structural values that code shares but the panel doesn't show. + +export const TUNE = { + camZ: 36, camY: 9, camF: 280, horizon: 40, + gravity: 9.2, // slightly floaty vs 9.8 for readability + spinFactor: 0.9, // extra dive on quality shots (fake topspin) + bounceY: 0.58, bounceXZ: 0.78, + playerSpeed: 5.4, aiSpeed: 4.3, + reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest + swingWindup: 0.06, // s before contact window opens + swingWindow: 0.16, // s contact window length + baseShotSpeed: 16, qualityShotSpeed: 12, // horizontal m/s = base + q*quality; perfect timing = real pace + serveSpeed: 21, serveQualitySpeed: 7, + aimError: 2.4, // m of scatter at quality 0 + perfectMs: 45, // |offset from window center| for PERFECT + aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09, + aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners + autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile) +}; + +export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 }; +COURT.svcFar = COURT.netZ - 6.4; // far service line +COURT.svcNear = COURT.netZ + 6.4; // near service line + +// world sizes (meters) +export const PLAYER_H = 1.88; +export const BOY_H = 1.2; +export const UMP_H = 2.7; + +// 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 }; + +// how deep from the far/near baseline a player's interception zone extends +export const ZONE_DEPTH = 9.5; + +// recovery positions between shots +export const HOME = { P: { x: 0, z: 22.8 }, O: { x: 0, z: 2.5 } }; + +// where the near player is allowed to move +export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 }; + +// serve toss: release height and upward speed (apex derives from these + gravity) +export const TOSS = { y0: 1.8, vy: 4.6 }; + +// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short) +export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 }; + +// how long swing/serve poses hold before returning to idle (s) +export const POSE_TIME = 0.38; + +// the server's idle ball bounce: sideways offset to the free hand, apex height, tempo. +// tempo drives the ball, the hand sprite AND the pock sound — one value, always in sync. +export const BOUNCE_IDLE = { dx: 0.5, apex: 0.55, tempo: 3.6 }; + +// generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it +export const SPRITE_H = 112; diff --git a/dom.js b/dom.js new file mode 100644 index 0000000..07b5bf9 --- /dev/null +++ b/dom.js @@ -0,0 +1,3 @@ +// Canvas handles — imported by everyone, imports nothing (keeps module cycles harmless). +export const cv = document.getElementById('c'); +export const ctx = cv.getContext('2d'); diff --git a/game.js b/game.js deleted file mode 100644 index ff75aa7..0000000 --- a/game.js +++ /dev/null @@ -1,822 +0,0 @@ -'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 - spinFactor: 0.9, // extra dive on quality shots (fake topspin) - bounceY: 0.58, bounceXZ: 0.78, - playerSpeed: 5.4, aiSpeed: 4.3, - reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest - swingWindup: 0.06, // s before contact window opens - swingWindow: 0.16, // s contact window length - baseShotSpeed: 16, qualityShotSpeed: 12, // horizontal m/s = base + q*quality; perfect timing = real pace - serveSpeed: 21, serveQualitySpeed: 7, - aimError: 2.4, // m of scatter at quality 0 - perfectMs: 45, // |offset from window center| for PERFECT - aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09, - aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners - autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile); slightly superhuman legs -}; - -const COURT = { L: 23.77, W: 8.23, dblW: 10.97, 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 RES = 2; // physical pixels per logical pixel — sprites keep 2x detail -let W = 480, H = 270; - -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); - -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 touchAxis = { x: 0, z: 0 }; -const axisX = () => touchAxis.x || (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0); -const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0); - -// One-handed touch: hold-drag = run, quick FLICK = swing aimed in the flick direction, -// plain tap = neutral swing / toss / serve. Extra finger tap still swings (two-thumb play). -let stick = null; // {id, x0, y0, t0, moved} -let flickAim = null; // {ax, az, t} aim override for the swing just triggered -const DEAD = 14; -cv.addEventListener('touchstart', e => { - e.preventDefault(); audio(); - for (const t of e.changedTouches) { - if (!stick) stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), moved: false }; - else keyPressed('Space'); - } -}, { passive: false }); -cv.addEventListener('touchmove', e => { - e.preventDefault(); - if (!stick) return; - for (const t of e.changedTouches) { - if (t.identifier !== stick.id) continue; - const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0; - if (Math.hypot(dx, dy) > DEAD) stick.moved = true; - touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; - touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; - } -}, { passive: false }); -const endTouch = e => { - e.preventDefault(); - for (const t of e.changedTouches) { - if (!stick || t.identifier !== stick.id) continue; - const dt = performance.now() - stick.t0; - const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); - if (!stick.moved && dt < 250) keyPressed('Space'); // tap - else if (dt < 300 && dist > 28) { // flick = aimed swing - flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, - az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, t: performance.now() }; - keyPressed('Space'); - } - stick = null; touchAxis.x = 0; touchAxis.z = 0; - } -}; -cv.addEventListener('touchend', endTouch, { passive: false }); -cv.addEventListener('touchcancel', endTouch, { passive: false }); - -// ----------------------------------------------------------------- audio ---- -let AC = null; -const audio = () => { - if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)(); - if (AC.state === 'suspended') AC.resume(); - return AC; -}; -// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio — -// works on mobile where speechSynthesis is unreliable. say() remains the fallback. -const VOICE = {}; -let reverbNode = null; -function reverb(ac) { // stadium PA tail: exponentially decaying noise as impulse response - if (reverbNode) return reverbNode; - const len = ac.sampleRate * 1.3, ir = ac.createBuffer(2, len, ac.sampleRate); - for (let c = 0; c < 2; c++) { - const d = ir.getChannelData(c); - for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.8); - } - reverbNode = ac.createConvolver(); reverbNode.buffer = ir; - const wet = ac.createGain(); wet.gain.value = 0.35; - reverbNode.connect(wet).connect(ac.destination); - return reverbNode; -} -function voice(name, delay = 0) { - const ac = audio(); - const play = buf => { - const src = ac.createBufferSource(); src.buffer = buf; - src.connect(ac.destination); src.connect(reverb(ac)); // dry + center-court echo - src.start(ac.currentTime + delay); - }; - if (VOICE[name]) return play(VOICE[name]); - fetch('assets/audio/' + name + '.m4a') - .then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); }) - .then(ab => ac.decodeAudioData(ab)) - .then(buf => { VOICE[name] = buf; play(buf); }) - .catch(() => say(name.replace(/_/g, ' '), delay)); -} -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 swish(vol = 0.3) { // racket cutting air — the sound of a whiff - try { - const ac = audio(), t = ac.currentTime; - const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.16); - const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, t); - bp.frequency.exponentialRampToValueAtTime(3200, t + 0.12); bp.Q.value = 1.5; - const g = ac.createGain(); - g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05); - g.gain.exponentialRampToValueAtTime(0.001, t + 0.16); - src.connect(bp).connect(g).connect(ac.destination); src.start(); - } catch (e) {} -} -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 sysVoice = null; -function say(text, delay = 0) { // fallback when a recorded umpire line is missing - try { - if (!sysVoice) sysVoice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null; - const u = new SpeechSynthesisUtterance(text); - if (sysVoice) u.voice = sysVoice; 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_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']) { - 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, idleT = 0, outMarker = null; // flash = {text,x,y,t} -let boy = null; // a ball boy sprints across the field, scoops the dead ball, exits the far side -function sendBoy() { - if (ball.z < -3 || ball.z > 27) return; // ball left the field lengthwise, let it go - const dir = ball.x > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run - boy = { x: dir > 0 ? -13 : 13, z: ball.z, dir, runT: 0 }; -} -let aiServeT = 0, aiSwingCool = 0, aiReactT = 9; -const crowd = { cheer: 0, dots: [] }; -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] - }); -} -buildCrowd(); - -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; - P.facing = O.facing = 1; // canonical righty stance — bounce/serve sprites must not render mirrored - aiServeT = score.server === 'O' ? 1.4 : 0; -} - -// ------------------------------------------------------------- ballistics ---- -// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. -function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) { - 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 * g * 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'; - // snap the ball to the racket at the contact frame so the hit visually connects - ball.x = ch.x + ch.facing * 0.5; - ball.z = ch.z + (toFar ? -0.3 : 0.3); - const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); - let tx = aim.ax * (ch.side === 'P' ? 3.3 : 2.7) + err(); // your aimed shots go properly wide - 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)); - let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; - // topspin: quality shots dive under extra effective gravity, which is what lets real - // pace arc over the net and still land in — the core tennis trade-off - const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); - let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); - // low contact → trade pace for loft until the net is cleared; good timing gets a - // comfortable margin, sloppy timing keeps some tape risk - const margin = 0.06 + 0.22 * quality; - for (let i = 0; i < 8; i++) { - const tN = (COURT.netZ - ball.z) / v.vz; - const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN; - if (tN <= 0 || yN >= COURT.netH + margin) break; - s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); - } - ball.g = g; - ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; - ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; - if (ch.side === 'P') { // shot marker: where this will land — red means it's going out - const ok = Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ; - outMarker = { x: tx, z: tz, out: !ok, t: 1.3 }; - aiReactT = 0; // CPU hasn't read the shot yet - } - 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.collected = false; - 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; ball.g = TUNE.gravity; - 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 tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; - const g = TUNE.gravity * (1 + quality * 0.35); // kick serves dip a little too - const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g); - ball.g = g; - ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; - ball.lastHit = sv.side; ball.bounces = 0; - if (sv.side === 'P') outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 }; - 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 scoreKey() { - const N = ['love', 'fifteen', 'thirty', 'forty']; - 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 (s[0] > s[1]) === (score.server === 'P') ? 'advantage_player' : 'advantage_receiver'; - } - if (s[0] === s[1]) return N[s[0]] + '_all'; - return N[s[0]] + '_' + N[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; - sendBoy(); - if (reason) voice(reason); - if (gameOver) { - voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); - applause(2.8, 0.6); - score.server = score.server === 'P' ? 'O' : 'P'; - } else { - voice(scoreKey(), 0.7); - if (side === 'P') applause(1.2, 0.3); - } - serveNum = 1; -} -function fault() { - if (serveNum === 1) { serveNum = 2; voice('fault'); phase = 'over'; overT = 1.4; sendBoy(); } - 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 ---- -// Where should a player run to? Simulate the ball forward and take the first point in -// their half at hitting height that they can actually REACH IN TIME; otherwise chase the -// deepest hittable point. This is the "every ball is catchable, timing is the skill" contract. -function intercept(side) { - const ch = side === 'O' ? O : P; - const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist; - let { x, y, z, vx, vy, vz } = ball; - const g = ball.g || TUNE.gravity, dt = 0.01; - const inZone = side === 'O' ? zz => zz <= 9.5 : zz => zz >= COURT.L - 9.5; - const home = side === 'O' ? { x: 0, z: 2.5 } : { x: 0, z: 22.8 }; - let fallback = null, deepest = null; - for (let i = 0; i < 300; i++) { - vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; - if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; } - if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) { - const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) }; - const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt; - if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time - if (reachable && !fallback) fallback = pt; // low but arrivable - deepest = pt; // last resort: run at the deepest hittable point - } - } - return fallback || deepest || home; -} - -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; - if (ball.y < 0.24) return; // below the ankles the ball is past you — no grass-scraping saves - // 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 fresh = flickAim && performance.now() - flickAim.t < 450; - const aim = isAI - ? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } - : fresh ? { ax: flickAim.ax, az: flickAim.az } - : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; - if (fresh && !isAI) flickAim = null; - 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); - idleT += dt; - if (phase === 'pretoss' && Math.sin(idleT * 3.6) * Math.sin((idleT - dt) * 3.6) < 0) pock(0.12, 420); // server bouncing the ball - if (phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } - - // --- 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; - let assist = 0; - if (!mx && !mz && TUNE.autoAssist > 0 && ball.active) { - const chasing = ball.lastHit === 'O'; - // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it - const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55); - const tgt = chasing ? intercept('P') : { x: 0, z: 22.8 }; // run to the ball, then recover home - const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); - if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it - P.x += dx / d * Math.min(rate, d); - P.z += dz / d * Math.min(rate, d); - assist = dx; - } - } - 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)); - const moving = mx || mz || assist !== 0; - P.runT += moving ? dt : 0; - if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle'; - if (mx) P.facing = mx > 0 ? 1 : -1; - else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing - } - } - if (P.swingT >= 0) { - P.swingT += dt; trySwing(P, false); // a contact resets swingT to -1 inside trySwing - if (P.swingT > TUNE.swingWindup + TUNE.swingWindow + 0.02) { P.swingT = -1; if (phase === 'rally') swish(); } - } - 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') { - aiReactT += dt; - const incoming = ball.active && ball.lastHit === 'P' && aiReactT >= TUNE.aiReaction; - const tgt = incoming ? intercept('O') : { x: 0, z: 2.5 }; - const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points - const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz); - if (d > 0.1) { - O.x += dx / d * Math.min(spd * dt, d); - O.z += dz / d * Math.min(spd * dt, d); - O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt; - if (Math.abs(dx) > 0.25) O.facing = dx > 0 ? 1 : -1; - } else if (O.pose === 'run') O.pose = 'idle'; - aiSwingCool -= dt; - if (incoming && aiSwingCool <= 0 && O.swingT < 0) { - // same radius trySwing uses for contact, so an armed swing actually connects - const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach && ball.y < 2.3 && ball.y > 0.24 && 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 = ball.lastHit === 'O' ? 0.4 : 0.12; } - } - } - } - 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 -= (ball.g || 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; - } - } - - // ball boy: sprint across, scoop the ball in passing, despawn off the far side - if (boy) { - boy.x += boy.dir * 7.5 * dt; boy.runT += dt; - if (!ball.collected && !ball.active) { - const dz = ball.z - boy.z; - boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little - if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; - } - if (Math.abs(boy.x) > 13.5) boy = null; - } - if (outMarker && (outMarker.t -= dt) <= 0) outMarker = null; - - if (phase === 'over') { overT -= dt; if (overT <= 0) setupPoint(); } -} - -function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters - const g = ball.g || TUNE.gravity; - const disc = ball.vy * ball.vy + 2 * g * ball.y; - if (disc < 0) return null; - const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0 - return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t }; -} - -// ---------------------------------------------------------------- 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 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; - ctx.fillStyle = 'rgba(0,0,0,0.25)'; - ctx.beginPath(); ctx.ellipse(p.x, p.y, w * 0.4, Math.max(1, h * 0.04), 0, 0, 7); ctx.fill(); - 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 = 1.2 * p.s, w = h * img.width / img.height; - ctx.fillStyle = 'rgba(0,0,0,0.25)'; - ctx.beginPath(); ctx.ellipse(p.x, p.y, w * 0.3, Math.max(1, h * 0.04), 0, 0, 7); ctx.fill(); - 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 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 = phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle'; - const sway = receiverWaiting ? Math.sin(idleT * 3.2) : 0; - const p = proj(ch.x + sway * 0.12, 0, ch.z); - const hpx = 1.88 * p.s; - // pick sprite - const pre = ch.side === 'P' ? 'p_' : 'o_'; - let name = pre + (Math.floor(idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift - if (phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') { - const w = idleT * 3.6; // 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 + (phase === 'toss' ? 'toss' : 'smash'); - if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob - 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 / 112; // sprites share one scale; idle pose is 112px 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() { - ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density - // 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.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, '#295c2d'); // 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, '#4cae52'); // lawn (doubles court) - for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), '#46a54c'); // 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 - - // landing rings: white = incoming ball, yellow/red = your shot (red lands out) - if (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 (outMarker) drawRing(outMarker.x, outMarker.z, outMarker.out ? '#ff5050' : '#f8e048'); - - // far-side drawables in z order (far → near), then the net, then the near side - const farSide = [{ z: O.z, f: () => drawChar(O) }, { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, 2.7) }]; - if (boy && boy.z <= COURT.netZ) farSide.push({ z: boy.z, f: () => drawBoy(boy) }); - 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 (boy && boy.z > COURT.netZ) drawBoy(boy); - - // server idly bouncing the ball while waiting - if (phase === 'pretoss') { - const sv = score.server === 'P' ? P : O; - const bx = sv.x - 0.5 * (sv.side === 'P' ? 1 : -1), by = Math.abs(Math.sin(idleT * 3.6)) * 0.55; // floor to the reaching hand - const sh = proj(bx, 0, sv.z); - ctx.fillStyle = 'rgba(0,0,0,0.3)'; - ctx.beginPath(); ctx.ellipse(sh.x, sh.y, Math.max(1.5, sh.s * 0.08), Math.max(1, sh.s * 0.035), 0, 0, 7); ctx.fill(); - const bp = proj(bx, by, sv.z); - ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1.5, bp.s * 0.09), 0, 7); ctx.fill(); - } - // ball shadow + ball - if ((ball.active || phase === 'over') && !ball.collected) { - 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); - ctx.textAlign = 'center'; - if (phase === 'pretoss' && score.server === 'P') { - ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; - const touch = matchMedia('(pointer: coarse)').matches; - ctx.fillText(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 (flash) { - ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; - ctx.fillText(flash.text, W / 2, 60 + Math.sin(flash.t * 20) * 1); - } - ctx.textAlign = 'left'; -} - -// ------------------------------------------------------------- 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); -} -fitViewport(); -setupPoint(); -// intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play -const intro = document.getElementById('intro'); -function dismissIntro() { - if (!intro.parentNode) return; - intro.remove(); voice('quiet_please'); -} -intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); }); -addEventListener('keydown', () => dismissIntro(), { once: true }); -requestAnimationFrame(frame); - -// exposed for in-browser sanity checks -window.T = { TUNE, ball, P, O, score, solveShot, inServeBox, inCourt, proj, setupPoint, - step: dt => update(dt), press: keyPressed, - get phase() { return phase; }, set phase(v) { phase = v; } }; diff --git a/gameplay.js b/gameplay.js new file mode 100644 index 0000000..8817e5d --- /dev/null +++ b/gameplay.js @@ -0,0 +1,354 @@ +// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. + +import { TUNE, COURT, CONTACT, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; +import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; +import { audio, pock, swish, applause, voice } from './audio.js'; +import { axisX, axisZ, takeFlickAim } from './input.js'; + +// ------------------------------------------------------------- ballistics ---- +// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. +export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) { + 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 * g * T * T) / T }; +} + +export function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters + const g = ball.g || TUNE.gravity; + const disc = ball.vy * ball.vy + 2 * g * ball.y; + if (disc < 0) return null; + const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0 + return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t }; +} + +function doHit(ch, quality, aim) { + // aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short) + const toFar = ch.side === 'P'; + // snap the ball to the racket at the contact frame so the hit visually connects + ball.x = ch.x + ch.facing * 0.5; + ball.z = ch.z + (toFar ? -0.3 : 0.3); + const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); + let tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide + const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; + const tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; + tx = Math.max(-5, Math.min(5, tx)); + let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; + // topspin: quality shots dive under extra effective gravity, which is what lets real + // pace arc over the net and still land in — the core tennis trade-off + const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); + let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); + // low contact → trade pace for loft until the net is cleared; good timing gets a + // comfortable margin, sloppy timing keeps some tape risk + const margin = 0.06 + 0.22 * quality; + for (let i = 0; i < 8; i++) { + const tN = (COURT.netZ - ball.z) / v.vz; + const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN; + if (tN <= 0 || yN >= COURT.netH + margin) break; + s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); + } + ball.g = g; + ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; + ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; + if (ch.side === 'P') { // shot marker: where this will land — red means it's going out + const ok = Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ; + G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 }; + G.aiReactT = 0; // CPU hasn't read the shot yet + } + pock(0.9, 950); + if (quality > 0.999) { G.hitstop = 0.05; G.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.collected = false; + ball.x = sv.x + 0.35 * (sv.side === 'P' ? 1 : -1); ball.z = sv.z; ball.y = TOSS.y0; + ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity; + G.phase = 'toss'; +} +export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (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 tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; + const g = TUNE.gravity * (1 + quality * 0.35); // kick serves dip a little too + const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g); + ball.g = g; + ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; + ball.lastHit = sv.side; ball.bounces = 0; + if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 }; + G.phase = 'rally'; pock(1, 1100); + sv.pose = 'serve'; sv.poseT = 0; + if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; } +} + +// ---------------------------------------------------------------- scoring ---- +function scoreKey() { + const N = ['love', 'fifteen', 'thirty', 'forty']; + 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 (s[0] > s[1]) === (score.server === 'P') ? 'advantage_player' : 'advantage_receiver'; + } + if (s[0] === s[1]) return N[s[0]] + '_all'; + return N[s[0]] + '_' + N[s[1]]; +} + +function cheer(seconds, vol) { + applause(seconds, vol); + crowd.cheer = Math.max(crowd.cheer, seconds); +} + +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; + } + G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; + sendBoy(); + if (reason) voice(reason); + if (gameOver) { + voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); + cheer(2.8, 0.6); + score.server = other(score.server); + } else { + voice(scoreKey(), 0.7); + if (side === 'P') cheer(1.2, 0.3); + } + G.serveNum = 1; +} + +function fault() { + if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); } + else pointTo(other(score.server), 'double_fault'); +} + +export function inCourt(x, z) { return Math.abs(x) <= COURT.W / 2 + 0.02 && z >= -0.02 && z <= COURT.L + 0.02; } +export 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 = G.wasServe; G.wasServe = 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); + } +} + +// -------------------------------------------------------------- movement ---- +// Where should a player run to? Simulate the ball forward and take the first point in +// their half at hitting height that they can actually REACH IN TIME; otherwise chase the +// deepest hittable point. This is the "every ball is catchable, timing is the skill" contract. +function intercept(side) { + const ch = side === 'O' ? O : P; + const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist; + let { x, y, z, vx, vy, vz } = ball; + const g = ball.g || TUNE.gravity, dt = 0.01; + const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; + const home = HOME[side]; + let fallback = null, deepest = null; + for (let i = 0; i < 300; i++) { + vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; + if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; } + if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) { + const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) }; + const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt; + if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time + if (reachable && !fallback) fallback = pt; // low but arrivable + deepest = pt; // last resort: run at the deepest hittable point + } + } + return fallback || deepest || home; +} + +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 || G.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 > CONTACT.maxY) return; + if (ball.y < CONTACT.minY) return; // below the ankles the ball is past you — no grass-scraping saves + // 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 flick = isAI ? null : takeFlickAim(); + const aim = isAI + ? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } + : flick || { 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; +} + +export function keyPressed(code) { + if (code !== 'Space') return; + audio(); // unlock on first gesture + if (G.phase === 'pretoss' && score.server === 'P') { tossBall(P); return; } + if (G.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; + G.wasServe = true; + hitServe(P, q); + return; + } + if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; } +} + +function expirePose(ch, dt) { + if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; } +} + +// ---------------------------------------------------------------- update ---- +export function update(dt) { + if (G.hitstop > 0) { G.hitstop -= dt; return; } + if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; } + crowd.cheer = Math.max(0, crowd.cheer - dt); + G.idleT += dt; + const w = BOUNCE_IDLE.tempo; + if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce + if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } + + // --- player movement (near half only, generous margins) + if (G.phase === 'rally') { + const slow = P.swingT >= 0 ? 0.35 : 1; + const mx = axisX(), mz = axisZ(); + P.x += mx * TUNE.playerSpeed * slow * dt; + P.z += mz * TUNE.playerSpeed * slow * dt; + let assist = 0; + if (!mx && !mz && TUNE.autoAssist > 0 && ball.active) { + const chasing = ball.lastHit === 'O'; + // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it + const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55); + const tgt = chasing ? intercept('P') : HOME.P; // run to the ball, then recover home + const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); + if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it + P.x += dx / d * Math.min(rate, d); + P.z += dz / d * Math.min(rate, d); + assist = dx; + } + } + P.x = Math.max(-P_BOUNDS.x, Math.min(P_BOUNDS.x, P.x)); + P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); + const moving = mx || mz || assist !== 0; + P.runT += moving ? dt : 0; + if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle'; + if (mx) P.facing = mx > 0 ? 1 : -1; + else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing + } + if (P.swingT >= 0) { + P.swingT += dt; trySwing(P, false); // a contact resets swingT to -1 inside trySwing + if (P.swingT > TUNE.swingWindup + TUNE.swingWindow + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); } + } + expirePose(P, dt); + + // --- AI + if (score.server === 'O' && G.phase === 'pretoss') { + G.aiServeT -= dt; + if (G.aiServeT <= 0) tossBall(O); + } else if (score.server === 'O' && G.phase === 'toss') { + if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); } + } + if (G.phase === 'rally') { + G.aiReactT += dt; + const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction; + const tgt = incoming ? intercept('O') : HOME.O; + const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points + const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz); + if (d > 0.1) { + O.x += dx / d * Math.min(spd * dt, d); + O.z += dz / d * Math.min(spd * dt, d); + O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt; + if (Math.abs(dx) > 0.25) O.facing = dx > 0 ? 1 : -1; + } else if (O.pose === 'run') O.pose = 'idle'; + G.aiSwingCool -= dt; + if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) { + // same radius trySwing uses for contact, so an armed swing actually connects + const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach + && ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0; + if (near) { + if (Math.random() < TUNE.aiFlub) G.aiSwingCool = 0.5; // whiff: hesitates and misses + else { + O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; + trySwing(O, true); O.swingT = -1; + G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12; + } + } + } + } + expirePose(O, dt); + + // --- ball physics + if (ball.active) { + ball.vy -= (ball.g || 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 (G.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 (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss + ball.active = false; G.phase = 'pretoss'; + if (score.server === 'O') G.aiServeT = 0.8; + } + } + + // ball boy: sprint across, scoop the ball in passing, despawn off the far side + const boy = G.boy; + if (boy) { + boy.x += boy.dir * 7.5 * dt; boy.runT += dt; + if (!ball.collected && !ball.active) { + const dz = ball.z - boy.z; + boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little + if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; + } + if (Math.abs(boy.x) > 13.5) G.boy = null; + } + if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null; + + if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) setupPoint(); } +} diff --git a/index.html b/index.html index d8eb517..f96f26f 100644 --- a/index.html +++ b/index.html @@ -35,6 +35,6 @@

TAP TO START

- + diff --git a/input.js b/input.js new file mode 100644 index 0000000..7c05d6b --- /dev/null +++ b/input.js @@ -0,0 +1,75 @@ +// Keyboard + one-handed touch. Exposes movement axes and the flick-aim for gameplay. +// Note: input <-> gameplay is an intentional import cycle; both only call each other's +// functions at runtime, never during module evaluation. + +import { cv } from './dom.js'; +import { audio } from './audio.js'; +import { keyPressed } from './gameplay.js'; +import { togglePanel } from './panel.js'; + +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 touchAxis = { x: 0, z: 0 }; +export const axisX = () => touchAxis.x || (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0); +export const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0); + +// One-handed touch: hold-drag = run, quick FLICK = swing aimed in the flick direction, +// plain tap = neutral swing / toss / serve. Extra finger tap still swings (two-thumb play). +let stick = null; // {id, x0, y0, t0, moved} +let flickAim = null; // {ax, az, t} aim override for the swing just triggered +const DEAD = 14; + +// gameplay consumes the flick at contact; returns null if none or expired +export function takeFlickAim(maxAgeMs = 450) { + if (flickAim && performance.now() - flickAim.t < maxAgeMs) { + const aim = { ax: flickAim.ax, az: flickAim.az }; + flickAim = null; + return aim; + } + return null; +} + +cv.addEventListener('touchstart', e => { + e.preventDefault(); audio(); + for (const t of e.changedTouches) { + if (!stick) stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), moved: false }; + else keyPressed('Space'); + } +}, { passive: false }); + +cv.addEventListener('touchmove', e => { + e.preventDefault(); + if (!stick) return; + for (const t of e.changedTouches) { + if (t.identifier !== stick.id) continue; + const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0; + if (Math.hypot(dx, dy) > DEAD) stick.moved = true; + touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; + touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; + } +}, { passive: false }); + +const endTouch = e => { + e.preventDefault(); + for (const t of e.changedTouches) { + if (!stick || t.identifier !== stick.id) continue; + const dt = performance.now() - stick.t0; + const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); + if (!stick.moved && dt < 250) keyPressed('Space'); // tap + else if (dt < 300 && dist > 28) { // flick = aimed swing + flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, + az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, t: performance.now() }; + keyPressed('Space'); + } + stick = null; touchAxis.x = 0; touchAxis.z = 0; + } +}; +cv.addEventListener('touchend', endTouch, { passive: false }); +cv.addEventListener('touchcancel', endTouch, { passive: false }); diff --git a/main.js b/main.js new file mode 100644 index 0000000..368dd44 --- /dev/null +++ b/main.js @@ -0,0 +1,37 @@ +// Splatter Tennis — boot, main loop, intro overlay, and the window.T test hooks. +// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline. +// 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 } from './config.js'; +import { P, O, ball, score, G, setupPoint } from './state.js'; +import { update, keyPressed, solveShot, inServeBox, inCourt } from './gameplay.js'; +import { fitViewport, render, proj } from './render.js'; +import { voice } from './audio.js'; +import './input.js'; + +let last = performance.now(); +function frame(now) { + const dt = Math.min(0.033, (now - last) / 1000); last = now; + update(dt); render(); + requestAnimationFrame(frame); +} + +fitViewport(); +setupPoint(); + +// intro overlay: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play +const intro = document.getElementById('intro'); +function dismissIntro() { + if (!intro.parentNode) return; + intro.remove(); voice('quiet_please'); +} +intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); }); +addEventListener('keydown', () => dismissIntro(), { once: true }); + +requestAnimationFrame(frame); + +// exposed for in-browser sanity checks and simulated playtests +window.T = { TUNE, ball, P, O, score, solveShot, inServeBox, inCourt, proj, setupPoint, + step: dt => update(dt), press: keyPressed, + get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/panel.js b/panel.js new file mode 100644 index 0000000..bfc7c6e --- /dev/null +++ b/panel.js @@ -0,0 +1,18 @@ +// Live tuning panel (T key): number inputs for every TUNE value. + +import { TUNE } from './config.js'; + +let panel = null; +export 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); +} diff --git a/render.js b/render.js new file mode 100644 index 0000000..75d5c0d --- /dev/null +++ b/render.js @@ -0,0 +1,242 @@ +// 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'; + +// ---------------------------------------------------------------- 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']) { + 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 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 + + // 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++) { + 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.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, '#295c2d'); // 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, '#4cae52'); // lawn (doubles court) + for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-dw, z, dw, Math.min(z + 1.49, COURT.L), '#46a54c'); // 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 + + // 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: O.z, f: () => drawChar(O) }, + { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, UMP_H) }, + ]; + if (G.boy && G.boy.z <= COURT.netZ) farSide.push({ z: G.boy.z, f: () => drawBoy(G.boy) }); + 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); + + // 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); + 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); + ctx.textAlign = 'center'; + if (G.phase === 'pretoss' && score.server === 'P') { + 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); + } + ctx.textAlign = 'left'; +} diff --git a/state.js b/state.js new file mode 100644 index 0000000..517ad8a --- /dev/null +++ b/state.js @@ -0,0 +1,47 @@ +// All mutable game state in one place, plus the point-reset that re-arms it. + +import { HOME } from './config.js'; + +export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; +export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; +export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, + active: false, lastHit: 'P', bounces: 0, firstLand: null, collected: false }; +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 + +// everything that used to be loose top-level lets +export const G = { + phase: 'pretoss', // pretoss | toss | rally | over + serveNum: 1, + overT: 0, + hitstop: 0, + flash: null, // {text, t} + idleT: 0, + outMarker: null, // {x, z, out, t} — where your last shot lands + boy: null, // ball boy sprinting across: {x, z, dir, runT} + aiServeT: 0, + aiSwingCool: 0, + aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction + wasServe: false, // the ball in flight is a serve (first bounce must hit the box) +}; + +export const other = s => s === 'P' ? 'O' : 'P'; +export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side + +export function setupPoint() { + G.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 : HOME.O.z - 1.3; + P.pose = O.pose = 'idle'; P.swingT = O.swingT = -1; + P.facing = O.facing = 1; // canonical righty stance — bounce/serve sprites must not render mirrored + G.aiServeT = score.server === 'O' ? 1.4 : 0; +} + +export function sendBoy() { + if (ball.z < -3 || ball.z > 27) return; // ball left the field lengthwise, let it go + const dir = ball.x > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run + G.boy = { x: dir > 0 ? -13 : 13, z: ball.z, dir, runT: 0 }; +}