Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72de1a9601 |
42
api/leaderboard.php
Normal file
42
api/leaderboard.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../tetris_data.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
|
||||||
|
function tetrisApiScoreRow(array $run): array
|
||||||
|
{
|
||||||
|
$timestamp = strtotime((string) ($run['created_at'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) ($run['id'] ?? 0),
|
||||||
|
'player_name' => (string) ($run['player_name'] ?? ''),
|
||||||
|
'score' => (int) ($run['score'] ?? 0),
|
||||||
|
'lines_cleared' => (int) ($run['lines_cleared'] ?? 0),
|
||||||
|
'level_reached' => (int) ($run['level_reached'] ?? 1),
|
||||||
|
'duration_seconds' => (int) ($run['duration_seconds'] ?? 0),
|
||||||
|
'created_at_iso' => $timestamp ? gmdate(DATE_ATOM, $timestamp) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$leaderboard = array_map('tetrisApiScoreRow', tetrisFetchLeaderboard(8));
|
||||||
|
$recentRuns = array_map('tetrisApiScoreRow', tetrisFetchRecent(5));
|
||||||
|
$bestRun = tetrisFetchBestScore();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'updated_at' => gmdate(DATE_ATOM),
|
||||||
|
'best_run' => $bestRun ? tetrisApiScoreRow($bestRun) : null,
|
||||||
|
'leaderboard' => $leaderboard,
|
||||||
|
'recent_runs' => $recentRuns,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Unable to load leaderboard right now.',
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
78
api/rooms.php
Normal file
78
api/rooms.php
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../multiplayer_data.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
|
||||||
|
function roomsApiRespond(array $payload, int $status = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$roomCode = (string) ($_GET['room_code'] ?? '');
|
||||||
|
$room = multiplayerFetchRoomByCode($roomCode);
|
||||||
|
if (!$room) {
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Room not found or expired.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'updated_at' => gmdate(DATE_ATOM),
|
||||||
|
'room' => $room,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Method not allowed.',
|
||||||
|
], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = strtolower(trim((string) ($_POST['action'] ?? '')));
|
||||||
|
if ($action === 'create') {
|
||||||
|
$room = multiplayerCreateRoom((string) ($_POST['player_name'] ?? ''));
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Room created.',
|
||||||
|
'updated_at' => gmdate(DATE_ATOM),
|
||||||
|
'room' => $room,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'join') {
|
||||||
|
$room = multiplayerJoinRoom((string) ($_POST['room_code'] ?? ''), (string) ($_POST['player_name'] ?? ''));
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Joined room.',
|
||||||
|
'updated_at' => gmdate(DATE_ATOM),
|
||||||
|
'room' => $room,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Unknown room action.',
|
||||||
|
], 422);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], 422);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
roomsApiRespond([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Unable to process the room request right now.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
1079
assets/js/main.js
1079
assets/js/main.js
File diff suppressed because it is too large
Load Diff
15
db/migrations/20260325_create_tetris_rooms.sql
Normal file
15
db/migrations/20260325_create_tetris_rooms.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- Step 1 of Tetris 1v1 multiplayer: room lobby storage
|
||||||
|
CREATE TABLE IF NOT EXISTS tetris_rooms (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
room_code VARCHAR(6) NOT NULL,
|
||||||
|
host_name VARCHAR(24) NOT NULL,
|
||||||
|
guest_name VARCHAR(24) DEFAULT NULL,
|
||||||
|
status ENUM('waiting', 'ready', 'closed') NOT NULL DEFAULT 'waiting',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
UNIQUE KEY uniq_room_code (room_code),
|
||||||
|
KEY idx_status (status),
|
||||||
|
KEY idx_expires_at (expires_at),
|
||||||
|
KEY idx_updated_at (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
464
index.php
464
index.php
@ -1,150 +1,346 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@ini_set('display_errors', '1');
|
|
||||||
@error_reporting(E_ALL);
|
|
||||||
@date_default_timezone_set('UTC');
|
@date_default_timezone_set('UTC');
|
||||||
|
|
||||||
$phpVersion = PHP_VERSION;
|
require_once __DIR__ . '/tetris_data.php';
|
||||||
$now = date('Y-m-d H:i:s');
|
|
||||||
|
$projectName = $_SERVER['PROJECT_NAME'] ?? 'Midnight Blocks';
|
||||||
|
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Play classic Tetris in your browser with a dark interface, next piece preview, touch controls, and score saving.';
|
||||||
|
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||||
|
$assetVersion = (string) max((int) @filemtime(__DIR__ . '/assets/css/custom.css'), (int) @filemtime(__DIR__ . '/assets/js/main.js'));
|
||||||
|
|
||||||
|
$leaderboard = [];
|
||||||
|
$recentRuns = [];
|
||||||
|
$bestRun = null;
|
||||||
|
$dbError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$leaderboard = tetrisFetchLeaderboard(8);
|
||||||
|
$recentRuns = tetrisFetchRecent(5);
|
||||||
|
$bestRun = tetrisFetchBestScore();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$dbError = 'Leaderboard storage is temporarily unavailable, but the game is ready to play.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$saved = isset($_GET['saved']) && $_GET['saved'] === '1';
|
||||||
|
$saveError = trim((string) ($_GET['save_error'] ?? ''));
|
||||||
|
$scoreId = isset($_GET['score_id']) ? (int) $_GET['score_id'] : 0;
|
||||||
|
$title = $projectName !== '' ? $projectName : 'Midnight Blocks';
|
||||||
?>
|
?>
|
||||||
<!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><?= htmlspecialchars($title) ?></title>
|
||||||
<?php
|
<meta name="description" content="<?= htmlspecialchars($projectDescription) ?>">
|
||||||
// Read project preview data from environment
|
<meta property="og:title" content="<?= htmlspecialchars($title) ?>">
|
||||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>">
|
||||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
<meta property="twitter:title" content="<?= htmlspecialchars($title) ?>">
|
||||||
?>
|
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>">
|
||||||
<?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): ?>
|
<?php if ($projectImageUrl): ?>
|
||||||
<!-- Open Graph image -->
|
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
|
||||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
|
||||||
<!-- Twitter image -->
|
|
||||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<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="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= urlencode($assetVersion ?: (string) time()) ?>">
|
||||||
<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 class="tetris-app game-focused">
|
||||||
<main>
|
<header class="game-topbar border-bottom border-secondary-subtle">
|
||||||
<div class="card">
|
<div class="container-fluid page-shell">
|
||||||
<h1>Analyzing your requirements and generating your website…</h1>
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 py-3">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<a class="navbar-brand fw-semibold letter-spacing-1 mb-0" href="/">Midnight Blocks</a>
|
||||||
<span class="sr-only">Loading…</span>
|
<div class="d-flex flex-wrap align-items-center gap-2 gap-lg-3">
|
||||||
|
<?php if ($bestRun): ?>
|
||||||
|
<div class="score-chip d-none d-md-block">
|
||||||
|
<span class="score-chip-label">Best</span>
|
||||||
|
<strong><?= number_format((int) $bestRun['score']) ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button class="btn btn-outline-light btn-sm" id="focus-game" type="button">Focus board</button>
|
||||||
|
<button class="btn btn-light btn-sm" id="start-top" type="button">Start</button>
|
||||||
|
<button class="btn btn-outline-light btn-sm" id="restart-inline" type="button">Restart</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="game-page py-4 py-lg-5">
|
||||||
|
<div class="container-fluid page-shell">
|
||||||
|
<?php if ($dbError): ?>
|
||||||
|
<div class="alert alert-warning soft-alert mb-4" role="alert"><?= htmlspecialchars($dbError) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($saved): ?>
|
||||||
|
<div class="alert alert-success soft-alert mb-4" role="alert">Score saved<?= $scoreId > 0 ? ' — run #' . (int) $scoreId : '' ?>.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($saveError !== ''): ?>
|
||||||
|
<div class="alert alert-danger soft-alert mb-4" role="alert"><?= htmlspecialchars($saveError) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-xl-8 col-xxl-9">
|
||||||
|
<section id="game" class="soft-panel board-panel p-3 p-lg-4">
|
||||||
|
<div class="game-layout focus-layout">
|
||||||
|
<div class="board-stage">
|
||||||
|
<div class="board-shell board-shell-large mx-auto">
|
||||||
|
<canvas id="tetris-board" width="300" height="600" tabindex="0" aria-label="Tetris board" role="img"></canvas>
|
||||||
|
<div class="board-start-overlay" id="board-start-overlay">
|
||||||
|
<div class="board-start-card">
|
||||||
|
<span class="metric-label">Ready when you are</span>
|
||||||
|
<button class="btn btn-light btn-lg" id="start-game" type="button">Start game</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-controls" aria-label="On-screen controls">
|
||||||
|
<button class="control-btn" data-action="left" type="button" aria-label="Move left">←</button>
|
||||||
|
<button class="control-btn" data-action="rotate" type="button" aria-label="Rotate piece">⟳</button>
|
||||||
|
<button class="control-btn" data-action="right" type="button" aria-label="Move right">→</button>
|
||||||
|
<button class="control-btn control-btn-wide" data-action="down" type="button" aria-label="Soft drop">Soft drop</button>
|
||||||
|
<button class="control-btn control-btn-wide accent" data-action="drop" type="button" aria-label="Hard drop">Hard drop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="board-sidebar sidebar-stack">
|
||||||
|
<article class="metric-card next-preview-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="metric-label">Next</span>
|
||||||
|
<span class="tiny-muted">Queued</span>
|
||||||
|
</div>
|
||||||
|
<canvas id="next-piece" width="120" height="120" aria-label="Next piece preview" role="img"></canvas>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="quick-stats">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Score</span>
|
||||||
|
<strong class="metric-value" id="score-value">0</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Level</span>
|
||||||
|
<strong class="metric-value" id="level-value">1</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Lines</span>
|
||||||
|
<strong class="metric-value" id="lines-value">0</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Status</span>
|
||||||
|
<strong class="metric-value status-text" id="status-value">Press Start</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="controls" class="soft-subpanel p-3">
|
||||||
|
<div class="keycaps compact-keycaps">
|
||||||
|
<span>← → move</span>
|
||||||
|
<span>↑ rotate</span>
|
||||||
|
<span>↓ soft drop</span>
|
||||||
|
<span>Space hard drop</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-4 col-xxl-3">
|
||||||
|
<div class="d-grid gap-3 sticky-column">
|
||||||
|
<section class="soft-panel p-4" id="multiplayer-room">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<span class="eyebrow">1v1 rooms beta</span>
|
||||||
|
<span class="badge text-bg-dark border border-secondary-subtle px-3 py-2">Step 1</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-secondary room-intro">Create a room, share the code with a friend, and wait for them to join. This step builds the multiplayer lobby and ready-state.</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="room_player_name">Nickname</label>
|
||||||
|
<input class="form-control form-control-dark" id="room_player_name" type="text" maxlength="24" placeholder="Arcade alias" autocomplete="nickname">
|
||||||
|
<div class="form-text">We will reuse this nickname for room invites and score publishing.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-actions mb-3">
|
||||||
|
<button class="btn btn-light" id="create-room-btn" type="button">Create room</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="room_code_input">Join with code</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input class="form-control form-control-dark room-code-input" id="room_code_input" type="text" maxlength="6" placeholder="ABC123" autocomplete="off" spellcheck="false">
|
||||||
|
<button class="btn btn-outline-light" id="join-room-btn" type="button">Join</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state compact room-empty-state" id="room-empty-state">
|
||||||
|
<p class="mb-0 text-secondary">No active room yet. Create one or paste a code from your friend.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="room-state-card d-none" id="room-state-card" aria-live="polite">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<span class="metric-label">Current room</span>
|
||||||
|
<div class="room-code-display" id="current-room-code">------</div>
|
||||||
|
</div>
|
||||||
|
<span class="room-status-badge room-status-waiting" id="room-status-badge">Waiting</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-player-list mb-3">
|
||||||
|
<div class="room-player-line">
|
||||||
|
<span class="tiny-muted">Host</span>
|
||||||
|
<strong id="room-host-name">—</strong>
|
||||||
|
</div>
|
||||||
|
<div class="room-player-line">
|
||||||
|
<span class="tiny-muted">Guest</span>
|
||||||
|
<strong id="room-guest-name">Waiting for friend…</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-meta-grid mb-3">
|
||||||
|
<div class="metric-card compact">
|
||||||
|
<span class="metric-label">Players</span>
|
||||||
|
<strong class="metric-value" id="room-player-count">1 / 2</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card compact">
|
||||||
|
<span class="metric-label">Updated</span>
|
||||||
|
<strong class="metric-value room-meta-value" id="room-updated-at">just now</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="room-message mb-3" id="room-message">Share the code with your friend. The lobby will switch to ready once they join.</p>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-outline-light btn-sm" id="copy-room-code-btn" type="button">Copy code</button>
|
||||||
|
<button class="btn btn-outline-light btn-sm" id="refresh-room-status-btn" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="soft-panel p-4" id="save-score">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<span class="eyebrow">Online leaderboard</span>
|
||||||
|
<span class="badge text-bg-dark border border-secondary-subtle px-3 py-2">Public nickname</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save-state <?= $saved ? 'd-none' : '' ?>" id="save-placeholder">
|
||||||
|
<p class="mb-0 text-secondary">Finish a run to publish your score online.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="save-form d-none" id="save-form" action="/save_score.php" method="post" novalidate>
|
||||||
|
<div class="run-summary mb-3" id="run-summary">No completed run yet.</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="player_name">Nickname</label>
|
||||||
|
<input class="form-control form-control-dark" id="player_name" name="player_name" type="text" maxlength="24" placeholder="Arcade alias" autocomplete="nickname" required>
|
||||||
|
<div class="form-text">This nickname is public and will be remembered on this device.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="final_score">Score</label>
|
||||||
|
<input class="form-control form-control-dark" id="final_score" type="text" value="0" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="final_lines">Lines</label>
|
||||||
|
<input class="form-control form-control-dark" id="final_lines" type="text" value="0" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="final_level">Level</label>
|
||||||
|
<input class="form-control form-control-dark" id="final_level" type="text" value="1" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="final_duration">Time</label>
|
||||||
|
<input class="form-control form-control-dark" id="final_duration" type="text" value="0s" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="score" id="score-input" value="0">
|
||||||
|
<input type="hidden" name="lines_cleared" id="lines-input" value="0">
|
||||||
|
<input type="hidden" name="level_reached" id="level-input" value="1">
|
||||||
|
<input type="hidden" name="duration_seconds" id="duration-input" value="0">
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-light" type="submit">Publish score</button>
|
||||||
|
<button class="btn btn-outline-light" type="button" id="restart-after-game">New run</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="soft-panel p-4 leaderboard-compact" id="leaderboard">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<span class="eyebrow">Online top scores</span>
|
||||||
|
<span class="tiny-muted online-status"><span class="online-dot"></span><span id="leaderboard-refresh-label">Live sync</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="leaderboard-meta mb-3">
|
||||||
|
<span class="tiny-muted" id="best-player-label"><?php if ($bestRun): ?>Best: <?= htmlspecialchars($bestRun['player_name']) ?><?php else: ?>Waiting for first score<?php endif; ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive <?= !$leaderboard ? 'd-none' : '' ?>" id="leaderboard-table-wrap">
|
||||||
|
<table class="table table-dark table-hover align-middle leaderboard-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col" class="text-end">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="leaderboard-body">
|
||||||
|
<?php foreach ($leaderboard as $index => $run): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $index + 1 ?></td>
|
||||||
|
<td>
|
||||||
|
<a class="table-link" href="/score.php?id=<?= (int) $run['id'] ?>"><?= htmlspecialchars($run['player_name']) ?></a>
|
||||||
|
</td>
|
||||||
|
<td class="text-end fw-semibold"><?= number_format((int) $run['score']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state compact <?= $leaderboard ? 'd-none' : '' ?>" id="leaderboard-empty-state">
|
||||||
|
<p class="text-secondary mb-0">No online scores yet.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="soft-panel p-4" id="recent-activity">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<span class="eyebrow">Recent players</span>
|
||||||
|
<span class="tiny-muted">Latest runs</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recent-feed <?= !$recentRuns ? 'd-none' : '' ?>" id="recent-runs-list">
|
||||||
|
<?php foreach ($recentRuns as $run): ?>
|
||||||
|
<a class="recent-item" href="/score.php?id=<?= (int) $run['id'] ?>">
|
||||||
|
<div>
|
||||||
|
<strong><?= htmlspecialchars($run['player_name']) ?></strong>
|
||||||
|
<div class="recent-item-meta">
|
||||||
|
<?= number_format((int) $run['score']) ?> pts · <?= number_format((int) $run['lines_cleared']) ?> lines
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="tiny-muted"><?= htmlspecialchars(date('H:i', strtotime((string) $run['created_at']))) ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state compact <?= $recentRuns ? 'd-none' : '' ?>" id="recent-empty-state">
|
||||||
|
<p class="text-secondary mb-0">No recent online runs yet.</p>
|
||||||
|
</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)
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toast-container"></div>
|
||||||
</footer>
|
|
||||||
|
<script>
|
||||||
|
window.TETRIS_PAGE = {
|
||||||
|
saved: <?= $saved ? 'true' : 'false' ?>,
|
||||||
|
scoreId: <?= $scoreId ?>,
|
||||||
|
saveError: <?= json_encode($saveError, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
|
||||||
|
leaderboardApi: '/api/leaderboard.php',
|
||||||
|
roomsApi: '/api/rooms.php'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
|
||||||
|
<script src="/assets/js/main.js?v=<?= urlencode($assetVersion ?: (string) time()) ?>" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
217
multiplayer_data.php
Normal file
217
multiplayer_data.php
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db/config.php';
|
||||||
|
|
||||||
|
const TETRIS_ROOM_CODE_LENGTH = 6;
|
||||||
|
const TETRIS_ROOM_TTL_HOURS = 24;
|
||||||
|
|
||||||
|
function multiplayerEnsureSchema(): void
|
||||||
|
{
|
||||||
|
static $ready = false;
|
||||||
|
if ($ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db()->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS tetris_rooms (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
room_code VARCHAR(6) NOT NULL,
|
||||||
|
host_name VARCHAR(24) NOT NULL,
|
||||||
|
guest_name VARCHAR(24) DEFAULT NULL,
|
||||||
|
status ENUM('waiting', 'ready', 'closed') NOT NULL DEFAULT 'waiting',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
UNIQUE KEY uniq_room_code (room_code),
|
||||||
|
KEY idx_status (status),
|
||||||
|
KEY idx_expires_at (expires_at),
|
||||||
|
KEY idx_updated_at (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplayerSanitizePlayerName(string $value): string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') {
|
||||||
|
throw new InvalidArgumentException('Enter your nickname first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^[\p{L}\p{N} _.-]+$/u', $value)) {
|
||||||
|
throw new InvalidArgumentException('Nickname can use letters, numbers, spaces, dots, dashes, and underscores only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
||||||
|
if ($length > 24) {
|
||||||
|
throw new InvalidArgumentException('Nickname must be 24 characters or fewer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplayerNormalizeRoomCode(string $value): string
|
||||||
|
{
|
||||||
|
$value = strtoupper(preg_replace('/[^A-Z0-9]+/', '', $value) ?? '');
|
||||||
|
if ($value === '') {
|
||||||
|
throw new InvalidArgumentException('Enter a room code.');
|
||||||
|
}
|
||||||
|
if (strlen($value) !== TETRIS_ROOM_CODE_LENGTH) {
|
||||||
|
throw new InvalidArgumentException('Room code must be exactly 6 characters.');
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplayerFormatRoom(array $room): array
|
||||||
|
{
|
||||||
|
$createdAt = strtotime((string) ($room['created_at'] ?? ''));
|
||||||
|
$updatedAt = strtotime((string) ($room['updated_at'] ?? ''));
|
||||||
|
$expiresAt = strtotime((string) ($room['expires_at'] ?? ''));
|
||||||
|
$guestName = isset($room['guest_name']) ? trim((string) $room['guest_name']) : '';
|
||||||
|
$status = (string) ($room['status'] ?? 'waiting');
|
||||||
|
$playerCount = $guestName !== '' ? 2 : 1;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) ($room['id'] ?? 0),
|
||||||
|
'room_code' => (string) ($room['room_code'] ?? ''),
|
||||||
|
'host_name' => (string) ($room['host_name'] ?? ''),
|
||||||
|
'guest_name' => $guestName !== '' ? $guestName : null,
|
||||||
|
'status' => $status,
|
||||||
|
'player_count' => $playerCount,
|
||||||
|
'is_ready' => $status === 'ready' && $playerCount >= 2,
|
||||||
|
'created_at_iso' => $createdAt ? gmdate(DATE_ATOM, $createdAt) : null,
|
||||||
|
'updated_at_iso' => $updatedAt ? gmdate(DATE_ATOM, $updatedAt) : null,
|
||||||
|
'expires_at_iso' => $expiresAt ? gmdate(DATE_ATOM, $expiresAt) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplayerFetchRoomByCode(string $roomCode): ?array
|
||||||
|
{
|
||||||
|
multiplayerEnsureSchema();
|
||||||
|
$roomCode = multiplayerNormalizeRoomCode($roomCode);
|
||||||
|
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at
|
||||||
|
FROM tetris_rooms
|
||||||
|
WHERE room_code = :room_code
|
||||||
|
AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP())
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
||||||
|
$stmt->execute();
|
||||||
|
$room = $stmt->fetch();
|
||||||
|
|
||||||
|
return $room ? multiplayerFormatRoom($room) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplayerGenerateRoomCode(): string
|
||||||
|
{
|
||||||
|
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
$maxIndex = strlen($alphabet) - 1;
|
||||||
|
$roomCode = '';
|
||||||
|
|
||||||
|
for ($i = 0; $i < TETRIS_ROOM_CODE_LENGTH; $i += 1) {
|
||||||
|
$roomCode .= $alphabet[random_int(0, $maxIndex)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roomCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplayerCreateRoom(string $hostName): array
|
||||||
|
{
|
||||||
|
multiplayerEnsureSchema();
|
||||||
|
$hostName = multiplayerSanitizePlayerName($hostName);
|
||||||
|
|
||||||
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600));
|
||||||
|
|
||||||
|
for ($attempt = 0; $attempt < 12; $attempt += 1) {
|
||||||
|
$roomCode = multiplayerGenerateRoomCode();
|
||||||
|
try {
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'INSERT INTO tetris_rooms (room_code, host_name, status, expires_at)
|
||||||
|
VALUES (:room_code, :host_name, :status, :expires_at)'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':host_name', $hostName, PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':status', 'waiting', PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$room = multiplayerFetchRoomByCode($roomCode);
|
||||||
|
if ($room) {
|
||||||
|
return $room;
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ((string) $e->getCode() !== '23000') {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Unable to create a room right now. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplayerJoinRoom(string $roomCode, string $guestName): array
|
||||||
|
{
|
||||||
|
multiplayerEnsureSchema();
|
||||||
|
$roomCode = multiplayerNormalizeRoomCode($roomCode);
|
||||||
|
$guestName = multiplayerSanitizePlayerName($guestName);
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at
|
||||||
|
FROM tetris_rooms
|
||||||
|
WHERE room_code = :room_code
|
||||||
|
AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP())
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
||||||
|
$stmt->execute();
|
||||||
|
$room = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$room) {
|
||||||
|
throw new InvalidArgumentException('Room not found or already expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingGuest = trim((string) ($room['guest_name'] ?? ''));
|
||||||
|
if ($existingGuest !== '' && strcasecmp($existingGuest, $guestName) !== 0) {
|
||||||
|
throw new InvalidArgumentException('This room already has two players.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingGuest === '') {
|
||||||
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600));
|
||||||
|
$update = $pdo->prepare(
|
||||||
|
'UPDATE tetris_rooms
|
||||||
|
SET guest_name = :guest_name,
|
||||||
|
status = :status,
|
||||||
|
expires_at = :expires_at
|
||||||
|
WHERE id = :id'
|
||||||
|
);
|
||||||
|
$update->bindValue(':guest_name', $guestName, PDO::PARAM_STR);
|
||||||
|
$update->bindValue(':status', 'ready', PDO::PARAM_STR);
|
||||||
|
$update->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR);
|
||||||
|
$update->bindValue(':id', (int) $room['id'], PDO::PARAM_INT);
|
||||||
|
$update->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$freshRoom = multiplayerFetchRoomByCode($roomCode);
|
||||||
|
if (!$freshRoom) {
|
||||||
|
throw new RuntimeException('Room state could not be loaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $freshRoom;
|
||||||
|
}
|
||||||
26
save_score.php
Normal file
26
save_score.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/tetris_data.php';
|
||||||
|
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
header('Location: /');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$scoreId = tetrisSaveScore([
|
||||||
|
'player_name' => $_POST['player_name'] ?? '',
|
||||||
|
'score' => $_POST['score'] ?? 0,
|
||||||
|
'lines_cleared' => $_POST['lines_cleared'] ?? 0,
|
||||||
|
'level_reached' => $_POST['level_reached'] ?? 1,
|
||||||
|
'duration_seconds' => $_POST['duration_seconds'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
header('Location: /?saved=1&score_id=' . $scoreId . '#leaderboard');
|
||||||
|
exit;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$message = rawurlencode($e instanceof InvalidArgumentException ? $e->getMessage() : 'Unable to save the score right now.');
|
||||||
|
header('Location: /?save_error=' . $message . '#save-score');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
139
score.php
Normal file
139
score.php
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
@date_default_timezone_set('UTC');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/tetris_data.php';
|
||||||
|
|
||||||
|
$projectName = $_SERVER['PROJECT_NAME'] ?? 'Midnight Blocks';
|
||||||
|
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Classic in-browser Tetris with a dark interface, leaderboard, and recent runs.';
|
||||||
|
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||||
|
$assetVersion = (string) @filemtime(__DIR__ . '/assets/css/custom.css');
|
||||||
|
|
||||||
|
$scoreId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||||
|
$score = null;
|
||||||
|
$dbError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($scoreId > 0) {
|
||||||
|
$score = tetrisFetchScoreById($scoreId);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$dbError = 'Leaderboard data is temporarily unavailable.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $score ? $score['player_name'] . ' run · ' . $projectName : 'Run not found · ' . $projectName;
|
||||||
|
$metaDescription = $score
|
||||||
|
? sprintf('%s scored %d points with %d cleared lines in Midnight Blocks.', $score['player_name'], $score['score'], $score['lines_cleared'])
|
||||||
|
: $projectDescription;
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars($title) ?></title>
|
||||||
|
<meta name="description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||||
|
<meta property="og:title" content="<?= htmlspecialchars($title) ?>">
|
||||||
|
<meta property="og:description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||||
|
<meta property="twitter:title" content="<?= htmlspecialchars($title) ?>">
|
||||||
|
<meta property="twitter:description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||||
|
<?php if ($projectImageUrl): ?>
|
||||||
|
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
|
||||||
|
<meta property="twitter:image" content="<?= htmlspecialchars($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($assetVersion ?: (string) time()) ?>">
|
||||||
|
</head>
|
||||||
|
<body class="tetris-app">
|
||||||
|
<header class="border-bottom border-secondary-subtle bg-body-tertiary bg-opacity-10">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark py-3">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand fw-semibold letter-spacing-1" href="/">Midnight Blocks</a>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<a class="btn btn-outline-light btn-sm" href="/#game">Play again</a>
|
||||||
|
<a class="btn btn-light btn-sm" href="/#leaderboard">Leaderboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-xl-8">
|
||||||
|
<?php if ($dbError): ?>
|
||||||
|
<div class="alert alert-warning border-0 soft-panel mb-4"><?= htmlspecialchars($dbError) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!$score): ?>
|
||||||
|
<section class="soft-panel p-4 p-lg-5 text-center">
|
||||||
|
<span class="eyebrow">Run detail</span>
|
||||||
|
<h1 class="display-6 fw-semibold mt-3 mb-2">That score could not be found.</h1>
|
||||||
|
<p class="text-secondary mb-4">Try another run from the leaderboard or start a fresh game.</p>
|
||||||
|
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-light" href="/">Back to game</a>
|
||||||
|
<a class="btn btn-outline-light" href="/#leaderboard">Browse leaderboard</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php else: ?>
|
||||||
|
<section class="soft-panel p-4 p-lg-5 mb-4">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Saved run</span>
|
||||||
|
<h1 class="display-6 fw-semibold mt-3 mb-2"><?= htmlspecialchars($score['player_name']) ?>’s session</h1>
|
||||||
|
<p class="text-secondary mb-0">Recorded <?= htmlspecialchars(date('M j, Y H:i', strtotime((string) $score['created_at']))) ?> UTC.</p>
|
||||||
|
</div>
|
||||||
|
<div class="score-chip score-chip-lg text-end">
|
||||||
|
<span class="score-chip-label">Score</span>
|
||||||
|
<strong><?= number_format((int) $score['score']) ?></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="metric-card h-100">
|
||||||
|
<span class="metric-label">Lines cleared</span>
|
||||||
|
<strong class="metric-value"><?= number_format((int) $score['lines_cleared']) ?></strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="metric-card h-100">
|
||||||
|
<span class="metric-label">Level reached</span>
|
||||||
|
<strong class="metric-value"><?= number_format((int) $score['level_reached']) ?></strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="metric-card h-100">
|
||||||
|
<span class="metric-label">Session length</span>
|
||||||
|
<strong class="metric-value"><?= number_format((int) $score['duration_seconds']) ?>s</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="soft-panel p-4 p-lg-5">
|
||||||
|
<span class="eyebrow">Breakdown</span>
|
||||||
|
<div class="row g-4 mt-1 align-items-start">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<h2 class="h4 fw-semibold mb-3">What this run means</h2>
|
||||||
|
<ul class="detail-list mb-0">
|
||||||
|
<li>High score value reflects line clears plus soft and hard drop bonuses.</li>
|
||||||
|
<li>Levels increase every 10 cleared lines, which accelerates the drop speed.</li>
|
||||||
|
<li>Each saved run can be revisited from the leaderboard for quick comparison.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="metric-card h-100">
|
||||||
|
<span class="metric-label">Next challenge</span>
|
||||||
|
<p class="text-secondary mb-3">Beat this run by improving stacking efficiency and chaining doubles or tetrises.</p>
|
||||||
|
<a class="btn btn-light w-100" href="/">Start a new run</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
119
tetris_data.php
Normal file
119
tetris_data.php
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<?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 AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
player_name VARCHAR(24) NOT NULL,
|
||||||
|
score INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
lines_cleared INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
level_reached INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
duration_seconds INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_score (score DESC, lines_cleared DESC),
|
||||||
|
INDEX idx_created_at (created_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
|
||||||
|
$ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tetrisFetchLeaderboard(int $limit = 10): array
|
||||||
|
{
|
||||||
|
tetrisEnsureSchema();
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at
|
||||||
|
FROM tetris_scores
|
||||||
|
ORDER BY score DESC, lines_cleared DESC, duration_seconds ASC, id ASC
|
||||||
|
LIMIT :limit'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tetrisFetchRecent(int $limit = 8): array
|
||||||
|
{
|
||||||
|
tetrisEnsureSchema();
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at
|
||||||
|
FROM tetris_scores
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT :limit'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tetrisFetchScoreById(int $id): ?array
|
||||||
|
{
|
||||||
|
tetrisEnsureSchema();
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at
|
||||||
|
FROM tetris_scores
|
||||||
|
WHERE id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$score = $stmt->fetch();
|
||||||
|
|
||||||
|
return $score ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tetrisFetchBestScore(): ?array
|
||||||
|
{
|
||||||
|
$scores = tetrisFetchLeaderboard(1);
|
||||||
|
return $scores[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tetrisSaveScore(array $input): int
|
||||||
|
{
|
||||||
|
tetrisEnsureSchema();
|
||||||
|
|
||||||
|
$name = trim((string) ($input['player_name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
throw new InvalidArgumentException('Enter your name to save the run.');
|
||||||
|
}
|
||||||
|
$nameLength = function_exists('mb_strlen') ? mb_strlen($name) : strlen($name);
|
||||||
|
if ($nameLength > 24) {
|
||||||
|
throw new InvalidArgumentException('Name must be 24 characters or fewer.');
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[\p{L}\p{N} _.-]+$/u', $name)) {
|
||||||
|
throw new InvalidArgumentException('Use letters, numbers, spaces, dots, dashes, or underscores only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$score = max(0, min(999999, (int) ($input['score'] ?? 0)));
|
||||||
|
$lines = max(0, min(9999, (int) ($input['lines_cleared'] ?? 0)));
|
||||||
|
$level = max(1, min(999, (int) ($input['level_reached'] ?? 1)));
|
||||||
|
$duration = max(0, min(86400, (int) ($input['duration_seconds'] ?? 0)));
|
||||||
|
|
||||||
|
if ($score === 0 && $lines === 0) {
|
||||||
|
throw new InvalidArgumentException('Finish a run before saving to the leaderboard.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'INSERT INTO tetris_scores (player_name, score, lines_cleared, level_reached, duration_seconds)
|
||||||
|
VALUES (:player_name, :score, :lines_cleared, :level_reached, :duration_seconds)'
|
||||||
|
);
|
||||||
|
$stmt->bindValue(':player_name', $name, PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':score', $score, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lines_cleared', $lines, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':level_reached', $level, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':duration_seconds', $duration, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return (int) db()->lastInsertId();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user