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