document.addEventListener('DOMContentLoaded', () => { const boardCanvas = document.getElementById('tetris-board'); const nextCanvas = document.getElementById('next-piece'); if (!boardCanvas || !nextCanvas) { return; } const boardCtx = boardCanvas.getContext('2d'); const nextCtx = nextCanvas.getContext('2d'); const scoreValue = document.getElementById('score-value'); const levelValue = document.getElementById('level-value'); const linesValue = document.getElementById('lines-value'); const statusValue = document.getElementById('status-value'); const savePlaceholder = document.getElementById('save-placeholder'); const saveForm = document.getElementById('save-form'); const runSummary = document.getElementById('run-summary'); const finalScore = document.getElementById('final_score'); const finalLines = document.getElementById('final_lines'); const finalLevel = document.getElementById('final_level'); const finalDuration = document.getElementById('final_duration'); const scoreInput = document.getElementById('score-input'); const linesInput = document.getElementById('lines-input'); const levelInput = document.getElementById('level-input'); const durationInput = document.getElementById('duration-input'); const playerNameInput = document.getElementById('player_name'); const roomNicknameInput = document.getElementById('room_player_name'); const roomCodeInput = document.getElementById('room_code_input'); const createRoomButton = document.getElementById('create-room-btn'); const joinRoomButton = document.getElementById('join-room-btn'); const roomEmptyState = document.getElementById('room-empty-state'); const roomStateCard = document.getElementById('room-state-card'); const roomStatusBadge = document.getElementById('room-status-badge'); const currentRoomCodeLabel = document.getElementById('current-room-code'); const roomHostName = document.getElementById('room-host-name'); const roomGuestName = document.getElementById('room-guest-name'); const roomPlayerCount = document.getElementById('room-player-count'); const roomUpdatedAt = document.getElementById('room-updated-at'); const roomMessage = document.getElementById('room-message'); const refreshRoomStatusButton = document.getElementById('refresh-room-status-btn'); const copyRoomCodeButton = document.getElementById('copy-room-code-btn'); const leaderboardTableWrap = document.getElementById('leaderboard-table-wrap'); const leaderboardBody = document.getElementById('leaderboard-body'); const leaderboardEmptyState = document.getElementById('leaderboard-empty-state'); const recentRunsList = document.getElementById('recent-runs-list'); const recentEmptyState = document.getElementById('recent-empty-state'); const bestPlayerLabel = document.getElementById('best-player-label'); const leaderboardRefreshLabel = document.getElementById('leaderboard-refresh-label'); const restartButtons = [ document.getElementById('restart-top'), document.getElementById('restart-inline'), document.getElementById('restart-after-game') ].filter(Boolean); const startButtons = [ document.getElementById('start-top'), document.getElementById('start-game') ].filter(Boolean); const startOverlay = document.getElementById('board-start-overlay'); const focusButton = document.getElementById('focus-game'); const controlButtons = Array.from(document.querySelectorAll('[data-action]')); const pageConfig = window.TETRIS_PAGE || {}; const playerNameStorageKey = 'midnight-blocks-player-name'; const roomCodeStorageKey = 'midnight-blocks-room-code'; let currentRoomCode = ''; let roomPollHandle = null; const boardScale = 30; const nextScale = 30; const rows = 20; const cols = 10; const lineScores = [0, 100, 300, 500, 800]; boardCtx.scale(boardScale, boardScale); nextCtx.scale(nextScale, nextScale); const colors = { I: { light: '#c9fbff', base: '#5ee8ff', dark: '#0891b2', glow: 'rgba(94, 232, 255, 0.42)' }, J: { light: '#c7dbff', base: '#60a5fa', dark: '#1d4ed8', glow: 'rgba(96, 165, 250, 0.42)' }, L: { light: '#ffe1b8', base: '#fb923c', dark: '#c2410c', glow: 'rgba(251, 146, 60, 0.4)' }, O: { light: '#fff4b8', base: '#facc15', dark: '#ca8a04', glow: 'rgba(250, 204, 21, 0.36)' }, S: { light: '#d1ffdd', base: '#4ade80', dark: '#15803d', glow: 'rgba(74, 222, 128, 0.4)' }, T: { light: '#ffd4dd', base: '#fb7185', dark: '#be123c', glow: 'rgba(251, 113, 133, 0.4)' }, Z: { light: '#ffd0bf', base: '#f97316', dark: '#c2410c', glow: 'rgba(249, 115, 22, 0.38)' } }; const pieces = { I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]], L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]], O: [[1, 1], [1, 1]], S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]], T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]], Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]] }; const state = { board: createMatrix(cols, rows), piece: null, nextQueue: [], dropCounter: 0, dropInterval: 900, lastTime: 0, score: 0, lines: 0, level: 1, isGameOver: false, isStarted: false, startedAt: null }; function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); } function sanitizePlayerName(value) { return String(value ?? '') .replace(/[^\p{L}\p{N} _.-]+/gu, '') .trim() .slice(0, 24); } function rememberPlayerName(value) { const clean = sanitizePlayerName(value); if (!clean) { return ''; } try { window.localStorage.setItem(playerNameStorageKey, clean); } catch (error) { // Ignore storage errors silently. } return clean; } function loadRememberedPlayerName() { try { return sanitizePlayerName(window.localStorage.getItem(playerNameStorageKey) || ''); } catch (error) { return ''; } } function sanitizeRoomCode(value) { return String(value ?? '') .replace(/[^A-Za-z0-9]+/g, '') .toUpperCase() .slice(0, 6); } function rememberRoomCode(value) { const clean = sanitizeRoomCode(value); try { if (clean) { window.localStorage.setItem(roomCodeStorageKey, clean); } else { window.localStorage.removeItem(roomCodeStorageKey); } } catch (error) { // Ignore storage errors silently. } return clean; } function loadRememberedRoomCode() { try { return sanitizeRoomCode(window.localStorage.getItem(roomCodeStorageKey) || ''); } catch (error) { return ''; } } function applyNicknameToInputs(value, sourceInput = null) { const clean = sanitizePlayerName(value); [playerNameInput, roomNicknameInput].forEach((input) => { if (input && input !== sourceInput && input.value !== clean) { input.value = clean; } }); return clean; } function syncNicknameFromInput(input) { if (!input) { return ''; } const clean = sanitizePlayerName(input.value); if (input.value !== clean) { input.value = clean; } applyNicknameToInputs(clean, input); rememberPlayerName(clean); return clean; } function getActiveNickname() { const value = sanitizePlayerName(roomNicknameInput?.value || playerNameInput?.value || loadRememberedPlayerName()); if (!value) { showToast('Nickname needed', 'Enter your nickname before creating or joining a room.'); roomNicknameInput?.focus({ preventScroll: false }); playerNameInput?.focus({ preventScroll: false }); return ''; } applyNicknameToInputs(value); rememberPlayerName(value); return value; } function formatRelativeTime(value) { const timestamp = Date.parse(value || ''); if (!Number.isFinite(timestamp)) { return 'Live sync'; } const diffSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); if (diffSeconds < 10) return 'Updated just now'; if (diffSeconds < 60) return `Updated ${diffSeconds}s ago`; if (diffSeconds < 3600) return `Updated ${Math.floor(diffSeconds / 60)}m ago`; return `Updated ${Math.floor(diffSeconds / 3600)}h ago`; } function formatRecentTime(value) { const timestamp = Date.parse(value || ''); if (!Number.isFinite(timestamp)) { return 'now'; } const diffSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); if (diffSeconds < 60) return 'now'; if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m`; if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h`; return `${Math.floor(diffSeconds / 86400)}d`; } function renderLeaderboard(leaderboard) { if (!leaderboardBody || !leaderboardTableWrap || !leaderboardEmptyState) { return; } if (!Array.isArray(leaderboard) || leaderboard.length === 0) { leaderboardBody.innerHTML = ''; leaderboardTableWrap.classList.add('d-none'); leaderboardEmptyState.classList.remove('d-none'); return; } leaderboardBody.innerHTML = leaderboard.map((run, index) => ` ${index + 1} ${escapeHtml(run.player_name || 'Anonymous')} ${Number(run.score || 0).toLocaleString()} `).join(''); leaderboardTableWrap.classList.remove('d-none'); leaderboardEmptyState.classList.add('d-none'); } function renderRecentRuns(recentRuns) { if (!recentRunsList || !recentEmptyState) { return; } if (!Array.isArray(recentRuns) || recentRuns.length === 0) { recentRunsList.innerHTML = ''; recentRunsList.classList.add('d-none'); recentEmptyState.classList.remove('d-none'); return; } recentRunsList.innerHTML = recentRuns.map((run) => `
${escapeHtml(run.player_name || 'Anonymous')}
${Number(run.score || 0).toLocaleString()} pts · ${Number(run.lines_cleared || 0).toLocaleString()} lines
${formatRecentTime(run.created_at_iso)}
`).join(''); recentRunsList.classList.remove('d-none'); recentEmptyState.classList.add('d-none'); } async function syncLeaderboard() { if (!pageConfig.leaderboardApi) { return; } try { const response = await fetch(`${pageConfig.leaderboardApi}?_=${Date.now()}`, { headers: { Accept: 'application/json' }, cache: 'no-store' }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const payload = await response.json(); if (!payload || payload.success !== true) { throw new Error('Invalid leaderboard payload'); } renderLeaderboard(payload.leaderboard || []); renderRecentRuns(payload.recent_runs || []); if (bestPlayerLabel) { if (payload.best_run && payload.best_run.player_name) { bestPlayerLabel.textContent = `Best: ${payload.best_run.player_name}`; } else { bestPlayerLabel.textContent = 'Waiting for first score'; } } if (leaderboardRefreshLabel) { leaderboardRefreshLabel.textContent = formatRelativeTime(payload.updated_at || ''); } } catch (error) { if (leaderboardRefreshLabel) { leaderboardRefreshLabel.textContent = 'Sync paused'; } } } function renderRoomState(room) { if (!roomStateCard || !roomEmptyState || !roomStatusBadge || !currentRoomCodeLabel || !roomHostName || !roomGuestName || !roomPlayerCount || !roomUpdatedAt || !roomMessage) { return; } currentRoomCode = sanitizeRoomCode(room?.room_code || ''); rememberRoomCode(currentRoomCode); roomEmptyState.classList.add('d-none'); roomStateCard.classList.remove('d-none'); currentRoomCodeLabel.textContent = currentRoomCode || '------'; roomHostName.textContent = room?.host_name || '—'; roomGuestName.textContent = room?.guest_name || 'Waiting for friend…'; roomPlayerCount.textContent = `${Number(room?.player_count || (room?.guest_name ? 2 : 1))} / 2`; roomUpdatedAt.textContent = formatRelativeTime(room?.updated_at_iso || '').replace(/^Updated\s+/i, ''); if (roomCodeInput && currentRoomCode) { roomCodeInput.value = currentRoomCode; } const ready = room?.is_ready || room?.status === 'ready'; roomStatusBadge.textContent = ready ? 'Ready' : 'Waiting'; roomStatusBadge.className = `room-status-badge ${ready ? 'room-status-ready' : 'room-status-waiting'}`; roomMessage.textContent = ready ? `${room?.guest_name || 'Your friend'} joined. The lobby is ready. Next step: sync the live head-to-head match.` : `Share code ${currentRoomCode}. The lobby will switch to ready as soon as your friend joins.`; } function clearRoomState(message = 'No active room yet. Create one or paste a code from your friend.') { currentRoomCode = ''; rememberRoomCode(''); if (roomPollHandle) { window.clearInterval(roomPollHandle); roomPollHandle = null; } if (roomStateCard) { roomStateCard.classList.add('d-none'); } if (roomEmptyState) { roomEmptyState.classList.remove('d-none'); roomEmptyState.innerHTML = `

${escapeHtml(message)}

`; } } function startRoomPolling() { if (roomPollHandle) { window.clearInterval(roomPollHandle); } if (!currentRoomCode || !pageConfig.roomsApi) { roomPollHandle = null; return; } roomPollHandle = window.setInterval(() => { syncRoomStatus(currentRoomCode, { silent: true }); }, 5000); } async function sendRoomRequest(action, payload = {}) { if (!pageConfig.roomsApi) { throw new Error('Rooms API is not available.'); } const body = new URLSearchParams({ action, ...payload }); const response = await fetch(pageConfig.roomsApi, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString(), cache: 'no-store' }); const payloadJson = await response.json().catch(() => null); if (!response.ok || !payloadJson || payloadJson.success !== true) { throw new Error(payloadJson?.error || 'Unable to update the room.'); } return payloadJson; } async function syncRoomStatus(roomCode, options = {}) { const code = sanitizeRoomCode(roomCode); if (!code || !pageConfig.roomsApi) { return null; } try { const response = await fetch(`${pageConfig.roomsApi}?room_code=${encodeURIComponent(code)}&_=${Date.now()}`, { headers: { Accept: 'application/json' }, cache: 'no-store' }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.success !== true || !payload.room) { throw new Error(payload?.error || 'Room not found.'); } renderRoomState(payload.room); startRoomPolling(); return payload.room; } catch (error) { if (!options.silent) { showToast('Room sync failed', error.message || 'Unable to refresh the room right now.'); } if (!options.keepState) { clearRoomState(error.message || 'Room not found.'); } return null; } } function createMatrix(width, height) { return Array.from({ length: height }, () => Array(width).fill(0)); } function cloneMatrix(matrix) { return matrix.map((row) => row.slice()); } function randomBag() { const bag = Object.keys(pieces); for (let i = bag.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; } return bag; } function ensureQueue() { while (state.nextQueue.length < 7) { state.nextQueue.push(...randomBag()); } } function createPiece(type) { return { type, matrix: cloneMatrix(pieces[type]), pos: { x: 0, y: 0 } }; } function spawnPiece() { ensureQueue(); const type = state.nextQueue.shift(); state.piece = createPiece(type); state.piece.pos.y = 0; state.piece.pos.x = Math.floor(cols / 2) - Math.ceil(state.piece.matrix[0].length / 2); drawNextPiece(); if (collides(state.board, state.piece)) { endGame(); } } function collides(board, player) { const { matrix, pos } = player; for (let y = 0; y < matrix.length; y += 1) { for (let x = 0; x < matrix[y].length; x += 1) { if (matrix[y][x] !== 0 && (board[y + pos.y] && board[y + pos.y][x + pos.x]) !== 0) { return true; } } } return false; } function merge(board, player) { player.matrix.forEach((row, y) => { row.forEach((value, x) => { if (value !== 0) { board[y + player.pos.y][x + player.pos.x] = player.type; } }); }); } 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 = 1) { if (state.isGameOver || !state.isStarted || !state.piece) { return; } const originalX = state.piece.pos.x; const rotated = rotate(state.piece.matrix, direction); state.piece.matrix = rotated; const kicks = [0, -1, 1, -2, 2]; for (const offset of kicks) { state.piece.pos.x = originalX + offset; if (!collides(state.board, state.piece)) { announce('Piece rotated'); draw(); return; } } state.piece.matrix = rotate(rotated, -direction); state.piece.pos.x = originalX; } function movePlayer(direction) { if (state.isGameOver || !state.isStarted || !state.piece) { return; } state.piece.pos.x += direction; if (collides(state.board, state.piece)) { state.piece.pos.x -= direction; return; } draw(); } function playerDrop(addSoftDropPoint = false) { if (state.isGameOver || !state.piece) { return false; } state.piece.pos.y += 1; if (collides(state.board, state.piece)) { state.piece.pos.y -= 1; merge(state.board, state.piece); clearLines(); spawnPiece(); updateStats(); state.dropCounter = 0; return false; } if (addSoftDropPoint) { state.score += 1; } state.dropCounter = 0; updateStats(); draw(); return true; } function hardDrop() { if (state.isGameOver || !state.piece) { return; } let rowsDropped = 0; while (playerDrop(false)) { rowsDropped += 1; } if (rowsDropped > 0) { state.score += rowsDropped * 2; updateStats(); draw(); announce(`Hard drop +${rowsDropped * 2}`); } } function clearLines() { let linesCleared = 0; outer: for (let y = state.board.length - 1; y >= 0; y -= 1) { for (let x = 0; x < state.board[y].length; x += 1) { if (state.board[y][x] === 0) { continue outer; } } const row = state.board.splice(y, 1)[0].fill(0); state.board.unshift(row); linesCleared += 1; y += 1; } if (linesCleared > 0) { state.lines += linesCleared; state.score += lineScores[linesCleared] * state.level; const newLevel = Math.floor(state.lines / 10) + 1; if (newLevel !== state.level) { state.level = newLevel; state.dropInterval = Math.max(140, 900 - (state.level - 1) * 70); announce(`Level ${state.level}`); } else { announce(`${linesCleared} line${linesCleared > 1 ? 's' : ''} cleared`); } } } function drawCell(x, y, type, context, size = 1) { const palette = colors[type] || { light: '#ffffff', base: '#dbeafe', dark: '#64748b', glow: 'rgba(255,255,255,0.28)' }; const inset = 0.06 * size; const width = size - inset * 2; const height = size - inset * 2; const gradient = context.createLinearGradient(x, y, x + size, y + size); gradient.addColorStop(0, palette.light); gradient.addColorStop(0.42, palette.base); gradient.addColorStop(1, palette.dark); context.save(); context.shadowColor = palette.glow; context.shadowBlur = 0.42 * size; context.fillStyle = gradient; context.fillRect(x + inset, y + inset, width, height); context.shadowBlur = 0; context.fillStyle = 'rgba(255,255,255,0.22)'; context.fillRect(x + inset + 0.07 * size, y + inset + 0.08 * size, width - 0.14 * size, 0.12 * size); context.strokeStyle = 'rgba(255,255,255,0.28)'; context.lineWidth = 0.05 * size; context.strokeRect(x + inset, y + inset, width, height); context.strokeStyle = 'rgba(4, 10, 18, 0.42)'; context.lineWidth = 0.04 * size; context.strokeRect(x + inset + 0.08 * size, y + inset + 0.08 * size, width - 0.16 * size, height - 0.16 * size); context.restore(); } function drawMatrix(matrix, offset, type, context) { matrix.forEach((row, y) => { row.forEach((value, x) => { if (value !== 0) { drawCell(x + offset.x, y + offset.y, type, context); } }); }); } function paintArenaBackground(context, width, height) { context.save(); const gradient = context.createLinearGradient(0, 0, width, height); gradient.addColorStop(0, '#08111f'); gradient.addColorStop(0.55, '#0b1730'); gradient.addColorStop(1, '#040913'); context.fillStyle = gradient; context.fillRect(0, 0, width, height); const glow = context.createRadialGradient(width / 2, height * 0.18, 0, width / 2, height * 0.18, width * 0.95); glow.addColorStop(0, 'rgba(124, 246, 255, 0.09)'); glow.addColorStop(1, 'rgba(124, 246, 255, 0)'); context.fillStyle = glow; context.fillRect(0, 0, width, height); context.restore(); } function drawGrid(context, width, height) { context.save(); context.strokeStyle = 'rgba(159, 214, 255, 0.09)'; context.lineWidth = 0.04; for (let x = 0; x <= width; x += 1) { context.beginPath(); context.moveTo(x, 0); context.lineTo(x, height); context.stroke(); } for (let y = 0; y <= height; y += 1) { context.beginPath(); context.moveTo(0, y); context.lineTo(width, y); context.stroke(); } context.restore(); } function drawGhostPiece() { if (!state.piece || state.isGameOver) { return; } const ghost = { matrix: state.piece.matrix, pos: { x: state.piece.pos.x, y: state.piece.pos.y }, type: state.piece.type }; while (!collides(state.board, ghost)) { ghost.pos.y += 1; } ghost.pos.y -= 1; boardCtx.save(); boardCtx.globalAlpha = 0.22; drawMatrix(ghost.matrix, ghost.pos, ghost.type, boardCtx); boardCtx.restore(); } function drawBoard() { paintArenaBackground(boardCtx, cols, rows); drawGrid(boardCtx, cols, rows); state.board.forEach((row, y) => { row.forEach((value, x) => { if (value !== 0) { drawCell(x, y, value, boardCtx); } }); }); drawGhostPiece(); if (state.piece) { drawMatrix(state.piece.matrix, state.piece.pos, state.piece.type, boardCtx); } } function drawNextPiece() { nextCtx.clearRect(0, 0, 4, 4); paintArenaBackground(nextCtx, 4, 4); drawGrid(nextCtx, 4, 4); const nextType = state.nextQueue[0]; if (!nextType) { return; } const preview = pieces[nextType]; const width = preview[0].length; const height = preview.length; const offset = { x: (4 - width) / 2, y: (4 - height) / 2 }; drawMatrix(preview, offset, nextType, nextCtx); } function draw() { drawBoard(); drawNextPiece(); } function formatDuration(seconds) { return `${seconds}s`; } function updateStats() { scoreValue.textContent = state.score.toLocaleString(); levelValue.textContent = state.level.toLocaleString(); linesValue.textContent = state.lines.toLocaleString(); if (!state.isGameOver && state.isStarted && (statusValue.textContent === 'Ready' || statusValue.textContent === 'Press Start')) { statusValue.textContent = 'Running'; } } function openSaveForm() { const durationSeconds = Math.max(1, Math.round((Date.now() - state.startedAt) / 1000)); savePlaceholder?.classList.add('d-none'); saveForm?.classList.remove('d-none'); if (runSummary) { runSummary.textContent = `Run complete · ${state.score.toLocaleString()} points, ${state.lines} lines, level ${state.level}, ${durationSeconds}s.`; } if (finalScore) finalScore.value = state.score.toLocaleString(); if (finalLines) finalLines.value = String(state.lines); if (finalLevel) finalLevel.value = String(state.level); if (finalDuration) finalDuration.value = formatDuration(durationSeconds); if (scoreInput) scoreInput.value = String(state.score); if (linesInput) linesInput.value = String(state.lines); if (levelInput) levelInput.value = String(state.level); if (durationInput) durationInput.value = String(durationSeconds); if (playerNameInput) { playerNameInput.value = sanitizePlayerName(playerNameInput.value || loadRememberedPlayerName()); playerNameInput.focus({ preventScroll: false }); } } function hideSaveForm() { savePlaceholder?.classList.remove('d-none'); saveForm?.classList.add('d-none'); } function endGame() { state.isGameOver = true; state.isStarted = false; statusValue.textContent = 'Game over'; draw(); openSaveForm(); showToast('Game over', 'Save your run or restart immediately.'); } function updateStartUi() { if (startOverlay) { startOverlay.classList.toggle('is-hidden', state.isStarted || state.isGameOver); } startButtons.forEach((button) => { button.disabled = state.isStarted || state.isGameOver; }); } function startGame() { if (state.isStarted || state.isGameOver) { return; } state.isStarted = true; state.startedAt = Date.now(); state.lastTime = performance.now(); state.dropCounter = 0; statusValue.textContent = 'Running'; updateStartUi(); boardCanvas.focus?.(); showToast('Game started', 'Good luck.'); } function resetGame(showNotice = true) { state.board = createMatrix(cols, rows); state.nextQueue = []; state.score = 0; state.lines = 0; state.level = 1; state.dropInterval = 900; state.dropCounter = 0; state.lastTime = 0; state.isGameOver = false; state.isStarted = false; state.startedAt = null; statusValue.textContent = 'Press Start'; hideSaveForm(); ensureQueue(); spawnPiece(); updateStats(); draw(); updateStartUi(); if (showNotice) { showToast('Board reset', 'Press Start when you are ready.'); } } function announce(message) { if (!statusValue || state.isGameOver || !state.isStarted) { return; } statusValue.textContent = message; clearTimeout(announce.timer); announce.timer = setTimeout(() => { if (!state.isGameOver && state.isStarted) { statusValue.textContent = 'Running'; } }, 900); } function showToast(title, message) { const container = document.getElementById('toast-container'); if (!container || typeof bootstrap === 'undefined') { return; } const wrapper = document.createElement('div'); wrapper.className = 'toast align-items-center text-bg-dark border-0'; wrapper.role = 'status'; wrapper.ariaLive = 'polite'; wrapper.ariaAtomic = 'true'; wrapper.innerHTML = `
${title} now
${message}
`; container.appendChild(wrapper); const toast = new bootstrap.Toast(wrapper, { delay: 2400 }); wrapper.addEventListener('hidden.bs.toast', () => wrapper.remove()); toast.show(); } function update(time = 0) { const deltaTime = time - state.lastTime; state.lastTime = time; if (state.isStarted) { state.dropCounter += deltaTime; } if (!state.isGameOver && state.isStarted && state.dropCounter > state.dropInterval) { playerDrop(false); } draw(); requestAnimationFrame(update); } function handleAction(action) { if (!state.isStarted || state.isGameOver) { return; } switch (action) { case 'left': movePlayer(-1); break; case 'right': movePlayer(1); break; case 'down': playerDrop(true); break; case 'rotate': attemptRotate(1); break; case 'drop': hardDrop(); break; default: break; } } document.addEventListener('keydown', (event) => { if ([37, 38, 39, 40, 32].includes(event.keyCode)) { event.preventDefault(); } if (event.key === 'ArrowLeft') handleAction('left'); if (event.key === 'ArrowRight') handleAction('right'); if (event.key === 'ArrowDown') handleAction('down'); if (event.key === 'ArrowUp') handleAction('rotate'); if (event.code === 'Space') handleAction('drop'); }, { passive: false }); controlButtons.forEach((button) => { const runAction = (event) => { event.preventDefault(); handleAction(button.dataset.action); }; button.addEventListener('click', runAction); button.addEventListener('touchstart', runAction, { passive: false }); }); restartButtons.forEach((button) => { button.addEventListener('click', () => resetGame()); }); startButtons.forEach((button) => { button.addEventListener('click', () => startGame()); }); focusButton?.addEventListener('click', () => { boardCanvas.scrollIntoView({ behavior: 'smooth', block: 'center' }); boardCanvas.focus?.(); }); [playerNameInput, roomNicknameInput].filter(Boolean).forEach((input) => { input.addEventListener('input', () => { syncNicknameFromInput(input); }); }); roomCodeInput?.addEventListener('input', () => { const clean = sanitizeRoomCode(roomCodeInput.value); if (roomCodeInput.value !== clean) { roomCodeInput.value = clean; } }); createRoomButton?.addEventListener('click', async () => { const playerName = getActiveNickname(); if (!playerName) { return; } createRoomButton.disabled = true; joinRoomButton && (joinRoomButton.disabled = true); try { const payload = await sendRoomRequest('create', { player_name: playerName }); renderRoomState(payload.room); startRoomPolling(); showToast('Room created', `Share code ${payload.room.room_code} with your friend.`); } catch (error) { showToast('Room error', error.message || 'Unable to create a room right now.'); } finally { createRoomButton.disabled = false; joinRoomButton && (joinRoomButton.disabled = false); } }); joinRoomButton?.addEventListener('click', async () => { const playerName = getActiveNickname(); const roomCode = sanitizeRoomCode(roomCodeInput?.value || ''); if (!playerName) { return; } if (!roomCode || roomCode.length !== 6) { showToast('Room code needed', 'Enter the 6-character room code from your friend.'); roomCodeInput?.focus({ preventScroll: false }); return; } createRoomButton && (createRoomButton.disabled = true); joinRoomButton.disabled = true; try { const payload = await sendRoomRequest('join', { player_name: playerName, room_code: roomCode }); renderRoomState(payload.room); startRoomPolling(); showToast('Room joined', `You joined room ${payload.room.room_code}.`); } catch (error) { showToast('Join failed', error.message || 'Unable to join the room right now.'); } finally { createRoomButton && (createRoomButton.disabled = false); joinRoomButton.disabled = false; } }); refreshRoomStatusButton?.addEventListener('click', () => { if (!currentRoomCode) { showToast('No room yet', 'Create a room or join one first.'); return; } syncRoomStatus(currentRoomCode); }); copyRoomCodeButton?.addEventListener('click', async () => { if (!currentRoomCode) { showToast('No room yet', 'Create a room first to copy its code.'); return; } try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(currentRoomCode); showToast('Code copied', `${currentRoomCode} copied to clipboard.`); return; } } catch (error) { // Fallback below. } if (roomCodeInput) { roomCodeInput.value = currentRoomCode; roomCodeInput.focus({ preventScroll: false }); roomCodeInput.select(); } showToast('Copy ready', `Room code ${currentRoomCode} is selected.`); }); const rememberedName = loadRememberedPlayerName(); if (rememberedName) { applyNicknameToInputs(rememberedName); } const rememberedRoomCode = loadRememberedRoomCode(); if (roomCodeInput && rememberedRoomCode && !roomCodeInput.value.trim()) { roomCodeInput.value = rememberedRoomCode; } saveForm?.addEventListener('submit', () => { if (playerNameInput) { playerNameInput.value = rememberPlayerName(playerNameInput.value); } }); if (pageConfig.saved) { showToast('Score saved', pageConfig.scoreId ? `Your run was added to the leaderboard. #${pageConfig.scoreId}` : 'Your run was added to the leaderboard.'); } if (pageConfig.saveError) { showToast('Save failed', pageConfig.saveError); } resetGame(false); syncLeaderboard(); window.setInterval(syncLeaderboard, 15000); if (rememberedRoomCode) { currentRoomCode = rememberedRoomCode; syncRoomStatus(rememberedRoomCode, { silent: true }); } requestAnimationFrame(update); });