Recorded umpire voice, court staff, out-zone borders, intro overlay, no-cache deploys

- 23 pre-recorded umpire lines (macOS say, en-GB Daniel) played via WebAudio,
  works on mobile; speechSynthesis stays as fallback only
- umpire chair + two ball boys (generated sprites) dress the court
- court redrawn: doubles alleys, white perimeter border, clearly darker out area
- intro overlay with control symbols; first tap dismisses it and unlocks audio
- nginx now serves everything Cache-Control: no-cache so a reload always
  revalidates and picks up new deploys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 13:38:28 +02:00
1 parent d69896ddb8
commit dc1e12b9b6
30 files changed
+92 -33

No files matched your search

+3
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+67 -31
View File
@@ -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/<name>.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
+22 -2
View File
@@ -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; } }
</style>
</head>
<body>
<canvas id="c" width="480" height="270"></canvas>
<div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div>
<script src="game.js"></script>
<div id="intro">
<div class="card">
<h1>🎾 SPLATTER TENNIS</h1>
<p><span>👆</span> tap — serve &amp; swing</p>
<p><span>👉</span> flick — aimed shot</p>
<p><span>✋</span> drag — run <em>(auto-run is on)</em></p>
<p class="kb"><span>⌨️</span> WASD / arrows + SPACE</p>
<p class="start">TAP TO START</p>
</div>
</div>
<script src="game.js?v=8"></script>
</body>
</html>