Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 {
|
body {
|
||||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
position: relative;
|
||||||
background-size: 400% 400%;
|
|
||||||
animation: gradient 15s ease infinite;
|
|
||||||
color: #212529;
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
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 {
|
body::before {
|
||||||
display: flex;
|
content: "";
|
||||||
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 {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
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;
|
position: absolute;
|
||||||
width: 500px;
|
inset: 0;
|
||||||
height: 500px;
|
border-radius: inherit;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
pointer-events: none;
|
||||||
border-radius: 50%;
|
background: linear-gradient(135deg, rgba(72, 231, 255, 0.08), transparent 35%, rgba(255, 79, 216, 0.08));
|
||||||
filter: blur(80px);
|
opacity: 0.9;
|
||||||
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob-1 {
|
.surface-panel > * {
|
||||||
top: -10%;
|
|
||||||
left: -10%;
|
|
||||||
background: rgba(238, 119, 82, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-2 {
|
|
||||||
bottom: -10%;
|
|
||||||
right: -10%;
|
|
||||||
background: rgba(35, 166, 213, 0.4);
|
|
||||||
animation-delay: -7s;
|
|
||||||
width: 600px;
|
|
||||||
height: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-3 {
|
|
||||||
top: 40%;
|
|
||||||
left: 30%;
|
|
||||||
background: rgba(231, 60, 126, 0.3);
|
|
||||||
animation-delay: -14s;
|
|
||||||
width: 450px;
|
|
||||||
height: 450px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes move {
|
|
||||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
|
||||||
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
|
|
||||||
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
|
|
||||||
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-link {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-link:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin Styles */
|
|
||||||
.admin-container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 3rem auto;
|
|
||||||
padding: 2.5rem;
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-container h1 {
|
.app-topbar {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
|
||||||
|
|
||||||
.header-links {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-card {
|
.eyebrow,
|
||||||
background: rgba(255, 255, 255, 0.6);
|
.section-label,
|
||||||
padding: 2rem;
|
.brand-subtitle {
|
||||||
border-radius: 20px;
|
margin: 0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
color: var(--accent);
|
||||||
margin-bottom: 2.5rem;
|
font-size: 0.72rem;
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-card h3 {
|
.app-title,
|
||||||
margin-top: 0;
|
.display-title,
|
||||||
margin-bottom: 1.5rem;
|
.brand-title {
|
||||||
font-weight: 700;
|
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 {
|
.topbar-actions {
|
||||||
background: #dc3545;
|
display: flex;
|
||||||
color: white;
|
gap: 0.75rem;
|
||||||
border: none;
|
flex-wrap: wrap;
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add {
|
.board-wrap {
|
||||||
background: #212529;
|
display: flex;
|
||||||
color: white;
|
justify-content: center;
|
||||||
border: none;
|
}
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 4px;
|
.board-frame {
|
||||||
cursor: pointer;
|
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;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save {
|
.stack-gap-sm > * + * {
|
||||||
background: #0088cc;
|
margin-top: 0.75rem;
|
||||||
color: white;
|
}
|
||||||
border: none;
|
|
||||||
padding: 0.8rem 1.5rem;
|
.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;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
min-height: 46px;
|
||||||
font-weight: 600;
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||||
width: 100%;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.webhook-url {
|
.form-control-dark::placeholder {
|
||||||
font-size: 0.85em;
|
color: #6e90a7;
|
||||||
color: #555;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-container {
|
.form-control-dark:focus {
|
||||||
overflow-x: auto;
|
background: rgba(9, 20, 38, 0.98);
|
||||||
background: rgba(255, 255, 255, 0.4);
|
color: var(--text);
|
||||||
padding: 1rem;
|
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-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 {
|
.btn:hover,
|
||||||
|
.btn:focus-visible,
|
||||||
|
.control-btn:hover,
|
||||||
|
.control-btn:focus-visible,
|
||||||
|
.leaderboard-item:hover,
|
||||||
|
.small-link:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-table-time {
|
@media (max-width: 575.98px) {
|
||||||
width: 15%;
|
.board-frame {
|
||||||
white-space: nowrap;
|
padding: 0.75rem;
|
||||||
font-size: 0.85em;
|
}
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table-user {
|
.mobile-controls {
|
||||||
width: 35%;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
background: rgba(255, 255, 255, 0.3);
|
}
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table-ai {
|
.control-btn.wide {
|
||||||
width: 50%;
|
grid-column: span 1;
|
||||||
background: rgba(255, 255, 255, 0.5);
|
}
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-messages {
|
|
||||||
text-align: center;
|
|
||||||
color: #777;
|
|
||||||
}
|
}
|
||||||
@ -1,39 +1,887 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
(() => {
|
||||||
const chatForm = document.getElementById('chat-form');
|
const bootstrapData = window.APP_BOOTSTRAP || {};
|
||||||
const chatInput = document.getElementById('chat-input');
|
const apiUrl = bootstrapData.apiUrl || 'api/scores.php';
|
||||||
const chatMessages = document.getElementById('chat-messages');
|
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) => {
|
if (!boardCanvas) {
|
||||||
const msgDiv = document.createElement('div');
|
return;
|
||||||
msgDiv.classList.add('message', sender);
|
}
|
||||||
msgDiv.textContent = text;
|
|
||||||
chatMessages.appendChild(msgDiv);
|
const ctx = boardCanvas.getContext('2d');
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
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';
|
||||||
|
|
||||||
|
boardCanvas.width = COLS * BLOCK;
|
||||||
|
boardCanvas.height = ROWS * BLOCK;
|
||||||
|
if (nextCanvas) {
|
||||||
|
nextCanvas.width = 120;
|
||||||
|
nextCanvas.height = 120;
|
||||||
|
}
|
||||||
|
if (holdCanvas) {
|
||||||
|
holdCanvas.width = 120;
|
||||||
|
holdCanvas.height = 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
I: '#39f6ff',
|
||||||
|
J: '#3d8bff',
|
||||||
|
L: '#ff9a3d',
|
||||||
|
O: '#ffe45c',
|
||||||
|
S: '#33ffb5',
|
||||||
|
T: '#ff4fd8',
|
||||||
|
Z: '#ff5f7a'
|
||||||
};
|
};
|
||||||
|
|
||||||
chatForm.addEventListener('submit', async (e) => {
|
const shapes = {
|
||||||
e.preventDefault();
|
I: [[1, 1, 1, 1]],
|
||||||
const message = chatInput.value.trim();
|
J: [[1, 0, 0], [1, 1, 1]],
|
||||||
if (!message) return;
|
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]]
|
||||||
|
};
|
||||||
|
|
||||||
appendMessage(message, 'visitor');
|
const genericKicks = [
|
||||||
chatInput.value = '';
|
[0, 0],
|
||||||
|
[-1, 0],
|
||||||
|
[1, 0],
|
||||||
|
[0, -1],
|
||||||
|
[-2, 0],
|
||||||
|
[2, 0],
|
||||||
|
[0, -2]
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
const audio = {
|
||||||
const response = await fetch('api/chat.php', {
|
enabled: false,
|
||||||
method: 'POST',
|
ctx: null,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
play(type) {
|
||||||
body: JSON.stringify({ message })
|
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);
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
pauseButton?.addEventListener('click', togglePause);
|
||||||
|
// resetButton?.addEventListener('click', () => startGame(true));
|
||||||
|
// soundButton?.addEventListener('click', toggleSound);
|
||||||
|
// refreshBoardButton?.addEventListener('click', () => fetchLeaderboard(true));
|
||||||
|
scoreForm?.addEventListener('submit', submitScore);
|
||||||
|
|
||||||
// Artificial delay for realism
|
controls.forEach((button) => {
|
||||||
setTimeout(() => {
|
const action = () => handleControl(button.dataset.control || '');
|
||||||
appendMessage(data.reply, 'bot');
|
button.addEventListener('click', action);
|
||||||
}, 500);
|
button.addEventListener('touchstart', (event) => {
|
||||||
} catch (error) {
|
event.preventDefault();
|
||||||
console.error('Error:', error);
|
action();
|
||||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
}, { 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
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@ini_set('display_errors', '1');
|
|
||||||
@error_reporting(E_ALL);
|
|
||||||
@date_default_timezone_set('UTC');
|
|
||||||
|
|
||||||
$phpVersion = PHP_VERSION;
|
require_once __DIR__ . '/lib/tetris_store.php';
|
||||||
$now = date('Y-m-d H:i:s');
|
|
||||||
|
$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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>New Style</title>
|
<title><?= esc($projectName !== '' ? $projectName : 'RetroStack') ?> — Play Tetris Online</title>
|
||||||
<?php
|
<meta name="description" content="Play a clean browser Tetris game with score tracking, next and hold preview, and an online leaderboard.">
|
||||||
// Read project preview data from environment
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
<link rel="stylesheet" href="assets/css/custom.css?v=<?= urlencode((string) filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
|
||||||
$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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main class="app-shell py-4 py-lg-5">
|
||||||
<div class="card">
|
<div class="container">
|
||||||
<h1>Analyzing your requirements and generating your website…</h1>
|
<header class="app-topbar surface-panel mb-4 p-3 p-lg-4">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div>
|
||||||
<span class="sr-only">Loading…</span>
|
<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>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
|
||||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
<script>
|
||||||
</footer>
|
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>
|
</body>
|
||||||
</html>
|
</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