From 72de1a96010d17b2be848e305b925652c55e4f7c Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 25 Mar 2026 15:17:29 +0000 Subject: [PATCH] Autosave: 20260325-151729 --- api/leaderboard.php | 42 + api/rooms.php | 78 + assets/css/custom.css | 1356 ++++++++++++----- assets/js/main.js | 1077 ++++++++++++- .../20260325_create_tetris_rooms.sql | 15 + index.php | 464 ++++-- multiplayer_data.php | 217 +++ save_score.php | 26 + score.php | 139 ++ tetris_data.php | 119 ++ 10 files changed, 3029 insertions(+), 504 deletions(-) create mode 100644 api/leaderboard.php create mode 100644 api/rooms.php create mode 100644 db/migrations/20260325_create_tetris_rooms.sql create mode 100644 multiplayer_data.php create mode 100644 save_score.php create mode 100644 score.php create mode 100644 tetris_data.php diff --git a/api/leaderboard.php b/api/leaderboard.php new file mode 100644 index 0000000..9380886 --- /dev/null +++ b/api/leaderboard.php @@ -0,0 +1,42 @@ + (int) ($run['id'] ?? 0), + 'player_name' => (string) ($run['player_name'] ?? ''), + 'score' => (int) ($run['score'] ?? 0), + 'lines_cleared' => (int) ($run['lines_cleared'] ?? 0), + 'level_reached' => (int) ($run['level_reached'] ?? 1), + 'duration_seconds' => (int) ($run['duration_seconds'] ?? 0), + 'created_at_iso' => $timestamp ? gmdate(DATE_ATOM, $timestamp) : null, + ]; +} + +try { + $leaderboard = array_map('tetrisApiScoreRow', tetrisFetchLeaderboard(8)); + $recentRuns = array_map('tetrisApiScoreRow', tetrisFetchRecent(5)); + $bestRun = tetrisFetchBestScore(); + + echo json_encode([ + 'success' => true, + 'updated_at' => gmdate(DATE_ATOM), + 'best_run' => $bestRun ? tetrisApiScoreRow($bestRun) : null, + 'leaderboard' => $leaderboard, + 'recent_runs' => $recentRuns, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'Unable to load leaderboard right now.', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} diff --git a/api/rooms.php b/api/rooms.php new file mode 100644 index 0000000..9291c7e --- /dev/null +++ b/api/rooms.php @@ -0,0 +1,78 @@ + false, + 'error' => 'Room not found or expired.', + ], 404); + } + + roomsApiRespond([ + 'success' => true, + 'updated_at' => gmdate(DATE_ATOM), + 'room' => $room, + ]); + } + + if ($method !== 'POST') { + roomsApiRespond([ + 'success' => false, + 'error' => 'Method not allowed.', + ], 405); + } + + $action = strtolower(trim((string) ($_POST['action'] ?? ''))); + if ($action === 'create') { + $room = multiplayerCreateRoom((string) ($_POST['player_name'] ?? '')); + roomsApiRespond([ + 'success' => true, + 'message' => 'Room created.', + 'updated_at' => gmdate(DATE_ATOM), + 'room' => $room, + ], 201); + } + + if ($action === 'join') { + $room = multiplayerJoinRoom((string) ($_POST['room_code'] ?? ''), (string) ($_POST['player_name'] ?? '')); + roomsApiRespond([ + 'success' => true, + 'message' => 'Joined room.', + 'updated_at' => gmdate(DATE_ATOM), + 'room' => $room, + ]); + } + + roomsApiRespond([ + 'success' => false, + 'error' => 'Unknown room action.', + ], 422); +} catch (InvalidArgumentException $e) { + roomsApiRespond([ + 'success' => false, + 'error' => $e->getMessage(), + ], 422); +} catch (Throwable $e) { + roomsApiRespond([ + 'success' => false, + 'error' => 'Unable to process the room request right now.', + ], 500); +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..3565890 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,1073 @@ -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; - margin: 0; - min-height: 100vh; + :root { + color-scheme: dark; + --bg: #060b16; + --surface: rgba(11, 20, 34, 0.84); + --surface-2: rgba(16, 30, 49, 0.92); + --surface-3: rgba(21, 44, 72, 0.96); + --border: rgba(120, 214, 255, 0.14); + --border-strong: rgba(133, 242, 255, 0.34); + --text: #f3f8ff; + --muted: #97add0; + --accent: #7cf6ff; + --accent-strong: #ffffff; + --success: #73f0a6; + --danger: #ff7d8f; + --shadow: 0 30px 80px rgba(1, 6, 16, 0.52); + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; } -.main-wrapper { +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body.tetris-app { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: -0.01em; +} + +body.tetris-app::selection { + background: rgba(219, 228, 240, 0.22); +} + +.site-header, +.hero-section, +footer { + background: rgba(8, 10, 13, 0.92); +} + +.py-lg-6 { + padding-top: 5rem !important; + padding-bottom: 5rem !important; +} + +.navbar-brand, +.letter-spacing-1 { + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.9rem; +} + +.navbar-dark, +.navbar-dark .nav-link, +.footer-link { + color: var(--text); +} + +.nav-link, +.footer-link { + text-decoration: none; + opacity: 0.78; +} + +.nav-link:hover, +.nav-link:focus, +.footer-link:hover, +.footer-link:focus, +.table-link:hover, +.table-link:focus { + opacity: 1; + color: var(--accent-strong); +} + +.btn { + border-radius: var(--radius-sm); + padding: 0.7rem 1rem; + font-weight: 600; + box-shadow: none !important; +} + +.btn-light { + background: var(--accent); + color: #0e1013; + border-color: var(--accent); +} + +.btn-light:hover, +.btn-light:focus { + background: var(--accent-strong); + color: #080a0d; +} + +.btn-outline-light { + border-color: var(--border-strong); + color: var(--text); +} + +.btn-outline-light:hover, +.btn-outline-light:focus { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.22); + color: var(--text); +} + +.hero-section .lead, +.text-secondary, +.form-text, +.tiny-muted, +.recent-run .tiny-muted, +.table .tiny-muted { + color: var(--muted) !important; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.12em; + color: #c4ccd5; +} + +.soft-panel, +.metric-card, +.recent-run, +.empty-state, +.board-shell, +.run-summary, +.toast, +.control-btn, +.score-chip { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow); +} + +.hero-panel, +.soft-panel { + background: rgba(18, 22, 27, 0.94); +} + +.metric-card { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + min-height: 100%; +} + +.metric-card.compact { + padding: 0.9rem; +} + +.metric-label { + color: var(--muted); + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-value { + font-size: clamp(1.2rem, 2vw, 1.6rem); + font-weight: 700; +} + +.status-text { + font-size: 1rem; +} + +.score-chip { + padding: 0.9rem 1rem; + min-width: 112px; +} + +.score-chip-lg { + min-width: 160px; +} + +.score-chip-label { + display: block; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; + color: var(--muted); + margin-bottom: 0.35rem; +} + +.score-chip strong { + font-size: 1.45rem; +} + +.game-layout { + display: grid; + grid-template-columns: minmax(0, 300px) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.board-shell { + padding: 0.85rem; + width: fit-content; + background: #0f1318; +} + +.board-shell { + position: relative; + overflow: hidden; +} + +.board-start-overlay { + position: absolute; + inset: 0.85rem; display: flex; align-items: center; justify-content: center; - min-height: 100vh; + background: rgba(9, 11, 13, 0.72); + backdrop-filter: blur(3px); + border-radius: 10px; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.board-start-overlay.is-hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.board-start-card { + display: grid; + gap: 0.75rem; + text-align: center; + justify-items: center; + padding: 1.25rem; + min-width: 200px; + background: rgba(18, 22, 27, 0.92); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow); +} + +#tetris-board, +#next-piece { + display: block; + background: #090b0d; + border-radius: 8px; +} + +#tetris-board { + width: min(100%, 300px); + height: auto; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +#next-piece { width: 100%; - padding: 20px; - box-sizing: border-box; + max-width: 120px; + height: auto; + margin: 0 auto; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.board-sidebar { + display: grid; + gap: 0.9rem; +} + +.next-preview-card { + align-items: stretch; +} + +.keycaps { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.keycaps span { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + color: var(--muted); + background: rgba(255, 255, 255, 0.03); +} + +.mobile-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.control-btn { + min-height: 56px; + color: var(--text); + font-weight: 700; + font-size: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border-strong); + touch-action: manipulation; +} + +.control-btn:hover, +.control-btn:focus-visible { + background: var(--surface-2); + outline: none; +} + +.control-btn:active { + transform: translateY(1px); +} + +.control-btn-wide { + grid-column: span 3; +} + +.control-btn.accent { + background: var(--accent); + color: #0b0d10; +} + +.control-btn.accent:hover, +.control-btn.accent:focus-visible { + background: var(--accent-strong); +} + +.run-summary { + padding: 1rem; + color: var(--text); +} + +.form-control-dark { + background: var(--surface-2); + border-color: var(--border-strong); + color: var(--text); + border-radius: 10px; + padding: 0.8rem 0.9rem; +} + +.form-control-dark:focus { + background: var(--surface-2); + border-color: rgba(255, 255, 255, 0.28); + color: var(--text); +} + +.form-control-dark::placeholder { + color: #728093; +} + +.rule-list, +.detail-list { + display: grid; + gap: 0.8rem; +} + +.rule-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + color: var(--muted); +} + +.rule-item:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.rule-item strong { + color: var(--text); +} + +.leaderboard-table { + --bs-table-bg: transparent; + --bs-table-color: var(--text); + --bs-table-border-color: rgba(255, 255, 255, 0.08); + --bs-table-hover-bg: rgba(255, 255, 255, 0.03); + margin-bottom: 0; +} + +.table-link { + color: var(--accent); + text-decoration: none; +} + +.recent-runs { + min-height: 100%; +} + +.recent-run { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + text-decoration: none; + color: inherit; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.recent-run:hover, +.recent-run:focus { + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-1px); +} + +.empty-state { + padding: 1.25rem; +} + +.empty-state.compact { + min-height: 132px; + display: flex; + align-items: center; +} + +.soft-alert, +.toast { + color: var(--text); + background: rgba(24, 29, 36, 0.96); + border-color: var(--border-strong); +} + +.toast { + min-width: 280px; +} + +.toast .toast-header { + background: rgba(255, 255, 255, 0.03); + color: var(--text); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.toast .btn-close { + filter: invert(1) grayscale(1); +} + +.detail-list { + margin: 0; + padding-left: 1.25rem; + color: var(--muted); +} + +.detail-list li + li { + margin-top: 0.65rem; +} + +@media (max-width: 1199.98px) { + .game-layout { + grid-template-columns: 1fr; + } + + .board-shell { + width: 100%; + display: flex; + justify-content: center; + } +} + +@media (max-width: 767.98px) { + .py-lg-6 { + padding-top: 3.75rem !important; + padding-bottom: 3.75rem !important; + } + + .display-5 { + font-size: 2.3rem; + } + + .mobile-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .control-btn-wide { + grid-column: span 2; + } + + .score-chip, + .score-chip-lg { + min-width: 0; + } +} + + +.page-shell { + width: min(100% - 1.5rem, 1440px); + margin: 0 auto; +} + +.game-topbar { + position: sticky; + top: 0; + z-index: 20; + background: rgba(8, 10, 13, 0.92); + backdrop-filter: blur(14px); +} + +.game-page { + min-height: calc(100vh - 72px); +} + +.board-panel { + background: linear-gradient(180deg, rgba(24, 29, 36, 0.98) 0%, rgba(14, 18, 22, 0.98) 100%); +} + +.focus-layout { + grid-template-columns: minmax(0, 1fr) 260px; + gap: 1.25rem; + align-items: start; +} + +.board-stage { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.board-shell-large { + width: 100%; + max-width: 480px; + padding: 1rem; +} + +#tetris-board { + width: min(100%, 420px); + max-height: calc(100vh - 220px); +} + +.sidebar-stack { + display: grid; + gap: 0.9rem; +} + +.quick-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.soft-subpanel { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.compact-keycaps { + gap: 0.45rem; +} + +.compact-keycaps span { + font-size: 0.76rem; + padding: 0.38rem 0.65rem; +} + +.leaderboard-compact .table th, +.leaderboard-compact .table td { + padding-top: 0.65rem; + padding-bottom: 0.65rem; +} + +.sticky-column { + position: sticky; + top: 88px; +} + +@media (max-width: 1199.98px) { + .sticky-column { + position: static; + } + + .focus-layout { + grid-template-columns: 1fr; + } + + .board-shell-large { + max-width: 420px; + } +} + +@media (max-width: 767.98px) { + .page-shell { + width: min(100% - 1rem, 1440px); + } + + .board-shell-large { + padding: 0.7rem; + } + + #tetris-board { + width: min(100%, 340px); + max-height: none; + } + + .quick-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* vivid arcade refresh */ +body.tetris-app { + position: relative; + background: + radial-gradient(circle at top left, rgba(34, 211, 238, 0.16), transparent 26%), + radial-gradient(circle at 82% 18%, rgba(59, 130, 246, 0.16), transparent 22%), + radial-gradient(circle at 50% 100%, rgba(249, 115, 22, 0.12), transparent 30%), + linear-gradient(180deg, #071120 0%, #050913 52%, #04070f 100%); +} + +body.tetris-app::before, +body.tetris-app::after { + content: ''; + position: fixed; + width: 26rem; + height: 26rem; + border-radius: 999px; + filter: blur(80px); + pointer-events: none; + opacity: 0.32; + z-index: 0; +} + +body.tetris-app::before { + top: -9rem; + left: -8rem; + background: rgba(34, 211, 238, 0.28); +} + +body.tetris-app::after { + right: -10rem; + bottom: -10rem; + background: rgba(249, 115, 22, 0.22); +} + +.game-topbar, +.game-page { position: relative; z-index: 1; } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +.game-topbar { + background: rgba(6, 11, 21, 0.76); + backdrop-filter: blur(18px); + border-color: rgba(124, 246, 255, 0.12) !important; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28); } -.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); +.navbar-brand { + background: linear-gradient(90deg, #ffffff 0%, #7cf6ff 45%, #60a5fa 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent !important; + text-shadow: 0 0 22px rgba(124, 246, 255, 0.18); +} + +.btn-light { + background: linear-gradient(135deg, #7cf6ff 0%, #60a5fa 100%); + border-color: rgba(124, 246, 255, 0.55); + color: #04111c; + box-shadow: 0 14px 30px rgba(64, 179, 255, 0.28) !important; +} + +.btn-light:hover, +.btn-light:focus { + background: linear-gradient(135deg, #b4fbff 0%, #7cc7ff 100%); + border-color: rgba(180, 251, 255, 0.75); + color: #03101a; + transform: translateY(-1px); +} + +.btn-outline-light { + background: rgba(255, 255, 255, 0.03); + border-color: rgba(124, 246, 255, 0.2); +} + +.btn-outline-light:hover, +.btn-outline-light:focus { + background: rgba(124, 246, 255, 0.1); + border-color: rgba(124, 246, 255, 0.42); +} + +.soft-panel, +.metric-card, +.recent-run, +.empty-state, +.board-shell, +.run-summary, +.toast, +.control-btn, +.score-chip { + background: + linear-gradient(180deg, rgba(13, 24, 40, 0.92) 0%, rgba(8, 15, 27, 0.92) 100%), + rgba(8, 15, 27, 0.92); + border-color: rgba(124, 246, 255, 0.12); + box-shadow: 0 22px 60px rgba(2, 9, 20, 0.46); +} + +.board-panel { + position: relative; overflow: hidden; + background: + radial-gradient(circle at top right, rgba(124, 246, 255, 0.1), transparent 24%), + radial-gradient(circle at bottom left, rgba(249, 115, 22, 0.08), transparent 20%), + linear-gradient(180deg, rgba(10, 20, 37, 0.96) 0%, rgba(5, 11, 20, 0.96) 100%); } -.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; +.board-panel::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(135deg, rgba(124, 246, 255, 0.05), transparent 38%, rgba(96, 165, 250, 0.06) 100%); +} + +.board-shell, +.board-shell-large { + background: + linear-gradient(180deg, rgba(7, 15, 28, 0.98) 0%, rgba(5, 10, 20, 0.98) 100%); + border: 1px solid rgba(124, 246, 255, 0.18); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 0 0 1px rgba(124, 246, 255, 0.08), + 0 30px 80px rgba(3, 10, 22, 0.55); +} + +.board-shell::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient(180deg, rgba(124, 246, 255, 0.06), transparent 22%, transparent 80%, rgba(249, 115, 22, 0.05)); +} + +#tetris-board, +#next-piece { + border: 1px solid rgba(124, 246, 255, 0.18); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.03), + 0 0 24px rgba(56, 189, 248, 0.1); +} + +.board-start-overlay { + background: linear-gradient(180deg, rgba(4, 9, 18, 0.5) 0%, rgba(4, 9, 18, 0.78) 100%); + backdrop-filter: blur(10px); +} + +.board-start-card { + min-width: 220px; + background: linear-gradient(180deg, rgba(16, 29, 48, 0.96) 0%, rgba(8, 17, 31, 0.98) 100%); + border-color: rgba(124, 246, 255, 0.24); + box-shadow: 0 20px 45px rgba(2, 10, 22, 0.52), 0 0 24px rgba(56, 189, 248, 0.14); +} + +.metric-card { + position: relative; + overflow: hidden; + background: linear-gradient(180deg, rgba(13, 25, 42, 0.96) 0%, rgba(8, 17, 30, 0.96) 100%); +} + +.metric-card::after { + content: ''; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 2px; + background: linear-gradient(90deg, rgba(124, 246, 255, 0.75), rgba(96, 165, 250, 0.12)); + opacity: 0.65; +} + +.next-preview-card { + border-color: rgba(124, 246, 255, 0.26); +} + +.quick-stats .metric-card:nth-child(1) { + border-color: rgba(34, 211, 238, 0.3); + box-shadow: 0 20px 50px rgba(34, 211, 238, 0.1); +} + +.quick-stats .metric-card:nth-child(2) { + border-color: rgba(96, 165, 250, 0.3); + box-shadow: 0 20px 50px rgba(96, 165, 250, 0.1); +} + +.quick-stats .metric-card:nth-child(3) { + border-color: rgba(52, 211, 153, 0.28); + box-shadow: 0 20px 50px rgba(52, 211, 153, 0.1); +} + +.quick-stats .metric-card:nth-child(4) { + border-color: rgba(249, 115, 22, 0.28); + box-shadow: 0 20px 50px rgba(249, 115, 22, 0.1); +} + +.metric-value { + color: #f8fbff; + text-shadow: 0 0 20px rgba(124, 246, 255, 0.08); +} + +.status-text { + color: #ffd39d; +} + +.score-chip { + background: linear-gradient(180deg, rgba(12, 24, 40, 0.96) 0%, rgba(8, 17, 30, 0.96) 100%); + border-color: rgba(124, 246, 255, 0.22); +} + +.soft-subpanel { + background: linear-gradient(180deg, rgba(10, 19, 34, 0.86) 0%, rgba(7, 14, 26, 0.9) 100%); + border-color: rgba(124, 246, 255, 0.12); +} + +.keycaps span { + background: rgba(124, 246, 255, 0.06); + border-color: rgba(124, 246, 255, 0.16); + color: #d7ecff; +} + +.mobile-controls { + gap: 0.85rem; +} + +.control-btn { + background: linear-gradient(180deg, rgba(16, 31, 50, 0.96) 0%, rgba(10, 20, 35, 0.96) 100%); + border-color: rgba(124, 246, 255, 0.18); + box-shadow: 0 12px 30px rgba(2, 10, 22, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.control-btn:hover, +.control-btn:focus-visible { + background: linear-gradient(180deg, rgba(23, 45, 74, 1) 0%, rgba(12, 24, 42, 1) 100%); + border-color: rgba(124, 246, 255, 0.34); + transform: translateY(-2px); +} + +.control-btn:active { + transform: translateY(1px); +} + +.control-btn.accent { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.96) 0%, rgba(251, 191, 36, 0.96) 100%); + border-color: rgba(251, 191, 36, 0.45); + color: #1e1204; + box-shadow: 0 16px 30px rgba(249, 115, 22, 0.22); +} + +.control-btn.accent:hover, +.control-btn.accent:focus-visible { + background: linear-gradient(135deg, rgba(251, 146, 60, 1) 0%, rgba(253, 224, 71, 1) 100%); +} + +.soft-alert { + background: linear-gradient(180deg, rgba(16, 29, 48, 0.94) 0%, rgba(8, 18, 31, 0.96) 100%); + border-color: rgba(124, 246, 255, 0.16); +} + +.badge.text-bg-dark { + background: rgba(124, 246, 255, 0.08) !important; + color: #dff9ff !important; +} + +.form-control-dark, +.form-control { + background: rgba(7, 14, 26, 0.92); + border-color: rgba(124, 246, 255, 0.14); + color: var(--text); +} + +.form-control-dark:focus, +.form-control:focus { + background: rgba(9, 18, 31, 0.96); + border-color: rgba(124, 246, 255, 0.4); + color: var(--text); + box-shadow: 0 0 0 0.2rem rgba(56, 189, 248, 0.12); +} + +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--text); + --bs-table-border-color: rgba(124, 246, 255, 0.1); +} + +@media (max-width: 767.98px) { + body.tetris-app::before, + body.tetris-app::after { + width: 16rem; + height: 16rem; + filter: blur(56px); + opacity: 0.22; + } +} + + + +.leaderboard-meta { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 0.75rem; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; +.online-status { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.online-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + background: #4ade80; + box-shadow: 0 0 0.8rem rgba(74, 222, 128, 0.65); + flex: 0 0 auto; +} + +.recent-feed { + display: grid; + gap: 0.75rem; +} + +.recent-item { display: flex; - flex-direction: column; - gap: 1.25rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 0.95rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: inherit; + text-decoration: none; + transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease; } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; +.recent-item:hover, +.recent-item:focus { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.05); } -::-webkit-scrollbar-track { - background: transparent; +.recent-item strong { + display: block; + color: var(--text); } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; +.recent-item-meta { + color: var(--muted); + font-size: 0.84rem; + margin-top: 0.2rem; } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); + +/* multiplayer lobby */ +.room-intro { + line-height: 1.55; } -.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 { +.room-actions { 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 { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - 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); -} - -.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); - 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; +.room-code-input { text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; + letter-spacing: 0.12em; } -.table td { - background: #fff; +.room-empty-state { + margin-bottom: 0; +} + +.room-state-card { padding: 1rem; - border: none; + border: 1px solid rgba(124, 246, 255, 0.16); + border-radius: var(--radius-md); + background: linear-gradient(180deg, rgba(12, 24, 41, 0.94) 0%, rgba(8, 16, 29, 0.96) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); } -.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; +.room-code-display { + margin-top: 0.35rem; + font-size: 1.7rem; + font-weight: 800; + line-height: 1; + letter-spacing: 0.18em; + color: var(--accent); + text-transform: uppercase; } -.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 { - display: flex; - justify-content: space-between; +.room-status-badge { + display: inline-flex; align-items: center; + justify-content: center; + min-width: 96px; + padding: 0.45rem 0.7rem; + border-radius: 999px; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid rgba(255, 255, 255, 0.1); } -.header-links { +.room-status-waiting { + color: #ffe4a8; + background: rgba(250, 204, 21, 0.12); + border-color: rgba(250, 204, 21, 0.26); +} + +.room-status-ready { + color: #c9ffe0; + background: rgba(34, 197, 94, 0.14); + border-color: rgba(34, 197, 94, 0.28); +} + +.room-player-list { + display: grid; + gap: 0.7rem; +} + +.room-player-line { display: flex; + align-items: center; + justify-content: space-between; 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); -} - -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; -} - -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; -} - -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; -} - -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; + padding: 0.75rem 0.9rem; border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(124, 246, 255, 0.08); } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.room-player-line strong { + text-align: right; } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.room-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; } -.history-table { - width: 100%; +.room-meta-value { + font-size: 1rem; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.room-message { + margin: 0; + color: var(--muted); + line-height: 1.55; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; -} +@media (max-width: 575.98px) { + .room-meta-grid { + grid-template-columns: 1fr; + } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; + .room-code-display { + font-size: 1.35rem; + letter-spacing: 0.14em; + } } - -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index d349598..6e664eb 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,1062 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const boardCanvas = document.getElementById('tetris-board'); + const nextCanvas = document.getElementById('next-piece'); + if (!boardCanvas || !nextCanvas) { + return; + } - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + 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)' } }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + 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]] + }; - appendMessage(message, 'visitor'); - chatInput.value = ''; + 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('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) + const response = await fetch(`${pageConfig.roomsApi}?room_code=${encodeURIComponent(code)}&_=${Date.now()}`, { + headers: { Accept: 'application/json' }, + cache: 'no-store' }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); + 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) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + 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); }); diff --git a/db/migrations/20260325_create_tetris_rooms.sql b/db/migrations/20260325_create_tetris_rooms.sql new file mode 100644 index 0000000..61b5116 --- /dev/null +++ b/db/migrations/20260325_create_tetris_rooms.sql @@ -0,0 +1,15 @@ +-- Step 1 of Tetris 1v1 multiplayer: room lobby storage +CREATE TABLE IF NOT EXISTS tetris_rooms ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + room_code VARCHAR(6) NOT NULL, + host_name VARCHAR(24) NOT NULL, + guest_name VARCHAR(24) DEFAULT NULL, + status ENUM('waiting', 'ready', 'closed') NOT NULL DEFAULT 'waiting', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expires_at TIMESTAMP NULL DEFAULT NULL, + UNIQUE KEY uniq_room_code (room_code), + KEY idx_status (status), + KEY idx_expires_at (expires_at), + KEY idx_updated_at (updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/index.php b/index.php index 7205f3d..dfb755d 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,346 @@ - - - New Style - - - - - - - - - + + + <?= htmlspecialchars($title) ?> + + + + + - - - - + + - - - - + + - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… + +
+
+
+ Midnight Blocks +
+ +
+ Best + +
+ + + + +
+
+
+
+ +
+
+ + + + + + + + + + + + +
+
+
+
+
+
+ +
+
+ Ready when you are + +
+
+
+ +
+ + + + + +
+
+ + +
+
+
+ +
+
+
+
+ 1v1 rooms beta + Step 1 +
+ +

Create a room, share the code with a friend, and wait for them to join. This step builds the multiplayer lobby and ready-state.

+ +
+ + +
We will reuse this nickname for room invites and score publishing.
+
+ +
+ +
+ +
+ +
+ + +
+
+ +
+

No active room yet. Create one or paste a code from your friend.

+
+ +
+
+
+ Current room +
------
+
+ Waiting +
+ +
+
+ Host + +
+
+ Guest + Waiting for friend… +
+
+ +
+
+ Players + 1 / 2 +
+
+ Updated + just now +
+
+ +

Share the code with your friend. The lobby will switch to ready once they join.

+ +
+ + +
+
+
+ +
+
+ Online leaderboard + Public nickname +
+ +
+

Finish a run to publish your score online.

+
+ +
+
No completed run yet.
+
+ + +
This nickname is public and will be remembered on this device.
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+ + +
+
+
+ +
+
+ Online top scores + Live sync +
+
+ Best: Waiting for first score +
+ +
+ + + + + + + + + + $run): ?> + + + + + + + +
#NameScore
+ +
+
+ +
+

No online scores yet.

+
+
+ +
+
+ Recent players + Latest runs +
+ + + +
+

No recent online runs yet.

+
+
+
+
-

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/multiplayer_data.php b/multiplayer_data.php new file mode 100644 index 0000000..0b8e1ea --- /dev/null +++ b/multiplayer_data.php @@ -0,0 +1,217 @@ +exec( + "CREATE TABLE IF NOT EXISTS tetris_rooms ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + room_code VARCHAR(6) NOT NULL, + host_name VARCHAR(24) NOT NULL, + guest_name VARCHAR(24) DEFAULT NULL, + status ENUM('waiting', 'ready', 'closed') NOT NULL DEFAULT 'waiting', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expires_at TIMESTAMP NULL DEFAULT NULL, + UNIQUE KEY uniq_room_code (room_code), + KEY idx_status (status), + KEY idx_expires_at (expires_at), + KEY idx_updated_at (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $ready = true; +} + +function multiplayerSanitizePlayerName(string $value): string +{ + $value = trim($value); + if ($value === '') { + throw new InvalidArgumentException('Enter your nickname first.'); + } + + if (!preg_match('/^[\p{L}\p{N} _.-]+$/u', $value)) { + throw new InvalidArgumentException('Nickname can use letters, numbers, spaces, dots, dashes, and underscores only.'); + } + + $length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + if ($length > 24) { + throw new InvalidArgumentException('Nickname must be 24 characters or fewer.'); + } + + return $value; +} + +function multiplayerNormalizeRoomCode(string $value): string +{ + $value = strtoupper(preg_replace('/[^A-Z0-9]+/', '', $value) ?? ''); + if ($value === '') { + throw new InvalidArgumentException('Enter a room code.'); + } + if (strlen($value) !== TETRIS_ROOM_CODE_LENGTH) { + throw new InvalidArgumentException('Room code must be exactly 6 characters.'); + } + return $value; +} + +function multiplayerFormatRoom(array $room): array +{ + $createdAt = strtotime((string) ($room['created_at'] ?? '')); + $updatedAt = strtotime((string) ($room['updated_at'] ?? '')); + $expiresAt = strtotime((string) ($room['expires_at'] ?? '')); + $guestName = isset($room['guest_name']) ? trim((string) $room['guest_name']) : ''; + $status = (string) ($room['status'] ?? 'waiting'); + $playerCount = $guestName !== '' ? 2 : 1; + + return [ + 'id' => (int) ($room['id'] ?? 0), + 'room_code' => (string) ($room['room_code'] ?? ''), + 'host_name' => (string) ($room['host_name'] ?? ''), + 'guest_name' => $guestName !== '' ? $guestName : null, + 'status' => $status, + 'player_count' => $playerCount, + 'is_ready' => $status === 'ready' && $playerCount >= 2, + 'created_at_iso' => $createdAt ? gmdate(DATE_ATOM, $createdAt) : null, + 'updated_at_iso' => $updatedAt ? gmdate(DATE_ATOM, $updatedAt) : null, + 'expires_at_iso' => $expiresAt ? gmdate(DATE_ATOM, $expiresAt) : null, + ]; +} + +function multiplayerFetchRoomByCode(string $roomCode): ?array +{ + multiplayerEnsureSchema(); + $roomCode = multiplayerNormalizeRoomCode($roomCode); + + $stmt = db()->prepare( + 'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at + FROM tetris_rooms + WHERE room_code = :room_code + AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP()) + LIMIT 1' + ); + $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR); + $stmt->execute(); + $room = $stmt->fetch(); + + return $room ? multiplayerFormatRoom($room) : null; +} + +function multiplayerGenerateRoomCode(): string +{ + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + $maxIndex = strlen($alphabet) - 1; + $roomCode = ''; + + for ($i = 0; $i < TETRIS_ROOM_CODE_LENGTH; $i += 1) { + $roomCode .= $alphabet[random_int(0, $maxIndex)]; + } + + return $roomCode; +} + +function multiplayerCreateRoom(string $hostName): array +{ + multiplayerEnsureSchema(); + $hostName = multiplayerSanitizePlayerName($hostName); + + $expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600)); + + for ($attempt = 0; $attempt < 12; $attempt += 1) { + $roomCode = multiplayerGenerateRoomCode(); + try { + $stmt = db()->prepare( + 'INSERT INTO tetris_rooms (room_code, host_name, status, expires_at) + VALUES (:room_code, :host_name, :status, :expires_at)' + ); + $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR); + $stmt->bindValue(':host_name', $hostName, PDO::PARAM_STR); + $stmt->bindValue(':status', 'waiting', PDO::PARAM_STR); + $stmt->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR); + $stmt->execute(); + + $room = multiplayerFetchRoomByCode($roomCode); + if ($room) { + return $room; + } + } catch (PDOException $e) { + if ((string) $e->getCode() !== '23000') { + throw $e; + } + } + } + + throw new RuntimeException('Unable to create a room right now. Please try again.'); +} + +function multiplayerJoinRoom(string $roomCode, string $guestName): array +{ + multiplayerEnsureSchema(); + $roomCode = multiplayerNormalizeRoomCode($roomCode); + $guestName = multiplayerSanitizePlayerName($guestName); + + $pdo = db(); + $pdo->beginTransaction(); + + try { + $stmt = $pdo->prepare( + 'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at + FROM tetris_rooms + WHERE room_code = :room_code + AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP()) + LIMIT 1 + FOR UPDATE' + ); + $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR); + $stmt->execute(); + $room = $stmt->fetch(); + + if (!$room) { + throw new InvalidArgumentException('Room not found or already expired.'); + } + + $existingGuest = trim((string) ($room['guest_name'] ?? '')); + if ($existingGuest !== '' && strcasecmp($existingGuest, $guestName) !== 0) { + throw new InvalidArgumentException('This room already has two players.'); + } + + if ($existingGuest === '') { + $expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600)); + $update = $pdo->prepare( + 'UPDATE tetris_rooms + SET guest_name = :guest_name, + status = :status, + expires_at = :expires_at + WHERE id = :id' + ); + $update->bindValue(':guest_name', $guestName, PDO::PARAM_STR); + $update->bindValue(':status', 'ready', PDO::PARAM_STR); + $update->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR); + $update->bindValue(':id', (int) $room['id'], PDO::PARAM_INT); + $update->execute(); + } + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } + + $freshRoom = multiplayerFetchRoomByCode($roomCode); + if (!$freshRoom) { + throw new RuntimeException('Room state could not be loaded.'); + } + + return $freshRoom; +} diff --git a/save_score.php b/save_score.php new file mode 100644 index 0000000..da1eccb --- /dev/null +++ b/save_score.php @@ -0,0 +1,26 @@ + $_POST['player_name'] ?? '', + 'score' => $_POST['score'] ?? 0, + 'lines_cleared' => $_POST['lines_cleared'] ?? 0, + 'level_reached' => $_POST['level_reached'] ?? 1, + 'duration_seconds' => $_POST['duration_seconds'] ?? 0, + ]); + + header('Location: /?saved=1&score_id=' . $scoreId . '#leaderboard'); + exit; +} catch (Throwable $e) { + $message = rawurlencode($e instanceof InvalidArgumentException ? $e->getMessage() : 'Unable to save the score right now.'); + header('Location: /?save_error=' . $message . '#save-score'); + exit; +} diff --git a/score.php b/score.php new file mode 100644 index 0000000..dc86a4a --- /dev/null +++ b/score.php @@ -0,0 +1,139 @@ + 0) { + $score = tetrisFetchScoreById($scoreId); + } +} catch (Throwable $e) { + $dbError = 'Leaderboard data is temporarily unavailable.'; +} + +$title = $score ? $score['player_name'] . ' run · ' . $projectName : 'Run not found · ' . $projectName; +$metaDescription = $score + ? sprintf('%s scored %d points with %d cleared lines in Midnight Blocks.', $score['player_name'], $score['score'], $score['lines_cleared']) + : $projectDescription; +?> + + + + + + <?= htmlspecialchars($title) ?> + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+ +
+ + + +
+ Run detail +

That score could not be found.

+

Try another run from the leaderboard or start a fresh game.

+ +
+ +
+
+
+ Saved run +

’s session

+

Recorded UTC.

+
+
+ Score + +
+
+
+
+
+ Lines cleared + +
+
+
+
+ Level reached + +
+
+
+
+ Session length + s +
+
+
+
+ +
+ Breakdown +
+
+

What this run means

+
    +
  • High score value reflects line clears plus soft and hard drop bonuses.
  • +
  • Levels increase every 10 cleared lines, which accelerates the drop speed.
  • +
  • Each saved run can be revisited from the leaderboard for quick comparison.
  • +
+
+
+
+ Next challenge +

Beat this run by improving stacking efficiency and chaining doubles or tetrises.

+ Start a new run +
+
+
+
+ +
+
+
+
+ + diff --git a/tetris_data.php b/tetris_data.php new file mode 100644 index 0000000..d22b81f --- /dev/null +++ b/tetris_data.php @@ -0,0 +1,119 @@ +exec( + "CREATE TABLE IF NOT EXISTS tetris_scores ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + player_name VARCHAR(24) 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, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_score (score DESC, lines_cleared DESC), + INDEX idx_created_at (created_at DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $ready = true; +} + +function tetrisFetchLeaderboard(int $limit = 10): array +{ + tetrisEnsureSchema(); + $stmt = db()->prepare( + 'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at + FROM tetris_scores + ORDER BY score DESC, lines_cleared DESC, duration_seconds ASC, id ASC + LIMIT :limit' + ); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); +} + +function tetrisFetchRecent(int $limit = 8): array +{ + tetrisEnsureSchema(); + $stmt = db()->prepare( + 'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at + FROM tetris_scores + ORDER BY created_at DESC, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); +} + +function tetrisFetchScoreById(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 tetrisFetchBestScore(): ?array +{ + $scores = tetrisFetchLeaderboard(1); + return $scores[0] ?? null; +} + +function tetrisSaveScore(array $input): int +{ + tetrisEnsureSchema(); + + $name = trim((string) ($input['player_name'] ?? '')); + if ($name === '') { + throw new InvalidArgumentException('Enter your name to save the run.'); + } + $nameLength = function_exists('mb_strlen') ? mb_strlen($name) : strlen($name); + if ($nameLength > 24) { + throw new InvalidArgumentException('Name must be 24 characters or fewer.'); + } + if (!preg_match('/^[\p{L}\p{N} _.-]+$/u', $name)) { + throw new InvalidArgumentException('Use letters, numbers, spaces, dots, dashes, or underscores only.'); + } + + $score = max(0, min(999999, (int) ($input['score'] ?? 0))); + $lines = max(0, min(9999, (int) ($input['lines_cleared'] ?? 0))); + $level = max(1, min(999, (int) ($input['level_reached'] ?? 1))); + $duration = max(0, min(86400, (int) ($input['duration_seconds'] ?? 0))); + + if ($score === 0 && $lines === 0) { + throw new InvalidArgumentException('Finish a run before saving to the leaderboard.'); + } + + $stmt = db()->prepare( + 'INSERT INTO tetris_scores (player_name, score, lines_cleared, level_reached, duration_seconds) + VALUES (:player_name, :score, :lines_cleared, :level_reached, :duration_seconds)' + ); + $stmt->bindValue(':player_name', $name, 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); + $stmt->execute(); + + return (int) db()->lastInsertId(); +}