This commit is contained in:
Flatlogic Bot 2026-03-25 17:37:01 +00:00
parent 373f8b6ba0
commit 342ba02e74

View File

@ -16,6 +16,7 @@ document.addEventListener('DOMContentLoaded', () => {
const SCOREBOARD_API_URL = 'api/scoreboard.php'; const SCOREBOARD_API_URL = 'api/scoreboard.php';
const MULTI_POLL_INTERVAL = 800; const MULTI_POLL_INTERVAL = 800;
const SCOREBOARD_TIMEOUT_MS = 4000; const SCOREBOARD_TIMEOUT_MS = 4000;
const SCOREBOARD_PENDING_KEY = 'tetris_scoreboard_pending_v1';
const PIECE_DEFS = { const PIECE_DEFS = {
I: { I: {
@ -548,188 +549,262 @@ document.addEventListener('DOMContentLoaded', () => {
function loadHistory() { function loadHistory() {
try { try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.history) || '[]'); const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.history) || "[]");
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch (error) { } catch (error) {
console.warn("Failed to load history", error);
return []; return [];
} }
} }
function saveHistory(run) { function saveHistory(run) {
history = [run, ...history].slice(0, 8); if (!run || !run.id) return;
localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)); history = [run, ...history.filter((entry) => String(entry.id) !== String(run.id))].slice(0, 10);
if (run.score > bestScore) {
bestScore = run.score;
localStorage.setItem(STORAGE_KEYS.best, String(bestScore));
showToast('New best score saved locally.');
}
selectedRunId = run.id; selectedRunId = run.id;
try {
localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history));
} catch (error) {
console.warn('Failed to save history', error);
}
renderHistory(); renderHistory();
} }
function renderHistory() { function renderHistory() {
ui.historyList.innerHTML = ''; if (!ui.historyList || !ui.historyEmpty || !ui.historyDetail) return;
ui.historyEmpty.classList.toggle('d-none', history.length > 0);
history.forEach((run) => { if (!Array.isArray(history) || history.length === 0) {
const button = document.createElement('button'); ui.historyList.innerHTML = '';
button.type = 'button'; ui.historyEmpty.classList.remove('d-none');
button.className = `history-item${selectedRunId === run.id ? ' active' : ''}`;
button.innerHTML = `
<div class="history-topline">
<span class="history-score">${run.score.toLocaleString()} pts</span>
<span class="history-date">${new Date(run.finishedAt).toLocaleDateString()}</span>
</div>
<div class="history-subline mt-2">
<span class="history-meta">Level ${run.level} · ${run.lines} line${run.lines === 1 ? '' : 's'}</span>
<span class="history-meta">${run.duration}</span>
</div>
`;
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 {
ui.historyDetail.classList.add('d-none'); ui.historyDetail.classList.add('d-none');
} selectedRunId = null;
}
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';
return; return;
} }
ui.scoreboardStatus.textContent = `Top ${scoreboardEntries.length}`; ui.historyEmpty.classList.add('d-none');
ui.historyDetail.classList.remove('d-none');
scoreboardEntries.forEach((entry, index) => { const normalizedSelectedId = selectedRunId ? String(selectedRunId) : String(history[0].id);
const item = document.createElement('article'); const selectedRun = history.find((entry) => String(entry.id) === normalizedSelectedId) || history[0];
item.className = 'scoreboard-item'; selectedRunId = selectedRun.id;
const rank = document.createElement('div'); ui.historyList.innerHTML = history.map((entry) => {
rank.className = 'scoreboard-rank'; const isActive = String(entry.id) === String(selectedRunId);
rank.textContent = `#${index + 1}`; const finishedLabel = entry.finishedAt
? new Date(entry.finishedAt).toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: 'Just now';
return `
<button type="button" class="history-item ${isActive ? 'active' : ''}" data-run-id="${String(entry.id)}">
<div class="history-topline">
<span class="history-score">${Number(entry.score || 0).toLocaleString()}</span>
<span class="history-date">${finishedLabel}</span>
</div>
<div class="history-subline">
<span class="history-meta">Level ${Number(entry.level || 0)} · Lines ${Number(entry.lines || 0)}</span>
<span class="history-meta">${entry.duration || '00:00'}</span>
</div>
</button>
`;
}).join('');
const body = document.createElement('div'); ui.historyList.querySelectorAll('[data-run-id]').forEach((button) => {
body.className = 'scoreboard-item-body'; button.addEventListener('click', () => {
selectedRunId = button.getAttribute('data-run-id');
const topRow = document.createElement('div'); renderHistory();
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.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; if (!ui.scoreboardStatus) return;
ui.scoreboardStatus.textContent = 'Loading…'; const preserveOnError = Boolean(options.preserveOnError);
ui.scoreboardStatus.textContent = "Loading…";
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), SCOREBOARD_TIMEOUT_MS); const timeoutId = window.setTimeout(() => controller.abort(), SCOREBOARD_TIMEOUT_MS);
try { try {
const response = await fetch(`${SCOREBOARD_API_URL}?limit=10`, { const response = await fetch(SCOREBOARD_API_URL + "?limit=10", {
cache: 'no-store', cache: "no-store",
signal: controller.signal signal: controller.signal
}); });
const payload = await response.json(); const payload = await response.json();
if (!response.ok || !payload.success) { 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 : []; scoreboardEntries = Array.isArray(payload.scores) ? payload.scores : [];
renderScoreboard(); renderScoreboard();
ui.scoreboardStatus.textContent = scoreboardEntries.length > 0 ? "Top " + scoreboardEntries.length : "Offline";
} catch (error) { } catch (error) {
console.warn('Scoreboard load error', error); if (error && error.name === "AbortError") {
scoreboardEntries = []; if (!preserveOnError && scoreboardEntries.length === 0 && ui.scoreboardStatus) {
ui.scoreboardStatus.textContent = "Offline";
}
return;
}
console.warn("Scoreboard load error", error);
if (!preserveOnError) {
scoreboardEntries = [];
}
renderScoreboard(); renderScoreboard();
ui.scoreboardStatus.textContent = 'Offline'; ui.scoreboardStatus.textContent = scoreboardEntries.length > 0 ? "Top " + scoreboardEntries.length : "Offline";
} finally { } finally {
window.clearTimeout(timeoutId); 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 { try {
const response = await fetch(SCOREBOARD_API_URL, { const response = await fetch(SCOREBOARD_API_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepalive: true,
action: 'save_score', body: JSON.stringify(payload)
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
})
}); });
const payload = await response.json(); const responsePayload = await response.json();
if (!response.ok || !payload.success) { if (!response.ok || !responsePayload.success) {
throw new Error(payload.error || 'Unable to save scoreboard entry.'); throw new Error(responsePayload.error || 'Unable to save scoreboard entry.');
} }
await loadScoreboard();
if (payload.placement) { const savedEntry = responsePayload.entry || {
showToast(`Run saved to the database scoreboard. Rank #${payload.placement}.`); 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 { } else {
showToast('Run saved to the database scoreboard.'); showToast('Run saved to the database scoreboard.');
} }
void loadScoreboard({ preserveOnError: true });
} catch (error) { } catch (error) {
console.warn('Scoreboard save error', 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) { if (ui.scoreboardStatus) {
ui.scoreboardStatus.textContent = 'Offline'; ui.scoreboardStatus.textContent = 'Offline';
} }
@ -982,6 +1057,17 @@ document.addEventListener('DOMContentLoaded', () => {
} }
function handleKeydown(event) { 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 code = event.code;
const gameKeys = ['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp', 'KeyX', 'KeyZ', 'Space', 'KeyP', 'KeyR', 'Enter']; const gameKeys = ['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp', 'KeyX', 'KeyZ', 'Space', 'KeyP', 'KeyR', 'Enter'];
if (gameKeys.includes(code)) { if (gameKeys.includes(code)) {
@ -1118,6 +1204,7 @@ document.addEventListener('DOMContentLoaded', () => {
renderHistory(); renderHistory();
void loadScoreboard(); void loadScoreboard();
void flushPendingScoreSaves();
updateStats(); updateStats();
updateMultiplayerUI(); 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'); 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');