This commit is contained in:
Flatlogic Bot 2026-03-25 12:13:13 +00:00
parent 67fa0fbe3f
commit 9191afc91a
6 changed files with 1830 additions and 525 deletions

63
api/scores.php Normal file
View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../lib/tetris_store.php';
function respond(int $status, array $payload): void
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
try {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 12;
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id > 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.']);
}

View File

@ -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;
}
.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;
}
}

View File

@ -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 `
<a class="leaderboard-item" href="score.php?id=${id}">
<span class="leaderboard-rank">#${index + 1}</span>
<span class="leaderboard-player">${safeName}</span>
<span class="leaderboard-meta">${score}</span>
</a>
`;
}).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;
}
})();

264
index.php
View File

@ -1,150 +1,142 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/lib/tetris_store.php';
$projectName = trim((string) ($_SERVER['PROJECT_NAME'] ?? 'RetroStack'));
$topScores = [];
try {
$topScores = tetrisFetchTopScores(10);
} catch (Throwable $exception) {
error_log('Tetris index error: ' . $exception->getMessage());
}
function esc(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= esc($projectName !== '' ? $projectName : 'RetroStack') ?> — Play Tetris Online</title>
<meta name="description" content="Play a clean browser Tetris game with score tracking, next and hold preview, and an online leaderboard.">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= urlencode((string) filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<main class="app-shell py-4 py-lg-5">
<div class="container">
<header class="app-topbar surface-panel mb-4 p-3 p-lg-4">
<div>
<p class="eyebrow mb-2">Arcade</p>
<h1 class="app-title mb-0"><?= esc($projectName !== '' ? $projectName : 'RetroStack') ?></h1>
</div>
<div class="topbar-actions">
<button class="btn btn-light" id="start-game-btn" type="button">Start</button>
<button class="btn btn-outline-light" id="pause-game-btn" type="button">Pause</button>
</div>
</header>
<div class="row g-4 align-items-start">
<div class="col-lg-7 col-xl-8">
<section class="surface-panel p-3 p-lg-4" id="play">
<div class="board-wrap">
<div class="board-frame mx-auto">
<canvas id="tetris-board" width="300" height="600" aria-label="Tetris board"></canvas>
</div>
</div>
<div class="mobile-controls mt-3" aria-label="Touch controls">
<button class="control-btn" type="button" data-control="left"></button>
<button class="control-btn" type="button" data-control="rotate"></button>
<button class="control-btn" type="button" data-control="right"></button>
<button class="control-btn" type="button" data-control="softDrop"></button>
<button class="control-btn wide" type="button" data-control="hardDrop">Drop</button>
<button class="control-btn wide" type="button" data-control="hold">Hold</button>
</div>
</section>
</div>
<div class="col-lg-5 col-xl-4">
<div class="stack-gap">
<section class="surface-panel p-3 p-lg-4">
<div class="stats-grid">
<article class="stat-card">
<span class="stat-label">Score</span>
<strong class="stat-value" id="score-value">0</strong>
</article>
<article class="stat-card">
<span class="stat-label">Lines</span>
<strong class="stat-value" id="lines-value">0</strong>
</article>
<article class="stat-card">
<span class="stat-label">Level</span>
<strong class="stat-value" id="level-value">1</strong>
</article>
<article class="stat-card">
<span class="stat-label">Best</span>
<strong class="stat-value" id="best-value">0</strong>
</article>
</div>
</section>
<section class="surface-panel p-3 p-lg-4">
<div class="row g-3">
<div class="col-6">
<div class="mini-panel">
<div class="mini-label">Next</div>
<canvas id="next-piece" width="120" height="120" aria-label="Next piece"></canvas>
</div>
</div>
<div class="col-6">
<div class="mini-panel">
<div class="mini-label">Hold</div>
<canvas id="hold-piece" width="120" height="120" aria-label="Held piece"></canvas>
</div>
</div>
</div>
</section>
<section class="surface-panel p-3 p-lg-4">
<form id="score-form" class="stack-gap-sm" autocomplete="off">
<label class="mini-label" for="player-name">Name</label>
<div class="d-flex gap-2">
<input class="form-control form-control-dark" id="player-name" name="player_name" type="text" maxlength="32" minlength="2" placeholder="Player" aria-label="Player name">
<button class="btn btn-light flex-shrink-0" id="submit-score-btn" type="submit">Save</button>
</div>
<div class="status-line" id="submission-state">Finish a run, then save.</div>
</form>
</section>
<section class="surface-panel p-3 p-lg-4" id="leaderboard">
<div class="section-head mb-3">
<h2 class="panel-title mb-0">Top 10</h2>
</div>
<div class="leaderboard-list" id="leaderboard-list">
<?php foreach ($topScores as $index => $entry): ?>
<a class="leaderboard-item" href="score.php?id=<?= (int) ($entry['id'] ?? 0) ?>">
<span class="leaderboard-rank">#<?= (int) $index + 1 ?></span>
<span class="leaderboard-player"><?= esc((string) ($entry['player_name'] ?? 'Player')) ?></span>
<span class="leaderboard-meta"><?= number_format((int) ($entry['score'] ?? 0)) ?></span>
</a>
<?php endforeach; ?>
</div>
</section>
</div>
</div>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<script>
window.APP_BOOTSTRAP = {
topScores: <?= json_encode($topScores, JSON_UNESCAPED_UNICODE) ?>,
apiUrl: 'api/scores.php'
};
</script>
<script src="assets/js/main.js?v=<?= urlencode((string) filemtime(__DIR__ . '/assets/js/main.js')) ?>" defer></script>
</body>
</html>

166
lib/tetris_store.php Normal file
View File

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
function tetrisEnsureSchema(): void
{
static $ready = false;
if ($ready) {
return;
}
db()->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),
];
}

170
score.php Normal file
View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lib/tetris_store.php';
$projectName = trim((string) ($_SERVER['PROJECT_NAME'] ?? 'RetroStack'));
$projectDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? 'Classic Tetris-style puzzle gameplay with an online leaderboard.'));
$projectImageUrl = trim((string) ($_SERVER['PROJECT_IMAGE_URL'] ?? ''));
$scoreId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$score = null;
$rank = null;
$errorMessage = null;
$topScores = [];
try {
if ($scoreId > 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');
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= esc($pageTitle) ?></title>
<meta name="description" content="<?= esc($pageDescription) ?>">
<?php if ($projectDescription !== ''): ?>
<meta property="og:description" content="<?= esc($projectDescription) ?>">
<meta property="twitter:description" content="<?= esc($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl !== ''): ?>
<meta property="og:image" content="<?= esc($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= esc($projectImageUrl) ?>">
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= urlencode((string) filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
</head>
<body>
<header class="border-bottom border-secondary-subtle sticky-top app-header">
<nav class="navbar navbar-expand-lg navbar-dark py-3">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="index.php#top">
<span class="brand-mark">RS</span>
<span>
<span class="d-block brand-title">RetroStack</span>
<span class="brand-subtitle">Online score detail</span>
</span>
</a>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-outline-light btn-sm" href="index.php#leaderboard">Leaderboard</a>
<a class="btn btn-light btn-sm" href="index.php#play">Play again</a>
</div>
</div>
</nav>
</header>
<main class="py-5">
<div class="container">
<?php if ($errorMessage): ?>
<div class="alert alert-warning border-0 surface-panel mb-4"><?= esc($errorMessage) ?></div>
<?php endif; ?>
<?php if (!$score): ?>
<section class="surface-panel p-4 p-lg-5 text-center mx-auto" style="max-width: 720px;">
<span class="section-label">Score detail</span>
<h1 class="h2 mt-3">That run could not be found.</h1>
<p class="text-secondary mb-4">Try a fresh round and submit a new score to populate the online leaderboard.</p>
<a class="btn btn-light" href="index.php#play">Return to the game</a>
</section>
<?php else: ?>
<div class="row g-4 align-items-start">
<div class="col-lg-8">
<section class="surface-panel p-4 p-lg-5">
<div class="d-flex flex-wrap gap-3 justify-content-between align-items-start mb-4">
<div>
<span class="section-label">Verified leaderboard run</span>
<h1 class="display-title mt-3 mb-2"><?= esc($score['player_name']) ?></h1>
<p class="text-secondary mb-0">Submitted <?= esc(date('M j, Y  H:i', strtotime((string) $score['created_at']))) ?> UTC</p>
</div>
<div class="score-rank-pill">Rank #<?= (int) $rank ?></div>
</div>
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-label">Score</div>
<div class="metric-value"><?= number_format((int) $score['score']) ?></div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-label">Lines</div>
<div class="metric-value"><?= number_format((int) $score['lines_cleared']) ?></div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-label">Level</div>
<div class="metric-value"><?= number_format((int) $score['level_reached']) ?></div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-label">Duration</div>
<div class="metric-value"><?= number_format((int) $score['duration_seconds']) ?>s</div>
</div>
</div>
</div>
<div class="surface-subpanel p-4">
<h2 class="h5 mb-3">Run notes</h2>
<ul class="detail-list mb-0">
<li>Online leaderboard entries are ordered by score, then lines, then level, then shorter survival time.</li>
<li>This detail page gives each score a shareable destination for friendly competition.</li>
<li>To improve your rank, return to the main board and chase a higher line clear count.</li>
</ul>
</div>
</section>
</div>
<div class="col-lg-4">
<aside class="surface-panel p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<span class="section-label">Top runs</span>
<h2 class="h5 mt-2 mb-0">Leaderboard snapshot</h2>
</div>
<a class="text-decoration-none small-link" href="index.php#leaderboard">View live board</a>
</div>
<?php if (!$topScores): ?>
<p class="text-secondary mb-0">No scores yet. Be the first to submit a run.</p>
<?php else: ?>
<div class="leaderboard-list compact-list">
<?php foreach ($topScores as $index => $entry): ?>
<a class="leaderboard-item <?= (int) $entry['id'] === (int) $score['id'] ? 'is-active' : '' ?>" href="score.php?id=<?= (int) $entry['id'] ?>">
<span class="leaderboard-rank">#<?= $index + 1 ?></span>
<span class="leaderboard-player"><?= esc($entry['player_name']) ?></span>
<span class="leaderboard-meta"><?= number_format((int) $entry['score']) ?> pts</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</aside>
</div>
</div>
<?php endif; ?>
</div>
</main>
</body>
</html>