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');