feat: add two-player scoring app with speech recognition

Plain HTML/JS app for issue #4 — tap or voice commands (de-DE) to score.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cursor
2026-07-05 15:07:40 +00:00
parent 3b362300c0
commit 93850eb982
+459
View File
@@ -0,0 +1,459 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scoring — zwei Spieler</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2f3d;
--text: #e8eaed;
--muted: #9aa0ad;
--p1: #6ee7b7;
--p1-dim: #34d399;
--p2: #818cf8;
--p2-dim: #6366f1;
--danger: #f87171;
--listening: #fbbf24;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100dvh;
display: flex;
flex-direction: column;
}
header {
padding: 1.25rem 1.5rem 0.75rem;
text-align: center;
}
header h1 {
font-size: 1.25rem;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.board {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
padding: 0.75rem 1rem 1rem;
max-width: 52rem;
margin: 0 auto;
width: 100%;
}
.player {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
transition: box-shadow 0.2s;
}
.player.active {
box-shadow: 0 0 0 2px var(--listening);
}
.player--1 { border-top: 3px solid var(--p1); }
.player--2 { border-top: 3px solid var(--p2); }
.player-name {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px dashed var(--border);
color: inherit;
font: inherit;
font-weight: 700;
font-size: 1rem;
text-align: center;
padding: 0.35rem 0.25rem;
outline: none;
}
.player--1 .player-name:focus { border-color: var(--p1); }
.player--2 .player-name:focus { border-color: var(--p2); }
.score {
font-size: clamp(3.5rem, 18vw, 5.5rem);
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
user-select: none;
}
.player--1 .score { color: var(--p1); }
.player--2 .score { color: var(--p2); }
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: center;
width: 100%;
}
button {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 0.5rem;
padding: 0.55rem 0.85rem;
font-weight: 600;
font-size: 0.95rem;
transition: background 0.15s, transform 0.1s;
}
button:active { transform: scale(0.96); }
button.plus { border-color: var(--p1-dim); color: var(--p1); }
button.minus { border-color: #4b5563; color: var(--muted); }
.player--2 button.plus { border-color: var(--p2-dim); color: var(--p2); }
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: center;
padding: 0 1rem 1rem;
max-width: 52rem;
margin: 0 auto;
width: 100%;
}
#mic-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-radius: 2rem;
font-size: 1rem;
border: 2px solid var(--border);
background: var(--surface);
}
#mic-btn.listening {
border-color: var(--listening);
color: var(--listening);
animation: pulse 1.4s ease-in-out infinite;
}
#mic-btn.unsupported {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.35); }
50% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); }
}
#reset-btn {
color: var(--danger);
border-color: #7f1d1d;
}
.status-bar {
min-height: 2.75rem;
padding: 0.5rem 1.25rem 1rem;
text-align: center;
color: var(--muted);
font-size: 0.9rem;
max-width: 36rem;
margin: 0 auto;
width: 100%;
}
.status-bar strong { color: var(--text); }
.hint {
max-width: 36rem;
margin: 0 auto;
padding: 0 1.25rem 1.5rem;
font-size: 0.82rem;
color: var(--muted);
line-height: 1.5;
text-align: center;
}
.hint code {
background: var(--surface);
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
footer a { color: var(--p1-dim); }
</style>
</head>
<body>
<header>
<h1>Scoring</h1>
</header>
<div class="board">
<article class="player player--1" id="player-1" data-player="1">
<input class="player-name" type="text" value="Spieler 1" maxlength="24" aria-label="Name Spieler 1">
<div class="score" id="score-1">0</div>
<div class="controls">
<button type="button" class="minus" data-delta="-1">1</button>
<button type="button" class="plus" data-delta="1">+1</button>
<button type="button" class="plus" data-delta="2">+2</button>
<button type="button" class="plus" data-delta="3">+3</button>
</div>
</article>
<article class="player player--2" id="player-2" data-player="2">
<input class="player-name" type="text" value="Spieler 2" maxlength="24" aria-label="Name Spieler 2">
<div class="score" id="score-2">0</div>
<div class="controls">
<button type="button" class="minus" data-delta="-1">1</button>
<button type="button" class="plus" data-delta="1">+1</button>
<button type="button" class="plus" data-delta="2">+2</button>
<button type="button" class="plus" data-delta="3">+3</button>
</div>
</article>
</div>
<div class="toolbar">
<button type="button" id="mic-btn" aria-pressed="false">
<span aria-hidden="true">🎤</span>
<span id="mic-label">Spracheingabe</span>
</button>
<button type="button" id="reset-btn">Zurücksetzen</button>
</div>
<p class="status-bar" id="status" role="status" aria-live="polite"></p>
<p class="hint">
Sprachbefehle (de-DE): <code>eins plus drei</code>, <code>zwei minus eins</code>,
<code>spieler eins plus fünf</code>, <code>zurücksetzen</code>.
Mikrofon-Zugriff nötig; funktioniert in Chrome/Edge/Safari (nicht Firefox).
</p>
<footer>
@cursor · Issue #4 ·
<a href="https://gitea.schwenk.online/froxxxy/playground/issues/4">scoring</a>
</footer>
<script>
const scores = { 1: 0, 2: 0 };
const statusEl = document.getElementById('status');
const micBtn = document.getElementById('mic-btn');
const micLabel = document.getElementById('mic-label');
function getName(player) {
return document.querySelector(`#player-${player} .player-name`).value.trim()
|| `Spieler ${player}`;
}
function render(player) {
document.getElementById(`score-${player}`).textContent = scores[player];
}
function adjust(player, delta) {
scores[player] = Math.max(0, scores[player] + delta);
render(player);
flashPlayer(player);
setStatus(`${getName(player)}: ${delta > 0 ? '+' : ''}${delta}${scores[player]}`);
}
function flashPlayer(player) {
const el = document.getElementById(`player-${player}`);
el.classList.add('active');
setTimeout(() => el.classList.remove('active'), 400);
}
function resetScores() {
scores[1] = 0;
scores[2] = 0;
render(1);
render(2);
setStatus('Punkte zurückgesetzt.');
}
function setStatus(msg) {
statusEl.innerHTML = msg ? `<strong>Letzter Befehl:</strong> ${msg}` : '';
}
document.querySelectorAll('.controls button').forEach(btn => {
btn.addEventListener('click', () => {
const player = Number(btn.closest('.player').dataset.player);
adjust(player, Number(btn.dataset.delta));
});
});
document.getElementById('reset-btn').addEventListener('click', resetScores);
// --- Speech recognition ---
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;
let listening = false;
const NUMBER_WORDS = {
null: 0, zero: 0, eins: 1, ein: 1, eine: 1, zwei: 2, zwo: 2, drei: 3,
vier: 4, fünf: 5, funf: 5, sechs: 6, sieben: 7, acht: 8, neun: 9, zehn: 10
};
function parseNumber(token) {
if (!token) return null;
const t = token.toLowerCase().replace(/[.,]/g, '');
if (NUMBER_WORDS[t] !== undefined) return NUMBER_WORDS[t];
const n = parseInt(t, 10);
return Number.isFinite(n) ? n : null;
}
function resolvePlayer(text) {
const t = text.toLowerCase();
if (/\b(zwei|2|zwo|rechts|blau)\b/.test(t)) return 2;
if (/\b(eins|1|ein|links|grün|gruen)\b/.test(t)) return 1;
return null;
}
function parseCommand(raw) {
const text = raw.trim().toLowerCase();
if (!text) return null;
if (/\b(zurücksetzen|zuruecksetzen|reset|neu|null|nullen)\b/.test(text)) {
return { action: 'reset' };
}
const player = resolvePlayer(text);
if (!player) return null;
const minus = /\b(minus|weniger|abzüglich|abzuglich|-)\b/.test(text);
const plus = /\b(plus|mehr|dazu|\+)\b/.test(text) || !minus;
const digitMatch = text.match(/(\d+)/);
let amount = digitMatch ? parseInt(digitMatch[1], 10) : null;
if (amount === null) {
for (const word of Object.keys(NUMBER_WORDS)) {
if (word === 'null' || word === 'zero') continue;
const re = new RegExp(`\\b${word}\\b`);
if (re.test(text)) {
amount = NUMBER_WORDS[word];
break;
}
}
}
if (amount === null) amount = 1;
const delta = minus ? -amount : plus ? amount : null;
if (delta === null) return null;
return { action: 'score', player, delta };
}
function handleTranscript(text) {
const cmd = parseCommand(text);
if (!cmd) {
setStatus(`Nicht erkannt: „${text}"`);
return;
}
if (cmd.action === 'reset') {
resetScores();
return;
}
adjust(cmd.player, cmd.delta);
}
function initSpeech() {
if (!SpeechRecognition) {
micBtn.classList.add('unsupported');
micLabel.textContent = 'Sprache nicht unterstützt';
micBtn.disabled = true;
setStatus('Web Speech API nicht verfügbar (Chrome/Edge/Safari probieren).');
return;
}
recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.maxAlternatives = 3;
recognition.addEventListener('start', () => {
listening = true;
micBtn.classList.add('listening');
micBtn.setAttribute('aria-pressed', 'true');
micLabel.textContent = 'Hört zu …';
});
recognition.addEventListener('end', () => {
listening = false;
micBtn.classList.remove('listening');
micBtn.setAttribute('aria-pressed', 'false');
micLabel.textContent = 'Spracheingabe';
});
recognition.addEventListener('result', (event) => {
let interim = '';
let final = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) final += transcript;
else interim += transcript;
}
if (interim) setStatus(`Höre: „${interim.trim()}"`);
if (final) handleTranscript(final.trim());
});
recognition.addEventListener('error', (event) => {
const messages = {
'not-allowed': 'Mikrofon-Zugriff verweigert.',
'no-speech': 'Keine Sprache erkannt — nochmal versuchen.',
'aborted': '',
'network': 'Netzwerkfehler bei der Spracherkennung.'
};
const msg = messages[event.error] || `Fehler: ${event.error}`;
if (msg) setStatus(msg);
});
micBtn.addEventListener('click', () => {
if (listening) {
recognition.stop();
} else {
try {
recognition.start();
} catch {
recognition.stop();
setTimeout(() => recognition.start(), 200);
}
}
});
}
initSpeech();
</script>
</body>
</html>