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

+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