diff --git a/Dockerfile b/Dockerfile index 1254b88..c0ee27f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,6 @@ 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 assets /usr/share/nginx/html/assets diff --git a/assets/_sheet_extras.png b/assets/_sheet_extras.png new file mode 100644 index 0000000..711c154 Binary files /dev/null and b/assets/_sheet_extras.png differ diff --git a/assets/audio/advantage_player.m4a b/assets/audio/advantage_player.m4a new file mode 100644 index 0000000..e685fb5 Binary files /dev/null and b/assets/audio/advantage_player.m4a differ diff --git a/assets/audio/advantage_receiver.m4a b/assets/audio/advantage_receiver.m4a new file mode 100644 index 0000000..6cf8789 Binary files /dev/null and b/assets/audio/advantage_receiver.m4a differ diff --git a/assets/audio/deuce.m4a b/assets/audio/deuce.m4a new file mode 100644 index 0000000..a18b300 Binary files /dev/null and b/assets/audio/deuce.m4a differ diff --git a/assets/audio/double_fault.m4a b/assets/audio/double_fault.m4a new file mode 100644 index 0000000..7082b16 Binary files /dev/null and b/assets/audio/double_fault.m4a differ diff --git a/assets/audio/fault.m4a b/assets/audio/fault.m4a new file mode 100644 index 0000000..e059a1b Binary files /dev/null and b/assets/audio/fault.m4a differ diff --git a/assets/audio/fifteen_all.m4a b/assets/audio/fifteen_all.m4a new file mode 100644 index 0000000..ed44973 Binary files /dev/null and b/assets/audio/fifteen_all.m4a differ diff --git a/assets/audio/fifteen_forty.m4a b/assets/audio/fifteen_forty.m4a new file mode 100644 index 0000000..8641e9e Binary files /dev/null and b/assets/audio/fifteen_forty.m4a differ diff --git a/assets/audio/fifteen_love.m4a b/assets/audio/fifteen_love.m4a new file mode 100644 index 0000000..65ffa29 Binary files /dev/null and b/assets/audio/fifteen_love.m4a differ diff --git a/assets/audio/fifteen_thirty.m4a b/assets/audio/fifteen_thirty.m4a new file mode 100644 index 0000000..03e9e51 Binary files /dev/null and b/assets/audio/fifteen_thirty.m4a differ diff --git a/assets/audio/forty_fifteen.m4a b/assets/audio/forty_fifteen.m4a new file mode 100644 index 0000000..ba544ad Binary files /dev/null and b/assets/audio/forty_fifteen.m4a differ diff --git a/assets/audio/forty_love.m4a b/assets/audio/forty_love.m4a new file mode 100644 index 0000000..caf9b35 Binary files /dev/null and b/assets/audio/forty_love.m4a differ diff --git a/assets/audio/forty_thirty.m4a b/assets/audio/forty_thirty.m4a new file mode 100644 index 0000000..67c7c85 Binary files /dev/null and b/assets/audio/forty_thirty.m4a differ diff --git a/assets/audio/game_opponent.m4a b/assets/audio/game_opponent.m4a new file mode 100644 index 0000000..886dc25 Binary files /dev/null and b/assets/audio/game_opponent.m4a differ diff --git a/assets/audio/game_player.m4a b/assets/audio/game_player.m4a new file mode 100644 index 0000000..7dd3200 Binary files /dev/null and b/assets/audio/game_player.m4a differ diff --git a/assets/audio/love_fifteen.m4a b/assets/audio/love_fifteen.m4a new file mode 100644 index 0000000..d2c6fc1 Binary files /dev/null and b/assets/audio/love_fifteen.m4a differ diff --git a/assets/audio/love_forty.m4a b/assets/audio/love_forty.m4a new file mode 100644 index 0000000..ef07924 Binary files /dev/null and b/assets/audio/love_forty.m4a differ diff --git a/assets/audio/love_thirty.m4a b/assets/audio/love_thirty.m4a new file mode 100644 index 0000000..8011262 Binary files /dev/null and b/assets/audio/love_thirty.m4a differ diff --git a/assets/audio/out.m4a b/assets/audio/out.m4a new file mode 100644 index 0000000..10a3841 Binary files /dev/null and b/assets/audio/out.m4a differ diff --git a/assets/audio/quiet_please.m4a b/assets/audio/quiet_please.m4a new file mode 100644 index 0000000..9d4a9de Binary files /dev/null and b/assets/audio/quiet_please.m4a differ diff --git a/assets/audio/thirty_all.m4a b/assets/audio/thirty_all.m4a new file mode 100644 index 0000000..460106a Binary files /dev/null and b/assets/audio/thirty_all.m4a differ diff --git a/assets/audio/thirty_fifteen.m4a b/assets/audio/thirty_fifteen.m4a new file mode 100644 index 0000000..29c0099 Binary files /dev/null and b/assets/audio/thirty_fifteen.m4a differ diff --git a/assets/audio/thirty_forty.m4a b/assets/audio/thirty_forty.m4a new file mode 100644 index 0000000..4612416 Binary files /dev/null and b/assets/audio/thirty_forty.m4a differ diff --git a/assets/audio/thirty_love.m4a b/assets/audio/thirty_love.m4a new file mode 100644 index 0000000..c77a460 Binary files /dev/null and b/assets/audio/thirty_love.m4a differ diff --git a/assets/x_boy_kneel.png b/assets/x_boy_kneel.png new file mode 100644 index 0000000..84f25f5 Binary files /dev/null and b/assets/x_boy_kneel.png differ diff --git a/assets/x_boy_stand.png b/assets/x_boy_stand.png new file mode 100644 index 0000000..d6b572c Binary files /dev/null and b/assets/x_boy_stand.png differ diff --git a/assets/x_ump.png b/assets/x_ump.png new file mode 100644 index 0000000..ab9a6b4 Binary files /dev/null and b/assets/x_ump.png differ diff --git a/game.js b/game.js index d1e1c07..3ab31a4 100644 --- a/game.js +++ b/game.js @@ -21,7 +21,7 @@ const TUNE = { autoAssist: matchMedia('(pointer: coarse)').matches ? 1.0 : 0, // auto-run toward the ball (mobile) }; -const COURT = { L: 23.77, W: 8.23, netZ: 11.885, netH: 0.914, netW: 10.06, serviceD: 6.4 }; +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 @@ -107,12 +107,26 @@ cv.addEventListener('touchcancel', endTouch, { passive: false }); // ----------------------------------------------------------------- audio ---- let AC = null; const audio = () => { - if (!AC) { - AC = new (window.AudioContext || window.webkitAudioContext)(); - try { speechSynthesis.resume(); speechSynthesis.speak(new SpeechSynthesisUtterance(' ')); } catch (e) {} // iOS: unlock voice inside a user gesture - } + 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 = {}; +function voice(name, delay = 0) { + const ac = audio(); + const play = buf => { + const src = ac.createBufferSource(); src.buffer = buf; + src.connect(ac.destination); 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; @@ -149,12 +163,12 @@ function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized claps; sw } } catch (e) {} } -let voice = null; -function say(text, delay = 0) { // ponytail: speechSynthesis umpire; swap for recorded lines if it reads flat +let sysVoice = null; +function say(text, delay = 0) { // fallback when a recorded umpire line is missing try { - if (!voice) voice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null; + if (!sysVoice) sysVoice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null; const u = new SpeechSynthesisUtterance(text); - if (voice) u.voice = voice; u.rate = 0.88; u.pitch = 0.75; u.volume = 0.9; + 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) {} } @@ -163,7 +177,8 @@ function say(text, delay = 0) { // ponytail: speechSynthesis umpire; swap for re // 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_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2', - 'o_idle1','o_idle2','o_bounce','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2']) { + 'o_idle1','o_idle2','o_bounce','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', + 'x_ump','x_boy_kneel','x_boy_stand']) { const img = new Image(); img.onload = () => SPR[n] = img; img.src = 'assets/' + n + '.png'; @@ -257,14 +272,15 @@ function hitServe(sv, quality) { } // ---------------------------------------------------------------- scoring ---- -function callScore() { +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 'Advantage ' + ((s[0] > s[1]) === (score.server === 'P') ? 'player' : 'receiver'); + 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 PTS[s[0]] + ' all'; - return PTS[s[0]] + ', ' + PTS[s[1]]; + 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; @@ -274,20 +290,20 @@ function pointTo(side, reason) { score.games[i]++; score.pts = [0, 0]; gameOver = true; } phase = 'over'; overT = gameOver ? 3.2 : 2.2; - if (reason) say(reason); + if (reason) voice(reason); if (gameOver) { - say('Game. ' + (side === 'P' ? 'Player' : 'Opponent') + ' leads ' + Math.max(score.games[0], score.games[1]) + ' games to ' + Math.min(score.games[0], score.games[1]) + '.', 0.6); + voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); applause(2.8, 0.6); score.server = score.server === 'P' ? 'O' : 'P'; } else { - say(callScore(), 0.7); + voice(scoreKey(), 0.7); if (side === 'P') applause(1.2, 0.3); } serveNum = 1; } function fault() { - if (serveNum === 1) { serveNum = 2; say('Fault'); phase = 'over'; overT = 1.4; } - else pointTo(other(score.server), 'Double fault'); + if (serveNum === 1) { serveNum = 2; voice('fault'); phase = 'over'; overT = 1.4; } + else pointTo(other(score.server), 'double_fault'); } const other = s => s === 'P' ? 'O' : 'P'; @@ -312,7 +328,7 @@ function onBounce() { // 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'); + ball.active = false; pointTo(other(ball.lastHit), 'out'); } } else if (ball.bounces === 2) { ball.active = false; pointTo(ball.lastHit, null); @@ -497,6 +513,15 @@ function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trap 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) { + 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, w, h); +} + function drawChar(ch) { const receiverWaiting = phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle'; const sway = receiverWaiting ? Math.sin(idleT * 3.2) : 0; @@ -563,21 +588,25 @@ function render() { 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) - quadXZ(-14, -6, 14, 30, '#2f7a35'); // surround - quadXZ(-COURT.W / 2 - 1.6, -2.5, COURT.W / 2 + 1.6, COURT.L + 2.5, '#3f9a45'); // apron - quadXZ(-COURT.W / 2, 0, COURT.W / 2, COURT.L, '#4cae52'); // court - // mow stripes - for (let z = 0; z < COURT.L; z += 2.97) quadXZ(-COURT.W / 2, z, COURT.W / 2, z + 1.49, '#46a54c'); - // lines - line(-COURT.W / 2, 0, COURT.W / 2, 0, 2); line(-COURT.W / 2, COURT.L, COURT.W / 2, COURT.L, 2); + // 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 - // entities far→near, net between + // court staff, then entities far→near, net between + drawStatic('x_boy_kneel', -4.2, -1.3, 1.1); + drawStatic('x_boy_stand', 4.2, -1.3, 1.45); + drawStatic('x_ump', 7.1, COURT.netZ, 2.7); drawChar(O); // net const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ); @@ -660,7 +689,14 @@ function frame(now) { } fitViewport(); setupPoint(); -say('Quiet please. Ready? Play.'); +// 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 diff --git a/index.html b/index.html index 9454af8..d8eb517 100644 --- a/index.html +++ b/index.html @@ -9,12 +9,32 @@ canvas { image-rendering: pixelated; width: min(96vw, calc(96vh * 16 / 9)); aspect-ratio: 16/9; touch-action: none; } #hint { position: fixed; bottom: 8px; width: 100%; text-align: center; font: 12px monospace; color: #6b7688; } - @media (pointer: coarse) { #hint { display: none; } } + @media (pointer: coarse) { #hint, #intro .kb { display: none; } } + #intro { position: fixed; inset: 0; background: rgba(10, 13, 20, 0.88); display: grid; + place-items: center; z-index: 10; touch-action: none; } + #intro .card { font: 16px monospace; color: #dfe6f2; text-align: left; line-height: 2; + border: 2px solid #5a6a85; background: #141a26; padding: 24px 32px; } + #intro h1 { font-size: 18px; margin: 0 0 12px; color: #f8e048; text-align: center; } + #intro p { margin: 0; } + #intro p span { display: inline-block; width: 32px; } + #intro em { color: #8a96ab; font-style: normal; font-size: 13px; } + #intro .start { margin-top: 16px; text-align: center; color: #f8e048; animation: blink 1.1s step-end infinite; } + @keyframes blink { 50% { opacity: 0.25; } }
move: WASD/arrows  ·  serve/swing: SPACE  ·  aim: hold a direction while swinging  ·  T: tuning panel
- +
+
+

🎾 SPLATTER TENNIS

+

👆 tap — serve & swing

+

👉 flick — aimed shot

+

✋ drag — run (auto-run is on)

+

⌨️ WASD / arrows + SPACE

+

TAP TO START

+
+
+