1127 lines
40 KiB
JavaScript
1127 lines
40 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 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) {
|
|
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.');
|
|
}
|
|
selectedRunId = run.id;
|
|
renderHistory();
|
|
}
|
|
|
|
function renderHistory() {
|
|
ui.historyList.innerHTML = '';
|
|
ui.historyEmpty.classList.toggle('d-none', history.length > 0);
|
|
|
|
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 {
|
|
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';
|
|
return;
|
|
}
|
|
|
|
ui.scoreboardStatus.textContent = `Top ${scoreboardEntries.length}`;
|
|
|
|
scoreboardEntries.forEach((entry, index) => {
|
|
const item = document.createElement('article');
|
|
item.className = 'scoreboard-item';
|
|
|
|
const rank = document.createElement('div');
|
|
rank.className = 'scoreboard-rank';
|
|
rank.textContent = `#${index + 1}`;
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
async function loadScoreboard() {
|
|
if (!ui.scoreboardStatus) return;
|
|
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();
|
|
} catch (error) {
|
|
console.warn('Scoreboard load error', error);
|
|
scoreboardEntries = [];
|
|
renderScoreboard();
|
|
ui.scoreboardStatus.textContent = 'Offline';
|
|
} finally {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
async function saveScoreToDatabase(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
|
|
})
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || !payload.success) {
|
|
throw new Error(payload.error || 'Unable to save scoreboard entry.');
|
|
}
|
|
await loadScoreboard();
|
|
if (payload.placement) {
|
|
showToast(`Run saved to the database scoreboard. Rank #${payload.placement}.`);
|
|
} else {
|
|
showToast('Run saved to the database scoreboard.');
|
|
}
|
|
} catch (error) {
|
|
console.warn('Scoreboard save error', error);
|
|
showToast('Run saved locally. Database scoreboard was unavailable.');
|
|
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 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();
|
|
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();
|
|
});
|