347 lines
18 KiB
PHP
347 lines
18 KiB
PHP
<?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'] ?? '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>
|
|
<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($projectDescription) ?>">
|
|
<meta property="og:title" content="<?= htmlspecialchars($title) ?>">
|
|
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>">
|
|
<meta property="twitter:title" content="<?= htmlspecialchars($title) ?>">
|
|
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>">
|
|
<?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 game-focused">
|
|
<header class="game-topbar border-bottom border-secondary-subtle">
|
|
<div class="container-fluid page-shell">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 py-3">
|
|
<a class="navbar-brand fw-semibold letter-spacing-1 mb-0" href="/">Midnight Blocks</a>
|
|
<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>
|
|
</main>
|
|
|
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toast-container"></div>
|
|
|
|
<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>
|
|
</html>
|