diff --git a/assets/js/main.js b/assets/js/main.js index 718cc0a..24ee788 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -16,6 +16,7 @@ document.addEventListener('DOMContentLoaded', () => { const SCOREBOARD_API_URL = 'api/scoreboard.php'; const MULTI_POLL_INTERVAL = 800; const SCOREBOARD_TIMEOUT_MS = 4000; + const SCOREBOARD_PENDING_KEY = 'tetris_scoreboard_pending_v1'; const PIECE_DEFS = { I: { @@ -548,188 +549,262 @@ document.addEventListener('DOMContentLoaded', () => { function loadHistory() { try { - const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.history) || '[]'); + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.history) || "[]"); return Array.isArray(parsed) ? parsed : []; } catch (error) { + console.warn("Failed to load history", error); return []; } } function saveHistory(run) { - history = [run, ...history].slice(0, 8); - localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)); - if (run.score > bestScore) { - bestScore = run.score; - localStorage.setItem(STORAGE_KEYS.best, String(bestScore)); - showToast('New best score saved locally.'); - } + if (!run || !run.id) return; + history = [run, ...history.filter((entry) => String(entry.id) !== String(run.id))].slice(0, 10); selectedRunId = run.id; + try { + localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)); + } catch (error) { + console.warn('Failed to save history', error); + } renderHistory(); } function renderHistory() { - ui.historyList.innerHTML = ''; - ui.historyEmpty.classList.toggle('d-none', history.length > 0); + if (!ui.historyList || !ui.historyEmpty || !ui.historyDetail) return; - history.forEach((run) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = `history-item${selectedRunId === run.id ? ' active' : ''}`; - button.innerHTML = ` -
- ${run.score.toLocaleString()} pts - ${new Date(run.finishedAt).toLocaleDateString()} -
-
- Level ${run.level} · ${run.lines} line${run.lines === 1 ? '' : 's'} - ${run.duration} -
- `; - button.addEventListener('click', () => { - selectedRunId = run.id; - renderHistory(); - }); - ui.historyList.appendChild(button); - }); - - const selectedRun = history.find((item) => item.id === selectedRunId) || history[0] || null; - if (selectedRun) { - ui.historyDetail.classList.remove('d-none'); - ui.selectedScore.textContent = selectedRun.score.toLocaleString(); - ui.selectedLevel.textContent = String(selectedRun.level); - ui.selectedLines.textContent = String(selectedRun.lines); - ui.selectedDuration.textContent = selectedRun.duration; - ui.selectedFinished.textContent = new Date(selectedRun.finishedAt).toLocaleString(); - } else { + if (!Array.isArray(history) || history.length === 0) { + ui.historyList.innerHTML = ''; + ui.historyEmpty.classList.remove('d-none'); ui.historyDetail.classList.add('d-none'); - } - } - - - function getPreferredPlayerName() { - const typedName = (ui.playerNameInput?.value || '').trim(); - if (typedName) return typedName; - if (multiplayer.displayName) return multiplayer.displayName; - return 'Player'; - } - - function formatDurationFromSeconds(totalSeconds) { - const safe = Math.max(0, Number(totalSeconds) || 0); - const minutes = Math.floor(safe / 60); - const seconds = safe % 60; - return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; - } - - function renderScoreboard() { - if (!ui.scoreboardList || !ui.scoreboardEmpty || !ui.scoreboardStatus) return; - - const hasEntries = scoreboardEntries.length > 0; - ui.scoreboardEmpty.classList.toggle('d-none', hasEntries); - ui.scoreboardList.innerHTML = ''; - - if (!hasEntries) { - ui.scoreboardStatus.textContent = 'Waiting for scores'; + selectedRunId = null; return; } - ui.scoreboardStatus.textContent = `Top ${scoreboardEntries.length}`; + ui.historyEmpty.classList.add('d-none'); + ui.historyDetail.classList.remove('d-none'); - scoreboardEntries.forEach((entry, index) => { - const item = document.createElement('article'); - item.className = 'scoreboard-item'; + const normalizedSelectedId = selectedRunId ? String(selectedRunId) : String(history[0].id); + const selectedRun = history.find((entry) => String(entry.id) === normalizedSelectedId) || history[0]; + selectedRunId = selectedRun.id; - const rank = document.createElement('div'); - rank.className = 'scoreboard-rank'; - rank.textContent = `#${index + 1}`; + ui.historyList.innerHTML = history.map((entry) => { + const isActive = String(entry.id) === String(selectedRunId); + const finishedLabel = entry.finishedAt + ? new Date(entry.finishedAt).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : 'Just now'; + return ` + + `; + }).join(''); - const body = document.createElement('div'); - body.className = 'scoreboard-item-body'; - - const topRow = document.createElement('div'); - topRow.className = 'scoreboard-topline'; - - const name = document.createElement('strong'); - name.className = 'scoreboard-name'; - name.textContent = entry.player_name || 'Player'; - - const scoreValue = document.createElement('span'); - scoreValue.className = 'scoreboard-score'; - scoreValue.textContent = `${Number(entry.score || 0).toLocaleString()} pts`; - - topRow.appendChild(name); - topRow.appendChild(scoreValue); - - const meta = document.createElement('div'); - meta.className = 'scoreboard-meta'; - const linesLabel = Number(entry.lines || 0) === 1 ? 'line' : 'lines'; - const dateValue = entry.created_at ? new Date(String(entry.created_at).replace(' ', 'T') + 'Z') : null; - const dateLabel = dateValue && !Number.isNaN(dateValue.getTime()) ? dateValue.toLocaleDateString() : 'Today'; - const modeLabel = entry.mode === 'multiplayer' ? 'Multiplayer' : 'Solo'; - meta.textContent = `Level ${entry.level} · ${entry.lines} ${linesLabel} · ${formatDurationFromSeconds(entry.duration_seconds)} · ${modeLabel} · ${dateLabel}`; - - body.appendChild(topRow); - body.appendChild(meta); - item.appendChild(rank); - item.appendChild(body); - ui.scoreboardList.appendChild(item); + ui.historyList.querySelectorAll('[data-run-id]').forEach((button) => { + button.addEventListener('click', () => { + selectedRunId = button.getAttribute('data-run-id'); + renderHistory(); + }); }); + + ui.selectedScore.textContent = Number(selectedRun.score || 0).toLocaleString(); + ui.selectedLevel.textContent = Number(selectedRun.level || 0).toString(); + ui.selectedLines.textContent = Number(selectedRun.lines || 0).toString(); + ui.selectedDuration.textContent = selectedRun.duration || '00:00'; + ui.selectedFinished.textContent = selectedRun.finishedAt + ? new Date(selectedRun.finishedAt).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : 'Just now'; } - async function loadScoreboard() { + async function loadScoreboard(options = {}) { if (!ui.scoreboardStatus) return; - ui.scoreboardStatus.textContent = 'Loading…'; + const preserveOnError = Boolean(options.preserveOnError); + ui.scoreboardStatus.textContent = "Loading…"; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), SCOREBOARD_TIMEOUT_MS); try { - const response = await fetch(`${SCOREBOARD_API_URL}?limit=10`, { - cache: 'no-store', + const response = await fetch(SCOREBOARD_API_URL + "?limit=10", { + cache: "no-store", signal: controller.signal }); const payload = await response.json(); if (!response.ok || !payload.success) { - throw new Error(payload.error || 'Unable to load scoreboard.'); + throw new Error(payload.error || "Unable to load scoreboard."); } scoreboardEntries = Array.isArray(payload.scores) ? payload.scores : []; renderScoreboard(); + ui.scoreboardStatus.textContent = scoreboardEntries.length > 0 ? "Top " + scoreboardEntries.length : "Offline"; } catch (error) { - console.warn('Scoreboard load error', error); - scoreboardEntries = []; + if (error && error.name === "AbortError") { + if (!preserveOnError && scoreboardEntries.length === 0 && ui.scoreboardStatus) { + ui.scoreboardStatus.textContent = "Offline"; + } + return; + } + console.warn("Scoreboard load error", error); + if (!preserveOnError) { + scoreboardEntries = []; + } renderScoreboard(); - ui.scoreboardStatus.textContent = 'Offline'; + ui.scoreboardStatus.textContent = scoreboardEntries.length > 0 ? "Top " + scoreboardEntries.length : "Offline"; } finally { window.clearTimeout(timeoutId); } } - async function saveScoreToDatabase(run) { + function getPreferredPlayerName() { + // Prefer multiplayer display name (if available), otherwise use the solo input value. + let name = + (multiplayer && multiplayer.displayName ? multiplayer.displayName : "") + || (ui && ui.playerNameInput ? ui.playerNameInput.value : "") + || (sessionStorage ? sessionStorage.getItem(MULTI_STORAGE_KEYS.name) : "") + || ""; + + name = String(name).trim(); + name = name.replace(/\s+/g, " "); + + // Keep it short; backend will also normalize/sanitize. + if (name.length > 48) name = name.slice(0, 48); + return name || "Player"; + } + + function buildScoreboardPayload(run) { + return { + action: 'save_score', + player_name: getPreferredPlayerName(), + score: run.score, + lines: run.lines, + level: run.level, + pieces_placed: run.piecesPlaced, + duration_seconds: run.durationSeconds, + room_code: multiplayer.roomCode || null + }; + } + + function queuePendingScoreSave(payload) { + if (!payload) return; + try { + const raw = localStorage.getItem(SCOREBOARD_PENDING_KEY); + const list = raw ? JSON.parse(raw) : []; + const safeList = Array.isArray(list) ? list : []; + safeList.unshift(payload); + localStorage.setItem(SCOREBOARD_PENDING_KEY, JSON.stringify(safeList.slice(0, 10))); + } catch (error) { + console.warn("Failed to queue pending scoreboard save", error); + } + } + async function flushPendingScoreSaves() { + let pending = []; + try { + const raw = localStorage.getItem(SCOREBOARD_PENDING_KEY); + const parsed = raw ? JSON.parse(raw) : []; + pending = Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.warn("Failed to read pending scoreboard saves", error); + return; + } + + if (pending.length === 0) return; + + // Stored newest -> oldest. Send oldest -> newest. + const toSend = pending.slice(0, 10).reverse(); + const keep = []; + + for (let i = 0; i < toSend.length; i++) { + const payload = toSend[i]; + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 2500); + try { + const response = await fetch(SCOREBOARD_API_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + keepalive: true, + cache: "no-store", + signal: controller.signal, + body: JSON.stringify(payload) + }); + + const responsePayload = await response.json().catch(() => null); + const ok = response.ok && responsePayload && responsePayload.success; + if (!ok) { + keep.push(payload); + } + } catch (error) { + // Network down / timeout: keep this + the rest (will retry later). + if (error && error.name === "AbortError") keep.push(payload); + else keep.push(payload); + for (let j = i + 1; j < toSend.length; j++) keep.push(toSend[j]); + break; + } finally { + window.clearTimeout(timeoutId); + } + } + + try { + // Write newest -> oldest back to storage. + const keepNewestFirst = keep.slice().reverse().slice(0, 10); + localStorage.setItem(SCOREBOARD_PENDING_KEY, JSON.stringify(keepNewestFirst)); + } catch (error) { + console.warn("Failed to update pending scoreboard saves", error); + } + } + +async function saveScoreToDatabase(run) { + const payload = buildScoreboardPayload(run); try { const response = await fetch(SCOREBOARD_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action: 'save_score', - player_name: getPreferredPlayerName(), - score: run.score, - lines: run.lines, - level: run.level, - pieces_placed: run.piecesPlaced, - duration_seconds: run.durationSeconds, - room_code: multiplayer.roomCode || null - }) + keepalive: true, + body: JSON.stringify(payload) }); - const payload = await response.json(); - if (!response.ok || !payload.success) { - throw new Error(payload.error || 'Unable to save scoreboard entry.'); + const responsePayload = await response.json(); + if (!response.ok || !responsePayload.success) { + throw new Error(responsePayload.error || 'Unable to save scoreboard entry.'); } - await loadScoreboard(); - if (payload.placement) { - showToast(`Run saved to the database scoreboard. Rank #${payload.placement}.`); + + const savedEntry = responsePayload.entry || { + id: run.id, + player_name: payload.player_name, + score: payload.score, + lines: payload.lines, + level: payload.level, + pieces_placed: payload.pieces_placed, + duration_seconds: payload.duration_seconds, + mode: payload.room_code ? 'multiplayer' : 'solo', + room_code: payload.room_code, + created_at: new Date().toISOString().slice(0, 19).replace('T', ' ') + }; + scoreboardEntries = [savedEntry, ...scoreboardEntries.filter((entry) => Number(entry.id) !== Number(savedEntry.id))].slice(0, 10); + renderScoreboard(); + if (responsePayload.placement) { + showToast(`Run saved to the database scoreboard. Rank #${responsePayload.placement}.`); } else { showToast('Run saved to the database scoreboard.'); } + void loadScoreboard({ preserveOnError: true }); } catch (error) { console.warn('Scoreboard save error', error); - showToast('Run saved locally. Database scoreboard was unavailable.'); + queuePendingScoreSave(payload); + showToast('Run saved locally. It will sync to the database scoreboard when the connection returns.'); if (ui.scoreboardStatus) { ui.scoreboardStatus.textContent = 'Offline'; } @@ -982,6 +1057,17 @@ document.addEventListener('DOMContentLoaded', () => { } function handleKeydown(event) { + const target = event.target; + const isTypingTarget = Boolean(target && ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + )); + + if (isTypingTarget) { + return; + } + const code = event.code; const gameKeys = ['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp', 'KeyX', 'KeyZ', 'Space', 'KeyP', 'KeyR', 'Enter']; if (gameKeys.includes(code)) { @@ -1118,6 +1204,7 @@ document.addEventListener('DOMContentLoaded', () => { renderHistory(); void loadScoreboard(); + void flushPendingScoreSaves(); updateStats(); updateMultiplayerUI(); showOverlay('Stack clean. Clear fast.', 'Use the keyboard to play a faithful Tetris loop with local history and a database scoreboard.', 'Press Enter to start');