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
+
= esc($projectName !== '' ? $projectName : 'RetroStack') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Score
+ 0
+
+
+ Lines
+ 0
+
+
+ Level
+ 1
+
+
+ Best
+ 0
+
+
+
+
+
+
+
+
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
+
+
+
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) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= esc($errorMessage) ?>
+
+
+
+
+ Score detail
+ That run could not be found.
+ Try a fresh round and submit a new score to populate the online leaderboard.
+ Return to the game
+
+
+
+
+
+
+
+
Verified leaderboard run
+
= esc($score['player_name']) ?>
+
Submitted = esc(date('M j, Y H:i', strtotime((string) $score['created_at']))) ?> UTC
+
+
Rank #= (int) $rank ?>
+
+
+
+
+
+
Score
+
= number_format((int) $score['score']) ?>
+
+
+
+
+
Lines
+
= number_format((int) $score['lines_cleared']) ?>
+
+
+
+
+
Level
+
= number_format((int) $score['level_reached']) ?>
+
+
+
+
+
Duration
+
= number_format((int) $score['duration_seconds']) ?>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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+