From c506a631c20eab81e26e4a8c5bfe2abf846dd37b Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 15:38:15 +0200 Subject: [PATCH] Split the monolith into ES modules; hoist scattered magic numbers game.js (830 lines) -> 9 modules, native ES modules so still zero build step: dom canvas handles (imports nothing; keeps cycles harmless) config TUNE (live-tunable) + structural constants state all mutable game state (G) + setupPoint/sendBoy audio sfx synthesis, recorded umpire voice, reverb, speech fallback input keyboard + touch, exports axes and takeFlickAim() gameplay ballistics, swings, serve, rules, AI, update loop render camera, viewport, sprites, crowd, all drawing panel T tuning UI main boot, loop, intro, window.T test hooks Deduped into named constants: bounce tempo (was in 3 places), contact height band, intercept zone depth, home positions, player bounds, toss params, shot depths, pose hold time, sprite scale, actor heights. Also extracted drawShadow/drawBall/expirePose from repeated code. One intentional import cycle (input <-> gameplay), safe: both sides only call functions at runtime, never during module evaluation. Regression-verified in browser sims: point cycle, CPU returns 11/12, assist contract 8/8, corner-winner gradient intact (0/12 perfect corners returned, 10/12 center balls returned). Co-Authored-By: Claude Fable 5 --- Dockerfile | 2 +- audio.js | 101 +++++++ config.js | 57 ++++ dom.js | 3 + game.js | 822 ---------------------------------------------------- gameplay.js | 354 ++++++++++++++++++++++ index.html | 2 +- input.js | 75 +++++ main.js | 37 +++ panel.js | 18 ++ render.js | 242 ++++++++++++++++ state.js | 47 +++ 12 files changed, 936 insertions(+), 824 deletions(-) create mode 100644 audio.js create mode 100644 config.js create mode 100644 dom.js delete mode 100644 game.js create mode 100644 gameplay.js create mode 100644 input.js create mode 100644 main.js create mode 100644 panel.js create mode 100644 render.js create mode 100644 state.js 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 }; +}