1267 lines
45 KiB
JavaScript
1267 lines
45 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const COLS = 10;
|
|
const ROWS = 20;
|
|
const BLOCK = 30;
|
|
const STORAGE_KEYS = {
|
|
best: 'tetris_best_score_v1',
|
|
history: 'tetris_run_history_v1'
|
|
};
|
|
const MULTI_STORAGE_KEYS = {
|
|
room: 'tetris_room_code_v1',
|
|
token: 'tetris_player_token_v1',
|
|
name: 'tetris_player_name_v1',
|
|
slot: 'tetris_player_slot_v1'
|
|
};
|
|
const MULTI_API_URL = 'api/multiplayer.php';
|
|
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: {
|
|
matrix: [
|
|
[0, 0, 0, 0],
|
|
[1, 1, 1, 1],
|
|
[0, 0, 0, 0],
|
|
[0, 0, 0, 0]
|
|
],
|
|
color: '#7cb8c7'
|
|
},
|
|
J: {
|
|
matrix: [
|
|
[2, 0, 0],
|
|
[2, 2, 2],
|
|
[0, 0, 0]
|
|
],
|
|
color: '#7a8db6'
|
|
},
|
|
L: {
|
|
matrix: [
|
|
[0, 0, 3],
|
|
[3, 3, 3],
|
|
[0, 0, 0]
|
|
],
|
|
color: '#c49a63'
|
|
},
|
|
O: {
|
|
matrix: [
|
|
[4, 4],
|
|
[4, 4]
|
|
],
|
|
color: '#d0b768'
|
|
},
|
|
S: {
|
|
matrix: [
|
|
[0, 5, 5],
|
|
[5, 5, 0],
|
|
[0, 0, 0]
|
|
],
|
|
color: '#86b494'
|
|
},
|
|
T: {
|
|
matrix: [
|
|
[0, 6, 0],
|
|
[6, 6, 6],
|
|
[0, 0, 0]
|
|
],
|
|
color: '#b18ab8'
|
|
},
|
|
Z: {
|
|
matrix: [
|
|
[7, 7, 0],
|
|
[0, 7, 7],
|
|
[0, 0, 0]
|
|
],
|
|
color: '#cb7d7d'
|
|
}
|
|
};
|
|
|
|
const colorLookup = Object.entries(PIECE_DEFS).reduce((acc, [type, def], index) => {
|
|
acc[index + 1] = def.color;
|
|
return acc;
|
|
}, {});
|
|
|
|
const boardCanvas = document.getElementById('tetris-board');
|
|
const nextCanvas = document.getElementById('next-piece');
|
|
const opponentCanvas = document.getElementById('opponent-board');
|
|
const boardCtx = boardCanvas.getContext('2d');
|
|
const nextCtx = nextCanvas.getContext('2d');
|
|
boardCtx.scale(BLOCK, BLOCK);
|
|
const opponentCtx = opponentCanvas ? opponentCanvas.getContext('2d') : null;
|
|
const OPPONENT_BLOCK = 20;
|
|
if (opponentCtx) {
|
|
opponentCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
opponentCtx.scale(OPPONENT_BLOCK, OPPONENT_BLOCK);
|
|
}
|
|
|
|
const ui = {
|
|
score: document.getElementById('score-value'),
|
|
lines: document.getElementById('lines-value'),
|
|
level: document.getElementById('level-value'),
|
|
speed: document.getElementById('speed-value'),
|
|
bestInline: document.getElementById('best-score-inline'),
|
|
elapsedInline: document.getElementById('elapsed-time-inline'),
|
|
badge: document.getElementById('game-state-badge'),
|
|
overlay: document.getElementById('board-overlay'),
|
|
pauseButton: document.getElementById('pause-button'),
|
|
restartButton: document.getElementById('restart-button'),
|
|
modalRestartButton: document.getElementById('modal-restart-button'),
|
|
detailStatus: document.getElementById('detail-status'),
|
|
detailPieces: document.getElementById('detail-pieces'),
|
|
detailLines: document.getElementById('detail-lines'),
|
|
detailDuration: document.getElementById('detail-duration'),
|
|
finalScore: document.getElementById('final-score'),
|
|
finalLines: document.getElementById('final-lines'),
|
|
finalLevel: document.getElementById('final-level'),
|
|
finalDuration: document.getElementById('final-duration'),
|
|
historyEmpty: document.getElementById('history-empty'),
|
|
historyList: document.getElementById('history-list'),
|
|
historyDetail: document.getElementById('history-detail'),
|
|
scoreboardStatus: document.getElementById('scoreboard-status'),
|
|
scoreboardEmpty: document.getElementById('scoreboard-empty'),
|
|
scoreboardList: document.getElementById('scoreboard-list'),
|
|
selectedScore: document.getElementById('selected-score'),
|
|
selectedLevel: document.getElementById('selected-level'),
|
|
selectedLines: document.getElementById('selected-lines'),
|
|
selectedDuration: document.getElementById('selected-duration'),
|
|
selectedFinished: document.getElementById('selected-finished'),
|
|
toastEl: document.getElementById('game-toast'),
|
|
toastBody: document.getElementById('toast-body'),
|
|
toastTimestamp: document.getElementById('toast-timestamp'),
|
|
playerNameInput: document.getElementById('player-name'),
|
|
createRoomBtn: document.getElementById('create-room-btn'),
|
|
joinRoomBtn: document.getElementById('join-room-btn'),
|
|
leaveRoomBtn: document.getElementById('leave-room-btn'),
|
|
roomCodeInput: document.getElementById('room-code-input'),
|
|
roomCodeCard: document.getElementById('room-code-card'),
|
|
roomCodeValue: document.getElementById('room-code-value'),
|
|
copyRoomBtn: document.getElementById('copy-room-btn'),
|
|
roomStatusBadge: document.getElementById('room-status-badge'),
|
|
roomStatusText: document.getElementById('room-status-text'),
|
|
opponentName: document.getElementById('opponent-name'),
|
|
opponentStats: document.getElementById('opponent-stats'),
|
|
opponentStatus: document.getElementById('opponent-status')
|
|
};
|
|
|
|
const bootstrapToast = window.bootstrap ? bootstrap.Toast.getOrCreateInstance(ui.toastEl, { delay: 2200 }) : null;
|
|
const gameOverModalEl = document.getElementById('gameOverModal');
|
|
const gameOverModal = window.bootstrap ? new bootstrap.Modal(gameOverModalEl) : null;
|
|
|
|
function safeOn(element, eventName, handler) {
|
|
if (!element) return;
|
|
element.addEventListener(eventName, handler);
|
|
}
|
|
|
|
let board = createBoard();
|
|
let currentPiece = null;
|
|
let nextQueue = [];
|
|
let bag = [];
|
|
let animationFrameId = null;
|
|
let lastTime = 0;
|
|
let dropCounter = 0;
|
|
let dropInterval = 900;
|
|
let score = 0;
|
|
let lines = 0;
|
|
let level = 1;
|
|
let piecesPlaced = 0;
|
|
let bestScore = Number(localStorage.getItem(STORAGE_KEYS.best) || 0);
|
|
let history = loadHistory();
|
|
let scoreboardEntries = [];
|
|
let selectedRunId = history[0]?.id || null;
|
|
let startedAt = null;
|
|
let endedAt = null;
|
|
let isRunning = false;
|
|
let isPaused = false;
|
|
let isGameOver = false;
|
|
const multiplayer = {
|
|
roomCode: null,
|
|
token: null,
|
|
slot: null,
|
|
displayName: '',
|
|
pollTimer: null,
|
|
isSyncing: false,
|
|
opponent: null,
|
|
roomStatus: 'offline',
|
|
playerCount: 0
|
|
};
|
|
|
|
function createBoard() {
|
|
return Array.from({ length: ROWS }, () => Array(COLS).fill(0));
|
|
}
|
|
|
|
function cloneMatrix(matrix) {
|
|
return matrix.map((row) => row.slice());
|
|
}
|
|
|
|
function shuffle(array) {
|
|
const clone = array.slice();
|
|
for (let i = clone.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[clone[i], clone[j]] = [clone[j], clone[i]];
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
function fillQueue() {
|
|
while (nextQueue.length < 4) {
|
|
if (bag.length === 0) {
|
|
bag = shuffle(Object.keys(PIECE_DEFS));
|
|
}
|
|
nextQueue.push(bag.pop());
|
|
}
|
|
}
|
|
|
|
function createPiece(type) {
|
|
const definition = PIECE_DEFS[type];
|
|
const matrix = cloneMatrix(definition.matrix);
|
|
return {
|
|
type,
|
|
matrix,
|
|
x: Math.floor(COLS / 2) - Math.ceil(matrix[0].length / 2),
|
|
y: 0,
|
|
color: definition.color
|
|
};
|
|
}
|
|
|
|
function spawnPiece() {
|
|
fillQueue();
|
|
currentPiece = createPiece(nextQueue.shift());
|
|
fillQueue();
|
|
|
|
if (collides(board, currentPiece)) {
|
|
finishGame();
|
|
}
|
|
}
|
|
|
|
function collides(targetBoard, piece, moveX = 0, moveY = 0, testMatrix = piece.matrix) {
|
|
for (let y = 0; y < testMatrix.length; y += 1) {
|
|
for (let x = 0; x < testMatrix[y].length; x += 1) {
|
|
if (testMatrix[y][x] === 0) continue;
|
|
const boardX = x + piece.x + moveX;
|
|
const boardY = y + piece.y + moveY;
|
|
if (boardX < 0 || boardX >= COLS || boardY >= ROWS) return true;
|
|
if (boardY >= 0 && targetBoard[boardY][boardX] !== 0) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function mergePiece() {
|
|
currentPiece.matrix.forEach((row, y) => {
|
|
row.forEach((value, x) => {
|
|
if (value !== 0 && board[y + currentPiece.y] && board[y + currentPiece.y][x + currentPiece.x] !== undefined) {
|
|
board[y + currentPiece.y][x + currentPiece.x] = value;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearLines() {
|
|
let cleared = 0;
|
|
outer: for (let y = ROWS - 1; y >= 0; y -= 1) {
|
|
for (let x = 0; x < COLS; x += 1) {
|
|
if (board[y][x] === 0) {
|
|
continue outer;
|
|
}
|
|
}
|
|
const row = board.splice(y, 1)[0].fill(0);
|
|
board.unshift(row);
|
|
cleared += 1;
|
|
y += 1;
|
|
}
|
|
return cleared;
|
|
}
|
|
|
|
function rotate(matrix, direction) {
|
|
const rotated = matrix.map((_, index) => matrix.map((row) => row[index]));
|
|
if (direction > 0) {
|
|
rotated.forEach((row) => row.reverse());
|
|
} else {
|
|
rotated.reverse();
|
|
}
|
|
return rotated;
|
|
}
|
|
|
|
function attemptRotate(direction) {
|
|
if (!currentPiece || isPaused || isGameOver) return;
|
|
const rotated = rotate(currentPiece.matrix, direction);
|
|
const offsets = [0, -1, 1, -2, 2];
|
|
const originalX = currentPiece.x;
|
|
|
|
for (const offset of offsets) {
|
|
if (!collides(board, currentPiece, offset, 0, rotated)) {
|
|
currentPiece.x += offset;
|
|
currentPiece.matrix = rotated;
|
|
draw();
|
|
return;
|
|
}
|
|
}
|
|
currentPiece.x = originalX;
|
|
}
|
|
|
|
function movePiece(deltaX) {
|
|
if (!currentPiece || isPaused || isGameOver) return;
|
|
if (!collides(board, currentPiece, deltaX, 0)) {
|
|
currentPiece.x += deltaX;
|
|
draw();
|
|
}
|
|
}
|
|
|
|
function dropPiece(softDrop = false) {
|
|
if (!currentPiece || isPaused || isGameOver) return;
|
|
if (!collides(board, currentPiece, 0, 1)) {
|
|
currentPiece.y += 1;
|
|
if (softDrop) score += 1;
|
|
dropCounter = 0;
|
|
updateStats();
|
|
draw();
|
|
return true;
|
|
}
|
|
|
|
mergePiece();
|
|
piecesPlaced += 1;
|
|
const cleared = clearLines();
|
|
if (cleared > 0) {
|
|
const lineScores = [0, 100, 300, 500, 800];
|
|
score += lineScores[cleared] * level;
|
|
lines += cleared;
|
|
showToast(cleared === 4 ? 'Tetris! Four lines cleared.' : `${cleared} line${cleared > 1 ? 's' : ''} cleared.`);
|
|
}
|
|
|
|
level = Math.floor(lines / 10) + 1;
|
|
dropInterval = Math.max(120, 900 - ((level - 1) * 65));
|
|
spawnPiece();
|
|
updateStats();
|
|
draw();
|
|
return false;
|
|
}
|
|
|
|
function hardDrop() {
|
|
if (!currentPiece || isPaused || isGameOver) return;
|
|
let distance = 0;
|
|
while (!collides(board, currentPiece, 0, 1)) {
|
|
currentPiece.y += 1;
|
|
distance += 1;
|
|
}
|
|
if (distance > 0) {
|
|
score += distance * 2;
|
|
}
|
|
dropPiece(false);
|
|
}
|
|
|
|
function calculateGhostY() {
|
|
if (!currentPiece) return 0;
|
|
let ghostY = currentPiece.y;
|
|
while (!collides(board, { ...currentPiece, y: ghostY }, 0, 1)) {
|
|
ghostY += 1;
|
|
}
|
|
return ghostY;
|
|
}
|
|
|
|
function drawCell(ctx, x, y, color, alpha = 1) {
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(x + 0.04, y + 0.04, 0.92, 0.92);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
|
ctx.lineWidth = 0.05;
|
|
ctx.strokeRect(x + 0.06, y + 0.06, 0.88, 0.88);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawBoard() {
|
|
boardCtx.fillStyle = '#0e1218';
|
|
boardCtx.fillRect(0, 0, COLS, ROWS);
|
|
|
|
for (let y = 0; y < ROWS; y += 1) {
|
|
for (let x = 0; x < COLS; x += 1) {
|
|
if (board[y][x] !== 0) {
|
|
drawCell(boardCtx, x, y, colorLookup[board[y][x]]);
|
|
}
|
|
}
|
|
}
|
|
|
|
boardCtx.strokeStyle = 'rgba(255,255,255,0.06)';
|
|
boardCtx.lineWidth = 0.03;
|
|
for (let x = 0; x <= COLS; x += 1) {
|
|
boardCtx.beginPath();
|
|
boardCtx.moveTo(x, 0);
|
|
boardCtx.lineTo(x, ROWS);
|
|
boardCtx.stroke();
|
|
}
|
|
for (let y = 0; y <= ROWS; y += 1) {
|
|
boardCtx.beginPath();
|
|
boardCtx.moveTo(0, y);
|
|
boardCtx.lineTo(COLS, y);
|
|
boardCtx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawPiece(piece, alpha = 1) {
|
|
piece.matrix.forEach((row, y) => {
|
|
row.forEach((value, x) => {
|
|
if (value !== 0) {
|
|
drawCell(boardCtx, x + piece.x, y + piece.y, colorLookup[value], alpha);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function drawGhostPiece() {
|
|
if (!currentPiece) return;
|
|
const ghostY = calculateGhostY();
|
|
drawPiece({ ...currentPiece, y: ghostY }, 0.2);
|
|
}
|
|
|
|
function drawNext() {
|
|
nextCtx.clearRect(0, 0, nextCanvas.width, nextCanvas.height);
|
|
nextCtx.fillStyle = '#0e1218';
|
|
nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
|
|
|
|
const previewType = nextQueue[0];
|
|
if (!previewType) return;
|
|
const matrix = PIECE_DEFS[previewType].matrix;
|
|
const cell = 28;
|
|
const width = matrix[0].length * cell;
|
|
const height = matrix.length * cell;
|
|
const offsetX = (nextCanvas.width - width) / 2;
|
|
const offsetY = (nextCanvas.height - height) / 2;
|
|
|
|
matrix.forEach((row, y) => {
|
|
row.forEach((value, x) => {
|
|
if (value !== 0) {
|
|
nextCtx.fillStyle = colorLookup[value];
|
|
nextCtx.fillRect(offsetX + (x * cell) + 2, offsetY + (y * cell) + 2, cell - 4, cell - 4);
|
|
nextCtx.strokeStyle = 'rgba(255,255,255,0.15)';
|
|
nextCtx.strokeRect(offsetX + (x * cell) + 2, offsetY + (y * cell) + 2, cell - 4, cell - 4);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function formatDuration(ms) {
|
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
|
|
const seconds = String(totalSeconds % 60).padStart(2, '0');
|
|
return `${minutes}:${seconds}`;
|
|
}
|
|
|
|
function getElapsedMs() {
|
|
if (!startedAt) return 0;
|
|
if (endedAt) return endedAt - startedAt;
|
|
return Date.now() - startedAt;
|
|
}
|
|
|
|
function updateStats() {
|
|
ui.score.textContent = score.toLocaleString();
|
|
ui.lines.textContent = lines.toString();
|
|
ui.level.textContent = level.toString();
|
|
ui.speed.textContent = `${(dropInterval / 1000).toFixed(2)}s`;
|
|
ui.bestInline.textContent = bestScore.toLocaleString();
|
|
ui.elapsedInline.textContent = formatDuration(getElapsedMs());
|
|
ui.detailPieces.textContent = piecesPlaced.toString();
|
|
ui.detailLines.textContent = lines.toString();
|
|
ui.detailDuration.textContent = formatDuration(getElapsedMs());
|
|
ui.detailStatus.textContent = isGameOver ? 'Game over' : (isPaused ? 'Paused' : (isRunning ? 'In progress' : 'Ready to start'));
|
|
drawNext();
|
|
}
|
|
|
|
function getGameStatus() {
|
|
if (isGameOver) return 'game_over';
|
|
if (isPaused) return 'paused';
|
|
if (isRunning) return 'playing';
|
|
return 'ready';
|
|
}
|
|
|
|
function getComposedBoard() {
|
|
const composed = board.map((row) => row.slice());
|
|
if (currentPiece) {
|
|
currentPiece.matrix.forEach((row, y) => {
|
|
row.forEach((value, x) => {
|
|
if (value === 0) return;
|
|
const boardY = y + currentPiece.y;
|
|
const boardX = x + currentPiece.x;
|
|
if (boardY < 0 || boardY >= ROWS || boardX < 0 || boardX >= COLS) return;
|
|
composed[boardY][boardX] = value;
|
|
});
|
|
});
|
|
}
|
|
return composed;
|
|
}
|
|
|
|
function drawOpponentBoard(boardState) {
|
|
if (!opponentCtx) return;
|
|
opponentCtx.fillStyle = '#0e1218';
|
|
opponentCtx.fillRect(0, 0, COLS, ROWS);
|
|
|
|
if (Array.isArray(boardState)) {
|
|
for (let y = 0; y < ROWS; y += 1) {
|
|
for (let x = 0; x < COLS; x += 1) {
|
|
if (boardState[y] && boardState[y][x]) {
|
|
drawCell(opponentCtx, x, y, colorLookup[boardState[y][x]]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
opponentCtx.strokeStyle = 'rgba(255,255,255,0.06)';
|
|
opponentCtx.lineWidth = 0.03;
|
|
for (let x = 0; x <= COLS; x += 1) {
|
|
opponentCtx.beginPath();
|
|
opponentCtx.moveTo(x, 0);
|
|
opponentCtx.lineTo(x, ROWS);
|
|
opponentCtx.stroke();
|
|
}
|
|
for (let y = 0; y <= ROWS; y += 1) {
|
|
opponentCtx.beginPath();
|
|
opponentCtx.moveTo(0, y);
|
|
opponentCtx.lineTo(COLS, y);
|
|
opponentCtx.stroke();
|
|
}
|
|
}
|
|
|
|
function setBadge(label, variant = 'ready') {
|
|
ui.badge.className = `badge status-badge state-${variant}`;
|
|
ui.badge.textContent = label;
|
|
}
|
|
|
|
function showOverlay(title, copy, label = 'Press Enter to start') {
|
|
ui.overlay.innerHTML = `
|
|
<div>
|
|
<p class="overlay-label mb-2">${label}</p>
|
|
<h3 class="overlay-title mb-3">${title}</h3>
|
|
<p class="overlay-copy mb-0">${copy}</p>
|
|
</div>
|
|
`;
|
|
ui.overlay.classList.remove('hidden');
|
|
}
|
|
|
|
function hideOverlay() {
|
|
ui.overlay.classList.add('hidden');
|
|
}
|
|
|
|
function showToast(message) {
|
|
ui.toastBody.textContent = message;
|
|
ui.toastTimestamp.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
if (bootstrapToast) bootstrapToast.show();
|
|
}
|
|
|
|
function loadHistory() {
|
|
try {
|
|
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) {
|
|
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() {
|
|
if (!ui.historyList || !ui.historyEmpty || !ui.historyDetail) return;
|
|
|
|
if (!Array.isArray(history) || history.length === 0) {
|
|
ui.historyList.innerHTML = '';
|
|
ui.historyEmpty.classList.remove('d-none');
|
|
ui.historyDetail.classList.add('d-none');
|
|
selectedRunId = null;
|
|
return;
|
|
}
|
|
|
|
ui.historyEmpty.classList.add('d-none');
|
|
ui.historyDetail.classList.remove('d-none');
|
|
|
|
const normalizedSelectedId = selectedRunId ? String(selectedRunId) : String(history[0].id);
|
|
const selectedRun = history.find((entry) => String(entry.id) === normalizedSelectedId) || history[0];
|
|
selectedRunId = selectedRun.id;
|
|
|
|
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('');
|
|
|
|
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';
|
|
}
|
|
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderScoreboard() {
|
|
if (!ui.scoreboardList || !ui.scoreboardEmpty) return;
|
|
|
|
if (!Array.isArray(scoreboardEntries) || scoreboardEntries.length === 0) {
|
|
ui.scoreboardList.innerHTML = '';
|
|
ui.scoreboardEmpty.classList.remove('d-none');
|
|
return;
|
|
}
|
|
|
|
ui.scoreboardEmpty.classList.add('d-none');
|
|
ui.scoreboardList.innerHTML = scoreboardEntries.map((entry, index) => {
|
|
const playerName = escapeHtml(entry.player_name || 'Player');
|
|
const scoreLabel = Number(entry.score || 0).toLocaleString();
|
|
const levelLabel = Number(entry.level || 0).toString();
|
|
const linesLabel = Number(entry.lines || 0).toString();
|
|
const modeLabel = entry.mode === 'multiplayer' ? 'Multiplayer' : 'Solo';
|
|
const durationLabel = formatDuration(Number(entry.duration_seconds || 0) * 1000);
|
|
const createdAt = entry.created_at
|
|
? new Date(String(entry.created_at).replace(' ', 'T')).toLocaleString([], {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
: 'Just now';
|
|
const roomCode = entry.room_code ? ` · Room ${escapeHtml(entry.room_code)}` : '';
|
|
|
|
return `
|
|
<article class="scoreboard-item">
|
|
<div class="scoreboard-rank">#${index + 1}</div>
|
|
<div class="scoreboard-item-body">
|
|
<div class="scoreboard-topline">
|
|
<span class="scoreboard-name">${playerName}</span>
|
|
<span class="scoreboard-score">${scoreLabel}</span>
|
|
</div>
|
|
<div class="scoreboard-meta">Level ${levelLabel} · Lines ${linesLabel} · ${durationLabel}</div>
|
|
<div class="scoreboard-meta">${modeLabel}${roomCode} · ${createdAt}</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function loadScoreboard(options = {}) {
|
|
if (!ui.scoreboardStatus) return;
|
|
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",
|
|
signal: controller.signal
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || !payload.success) {
|
|
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) {
|
|
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 = scoreboardEntries.length > 0 ? "Top " + scoreboardEntries.length : "Offline";
|
|
} finally {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
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' },
|
|
keepalive: true,
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const responsePayload = await response.json();
|
|
if (!response.ok || !responsePayload.success) {
|
|
throw new Error(responsePayload.error || 'Unable to save scoreboard entry.');
|
|
}
|
|
|
|
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);
|
|
queuePendingScoreSave(payload);
|
|
showToast('Run saved locally. It will sync to the database scoreboard when the connection returns.');
|
|
if (ui.scoreboardStatus) {
|
|
ui.scoreboardStatus.textContent = 'Offline';
|
|
}
|
|
}
|
|
}
|
|
|
|
function setRoomBadge(label, variant) {
|
|
ui.roomStatusBadge.className = `badge status-badge state-${variant}`;
|
|
ui.roomStatusBadge.textContent = label;
|
|
}
|
|
|
|
function updateOpponentUI(opponent) {
|
|
if (!opponent) {
|
|
ui.opponentName.textContent = 'Waiting for opponent…';
|
|
ui.opponentStats.textContent = 'Score — · Level — · Lines —';
|
|
ui.opponentStatus.textContent = 'Room idle';
|
|
drawOpponentBoard(null);
|
|
return;
|
|
}
|
|
|
|
ui.opponentName.textContent = opponent.display_name || 'Opponent';
|
|
ui.opponentStats.textContent = `Score ${opponent.score.toLocaleString()} · Level ${opponent.level} · Lines ${opponent.lines}`;
|
|
ui.opponentStatus.textContent = opponent.game_status === 'playing'
|
|
? 'In play'
|
|
: (opponent.game_status === 'paused' ? 'Paused' : (opponent.game_status === 'game_over' ? 'Game over' : 'Ready'));
|
|
drawOpponentBoard(opponent.board);
|
|
}
|
|
|
|
function updateMultiplayerUI() {
|
|
const connected = Boolean(multiplayer.roomCode && multiplayer.token);
|
|
ui.leaveRoomBtn.classList.toggle('d-none', !connected);
|
|
ui.createRoomBtn.disabled = connected;
|
|
ui.joinRoomBtn.disabled = connected;
|
|
ui.roomCodeInput.disabled = connected;
|
|
ui.roomCodeCard.classList.toggle('d-none', !connected);
|
|
if (connected) {
|
|
ui.roomCodeValue.textContent = multiplayer.roomCode;
|
|
} else {
|
|
ui.roomCodeValue.textContent = '—';
|
|
}
|
|
|
|
if (!connected) {
|
|
setRoomBadge('Offline', 'ready');
|
|
ui.roomStatusText.textContent = 'Create a room to start.';
|
|
updateOpponentUI(null);
|
|
return;
|
|
}
|
|
|
|
if (multiplayer.playerCount < 2) {
|
|
setRoomBadge('Waiting', 'paused');
|
|
ui.roomStatusText.textContent = 'Share the code so a friend can join.';
|
|
} else {
|
|
setRoomBadge('Live', 'playing');
|
|
ui.roomStatusText.textContent = 'Opponent connected.';
|
|
}
|
|
}
|
|
|
|
function saveMultiplayerSession(roomCode, token, displayName, slot) {
|
|
multiplayer.roomCode = roomCode;
|
|
multiplayer.token = token;
|
|
multiplayer.displayName = displayName;
|
|
multiplayer.slot = slot;
|
|
sessionStorage.setItem(MULTI_STORAGE_KEYS.room, roomCode);
|
|
sessionStorage.setItem(MULTI_STORAGE_KEYS.token, token);
|
|
sessionStorage.setItem(MULTI_STORAGE_KEYS.name, displayName);
|
|
sessionStorage.setItem(MULTI_STORAGE_KEYS.slot, String(slot));
|
|
updateMultiplayerUI();
|
|
}
|
|
|
|
function clearMultiplayerSession() {
|
|
multiplayer.roomCode = null;
|
|
multiplayer.token = null;
|
|
multiplayer.displayName = '';
|
|
multiplayer.slot = null;
|
|
multiplayer.playerCount = 0;
|
|
multiplayer.opponent = null;
|
|
sessionStorage.removeItem(MULTI_STORAGE_KEYS.room);
|
|
sessionStorage.removeItem(MULTI_STORAGE_KEYS.token);
|
|
sessionStorage.removeItem(MULTI_STORAGE_KEYS.name);
|
|
sessionStorage.removeItem(MULTI_STORAGE_KEYS.slot);
|
|
stopMultiplayerLoop();
|
|
updateMultiplayerUI();
|
|
}
|
|
|
|
async function postMultiplayer(action, data) {
|
|
const response = await fetch(MULTI_API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action, ...data })
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || !payload.success) {
|
|
throw new Error(payload.error || 'Multiplayer request failed.');
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function syncMultiplayer() {
|
|
if (!multiplayer.roomCode || !multiplayer.token || multiplayer.isSyncing) return;
|
|
multiplayer.isSyncing = true;
|
|
try {
|
|
await postMultiplayer('update_state', {
|
|
room_code: multiplayer.roomCode,
|
|
player_token: multiplayer.token,
|
|
board: getComposedBoard(),
|
|
piece: currentPiece ? { type: currentPiece.type } : null,
|
|
meta: { piecesPlaced, updatedAt: Date.now() },
|
|
score,
|
|
lines,
|
|
level,
|
|
game_status: getGameStatus()
|
|
});
|
|
|
|
const state = await postMultiplayer('get_state', {
|
|
room_code: multiplayer.roomCode,
|
|
player_token: multiplayer.token
|
|
});
|
|
|
|
multiplayer.playerCount = state.room?.player_count || 0;
|
|
multiplayer.opponent = (state.players || []).find((player) => !player.is_self) || null;
|
|
updateOpponentUI(multiplayer.opponent);
|
|
updateMultiplayerUI();
|
|
} catch (error) {
|
|
console.warn('Multiplayer sync error', error);
|
|
ui.roomStatusText.textContent = 'Connection lost. Try rejoining.';
|
|
setRoomBadge('Error', 'game-over');
|
|
} finally {
|
|
multiplayer.isSyncing = false;
|
|
}
|
|
}
|
|
|
|
function startMultiplayerLoop() {
|
|
stopMultiplayerLoop();
|
|
syncMultiplayer();
|
|
multiplayer.pollTimer = window.setInterval(syncMultiplayer, MULTI_POLL_INTERVAL);
|
|
}
|
|
|
|
function stopMultiplayerLoop() {
|
|
if (multiplayer.pollTimer) {
|
|
clearInterval(multiplayer.pollTimer);
|
|
multiplayer.pollTimer = null;
|
|
}
|
|
}
|
|
|
|
function resetState() {
|
|
board = createBoard();
|
|
nextQueue = [];
|
|
bag = [];
|
|
score = 0;
|
|
lines = 0;
|
|
level = 1;
|
|
piecesPlaced = 0;
|
|
dropInterval = 900;
|
|
dropCounter = 0;
|
|
lastTime = 0;
|
|
startedAt = Date.now();
|
|
endedAt = null;
|
|
isRunning = true;
|
|
isPaused = false;
|
|
isGameOver = false;
|
|
fillQueue();
|
|
spawnPiece();
|
|
hideOverlay();
|
|
setBadge('Playing', 'playing');
|
|
ui.pauseButton.textContent = 'Pause';
|
|
updateStats();
|
|
draw();
|
|
}
|
|
|
|
function startGame() {
|
|
if (gameOverModal) gameOverModal.hide();
|
|
resetState();
|
|
showToast('Game started. Good luck.');
|
|
syncMultiplayer();
|
|
}
|
|
|
|
function pauseGame(showNotice = true) {
|
|
if (!isRunning || isGameOver) return;
|
|
isPaused = !isPaused;
|
|
setBadge(isPaused ? 'Paused' : 'Playing', isPaused ? 'paused' : 'playing');
|
|
ui.pauseButton.textContent = isPaused ? 'Resume' : 'Pause';
|
|
ui.detailStatus.textContent = isPaused ? 'Paused' : 'In progress';
|
|
if (isPaused) {
|
|
showOverlay('Paused', 'Press P to resume, or restart for a clean board.', 'Game paused');
|
|
} else {
|
|
hideOverlay();
|
|
}
|
|
if (showNotice) showToast(isPaused ? 'Game paused.' : 'Back in play.');
|
|
syncMultiplayer();
|
|
}
|
|
|
|
function finishGame() {
|
|
endedAt = Date.now();
|
|
isGameOver = true;
|
|
isRunning = false;
|
|
isPaused = false;
|
|
setBadge('Game over', 'game-over');
|
|
ui.pauseButton.textContent = 'Pause';
|
|
showOverlay('Run complete', 'Restart to jump into a fresh round. This run was saved locally and is being submitted to the scoreboard.', 'Game over');
|
|
|
|
const elapsedMs = getElapsedMs();
|
|
const run = {
|
|
id: `${Date.now()}`,
|
|
score,
|
|
lines,
|
|
level,
|
|
piecesPlaced,
|
|
duration: formatDuration(elapsedMs),
|
|
durationSeconds: Math.max(0, Math.round(elapsedMs / 1000)),
|
|
finishedAt: endedAt
|
|
};
|
|
saveHistory(run);
|
|
|
|
ui.finalScore.textContent = score.toLocaleString();
|
|
ui.finalLines.textContent = lines.toString();
|
|
ui.finalLevel.textContent = level.toString();
|
|
ui.finalDuration.textContent = run.duration;
|
|
updateStats();
|
|
draw();
|
|
showToast('Game over. Run saved locally.');
|
|
if (gameOverModal) gameOverModal.show();
|
|
void saveScoreToDatabase(run);
|
|
syncMultiplayer();
|
|
}
|
|
|
|
function draw() {
|
|
drawBoard();
|
|
if (currentPiece && !isGameOver) {
|
|
drawGhostPiece();
|
|
drawPiece(currentPiece);
|
|
} else if (currentPiece && isGameOver) {
|
|
drawPiece(currentPiece, 0.8);
|
|
}
|
|
}
|
|
|
|
function update(time = 0) {
|
|
const deltaTime = time - lastTime;
|
|
lastTime = time;
|
|
|
|
if (isRunning && !isPaused && !isGameOver) {
|
|
dropCounter += deltaTime;
|
|
if (dropCounter > dropInterval) {
|
|
dropPiece(false);
|
|
}
|
|
}
|
|
|
|
updateStats();
|
|
draw();
|
|
animationFrameId = requestAnimationFrame(update);
|
|
}
|
|
|
|
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)) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
if ((code === 'Enter' && !isRunning) || (code === 'KeyR')) {
|
|
startGame();
|
|
return;
|
|
}
|
|
|
|
if (!isRunning || isGameOver) {
|
|
return;
|
|
}
|
|
|
|
switch (code) {
|
|
case 'ArrowLeft':
|
|
movePiece(-1);
|
|
break;
|
|
case 'ArrowRight':
|
|
movePiece(1);
|
|
break;
|
|
case 'ArrowDown':
|
|
dropPiece(true);
|
|
break;
|
|
case 'ArrowUp':
|
|
case 'KeyX':
|
|
attemptRotate(1);
|
|
break;
|
|
case 'KeyZ':
|
|
attemptRotate(-1);
|
|
break;
|
|
case 'Space':
|
|
hardDrop();
|
|
break;
|
|
case 'KeyP':
|
|
pauseGame(true);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
safeOn(ui.playerNameInput, 'change', () => {
|
|
const name = (ui.playerNameInput.value || '').trim();
|
|
if (name) {
|
|
sessionStorage.setItem(MULTI_STORAGE_KEYS.name, name);
|
|
}
|
|
});
|
|
|
|
safeOn(ui.createRoomBtn, 'click', async () => {
|
|
if (multiplayer.roomCode) return;
|
|
const displayName = (ui.playerNameInput.value || '').trim() || 'Player 1';
|
|
try {
|
|
const result = await postMultiplayer('create_room', { display_name: displayName });
|
|
saveMultiplayerSession(result.room.code, result.self.token, result.self.display_name, result.self.slot);
|
|
startMultiplayerLoop();
|
|
showToast(`Room ${result.room.code} created. Share the code.`);
|
|
} catch (error) {
|
|
showToast(error.message || 'Unable to create room.');
|
|
}
|
|
});
|
|
|
|
safeOn(ui.joinRoomBtn, 'click', async () => {
|
|
if (multiplayer.roomCode) return;
|
|
const roomCode = (ui.roomCodeInput.value || '').trim();
|
|
if (!roomCode) {
|
|
showToast('Enter a room code to join.');
|
|
return;
|
|
}
|
|
const displayName = (ui.playerNameInput.value || '').trim() || 'Player 2';
|
|
try {
|
|
const result = await postMultiplayer('join_room', { room_code: roomCode, display_name: displayName });
|
|
saveMultiplayerSession(result.room.code, result.self.token, result.self.display_name, result.self.slot);
|
|
startMultiplayerLoop();
|
|
showToast(`Joined room ${result.room.code}.`);
|
|
} catch (error) {
|
|
showToast(error.message || 'Unable to join room.');
|
|
}
|
|
});
|
|
|
|
safeOn(ui.leaveRoomBtn, 'click', async () => {
|
|
if (!multiplayer.roomCode || !multiplayer.token) return;
|
|
try {
|
|
await postMultiplayer('leave_room', { room_code: multiplayer.roomCode, player_token: multiplayer.token });
|
|
} catch (error) {
|
|
console.warn('Leave room error', error);
|
|
} finally {
|
|
clearMultiplayerSession();
|
|
showToast('Left the room.');
|
|
}
|
|
});
|
|
|
|
safeOn(ui.copyRoomBtn, 'click', async () => {
|
|
if (!multiplayer.roomCode) return;
|
|
try {
|
|
await navigator.clipboard.writeText(multiplayer.roomCode);
|
|
showToast('Room code copied.');
|
|
} catch (error) {
|
|
showToast('Copy failed. Select and copy the code manually.');
|
|
}
|
|
});
|
|
|
|
safeOn(ui.pauseButton, 'click', () => {
|
|
if (!isRunning || isGameOver) {
|
|
showToast('Start a run before using pause.');
|
|
return;
|
|
}
|
|
pauseGame(true);
|
|
});
|
|
|
|
safeOn(ui.restartButton, 'click', startGame);
|
|
safeOn(ui.modalRestartButton, 'click', startGame);
|
|
document.addEventListener('keydown', handleKeydown);
|
|
if (gameOverModalEl) {
|
|
gameOverModalEl.addEventListener('hidden.bs.modal', () => {
|
|
if (isGameOver) {
|
|
boardCanvas.focus?.();
|
|
}
|
|
});
|
|
}
|
|
|
|
const savedName = sessionStorage.getItem(MULTI_STORAGE_KEYS.name);
|
|
if (savedName && ui.playerNameInput) {
|
|
ui.playerNameInput.value = savedName;
|
|
}
|
|
const savedRoom = sessionStorage.getItem(MULTI_STORAGE_KEYS.room);
|
|
const savedToken = sessionStorage.getItem(MULTI_STORAGE_KEYS.token);
|
|
const savedSlot = sessionStorage.getItem(MULTI_STORAGE_KEYS.slot);
|
|
if (savedRoom && savedToken) {
|
|
saveMultiplayerSession(savedRoom, savedToken, savedName || 'Player', Number(savedSlot || 0));
|
|
startMultiplayerLoop();
|
|
}
|
|
|
|
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');
|
|
setBadge('Ready', 'ready');
|
|
update();
|
|
});
|