Flatlogic Bot 9191afc91a 0
2026-03-25 12:13:13 +00:00

887 lines
26 KiB
JavaScript

(() => {
const bootstrapData = window.APP_BOOTSTRAP || {};
const apiUrl = bootstrapData.apiUrl || 'api/scores.php';
const boardCanvas = document.getElementById('tetris-board');
const nextCanvas = document.getElementById('next-piece');
const holdCanvas = document.getElementById('hold-piece');
const startButton = document.getElementById('start-game-btn');
const pauseButton = document.getElementById('pause-game-btn');
const resetButton = document.getElementById('reset-run-btn');
const soundButton = document.getElementById('sound-toggle-btn');
const refreshBoardButton = document.getElementById('refresh-board-btn');
const scoreForm = document.getElementById('score-form');
const submitButton = document.getElementById('submit-score-btn');
const playerNameInput = document.getElementById('player-name');
const submissionState = document.getElementById('submission-state');
const leaderboardList = document.getElementById('leaderboard-list');
const overlay = document.getElementById('board-overlay');
const overlayTitle = document.getElementById('overlay-title');
const overlayCopy = document.getElementById('overlay-copy');
const toastElement = document.getElementById('app-toast');
const toastMessage = document.getElementById('toast-message');
const toastContext = document.getElementById('toast-context');
const controls = document.querySelectorAll('[data-control]');
if (!boardCanvas) {
return;
}
const ctx = boardCanvas.getContext('2d');
const nextCtx = nextCanvas ? nextCanvas.getContext('2d') : null;
const holdCtx = holdCanvas ? holdCanvas.getContext('2d') : null;
const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 2600 }) : null;
const COLS = 10;
const ROWS = 20;
const BLOCK = 30;
const PREVIEW_BLOCK = 24;
const LOCAL_BEST_KEY = 'retrostack-best-score';
const PLAYER_NAME_KEY = 'retrostack-player-name';
const DEVICE_KEY = 'retrostack-device-id';
boardCanvas.width = COLS * BLOCK;
boardCanvas.height = ROWS * BLOCK;
if (nextCanvas) {
nextCanvas.width = 120;
nextCanvas.height = 120;
}
if (holdCanvas) {
holdCanvas.width = 120;
holdCanvas.height = 120;
}
const palette = {
I: '#39f6ff',
J: '#3d8bff',
L: '#ff9a3d',
O: '#ffe45c',
S: '#33ffb5',
T: '#ff4fd8',
Z: '#ff5f7a'
};
const shapes = {
I: [[1, 1, 1, 1]],
J: [[1, 0, 0], [1, 1, 1]],
L: [[0, 0, 1], [1, 1, 1]],
O: [[1, 1], [1, 1]],
S: [[0, 1, 1], [1, 1, 0]],
T: [[0, 1, 0], [1, 1, 1]],
Z: [[1, 1, 0], [0, 1, 1]]
};
const genericKicks = [
[0, 0],
[-1, 0],
[1, 0],
[0, -1],
[-2, 0],
[2, 0],
[0, -2]
];
const audio = {
enabled: false,
ctx: null,
play(type) {
if (!this.enabled) return;
if (!this.ctx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
this.ctx = new AudioContext();
}
const now = this.ctx.currentTime;
const oscillator = this.ctx.createOscillator();
const gain = this.ctx.createGain();
oscillator.connect(gain);
gain.connect(this.ctx.destination);
const tones = {
move: [200, 0.03, 'square'],
rotate: [280, 0.04, 'triangle'],
clear: [420, 0.12, 'sawtooth'],
drop: [160, 0.06, 'square'],
hold: [320, 0.06, 'triangle'],
gameOver: [110, 0.3, 'sine']
};
const [frequency, duration, wave] = tones[type] || tones.move;
oscillator.type = wave;
oscillator.frequency.setValueAtTime(frequency, now);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.1, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.start(now);
oscillator.stop(now + duration + 0.02);
}
};
const initialPlayerName = window.localStorage.getItem(PLAYER_NAME_KEY) || '';
if (playerNameInput && initialPlayerName) {
playerNameInput.value = initialPlayerName;
}
const state = {
board: createBoard(),
activePiece: null,
queue: [],
holdType: null,
canHold: true,
score: 0,
lines: 0,
level: 1,
gameOver: false,
paused: false,
running: false,
dropAccumulator: 0,
dropInterval: 900,
lastFrame: 0,
animationFrame: null,
durationStart: null,
lastResult: { score: 0, lines: 0, level: 1, duration: 0 },
localBest: Number(window.localStorage.getItem(LOCAL_BEST_KEY) || 0),
submitting: false,
particles: [],
lineBursts: [],
screenFlash: 0
};
updateBestDisplay();
updateScoreDisplays();
updateActionState();
renderLeaderboard(Array.isArray(bootstrapData.topScores) ? bootstrapData.topScores : []);
renderBoard();
renderPreview(nextCtx, null);
renderPreview(holdCtx, null);
// setOverlay('Press Start', 'The board is idle. Start a run, clear lines, then submit your score online.', true);
// Replaced manual call to overlay since it was removed/simplified in HTML
console.log("Game initialized.");
startButton?.addEventListener('click', () => {
releaseActiveButtonFocus();
startGame(true);
});
pauseButton?.addEventListener('click', togglePause);
// resetButton?.addEventListener('click', () => startGame(true));
// soundButton?.addEventListener('click', toggleSound);
// refreshBoardButton?.addEventListener('click', () => fetchLeaderboard(true));
scoreForm?.addEventListener('submit', submitScore);
controls.forEach((button) => {
const action = () => handleControl(button.dataset.control || '');
button.addEventListener('click', action);
button.addEventListener('touchstart', (event) => {
event.preventDefault();
action();
}, { passive: false });
});
document.addEventListener('keydown', (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName || '')) {
return;
}
switch (event.code) {
case 'ArrowLeft':
event.preventDefault();
handleControl('left');
break;
case 'ArrowRight':
event.preventDefault();
handleControl('right');
break;
case 'ArrowUp':
case 'KeyX':
event.preventDefault();
handleControl('rotate');
break;
case 'ArrowDown':
event.preventDefault();
handleControl('softDrop');
break;
case 'Space':
event.preventDefault();
handleControl('hardDrop');
break;
case 'KeyC':
case 'ShiftLeft':
case 'ShiftRight':
event.preventDefault();
handleControl('hold');
break;
case 'Enter':
case 'NumpadEnter':
event.preventDefault();
if (!state.running || state.gameOver) {
releaseActiveButtonFocus();
startGame(true);
}
break;
case 'KeyP':
event.preventDefault();
togglePause();
break;
default:
break;
}
});
function releaseActiveButtonFocus() {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement && activeElement.tagName === 'BUTTON') {
activeElement.blur();
}
}
function createBoard() {
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
}
function createBag() {
const bag = Object.keys(shapes).slice();
for (let i = bag.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
return bag;
}
function cloneMatrix(matrix) {
return matrix.map((row) => row.slice());
}
function createPiece(type) {
const matrix = cloneMatrix(shapes[type]);
return {
type,
matrix,
x: Math.floor((COLS - matrix[0].length) / 2),
y: -getTopPadding(matrix),
color: palette[type]
};
}
function getTopPadding(matrix) {
let padding = 0;
for (const row of matrix) {
if (row.every((cell) => cell === 0)) {
padding += 1;
} else {
break;
}
}
return padding;
}
function ensureQueue() {
while (state.queue.length < 5) {
state.queue.push(...createBag());
}
}
function spawnPiece() {
ensureQueue();
const type = state.queue.shift();
state.activePiece = createPiece(type);
state.canHold = true;
if (collides(state.activePiece, 0, 0, state.activePiece.matrix)) {
endGame();
}
renderPreview(nextCtx, state.queue[0]);
}
function startGame(showToast = false) {
state.board = createBoard();
state.queue = [];
state.holdType = null;
state.canHold = true;
state.score = 0;
state.lines = 0;
state.level = 1;
state.dropInterval = getDropInterval();
state.dropAccumulator = 0;
state.gameOver = false;
state.paused = false;
state.running = true;
state.durationStart = performance.now();
state.lastResult = { score: 0, lines: 0, level: 1, duration: 0 };
state.particles = [];
state.lineBursts = [];
state.screenFlash = 0;
if (submissionState) {
submissionState.textContent = 'Finish a run, then save.';
}
if (pauseButton) {
pauseButton.textContent = 'Pause';
}
updateScoreDisplays();
updateActionState();
renderPreview(holdCtx, null);
ensureQueue();
spawnPiece();
// submissionState.textContent = 'Complete the run to unlock submission';
// submitButton.disabled = true;
if (state.animationFrame) {
cancelAnimationFrame(state.animationFrame);
}
state.lastFrame = 0;
tick(0);
}
function getDropInterval() {
return Math.max(110, 900 - (state.level - 1) * 70);
}
function tick(timestamp) {
if (!state.running) {
renderBoard();
return;
}
if (!state.lastFrame) {
state.lastFrame = timestamp;
}
const delta = timestamp - state.lastFrame;
state.lastFrame = timestamp;
if (!state.paused && !state.gameOver) {
state.dropAccumulator += delta;
if (state.dropAccumulator >= state.dropInterval) {
state.dropAccumulator = 0;
stepDown();
}
}
updateEffects(delta);
renderBoard();
state.animationFrame = requestAnimationFrame(tick);
}
function collides(piece, offsetX = 0, offsetY = 0, matrix = piece.matrix) {
for (let y = 0; y < matrix.length; y += 1) {
for (let x = 0; x < matrix[y].length; x += 1) {
if (!matrix[y][x]) continue;
const boardX = piece.x + x + offsetX;
const boardY = piece.y + y + offsetY;
if (boardX < 0 || boardX >= COLS || boardY >= ROWS) {
return true;
}
if (boardY >= 0 && state.board[boardY][boardX]) {
return true;
}
}
}
return false;
}
function mergePiece() {
const lockedCells = [];
state.activePiece.matrix.forEach((row, y) => {
row.forEach((cell, x) => {
if (!cell) return;
const boardY = state.activePiece.y + y;
const boardX = state.activePiece.x + x;
if (boardY >= 0 && boardY < ROWS && boardX >= 0 && boardX < COLS) {
state.board[boardY][boardX] = state.activePiece.color;
lockedCells.push({ x: boardX, y: boardY, color: state.activePiece.color });
}
});
});
return lockedCells;
}
function clearLines() {
let cleared = 0;
const clearedRows = [];
for (let y = ROWS - 1; y >= 0; y -= 1) {
if (state.board[y].every(Boolean)) {
clearedRows.push(y);
state.board.splice(y, 1);
state.board.unshift(Array(COLS).fill(null));
cleared += 1;
y += 1;
}
}
if (cleared > 0) {
const scoreTable = [0, 100, 300, 500, 800];
state.score += scoreTable[cleared] * state.level;
state.lines += cleared;
state.level = Math.floor(state.lines / 10) + 1;
state.dropInterval = getDropInterval();
triggerLineClearEffect(clearedRows);
audio.play('clear');
}
return clearedRows;
}
function stepDown() {
if (!state.activePiece) return;
if (!collides(state.activePiece, 0, 1)) {
state.activePiece.y += 1;
return;
}
const lockedCells = mergePiece();
triggerLockEffect(lockedCells);
clearLines();
updateScoreDisplays();
spawnPiece();
}
function move(direction) {
if (!canPlay()) return;
if (!collides(state.activePiece, direction, 0)) {
state.activePiece.x += direction;
audio.play('move');
renderBoard();
}
}
function rotateMatrix(matrix) {
return matrix[0].map((_, index) => matrix.map((row) => row[index]).reverse());
}
function rotatePiece() {
if (!canPlay()) return;
const rotated = rotateMatrix(state.activePiece.matrix);
const kicks = state.activePiece.type === 'O' ? [[0, 0]] : genericKicks;
for (const [x, y] of kicks) {
if (!collides(state.activePiece, x, y, rotated)) {
state.activePiece.matrix = rotated;
state.activePiece.x += x;
state.activePiece.y += y;
audio.play('rotate');
renderBoard();
return;
}
}
}
function softDrop() {
if (!canPlay()) return;
if (!collides(state.activePiece, 0, 1)) {
state.activePiece.y += 1;
state.score += 1;
updateScoreDisplays();
renderBoard();
} else {
stepDown();
}
}
function hardDrop() {
if (!canPlay()) return;
let dropDistance = 0;
while (!collides(state.activePiece, 0, 1)) {
state.activePiece.y += 1;
dropDistance += 1;
}
state.score += dropDistance * 2;
audio.play('drop');
stepDown();
updateScoreDisplays();
}
function holdPiece() {
if (!canPlay() || !state.canHold) return;
const currentType = state.activePiece.type;
if (state.holdType) {
const swapType = state.holdType;
state.holdType = currentType;
state.activePiece = createPiece(swapType);
if (collides(state.activePiece, 0, 0, state.activePiece.matrix)) {
endGame();
return;
}
} else {
state.holdType = currentType;
spawnPiece();
}
state.canHold = false;
renderPreview(holdCtx, state.holdType);
audio.play('hold');
renderBoard();
}
function canPlay() {
return state.running && !state.paused && !state.gameOver && state.activePiece;
}
function handleControl(control) {
switch (control) {
case 'left':
move(-1);
break;
case 'right':
move(1);
break;
case 'rotate':
rotatePiece();
break;
case 'softDrop':
softDrop();
break;
case 'hardDrop':
hardDrop();
break;
case 'hold':
holdPiece();
break;
default:
break;
}
}
function togglePause() {
if (!state.running || state.gameOver) return;
state.paused = !state.paused;
if (pauseButton) {
pauseButton.textContent = state.paused ? 'Resume' : 'Pause';
}
}
function endGame() {
state.gameOver = true;
state.running = false;
const duration = getDurationSeconds();
state.lastResult = {
score: state.score,
lines: state.lines,
level: state.level,
duration
};
if (state.score > state.localBest) {
state.localBest = state.score;
window.localStorage.setItem(LOCAL_BEST_KEY, String(state.localBest));
updateBestDisplay();
}
updateScoreDisplays();
updateActionState();
if (submissionState) {
submissionState.textContent = 'Ready to save.';
}
audio.play('gameOver');
}
function getDurationSeconds() {
if (!state.durationStart) return state.lastResult.duration || 0;
return Math.max(0, Math.round((performance.now() - state.durationStart) / 1000));
}
function updateBestDisplay() {
const bestValue = document.getElementById('best-value');
if (bestValue) {
bestValue.textContent = Number(state.localBest).toLocaleString();
}
}
function updateActionState() {
if (pauseButton) {
pauseButton.disabled = !state.running || state.gameOver;
}
if (submitButton) {
submitButton.disabled = state.submitting || !state.gameOver || state.lastResult.score <= 0;
}
}
function updateScoreDisplays() {
const entries = {
'score-value': state.score,
'lines-value': state.lines,
'level-value': state.level
};
Object.entries(entries).forEach(([id, value]) => {
const element = document.getElementById(id);
if (element) {
element.textContent = typeof value === 'number' ? Number(value).toLocaleString() : value;
}
});
updateActionState();
}
function hexToRgba(hex, alpha = 1) {
let value = String(hex || '').replace('#', '');
if (value.length === 3) {
value = value.split('').map((part) => part + part).join('');
}
const parsed = Number.parseInt(value, 16);
if (Number.isNaN(parsed)) {
return `rgba(255, 255, 255, ${alpha})`;
}
const r = (parsed >> 16) & 255;
const g = (parsed >> 8) & 255;
const b = parsed & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function randomBetween(min, max) {
return Math.random() * (max - min) + min;
}
function triggerLockEffect(cells) {
if (!Array.isArray(cells) || !cells.length) return;
cells.slice(0, 6).forEach((cell) => {
const centerX = cell.x * BLOCK + BLOCK / 2;
const centerY = cell.y * BLOCK + BLOCK / 2;
for (let index = 0; index < 3; index += 1) {
state.particles.push({
x: centerX + randomBetween(-4, 4),
y: centerY + randomBetween(-4, 4),
vx: randomBetween(-65, 65),
vy: randomBetween(-140, -30),
size: randomBetween(3, 6),
life: randomBetween(140, 240),
maxLife: 240,
color: cell.color
});
}
});
state.screenFlash = Math.max(state.screenFlash, 0.08);
}
function triggerLineClearEffect(rows) {
if (!Array.isArray(rows) || !rows.length) return;
rows.forEach((row) => {
state.lineBursts.push({ row, life: 260, maxLife: 260 });
for (let x = 0; x < COLS; x += 1) {
const centerX = x * BLOCK + BLOCK / 2;
const centerY = row * BLOCK + BLOCK / 2;
for (let index = 0; index < 2; index += 1) {
state.particles.push({
x: centerX + randomBetween(-6, 6),
y: centerY + randomBetween(-5, 5),
vx: randomBetween(-170, 170),
vy: randomBetween(-120, 45),
size: randomBetween(4, 8),
life: randomBetween(220, 360),
maxLife: 360,
color: x % 2 === 0 ? '#48e7ff' : '#ff4fd8'
});
}
}
});
state.screenFlash = Math.max(state.screenFlash, Math.min(0.26, 0.12 + rows.length * 0.04));
}
function updateEffects(delta) {
const safeDelta = Math.max(0, Math.min(delta || 0, 48));
const seconds = safeDelta / 1000;
if (state.screenFlash > 0) {
state.screenFlash = Math.max(0, state.screenFlash - seconds * 1.8);
}
state.lineBursts = state.lineBursts.filter((burst) => {
burst.life -= safeDelta;
return burst.life > 0;
});
state.particles = state.particles.filter((particle) => {
particle.life -= safeDelta;
if (particle.life <= 0) {
return false;
}
particle.x += particle.vx * seconds;
particle.y += particle.vy * seconds;
particle.vy += 420 * seconds;
particle.vx *= 0.985;
return true;
});
}
function renderEffects() {
if (!state.lineBursts.length && !state.particles.length && state.screenFlash <= 0) {
return;
}
ctx.save();
state.lineBursts.forEach((burst) => {
const progress = Math.max(0, burst.life / burst.maxLife);
const glowY = burst.row * BLOCK;
ctx.fillStyle = `rgba(72, 231, 255, ${0.18 * progress})`;
ctx.fillRect(0, glowY + 2, boardCanvas.width, BLOCK - 4);
ctx.fillStyle = `rgba(255, 79, 216, ${0.78 * progress})`;
ctx.fillRect(0, glowY + Math.floor(BLOCK / 2) - 1, boardCanvas.width, 2);
});
state.particles.forEach((particle) => {
const alpha = Math.max(0, particle.life / particle.maxLife);
ctx.fillStyle = hexToRgba(particle.color, alpha);
ctx.fillRect(Math.round(particle.x), Math.round(particle.y), particle.size, particle.size);
ctx.fillStyle = `rgba(236, 253, 255, ${alpha * 0.68})`;
ctx.fillRect(Math.round(particle.x), Math.round(particle.y), Math.max(1, particle.size - 2), 1);
});
if (state.screenFlash > 0) {
ctx.fillStyle = `rgba(72, 231, 255, ${state.screenFlash * 0.2})`;
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
}
ctx.restore();
}
function drawCell(context, x, y, color, size = BLOCK, padding = 1) {
const px = x * size;
const py = y * size;
const innerSize = size - padding * 2;
context.fillStyle = color;
context.fillRect(px + padding, py + padding, innerSize, innerSize);
context.fillStyle = hexToRgba('#ecfdff', 0.24);
context.fillRect(px + padding + 2, py + padding + 2, Math.max(4, innerSize - 6), Math.max(2, Math.floor(size * 0.14)));
context.fillStyle = hexToRgba('#03131c', 0.34);
context.fillRect(px + padding + 2, py + padding + innerSize - 5, Math.max(4, innerSize - 6), 3);
context.strokeStyle = 'rgba(6, 18, 30, 0.86)';
context.strokeRect(px + padding + 0.5, py + padding + 0.5, innerSize - 1, innerSize - 1);
}
function renderGrid() {
ctx.strokeStyle = 'rgba(72, 231, 255, 0.08)';
ctx.lineWidth = 1;
for (let x = 0; x <= COLS; x += 1) {
ctx.beginPath();
ctx.moveTo(x * BLOCK, 0);
ctx.lineTo(x * BLOCK, ROWS * BLOCK);
ctx.stroke();
}
for (let y = 0; y <= ROWS; y += 1) {
ctx.beginPath();
ctx.moveTo(0, y * BLOCK);
ctx.lineTo(COLS * BLOCK, y * BLOCK);
ctx.stroke();
}
}
function renderBoard() {
ctx.fillStyle = '#040914';
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
renderGrid();
state.board.forEach((row, y) => {
row.forEach((cell, x) => {
if (cell) {
drawCell(ctx, x, y, cell);
}
});
});
if (state.activePiece) {
state.activePiece.matrix.forEach((row, y) => {
row.forEach((cell, x) => {
if (!cell) return;
const drawY = state.activePiece.y + y;
if (drawY >= 0) {
drawCell(ctx, state.activePiece.x + x, drawY, state.activePiece.color);
}
});
});
}
renderEffects();
renderPreview(holdCtx, state.holdType);
renderPreview(nextCtx, state.queue[0]);
updateScoreDisplays();
}
function renderPreview(context, type) {
if (!context) return;
context.fillStyle = '#06101d';
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.strokeStyle = 'rgba(72, 231, 255, 0.12)';
context.strokeRect(0.5, 0.5, context.canvas.width - 1, context.canvas.height - 1);
if (!type || !shapes[type]) return;
const matrix = shapes[type];
const offsetX = Math.floor((context.canvas.width - matrix[0].length * PREVIEW_BLOCK) / 2 / PREVIEW_BLOCK);
const offsetY = Math.floor((context.canvas.height - matrix.length * PREVIEW_BLOCK) / 2 / PREVIEW_BLOCK);
matrix.forEach((row, y) => {
row.forEach((cell, x) => {
if (!cell) return;
drawCell(context, x + offsetX, y + offsetY, palette[type], PREVIEW_BLOCK, 1);
});
});
}
async function submitScore(event) {
event.preventDefault();
if (state.submitting) return;
const playerName = (playerNameInput?.value || '').trim();
if (!state.gameOver || state.lastResult.score <= 0) {
return;
}
if (playerName.length < 2) return;
window.localStorage.setItem(PLAYER_NAME_KEY, playerName);
state.submitting = true;
updateActionState();
if (submissionState) {
submissionState.textContent = 'Saving...';
}
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
player_name: playerName,
score: state.lastResult.score,
lines_cleared: state.lastResult.lines,
level_reached: state.lastResult.level,
duration_seconds: state.lastResult.duration,
client_signature: getDeviceSignature()
})
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message);
renderLeaderboard(data.scores || []);
if (submissionState) {
submissionState.textContent = 'Saved.';
}
} catch (error) {
console.error(error);
if (submissionState) {
submissionState.textContent = 'Save failed.';
}
} finally {
state.submitting = false;
updateActionState();
}
}
async function fetchLeaderboard() {
try {
const response = await fetch(`${apiUrl}?limit=12`, { headers: { Accept: 'application/json' } });
const data = await response.json();
if (response.ok && data.success) renderLeaderboard(data.scores || []);
} catch (error) {
console.error(error);
}
}
function renderLeaderboard(scores) {
if (!leaderboardList) return;
leaderboardList.innerHTML = scores.map((entry, index) => {
const id = Number(entry.id || 0);
const safeName = entry.player_name || 'Player';
const score = Number(entry.score || 0).toLocaleString();
return `
<a class="leaderboard-item" href="score.php?id=${id}">
<span class="leaderboard-rank">#${index + 1}</span>
<span class="leaderboard-player">${safeName}</span>
<span class="leaderboard-meta">${score}</span>
</a>
`;
}).join('');
}
function getDeviceSignature() {
let deviceId = window.localStorage.getItem(DEVICE_KEY);
if (!deviceId) {
deviceId = `device-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
window.localStorage.setItem(DEVICE_KEY, deviceId);
}
return deviceId;
}
})();