39321-vm/assets/js/main.js
2026-03-25 17:19:10 +00:00

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();
});