1
This commit is contained in:
parent
373f8b6ba0
commit
342ba02e74
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user