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 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 = `
<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 {
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 `
<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');
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');