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