diff --git a/api/scores.php b/api/scores.php new file mode 100644 index 0000000..e745d8c --- /dev/null +++ b/api/scores.php @@ -0,0 +1,63 @@ + 0) { + $score = tetrisFetchScore($id); + if (!$score) { + respond(404, ['success' => false, 'message' => 'Score not found.']); + } + + respond(200, [ + 'success' => true, + 'score' => $score, + 'rank' => tetrisFetchScoreRank($id), + ]); + } + + respond(200, [ + 'success' => true, + 'scores' => tetrisFetchTopScores($limit), + 'recent' => tetrisFetchRecentScores(6), + ]); + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + respond(405, ['success' => false, 'message' => 'Method not allowed.']); + } + + $raw = file_get_contents('php://input'); + $data = json_decode($raw ?: '{}', true); + if (!is_array($data)) { + respond(400, ['success' => false, 'message' => 'Invalid JSON payload.']); + } + + $result = tetrisInsertScore($data); + respond(201, [ + 'success' => true, + 'message' => 'Score submitted to the online leaderboard.', + 'score' => $result['score'], + 'rank' => $result['rank'], + 'scores' => tetrisFetchTopScores(12), + ]); +} catch (InvalidArgumentException $exception) { + respond(422, ['success' => false, 'message' => $exception->getMessage()]); +} catch (Throwable $exception) { + error_log('Tetris score API error: ' . $exception->getMessage()); + respond(500, ['success' => false, 'message' => 'Leaderboard service is temporarily unavailable.']); +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..8c9966d 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,469 @@ +:root { + --bg: #050816; + --bg-elevated: #091022; + --surface: rgba(8, 14, 28, 0.84); + --surface-strong: rgba(11, 20, 40, 0.96); + --surface-soft: rgba(7, 12, 22, 0.92); + --border: rgba(72, 231, 255, 0.18); + --border-strong: rgba(72, 231, 255, 0.42); + --text: #ecfdff; + --muted: #9bb6c8; + --accent: #48e7ff; + --accent-strong: #00ffc6; + --accent-secondary: #ff4fd8; + --accent-dark: #03131c; + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 24px; + --shadow: 0 28px 80px rgba(0, 0, 0, 0.42); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; + position: relative; margin: 0; min-height: 100vh; + background: + radial-gradient(circle at 18% 18%, rgba(72, 231, 255, 0.18), transparent 24%), + radial-gradient(circle at 82% 14%, rgba(255, 79, 216, 0.16), transparent 24%), + radial-gradient(circle at 50% 100%, rgba(0, 255, 198, 0.12), transparent 28%), + linear-gradient(180deg, #08101f 0%, #040814 54%, #02050d 100%); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -.main-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; -} - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { +body::before { + content: ""; position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; + inset: 0; pointer-events: none; + background: + linear-gradient(rgba(72, 231, 255, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(72, 231, 255, 0.06) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.05)); + opacity: 0.28; } -.blob { +canvas { + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.app-shell { + min-height: 100vh; +} + +.surface-panel { + position: relative; + background: linear-gradient(180deg, rgba(11, 20, 40, 0.88), rgba(6, 12, 24, 0.9)); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: + var(--shadow), + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 0 0 1px rgba(72, 231, 255, 0.04), + 0 0 28px rgba(72, 231, 255, 0.08); + backdrop-filter: blur(16px); +} + +.surface-panel::after { + content: ""; position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient(135deg, rgba(72, 231, 255, 0.08), transparent 35%, rgba(255, 79, 216, 0.08)); + opacity: 0.9; } -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); +.surface-panel > * { position: relative; z-index: 1; } -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; -} - -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { +.app-topbar { display: flex; justify-content: space-between; align-items: center; -} - -.header-links { - display: flex; gap: 1rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.eyebrow, +.section-label, +.brand-subtitle { + margin: 0; + color: var(--accent); + font-size: 0.72rem; + letter-spacing: 0.24em; + text-transform: uppercase; } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; +.app-title, +.display-title, +.brand-title { + font-size: clamp(1.8rem, 3.5vw, 2.5rem); + font-weight: 800; + letter-spacing: -0.04em; + text-shadow: 0 0 18px rgba(72, 231, 255, 0.16); } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.topbar-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; +.board-wrap { + display: flex; + justify-content: center; +} + +.board-frame { + position: relative; + overflow: hidden; + width: min(100%, 356px); + border-radius: calc(var(--radius-lg) - 4px); + border: 1px solid rgba(72, 231, 255, 0.28); + background: linear-gradient(180deg, rgba(5, 10, 20, 0.98) 0%, rgba(2, 5, 12, 1) 100%); + padding: 1rem; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.03), + inset 0 0 28px rgba(72, 231, 255, 0.08), + 0 0 34px rgba(72, 231, 255, 0.16), + 0 0 60px rgba(255, 79, 216, 0.08); +} + +.board-frame::before { + content: ""; + position: absolute; + inset: -20%; + background: + radial-gradient(circle at 20% 20%, rgba(72, 231, 255, 0.24), transparent 26%), + radial-gradient(circle at 80% 10%, rgba(255, 79, 216, 0.22), transparent 24%), + radial-gradient(circle at 50% 100%, rgba(0, 255, 198, 0.12), transparent 32%); + filter: blur(26px); + opacity: 0.95; + pointer-events: none; +} + +#tetris-board { + position: relative; + z-index: 1; + display: block; + width: 100%; + height: auto; + background: #040914; + border-radius: 16px; + border: 1px solid rgba(72, 231, 255, 0.16); + box-shadow: + inset 0 0 30px rgba(72, 231, 255, 0.08), + inset 0 0 80px rgba(255, 79, 216, 0.04), + 0 0 16px rgba(72, 231, 255, 0.08); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.stat-card, +.mini-panel, +.metric-card, +.surface-subpanel { + background: linear-gradient(180deg, rgba(72, 231, 255, 0.08), rgba(255, 79, 216, 0.05)); + border: 1px solid rgba(72, 231, 255, 0.16); + border-radius: var(--radius-md); + padding: 0.95rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 0 18px rgba(72, 231, 255, 0.06); +} + +.stat-label, +.mini-label, +.status-line, +.metric-label, +.small-link, +.text-secondary, +.detail-list { + color: var(--muted) !important; +} + +.stat-label, +.mini-label, +.status-line, +.metric-label { + display: block; + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.stat-value, +.metric-value, +.score-rank-pill { + display: block; + margin-top: 0.45rem; + font-size: clamp(1.35rem, 2vw, 1.8rem); + font-weight: 800; + line-height: 1; + color: var(--text); + text-shadow: 0 0 14px rgba(72, 231, 255, 0.18); +} + +.mini-panel canvas { + display: block; + width: 100%; + height: auto; + margin-top: 0.75rem; + border-radius: 14px; + border: 1px solid rgba(72, 231, 255, 0.16); + background: #040914; + box-shadow: inset 0 0 24px rgba(72, 231, 255, 0.05); +} + +.panel-title, +.h5, +.h2 { + color: var(--text); +} + +.stack-gap > * + * { margin-top: 1rem; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; +.stack-gap-sm > * + * { + margin-top: 0.75rem; +} + +.form-control-dark { + background: rgba(7, 16, 30, 0.92); + color: var(--text); + border: 1px solid rgba(72, 231, 255, 0.2); border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; + min-height: 46px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.form-control-dark::placeholder { + color: #6e90a7; } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; +.form-control-dark:focus { + background: rgba(9, 20, 38, 0.98); + color: var(--text); + border-color: rgba(72, 231, 255, 0.52); + box-shadow: 0 0 0 0.2rem rgba(72, 231, 255, 0.12); +} + +.btn { border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); + font-weight: 700; + min-height: 46px; + padding-inline: 1rem; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease, background 0.16s ease; } -.history-table { - width: 100%; +.btn:hover, +.btn:focus-visible, +.control-btn:hover, +.control-btn:focus-visible, +.leaderboard-item:hover, +.small-link:hover { + transform: translateY(-1px); } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.btn-light { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: var(--accent-dark); + border: 0; + box-shadow: 0 0 18px rgba(72, 231, 255, 0.28); } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.btn-light:hover, +.btn-light:focus-visible { + background: linear-gradient(135deg, #6cf0ff, #34ffd0); + color: var(--accent-dark); + box-shadow: 0 0 24px rgba(72, 231, 255, 0.36); } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.btn-outline-light { + color: var(--text); + border-color: rgba(255, 79, 216, 0.35); + background: rgba(255, 79, 216, 0.08); + box-shadow: inset 0 0 0 1px rgba(255, 79, 216, 0.05); } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +.btn-outline-light:hover, +.btn-outline-light:focus-visible { + color: var(--text); + border-color: rgba(255, 79, 216, 0.52); + background: rgba(255, 79, 216, 0.16); + box-shadow: 0 0 22px rgba(255, 79, 216, 0.2); +} + +.mobile-controls { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; +} + +.control-btn { + border: 1px solid rgba(72, 231, 255, 0.2); + background: linear-gradient(180deg, rgba(10, 20, 36, 0.95), rgba(6, 12, 22, 0.98)); + color: var(--text); + border-radius: 14px; + min-height: 52px; + font-weight: 800; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 0 18px rgba(72, 231, 255, 0.08); + transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.control-btn:hover, +.control-btn:focus-visible { + background: linear-gradient(180deg, rgba(16, 28, 48, 0.98), rgba(8, 16, 28, 0.98)); + border-color: rgba(72, 231, 255, 0.44); + box-shadow: 0 0 22px rgba(72, 231, 255, 0.16); +} + +.control-btn.wide { + grid-column: span 2; +} + +.leaderboard-list { + display: grid; + gap: 0.65rem; +} + +.leaderboard-item { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.75rem; + padding: 0.8rem 0.9rem; + border-radius: 14px; + border: 1px solid rgba(72, 231, 255, 0.14); + background: linear-gradient(180deg, rgba(11, 19, 34, 0.9), rgba(8, 14, 26, 0.92)); + color: var(--text); + text-decoration: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.leaderboard-item:hover, +.leaderboard-item.is-active { + border-color: rgba(72, 231, 255, 0.36); + box-shadow: 0 0 22px rgba(72, 231, 255, 0.12), inset 0 0 0 1px rgba(255, 79, 216, 0.12); +} + +.leaderboard-rank { + color: var(--accent); + font-weight: 700; +} + +.leaderboard-player { + font-weight: 700; +} + +.leaderboard-meta { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.status-line { + min-height: 1.2rem; +} + +.app-header { + background: rgba(4, 8, 18, 0.78); + backdrop-filter: blur(12px); + border-color: rgba(72, 231, 255, 0.14) !important; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: 14px; + border: 1px solid rgba(72, 231, 255, 0.28); + background: linear-gradient(135deg, rgba(72, 231, 255, 0.18), rgba(255, 79, 216, 0.14)); + color: var(--text); + font-weight: 800; + letter-spacing: 0.08em; + box-shadow: 0 0 18px rgba(72, 231, 255, 0.14); +} + +.score-rank-pill { + padding: 0.85rem 1rem; + border-radius: 999px; + border: 1px solid rgba(72, 231, 255, 0.24); + background: linear-gradient(135deg, rgba(72, 231, 255, 0.12), rgba(255, 79, 216, 0.12)); + box-shadow: 0 0 20px rgba(72, 231, 255, 0.1); +} + +.metric-value { + margin-top: 0.4rem; +} + +.detail-list { + padding-left: 1.1rem; +} + +.detail-list li + li { + margin-top: 0.65rem; +} + +.small-link { + color: var(--accent); +} + +.compact-list .leaderboard-item { + padding-block: 0.72rem; +} + +.alert.surface-panel { + color: var(--text); +} + +@media (max-width: 991.98px) { + .app-topbar { + flex-direction: column; + align-items: stretch; + } + + .topbar-actions { + width: 100%; + } + + .topbar-actions .btn { + flex: 1 1 0; + } +} + +@media (max-width: 575.98px) { + .board-frame { + padding: 0.75rem; + } + + .mobile-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .control-btn.wide { + grid-column: span 1; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..15fcd70 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,887 @@ -document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); +(() => { + 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]'); - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - }; + if (!boardCanvas) { + return; + } - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) 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'; - appendMessage(message, 'visitor'); - chatInput.value = ''; + boardCanvas.width = COLS * BLOCK; + boardCanvas.height = ROWS * BLOCK; + if (nextCanvas) { + nextCanvas.width = 120; + nextCanvas.height = 120; + } + if (holdCanvas) { + holdCanvas.width = 120; + holdCanvas.height = 120; + } - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + 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; + } +})(); \ No newline at end of file diff --git a/index.php b/index.php index 7205f3d..e3b050f 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,142 @@ getMessage()); +} + +function esc(?string $value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} ?> - - - New Style - - - - - - - - - - - - - - - - - - - + + + <?= esc($projectName !== '' ? $projectName : 'RetroStack') ?> — Play Tetris Online + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+
+
+

Arcade

+

+
+
+ + +
+
+ +
+
+
+
+
+ +
+
+ +
+ + + + + + +
+
+
+ +
+
+
+
+
+ Score + 0 +
+
+ Lines + 0 +
+
+ Level + 1 +
+
+ Best + 0 +
+
+
+ +
+
+
+
+
Next
+ +
+
+
+
+
Hold
+ +
+
+
+
+ +
+
+ +
+ + +
+
Finish a run, then save.
+
+
+ +
+
+

Top 10

+
+
+ $entry): ?> + + # + + + + +
+
+
+
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
- Page updated: (UTC) -
+ + + diff --git a/lib/tetris_store.php b/lib/tetris_store.php new file mode 100644 index 0000000..6a713dc --- /dev/null +++ b/lib/tetris_store.php @@ -0,0 +1,166 @@ +exec( + "CREATE TABLE IF NOT EXISTS tetris_scores ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + player_name VARCHAR(32) NOT NULL, + score INT UNSIGNED NOT NULL DEFAULT 0, + lines_cleared INT UNSIGNED NOT NULL DEFAULT 0, + level_reached INT UNSIGNED NOT NULL DEFAULT 1, + duration_seconds INT UNSIGNED NOT NULL DEFAULT 0, + client_signature VARCHAR(64) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_score_order (score DESC, lines_cleared DESC, level_reached DESC, duration_seconds ASC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $ready = true; +} + +function tetrisNormalizePlayerName(string $name): string +{ + $name = trim(preg_replace('/\s+/', ' ', $name) ?? ''); + return function_exists('mb_substr') ? mb_substr($name, 0, 32) : substr($name, 0, 32); +} + +function tetrisFetchTopScores(int $limit = 10): array +{ + tetrisEnsureSchema(); + $limit = max(1, min(100, $limit)); + $stmt = db()->query( + "SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at + FROM tetris_scores + ORDER BY score DESC, lines_cleared DESC, level_reached DESC, duration_seconds ASC, id ASC + LIMIT {$limit}" + ); + + return $stmt->fetchAll() ?: []; +} + +function tetrisFetchRecentScores(int $limit = 8): array +{ + tetrisEnsureSchema(); + $limit = max(1, min(100, $limit)); + $stmt = db()->query( + "SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at + FROM tetris_scores + ORDER BY id DESC + LIMIT {$limit}" + ); + + return $stmt->fetchAll() ?: []; +} + +function tetrisFetchScore(int $id): ?array +{ + tetrisEnsureSchema(); + $stmt = db()->prepare( + 'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at + FROM tetris_scores + WHERE id = :id + LIMIT 1' + ); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->execute(); + $score = $stmt->fetch(); + + return $score ?: null; +} + +function tetrisFetchScoreRank(int $id): ?int +{ + $score = tetrisFetchScore($id); + if (!$score) { + return null; + } + + $stmt = db()->prepare( + 'SELECT COUNT(*) + 1 AS score_rank + FROM tetris_scores + WHERE score > :score + OR (score = :score AND lines_cleared > :lines) + OR (score = :score AND lines_cleared = :lines AND level_reached > :level) + OR (score = :score AND lines_cleared = :lines AND level_reached = :level AND duration_seconds < :duration) + OR (score = :score AND lines_cleared = :lines AND level_reached = :level AND duration_seconds = :duration AND id < :id)' + ); + $stmt->bindValue(':score', (int) $score['score'], PDO::PARAM_INT); + $stmt->bindValue(':lines', (int) $score['lines_cleared'], PDO::PARAM_INT); + $stmt->bindValue(':level', (int) $score['level_reached'], PDO::PARAM_INT); + $stmt->bindValue(':duration', (int) $score['duration_seconds'], PDO::PARAM_INT); + $stmt->bindValue(':id', (int) $score['id'], PDO::PARAM_INT); + $stmt->execute(); + + return (int) ($stmt->fetchColumn() ?: 1); +} + +function tetrisInsertScore(array $input): array +{ + tetrisEnsureSchema(); + + $playerName = tetrisNormalizePlayerName((string) ($input['player_name'] ?? '')); + $score = (int) ($input['score'] ?? 0); + $lines = (int) ($input['lines_cleared'] ?? 0); + $level = (int) ($input['level_reached'] ?? 1); + $duration = (int) ($input['duration_seconds'] ?? 0); + $clientSignature = trim((string) ($input['client_signature'] ?? '')); + + $playerLength = function_exists('mb_strlen') ? mb_strlen($playerName) : strlen($playerName); + + if ($playerName === '' || $playerLength < 2) { + throw new InvalidArgumentException('Enter a player name with at least 2 characters.'); + } + + if (!preg_match('/^[\p{L}\p{N} ._\-]+$/u', $playerName)) { + throw new InvalidArgumentException('Use letters, numbers, spaces, dots, dashes, or underscores in the player name.'); + } + + if ($score < 0 || $score > 9999999) { + throw new InvalidArgumentException('Score is outside the allowed range.'); + } + + if ($lines < 0 || $lines > 9999) { + throw new InvalidArgumentException('Lines cleared is outside the allowed range.'); + } + + if ($level < 1 || $level > 999) { + throw new InvalidArgumentException('Level is outside the allowed range.'); + } + + if ($duration < 0 || $duration > 86400) { + throw new InvalidArgumentException('Duration is outside the allowed range.'); + } + + if ($score === 0 && $lines === 0) { + throw new InvalidArgumentException('Play a round before submitting a score.'); + } + + $stmt = db()->prepare( + 'INSERT INTO tetris_scores (player_name, score, lines_cleared, level_reached, duration_seconds, client_signature) + VALUES (:player_name, :score, :lines_cleared, :level_reached, :duration_seconds, :client_signature)' + ); + $stmt->bindValue(':player_name', $playerName, PDO::PARAM_STR); + $stmt->bindValue(':score', $score, PDO::PARAM_INT); + $stmt->bindValue(':lines_cleared', $lines, PDO::PARAM_INT); + $stmt->bindValue(':level_reached', $level, PDO::PARAM_INT); + $stmt->bindValue(':duration_seconds', $duration, PDO::PARAM_INT); + $safeSignature = $clientSignature !== '' ? (function_exists('mb_substr') ? mb_substr($clientSignature, 0, 64) : substr($clientSignature, 0, 64)) : null; + $stmt->bindValue(':client_signature', $safeSignature, $safeSignature !== null ? PDO::PARAM_STR : PDO::PARAM_NULL); + $stmt->execute(); + + $id = (int) db()->lastInsertId(); + + return [ + 'score' => tetrisFetchScore($id), + 'rank' => tetrisFetchScoreRank($id), + ]; +} diff --git a/score.php b/score.php new file mode 100644 index 0000000..b3e9183 --- /dev/null +++ b/score.php @@ -0,0 +1,170 @@ + 0) { + $score = tetrisFetchScore($scoreId); + $rank = $score ? tetrisFetchScoreRank($scoreId) : null; + } + $topScores = tetrisFetchTopScores(10); +} catch (Throwable $exception) { + $errorMessage = 'Leaderboard data is unavailable right now.'; + error_log('Tetris score page error: ' . $exception->getMessage()); +} + +if (!$score) { + http_response_code(404); +} + +$pageTitle = $score ? sprintf('%s — %s score %d', $projectName !== '' ? $projectName : 'RetroStack', $score['player_name'], (int) $score['score']) : (($projectName !== '' ? $projectName : 'RetroStack') . ' — Score not found'); +$pageDescription = $score + ? sprintf('%s reached %d points, %d lines, and level %d in this RetroStack run.', $score['player_name'], (int) $score['score'], (int) $score['lines_cleared'], (int) $score['level_reached']) + : $projectDescription; + +function esc(?string $value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} +?> + + + + + + <?= esc($pageTitle) ?> + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+ + + +
+ +

That run could not be found.

+

Try a fresh round and submit a new score to populate the online leaderboard.

+ Return to the game +
+ +
+
+
+
+
+ +

+

Submitted UTC

+
+
Rank #
+
+ +
+
+
+
Score
+
+
+
+
+
+
Lines
+
+
+
+
+
+
Level
+
+
+
+
+
+
Duration
+
s
+
+
+
+ +
+

Run notes

+
    +
  • Online leaderboard entries are ordered by score, then lines, then level, then shorter survival time.
  • +
  • This detail page gives each score a shareable destination for friendly competition.
  • +
  • To improve your rank, return to the main board and chase a higher line clear count.
  • +
+
+
+
+ +
+ +
+
+ +
+
+ +