93850eb982
Plain HTML/JS app for issue #4 — tap or voice commands (de-DE) to score. Co-authored-by: Cursor <cursoragent@cursor.com>
460 lines
12 KiB
HTML
460 lines
12 KiB
HTML
<!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>
|