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:
@@ -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>
|
||||||
Reference in New Issue
Block a user