Files
playground/public/scoring/index.html
T
cursor 93850eb982 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>
2026-07-05 15:07:40 +00:00

460 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>