0
This commit is contained in:
parent
67fa0fbe3f
commit
9191afc91a
63
api/scores.php
Normal file
63
api/scores.php
Normal 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.']);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
264
index.php
@ -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
166
lib/tetris_store.php
Normal 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
170
score.php
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user