feat(scoreboard): Landscape-Scoreboard für 4–6 Spieler
Nach Issue #10 — Tabelle wie im Anhang mit Kategorien, Summenzeile, Spieler ein/ausblenden und localStorage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Scoreboard für 4–6 Spieler — Landscape, plain JS">
|
||||
<title>Scoreboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1a1814;
|
||||
--paper: #f4efe4;
|
||||
--ink: #1c1915;
|
||||
--muted: #6b6358;
|
||||
--line: #d4cbb8;
|
||||
--line-strong: #a89880;
|
||||
--accent: #c45c26;
|
||||
--sum-bg: #ebe4d4;
|
||||
--shadow: rgba(12, 10, 8, 0.35);
|
||||
--player-colors: #3d7a4a, #2f6fad, #b84d8a, #c45c26, #6b5b95, #4a8f8f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background:
|
||||
radial-gradient(ellipse 120% 80% at 50% -20%, rgba(196, 92, 38, 0.18), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: min(100%, 72rem);
|
||||
background: var(--paper);
|
||||
border-radius: 0.65rem;
|
||||
box-shadow: 0 18px 50px var(--shadow), 0 2px 0 rgba(255, 255, 255, 0.06) inset;
|
||||
padding: 1rem 1.1rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 2px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.controls select,
|
||||
.controls button {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls button:hover { background: #faf7f0; }
|
||||
.controls button:active { transform: scale(0.98); }
|
||||
|
||||
.controls button.danger {
|
||||
color: #9b2c2c;
|
||||
border-color: #d4a0a0;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.board {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
min-width: 34rem;
|
||||
}
|
||||
|
||||
.board th,
|
||||
.board td {
|
||||
border: 1.5px solid var(--line-strong);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.board thead th {
|
||||
background: #fff;
|
||||
padding: 0.15rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.board thead th.corner {
|
||||
width: 3.25rem;
|
||||
background: var(--sum-bg);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.player-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
padding: 0.2rem 0.15rem 0.35rem;
|
||||
min-height: 3.1rem;
|
||||
border-top: 4px solid var(--player-color, var(--accent));
|
||||
border-radius: 0.15rem 0.15rem 0 0;
|
||||
}
|
||||
|
||||
.player-head.inactive {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.player-head.inactive .player-name {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-size: clamp(0.82rem, 2.2vw, 1rem);
|
||||
text-align: center;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
padding: 0.15rem 0.1rem;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.player-name:focus {
|
||||
border-bottom-color: var(--player-color, var(--accent));
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.toggle-player {
|
||||
align-self: center;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
|
||||
.toggle-player:hover { color: var(--ink); }
|
||||
|
||||
.cat-label {
|
||||
width: 3.25rem;
|
||||
background: var(--sum-bg);
|
||||
font-weight: 800;
|
||||
font-size: clamp(0.95rem, 2.4vw, 1.15rem);
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.35rem 0.2rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.cat-label input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cat-label input:focus {
|
||||
background: #fff;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.score-cell {
|
||||
padding: 0.2rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.score-cell.inactive {
|
||||
background: #f0ebe0;
|
||||
}
|
||||
|
||||
.score-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.1rem, 3vw, 1.55rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
padding: 0.35rem 0.15rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.score-input:focus {
|
||||
background: #fffbe8;
|
||||
box-shadow: 0 0 0 2px rgba(196, 92, 38, 0.35);
|
||||
}
|
||||
|
||||
.score-input.zero {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.score-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sum-row .cat-label {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.sum-row td {
|
||||
background: var(--sum-bg);
|
||||
padding: 0.35rem 0.2rem;
|
||||
}
|
||||
|
||||
.sum-value {
|
||||
font-weight: 900;
|
||||
font-size: clamp(1.2rem, 3.2vw, 1.7rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sum-value.inactive {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
line-height: 1.45;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
color: #8a8074;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer a { color: #d4a574; }
|
||||
|
||||
@media (orientation: landscape) and (min-height: 420px) {
|
||||
body { padding: 0.5rem 0.75rem; }
|
||||
.sheet { padding: 0.75rem 1rem 0.65rem; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page { size: landscape; margin: 10mm; }
|
||||
body { background: #fff; padding: 0; }
|
||||
.topbar .controls { display: none; }
|
||||
.sheet { box-shadow: none; width: 100%; border-radius: 0; }
|
||||
.toggle-player { display: none; }
|
||||
.hint, footer { color: #666; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="sheet">
|
||||
<div class="topbar">
|
||||
<h1>Scoreboard</h1>
|
||||
<div class="controls">
|
||||
<label for="player-count">Spieler</label>
|
||||
<select id="player-count" aria-label="Anzahl Spieler">
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6" selected>6</option>
|
||||
</select>
|
||||
<button type="button" id="reset-btn" class="danger">Zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board-wrap">
|
||||
<table class="board" id="board" aria-label="Punktestand">
|
||||
<thead>
|
||||
<tr id="head-row">
|
||||
<th class="corner" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="body-rows"></tbody>
|
||||
<tfoot>
|
||||
<tr class="sum-row" id="sum-row">
|
||||
<th class="cat-label" scope="row">Σ</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="hint">Nullen durchgestrichen · Spieler antippen zum Ausblenden · Stand wird lokal gespeichert</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
@cursor · Issue #10 ·
|
||||
<a href="https://gitea.schwenk.online/froxxxy/playground/issues/10">scoreboard</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const STORAGE_KEY = 'playground-scoreboard-v1';
|
||||
const DEFAULT_CATEGORIES = ['Ø', 'Le', 'L', 'La', 'P', 'Zi', 'Z'];
|
||||
const DEFAULT_NAMES = ['Marc', 'Nepo', 'Franzi', 'Frank', 'Dirk', 'Gast'];
|
||||
const PLAYER_COLORS = ['#3d7a4a', '#2f6fad', '#b84d8a', '#c45c26', '#6b5b95', '#4a8f8f'];
|
||||
|
||||
const playerCountEl = document.getElementById('player-count');
|
||||
const headRow = document.getElementById('head-row');
|
||||
const bodyRows = document.getElementById('body-rows');
|
||||
const sumRow = document.getElementById('sum-row');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
|
||||
let state = loadState();
|
||||
|
||||
function defaultState() {
|
||||
return {
|
||||
playerCount: 6,
|
||||
categories: [...DEFAULT_CATEGORIES],
|
||||
names: [...DEFAULT_NAMES],
|
||||
active: [true, true, true, true, false, false],
|
||||
scores: DEFAULT_CATEGORIES.map(() => Array(6).fill(0))
|
||||
};
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultState();
|
||||
const parsed = JSON.parse(raw);
|
||||
const base = defaultState();
|
||||
return {
|
||||
playerCount: clamp(parsed.playerCount ?? base.playerCount, 4, 6),
|
||||
categories: normalizeCategories(parsed.categories, base.categories),
|
||||
names: normalizeNames(parsed.names, base.names),
|
||||
active: normalizeActive(parsed.active, base.active),
|
||||
scores: normalizeScores(parsed.scores, base.scores)
|
||||
};
|
||||
} catch {
|
||||
return defaultState();
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.min(max, Math.max(min, Number(n) || min));
|
||||
}
|
||||
|
||||
function normalizeCategories(cats, fallback) {
|
||||
const out = Array.isArray(cats) ? cats.slice(0, 7) : [];
|
||||
while (out.length < 7) out.push(fallback[out.length] ?? '');
|
||||
return out.map((c, i) => String(c ?? fallback[i] ?? '').slice(0, 4) || fallback[i]);
|
||||
}
|
||||
|
||||
function normalizeNames(names, fallback) {
|
||||
const out = Array.isArray(names) ? names.slice(0, 6) : [];
|
||||
while (out.length < 6) out.push(fallback[out.length] ?? `Spieler ${out.length + 1}`);
|
||||
return out.map((n, i) => String(n ?? fallback[i]).slice(0, 18) || fallback[i]);
|
||||
}
|
||||
|
||||
function normalizeActive(active, fallback) {
|
||||
const out = Array.isArray(active) ? active.slice(0, 6) : [];
|
||||
while (out.length < 6) out.push(fallback[out.length] ?? true);
|
||||
return out.map(Boolean);
|
||||
}
|
||||
|
||||
function normalizeScores(scores, fallback) {
|
||||
const rows = Array.isArray(scores) ? scores.slice(0, 7) : [];
|
||||
while (rows.length < 7) rows.push([...fallback[rows.length] ?? Array(6).fill(0)]);
|
||||
return rows.map((row, ri) => {
|
||||
const cells = Array.isArray(row) ? row.slice(0, 6) : [];
|
||||
while (cells.length < 6) cells.push(fallback[ri]?.[cells.length] ?? 0);
|
||||
return cells.map((v) => clampScore(v));
|
||||
});
|
||||
}
|
||||
|
||||
function clampScore(v) {
|
||||
const n = parseInt(String(v).replace(/[^\d-]/g, ''), 10);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(99, n));
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function visibleCount() {
|
||||
return state.playerCount;
|
||||
}
|
||||
|
||||
function render() {
|
||||
playerCountEl.value = String(state.playerCount);
|
||||
|
||||
headRow.innerHTML = '<th class="corner" scope="col"></th>';
|
||||
sumRow.innerHTML = '<th class="cat-label" scope="row">Σ</th>';
|
||||
bodyRows.innerHTML = '';
|
||||
|
||||
for (let p = 0; p < visibleCount(); p++) {
|
||||
const th = document.createElement('th');
|
||||
th.scope = 'col';
|
||||
th.innerHTML = `
|
||||
<div class="player-head${state.active[p] ? '' : ' inactive'}" style="--player-color:${PLAYER_COLORS[p]}">
|
||||
<input class="player-name" type="text" value="${escapeAttr(state.names[p])}" maxlength="18" aria-label="Name Spieler ${p + 1}" data-player="${p}">
|
||||
<button type="button" class="toggle-player" data-player="${p}" aria-pressed="${state.active[p] ? 'false' : 'true'}">
|
||||
${state.active[p] ? 'ausblenden' : 'einblenden'}
|
||||
</button>
|
||||
</div>`;
|
||||
headRow.appendChild(th);
|
||||
}
|
||||
|
||||
state.categories.forEach((cat, row) => {
|
||||
const tr = document.createElement('tr');
|
||||
const label = document.createElement('th');
|
||||
label.className = 'cat-label';
|
||||
label.scope = 'row';
|
||||
label.innerHTML = `<input type="text" value="${escapeAttr(cat)}" maxlength="4" aria-label="Kategorie ${row + 1}" data-cat="${row}">`;
|
||||
tr.appendChild(label);
|
||||
|
||||
for (let p = 0; p < visibleCount(); p++) {
|
||||
const td = document.createElement('td');
|
||||
td.className = `score-cell${state.active[p] ? '' : ' inactive'}`;
|
||||
const val = state.scores[row][p];
|
||||
td.innerHTML = `<input class="score-input${val === 0 ? ' zero' : ''}" type="text" inputmode="numeric" pattern="[0-9]*" value="${val}" aria-label="${escapeAttr(cat)} ${escapeAttr(state.names[p])}" data-row="${row}" data-player="${p}"${state.active[p] ? '' : ' disabled'}>`;
|
||||
tr.appendChild(td);
|
||||
}
|
||||
|
||||
bodyRows.appendChild(tr);
|
||||
});
|
||||
|
||||
for (let p = 0; p < visibleCount(); p++) {
|
||||
const td = document.createElement('td');
|
||||
const sum = columnSum(p);
|
||||
td.innerHTML = `<span class="sum-value${state.active[p] ? '' : ' inactive'}">${sum}</span>`;
|
||||
sumRow.appendChild(td);
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
function columnSum(player) {
|
||||
return state.categories.reduce((acc, _, row) => acc + state.scores[row][player], 0);
|
||||
}
|
||||
|
||||
function updateSums() {
|
||||
const sums = sumRow.querySelectorAll('.sum-value');
|
||||
for (let p = 0; p < visibleCount(); p++) {
|
||||
if (!sums[p]) continue;
|
||||
const sum = columnSum(p);
|
||||
sums[p].textContent = sum;
|
||||
sums[p].classList.toggle('inactive', !state.active[p]);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.querySelectorAll('.player-name').forEach((el) => {
|
||||
el.addEventListener('input', (e) => {
|
||||
const p = Number(e.target.dataset.player);
|
||||
state.names[p] = e.target.value;
|
||||
saveState();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.toggle-player').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const p = Number(e.target.dataset.player);
|
||||
state.active[p] = !state.active[p];
|
||||
saveState();
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-cat]').forEach((el) => {
|
||||
el.addEventListener('input', (e) => {
|
||||
const row = Number(e.target.dataset.cat);
|
||||
state.categories[row] = e.target.value;
|
||||
saveState();
|
||||
});
|
||||
el.addEventListener('blur', (e) => {
|
||||
const row = Number(e.target.dataset.cat);
|
||||
if (!state.categories[row].trim()) state.categories[row] = DEFAULT_CATEGORIES[row];
|
||||
saveState();
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.score-input').forEach((el) => {
|
||||
el.addEventListener('focus', (e) => e.target.select());
|
||||
el.addEventListener('input', (e) => {
|
||||
const row = Number(e.target.dataset.row);
|
||||
const p = Number(e.target.dataset.player);
|
||||
const digits = e.target.value.replace(/[^\d]/g, '');
|
||||
e.target.value = digits;
|
||||
const val = digits === '' ? 0 : clampScore(digits);
|
||||
state.scores[row][p] = val;
|
||||
e.target.classList.toggle('zero', val === 0);
|
||||
updateSums();
|
||||
saveState();
|
||||
});
|
||||
el.addEventListener('blur', (e) => {
|
||||
const row = Number(e.target.dataset.row);
|
||||
const p = Number(e.target.dataset.player);
|
||||
const val = clampScore(e.target.value);
|
||||
state.scores[row][p] = val;
|
||||
e.target.value = val;
|
||||
e.target.classList.toggle('zero', val === 0);
|
||||
saveState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
playerCountEl.addEventListener('change', () => {
|
||||
state.playerCount = clamp(playerCountEl.value, 4, 6);
|
||||
saveState();
|
||||
render();
|
||||
});
|
||||
|
||||
resetBtn.addEventListener('click', () => {
|
||||
if (!confirm('Alle Punkte und Namen zurücksetzen?')) return;
|
||||
state = defaultState();
|
||||
saveState();
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user