Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a199bfeb2 | ||
|
|
342ba02e74 | ||
|
|
373f8b6ba0 |
493
api/multiplayer.php
Normal file
493
api/multiplayer.php
Normal file
@ -0,0 +1,493 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
require_once __DIR__ . '/../db/migrate.php';
|
||||
|
||||
run_migrations();
|
||||
cleanupMultiplayerState();
|
||||
|
||||
try {
|
||||
$payload = getRequestPayload();
|
||||
$action = strtolower((string) ($payload['action'] ?? $_GET['action'] ?? ''));
|
||||
|
||||
if ($action === '') {
|
||||
jsonResponse(['success' => false, 'error' => 'Missing action.'], 400);
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'create_room':
|
||||
ensurePostRequest();
|
||||
createRoom($payload);
|
||||
break;
|
||||
|
||||
case 'join_room':
|
||||
ensurePostRequest();
|
||||
joinRoom($payload);
|
||||
break;
|
||||
|
||||
case 'update_state':
|
||||
ensurePostRequest();
|
||||
updatePlayerState($payload);
|
||||
break;
|
||||
|
||||
case 'get_state':
|
||||
getRoomState($payload);
|
||||
break;
|
||||
|
||||
case 'leave_room':
|
||||
ensurePostRequest();
|
||||
leaveRoom($payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['success' => false, 'error' => 'Unsupported action.'], 400);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Multiplayer API error: ' . $exception->getMessage());
|
||||
jsonResponse(['success' => false, 'error' => 'Internal server error.'], 500);
|
||||
}
|
||||
|
||||
function getRequestPayload(): array
|
||||
{
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$jsonBody = json_decode($rawBody ?: '', true);
|
||||
|
||||
if (is_array($jsonBody)) {
|
||||
return $jsonBody;
|
||||
}
|
||||
|
||||
if (!empty($_POST)) {
|
||||
return $_POST;
|
||||
}
|
||||
|
||||
return $_GET;
|
||||
}
|
||||
|
||||
function ensurePostRequest(): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
jsonResponse(['success' => false, 'error' => 'POST required.'], 405);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(array $payload, int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function normalizeDisplayName(?string $displayName, int $slot = 0): string
|
||||
{
|
||||
$name = trim((string) $displayName);
|
||||
$name = preg_replace('/\s+/', ' ', $name ?? '') ?? '';
|
||||
|
||||
if ($name === '') {
|
||||
return $slot > 0 ? 'Player ' . $slot : 'Player';
|
||||
}
|
||||
|
||||
$name = function_exists('mb_substr') ? mb_substr($name, 0, 48) : substr($name, 0, 48);
|
||||
$name = strip_tags($name);
|
||||
return trim($name) !== '' ? $name : ($slot > 0 ? 'Player ' . $slot : 'Player');
|
||||
}
|
||||
|
||||
function normalizeRoomCode(?string $roomCode): string
|
||||
{
|
||||
return strtoupper(preg_replace('/[^A-Z0-9]/', '', (string) $roomCode) ?? '');
|
||||
}
|
||||
|
||||
function generateRoomCode(PDO $pdo): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$checkStmt = $pdo->prepare('SELECT 1 FROM tetris_rooms WHERE room_code = :room_code LIMIT 1');
|
||||
|
||||
for ($attempt = 0; $attempt < 12; $attempt++) {
|
||||
$code = '';
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
||||
}
|
||||
|
||||
$checkStmt->bindValue(':room_code', $code, PDO::PARAM_STR);
|
||||
$checkStmt->execute();
|
||||
if (!$checkStmt->fetchColumn()) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Could not allocate a unique room code.');
|
||||
}
|
||||
|
||||
function generatePlayerToken(): string
|
||||
{
|
||||
return bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
function cleanupMultiplayerState(): void
|
||||
{
|
||||
$pdo = db();
|
||||
$pdo->exec("UPDATE tetris_room_players SET connection_status = 'disconnected' WHERE last_seen_at < (UTC_TIMESTAMP() - INTERVAL 2 MINUTE)");
|
||||
$pdo->exec("UPDATE tetris_rooms SET status = 'closed' WHERE updated_at < (UTC_TIMESTAMP() - INTERVAL 12 HOUR)");
|
||||
}
|
||||
|
||||
function createRoom(array $payload): void
|
||||
{
|
||||
$pdo = db();
|
||||
$roomCode = generateRoomCode($pdo);
|
||||
$playerToken = generatePlayerToken();
|
||||
$displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 1);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$insertRoom = $pdo->prepare('INSERT INTO tetris_rooms (room_code, status, expires_at) VALUES (:room_code, :status, (UTC_TIMESTAMP() + INTERVAL 12 HOUR))');
|
||||
$insertRoom->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
||||
$insertRoom->bindValue(':status', 'waiting', PDO::PARAM_STR);
|
||||
$insertRoom->execute();
|
||||
|
||||
$roomId = (int) $pdo->lastInsertId();
|
||||
|
||||
$insertPlayer = $pdo->prepare(
|
||||
'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) '
|
||||
. 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())'
|
||||
);
|
||||
$insertPlayer->bindValue(':room_id', $roomId, PDO::PARAM_INT);
|
||||
$insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
|
||||
$insertPlayer->bindValue(':player_slot', 1, PDO::PARAM_INT);
|
||||
$insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR);
|
||||
$insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
|
||||
$insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR);
|
||||
$insertPlayer->execute();
|
||||
|
||||
$playerId = (int) $pdo->lastInsertId();
|
||||
|
||||
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET host_player_id = :host_player_id WHERE id = :room_id');
|
||||
$updateRoom->bindValue(':host_player_id', $playerId, PDO::PARAM_INT);
|
||||
$updateRoom->bindValue(':room_id', $roomId, PDO::PARAM_INT);
|
||||
$updateRoom->execute();
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'room' => [
|
||||
'code' => $roomCode,
|
||||
'status' => 'waiting',
|
||||
'player_count' => 1,
|
||||
],
|
||||
'self' => [
|
||||
'token' => $playerToken,
|
||||
'slot' => 1,
|
||||
'display_name' => $displayName,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function joinRoom(array $payload): void
|
||||
{
|
||||
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
|
||||
if ($roomCode === '') {
|
||||
jsonResponse(['success' => false, 'error' => 'Room code is required.'], 422);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$roomStmt = $pdo->prepare('SELECT * FROM tetris_rooms WHERE room_code = :room_code LIMIT 1 FOR UPDATE');
|
||||
$roomStmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
||||
$roomStmt->execute();
|
||||
$room = $roomStmt->fetch();
|
||||
|
||||
if (!$room || $room['status'] === 'closed') {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
jsonResponse(['success' => false, 'error' => 'Room not found.'], 404);
|
||||
}
|
||||
|
||||
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id');
|
||||
$countStmt->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
|
||||
$countStmt->execute();
|
||||
$playerCount = (int) $countStmt->fetchColumn();
|
||||
|
||||
if ($playerCount >= 2) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
jsonResponse(['success' => false, 'error' => 'Room is full.'], 409);
|
||||
}
|
||||
|
||||
$playerToken = generatePlayerToken();
|
||||
$displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 2);
|
||||
|
||||
$insertPlayer = $pdo->prepare(
|
||||
'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) '
|
||||
. 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())'
|
||||
);
|
||||
$insertPlayer->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
|
||||
$insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
|
||||
$insertPlayer->bindValue(':player_slot', 2, PDO::PARAM_INT);
|
||||
$insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR);
|
||||
$insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
|
||||
$insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR);
|
||||
$insertPlayer->execute();
|
||||
|
||||
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
|
||||
$updateRoom->bindValue(':status', 'active', PDO::PARAM_STR);
|
||||
$updateRoom->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
|
||||
$updateRoom->execute();
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'room' => [
|
||||
'code' => $roomCode,
|
||||
'status' => 'active',
|
||||
'player_count' => 2,
|
||||
],
|
||||
'self' => [
|
||||
'token' => $playerToken,
|
||||
'slot' => 2,
|
||||
'display_name' => $displayName,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function updatePlayerState(array $payload): void
|
||||
{
|
||||
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
|
||||
$playerToken = trim((string) ($payload['player_token'] ?? ''));
|
||||
|
||||
if ($roomCode === '' || $playerToken === '') {
|
||||
jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
|
||||
}
|
||||
|
||||
$boardStateJson = encodeNullableJson($payload['board'] ?? null);
|
||||
$pieceStateJson = encodeNullableJson($payload['piece'] ?? null);
|
||||
$metaJson = encodeNullableJson($payload['meta'] ?? null);
|
||||
|
||||
$score = max(0, (int) ($payload['score'] ?? 0));
|
||||
$linesCleared = max(0, (int) ($payload['lines'] ?? 0));
|
||||
$level = max(1, (int) ($payload['level'] ?? 1));
|
||||
$gameStatus = normalizeGameStatus((string) ($payload['game_status'] ?? 'ready'));
|
||||
|
||||
$pdo = db();
|
||||
$player = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
|
||||
if (!$player) {
|
||||
jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
|
||||
}
|
||||
|
||||
$updatePlayer = $pdo->prepare(
|
||||
'UPDATE tetris_room_players SET board_state_json = :board_state_json, piece_state_json = :piece_state_json, meta_json = :meta_json, '
|
||||
. 'score = :score, lines_cleared = :lines_cleared, level = :level, game_status = :game_status, connection_status = :connection_status, '
|
||||
. 'last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id'
|
||||
);
|
||||
$updatePlayer->bindValue(':board_state_json', $boardStateJson, $boardStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
|
||||
$updatePlayer->bindValue(':piece_state_json', $pieceStateJson, $pieceStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
|
||||
$updatePlayer->bindValue(':meta_json', $metaJson, $metaJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
|
||||
$updatePlayer->bindValue(':score', $score, PDO::PARAM_INT);
|
||||
$updatePlayer->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT);
|
||||
$updatePlayer->bindValue(':level', $level, PDO::PARAM_INT);
|
||||
$updatePlayer->bindValue(':game_status', $gameStatus, PDO::PARAM_STR);
|
||||
$updatePlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
|
||||
$updatePlayer->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT);
|
||||
$updatePlayer->execute();
|
||||
|
||||
$roomStatus = 'active';
|
||||
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
|
||||
$updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR);
|
||||
$updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
|
||||
$updateRoom->execute();
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'room' => [
|
||||
'code' => $roomCode,
|
||||
'status' => $roomStatus,
|
||||
'updated_at' => gmdate('c'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function getRoomState(array $payload): void
|
||||
{
|
||||
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
|
||||
$playerToken = trim((string) ($payload['player_token'] ?? ''));
|
||||
|
||||
if ($roomCode === '' || $playerToken === '') {
|
||||
jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$self = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
|
||||
if (!$self) {
|
||||
jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
|
||||
}
|
||||
|
||||
$touchStmt = $pdo->prepare('UPDATE tetris_room_players SET last_seen_at = UTC_TIMESTAMP(), connection_status = :connection_status WHERE id = :player_id');
|
||||
$touchStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
|
||||
$touchStmt->bindValue(':player_id', (int) $self['player_id'], PDO::PARAM_INT);
|
||||
$touchStmt->execute();
|
||||
|
||||
$playersStmt = $pdo->prepare(
|
||||
'SELECT p.id, p.player_slot, p.display_name, p.connection_status, p.game_status, p.score, p.lines_cleared, p.level, '
|
||||
. 'p.board_state_json, p.piece_state_json, p.meta_json, p.last_seen_at, r.room_code, r.status AS room_status, r.updated_at AS room_updated_at '
|
||||
. 'FROM tetris_room_players p '
|
||||
. 'INNER JOIN tetris_rooms r ON r.id = p.room_id '
|
||||
. 'WHERE r.id = :room_id '
|
||||
. 'ORDER BY p.player_slot ASC'
|
||||
);
|
||||
$playersStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT);
|
||||
$playersStmt->execute();
|
||||
$rows = $playersStmt->fetchAll();
|
||||
|
||||
$players = [];
|
||||
foreach ($rows as $row) {
|
||||
$players[] = [
|
||||
'slot' => (int) $row['player_slot'],
|
||||
'display_name' => (string) $row['display_name'],
|
||||
'connection_status' => (string) $row['connection_status'],
|
||||
'game_status' => (string) $row['game_status'],
|
||||
'score' => (int) $row['score'],
|
||||
'lines' => (int) $row['lines_cleared'],
|
||||
'level' => (int) $row['level'],
|
||||
'board' => decodeNullableJson($row['board_state_json'] ?? null),
|
||||
'piece' => decodeNullableJson($row['piece_state_json'] ?? null),
|
||||
'meta' => decodeNullableJson($row['meta_json'] ?? null),
|
||||
'last_seen_at' => (string) $row['last_seen_at'],
|
||||
'is_self' => ((int) ($row['player_slot'] ?? 0)) === ((int) $self['player_slot']),
|
||||
];
|
||||
}
|
||||
|
||||
$playerCount = count($players);
|
||||
$connectedCount = 0;
|
||||
foreach ($players as $player) {
|
||||
if ($player['connection_status'] === 'connected') {
|
||||
$connectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$roomStatus = $playerCount >= 2 ? 'active' : 'waiting';
|
||||
if ($connectedCount === 0) {
|
||||
$roomStatus = 'closed';
|
||||
}
|
||||
|
||||
$roomUpdateStmt = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
|
||||
$roomUpdateStmt->bindValue(':status', $roomStatus, PDO::PARAM_STR);
|
||||
$roomUpdateStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT);
|
||||
$roomUpdateStmt->execute();
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'room' => [
|
||||
'code' => $roomCode,
|
||||
'status' => $roomStatus,
|
||||
'player_count' => $playerCount,
|
||||
],
|
||||
'self' => [
|
||||
'slot' => (int) $self['player_slot'],
|
||||
'display_name' => (string) $self['display_name'],
|
||||
],
|
||||
'players' => $players,
|
||||
]);
|
||||
}
|
||||
|
||||
function leaveRoom(array $payload): void
|
||||
{
|
||||
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
|
||||
$playerToken = trim((string) ($payload['player_token'] ?? ''));
|
||||
|
||||
if ($roomCode === '' || $playerToken === '') {
|
||||
jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
$player = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
|
||||
if (!$player) {
|
||||
jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
|
||||
}
|
||||
|
||||
$disconnectStmt = $pdo->prepare('UPDATE tetris_room_players SET connection_status = :connection_status, game_status = :game_status, last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id');
|
||||
$disconnectStmt->bindValue(':connection_status', 'disconnected', PDO::PARAM_STR);
|
||||
$disconnectStmt->bindValue(':game_status', 'paused', PDO::PARAM_STR);
|
||||
$disconnectStmt->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT);
|
||||
$disconnectStmt->execute();
|
||||
|
||||
$countConnectedStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id AND connection_status = :connection_status');
|
||||
$countConnectedStmt->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
|
||||
$countConnectedStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
|
||||
$countConnectedStmt->execute();
|
||||
$connectedCount = (int) $countConnectedStmt->fetchColumn();
|
||||
|
||||
$roomStatus = $connectedCount > 0 ? 'active' : 'closed';
|
||||
$updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status WHERE id = :room_id');
|
||||
$updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR);
|
||||
$updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
|
||||
$updateRoom->execute();
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'room' => [
|
||||
'code' => $roomCode,
|
||||
'status' => $roomStatus,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function fetchPlayerForRoom(PDO $pdo, string $roomCode, string $playerToken): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT p.id AS player_id, p.player_slot, p.display_name, p.room_id '
|
||||
. 'FROM tetris_room_players p '
|
||||
. 'INNER JOIN tetris_rooms r ON r.id = p.room_id '
|
||||
. 'WHERE r.room_code = :room_code AND p.player_token = :player_token LIMIT 1'
|
||||
);
|
||||
$stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
|
||||
$stmt->execute();
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
function encodeNullableJson($value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_encode($value, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
function decodeNullableJson($value)
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode((string) $value, true);
|
||||
}
|
||||
|
||||
function normalizeGameStatus(string $gameStatus): string
|
||||
{
|
||||
$normalized = strtolower(trim($gameStatus));
|
||||
$allowed = ['ready', 'playing', 'paused', 'game_over'];
|
||||
|
||||
return in_array($normalized, $allowed, true) ? $normalized : 'ready';
|
||||
}
|
||||
187
api/scoreboard.php
Normal file
187
api/scoreboard.php
Normal file
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
require_once __DIR__ . '/../db/migrate.php';
|
||||
|
||||
run_migrations();
|
||||
|
||||
try {
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
header('Allow: GET, POST, HEAD, OPTIONS');
|
||||
|
||||
if ($method === 'OPTIONS') {
|
||||
jsonResponse(['success' => true], 204);
|
||||
}
|
||||
|
||||
if ($method === 'HEAD') {
|
||||
listScores((int) ($_GET['limit'] ?? 10), false);
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
listScores((int) ($_GET['limit'] ?? 10));
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
jsonResponse(['success' => false, 'error' => 'Method not allowed.'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$payload = json_decode($raw ?: '[]', true);
|
||||
if (!is_array($payload)) {
|
||||
jsonResponse(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
$action = (string) ($payload['action'] ?? '');
|
||||
if ($action === 'save_score') {
|
||||
saveScore($payload);
|
||||
}
|
||||
|
||||
if ($action === 'list_scores') {
|
||||
listScores((int) ($payload['limit'] ?? 10));
|
||||
}
|
||||
|
||||
jsonResponse(['success' => false, 'error' => 'Unknown action.'], 422);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Scoreboard API error: ' . $exception->getMessage());
|
||||
jsonResponse(['success' => false, 'error' => 'Scoreboard is temporarily unavailable.'], 500);
|
||||
}
|
||||
|
||||
function saveScore(array $payload): void
|
||||
{
|
||||
$playerName = normalizePlayerName((string) ($payload['player_name'] ?? ''));
|
||||
$score = max(0, (int) ($payload['score'] ?? 0));
|
||||
$linesCleared = max(0, (int) ($payload['lines'] ?? 0));
|
||||
$level = max(1, (int) ($payload['level'] ?? 1));
|
||||
$piecesPlaced = max(0, (int) ($payload['pieces_placed'] ?? 0));
|
||||
$durationSeconds = max(0, (int) ($payload['duration_seconds'] ?? 0));
|
||||
$roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
|
||||
$mode = $roomCode !== '' ? 'multiplayer' : 'solo';
|
||||
|
||||
$pdo = db();
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO tetris_scores (player_name, score, lines_cleared, level, pieces_placed, duration_seconds, mode, room_code) '
|
||||
. 'VALUES (:player_name, :score, :lines_cleared, :level, :pieces_placed, :duration_seconds, :mode, :room_code)'
|
||||
);
|
||||
$insert->bindValue(':player_name', $playerName, PDO::PARAM_STR);
|
||||
$insert->bindValue(':score', $score, PDO::PARAM_INT);
|
||||
$insert->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT);
|
||||
$insert->bindValue(':level', $level, PDO::PARAM_INT);
|
||||
$insert->bindValue(':pieces_placed', $piecesPlaced, PDO::PARAM_INT);
|
||||
$insert->bindValue(':duration_seconds', $durationSeconds, PDO::PARAM_INT);
|
||||
$insert->bindValue(':mode', $mode, PDO::PARAM_STR);
|
||||
if ($roomCode === '') {
|
||||
$insert->bindValue(':room_code', null, PDO::PARAM_NULL);
|
||||
} else {
|
||||
$insert->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
|
||||
}
|
||||
$insert->execute();
|
||||
|
||||
$scoreId = (int) $pdo->lastInsertId();
|
||||
|
||||
$placementQuery = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM tetris_scores WHERE '
|
||||
. 'score > :score '
|
||||
. 'OR (score = :score AND lines_cleared > :lines_cleared) '
|
||||
. 'OR (score = :score AND lines_cleared = :lines_cleared AND level > :level) '
|
||||
. 'OR (score = :score AND lines_cleared = :lines_cleared AND level = :level AND duration_seconds < :duration_seconds) '
|
||||
. 'OR (score = :score AND lines_cleared = :lines_cleared AND level = :level AND duration_seconds = :duration_seconds AND id < :score_id)'
|
||||
);
|
||||
$placementQuery->bindValue(':score', $score, PDO::PARAM_INT);
|
||||
$placementQuery->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT);
|
||||
$placementQuery->bindValue(':level', $level, PDO::PARAM_INT);
|
||||
$placementQuery->bindValue(':duration_seconds', $durationSeconds, PDO::PARAM_INT);
|
||||
$placementQuery->bindValue(':score_id', $scoreId, PDO::PARAM_INT);
|
||||
$placementQuery->execute();
|
||||
$placement = ((int) $placementQuery->fetchColumn()) + 1;
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'entry' => [
|
||||
'id' => $scoreId,
|
||||
'player_name' => $playerName,
|
||||
'score' => $score,
|
||||
'lines' => $linesCleared,
|
||||
'level' => $level,
|
||||
'pieces_placed' => $piecesPlaced,
|
||||
'duration_seconds' => $durationSeconds,
|
||||
'mode' => $mode,
|
||||
'room_code' => $roomCode !== '' ? $roomCode : null,
|
||||
],
|
||||
'placement' => $placement,
|
||||
]);
|
||||
}
|
||||
|
||||
function listScores(int $limit = 10, bool $includeBody = true): void
|
||||
{
|
||||
$limit = max(1, min(25, $limit));
|
||||
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, player_name, score, lines_cleared, level, pieces_placed, duration_seconds, mode, room_code, created_at '
|
||||
. 'FROM tetris_scores '
|
||||
. 'ORDER BY score DESC, lines_cleared DESC, level DESC, duration_seconds ASC, id ASC '
|
||||
. 'LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$scores = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$scores[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'player_name' => (string) $row['player_name'],
|
||||
'score' => (int) $row['score'],
|
||||
'lines' => (int) $row['lines_cleared'],
|
||||
'level' => (int) $row['level'],
|
||||
'pieces_placed' => (int) $row['pieces_placed'],
|
||||
'duration_seconds' => (int) $row['duration_seconds'],
|
||||
'mode' => (string) $row['mode'],
|
||||
'room_code' => $row['room_code'] !== null ? (string) $row['room_code'] : null,
|
||||
'created_at' => (string) $row['created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
if (!$includeBody) {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'scores' => $scores,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizePlayerName(string $value): string
|
||||
{
|
||||
$value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||||
$value = preg_replace('/[^\p{L}\p{N} ._\-]/u', '', $value) ?? '';
|
||||
if ($value === '') {
|
||||
return 'Player';
|
||||
}
|
||||
|
||||
if (function_exists('mb_substr')) {
|
||||
return mb_substr($value, 0, 48);
|
||||
}
|
||||
|
||||
return substr($value, 0, 48);
|
||||
}
|
||||
|
||||
function normalizeRoomCode(string $value): string
|
||||
{
|
||||
$value = strtoupper(trim($value));
|
||||
$value = preg_replace('/[^A-Z0-9]/', '', $value) ?? '';
|
||||
return substr($value, 0, 8);
|
||||
}
|
||||
|
||||
function jsonResponse(array $payload, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
@ -1,403 +1,576 @@
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
--bg-elevated: #12161d;
|
||||
--surface: #161b22;
|
||||
--surface-strong: #1d242d;
|
||||
--surface-soft: #11161d;
|
||||
--border: #27303a;
|
||||
--border-strong: #37424f;
|
||||
--text: #edf1f5;
|
||||
--muted: #98a2ad;
|
||||
--accent: #d2b062;
|
||||
--accent-soft: rgba(210, 176, 98, 0.12);
|
||||
--success: #79c08f;
|
||||
--danger: #e37979;
|
||||
--shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.5rem;
|
||||
--spacing-6: 2rem;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
color: #212529;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body.tetris-body {
|
||||
margin: 0;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(210, 176, 98, 0.24);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 85vh;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.message.visitor {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.bot {
|
||||
align-self: flex-start;
|
||||
background: #ffffff;
|
||||
color: #212529;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-input-area form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus {
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area button:hover {
|
||||
background: #000;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Background Animations */
|
||||
.bg-animations {
|
||||
position: fixed;
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
backdrop-filter: blur(14px);
|
||||
background: rgba(11, 13, 18, 0.92);
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
background: rgba(238, 119, 82, 0.4);
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
background: rgba(35, 166, 213, 0.4);
|
||||
animation-delay: -7s;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.blob-3 {
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
background: rgba(231, 60, 126, 0.3);
|
||||
animation-delay: -14s;
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
|
||||
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
|
||||
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.header-link {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Admin Styles */
|
||||
.admin-container {
|
||||
max-width: 900px;
|
||||
margin: 3rem auto;
|
||||
padding: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.admin-container h1 {
|
||||
margin-top: 0;
|
||||
color: #212529;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
.eyebrow,
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table td {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
.app-title {
|
||||
font-size: clamp(1.4rem, 2vw, 1.9rem);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.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;
|
||||
.status-badge {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-strong) !important;
|
||||
color: var(--text);
|
||||
padding: 0.55rem 0.75rem;
|
||||
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;
|
||||
.control-btn {
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
.status-badge.state-ready {
|
||||
background: rgba(39, 48, 58, 0.9) !important;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.status-badge.state-playing {
|
||||
background: rgba(210, 176, 98, 0.16) !important;
|
||||
color: #f3e6c8;
|
||||
border-color: rgba(210, 176, 98, 0.5);
|
||||
}
|
||||
|
||||
.status-badge.state-paused {
|
||||
background: rgba(122, 141, 182, 0.16) !important;
|
||||
color: #d3deef;
|
||||
border-color: rgba(122, 141, 182, 0.4);
|
||||
}
|
||||
|
||||
.status-badge.state-game-over {
|
||||
background: rgba(203, 125, 125, 0.16) !important;
|
||||
color: #f3d6d6;
|
||||
border-color: rgba(203, 125, 125, 0.4);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(18, 22, 29, 0.96);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: var(--spacing-5);
|
||||
}
|
||||
|
||||
.compact-panel {
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-inline-stats {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.inline-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: rgba(17, 22, 29, 0.72);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
.board-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
.board-frame {
|
||||
position: relative;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #0e1218;
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
#tetris-board {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: min(60vh, 560px);
|
||||
aspect-ratio: 1 / 2;
|
||||
margin-inline: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.board-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
background: rgba(11, 13, 18, 0.82);
|
||||
color: var(--text);
|
||||
transition: opacity 180ms ease, visibility 180ms ease;
|
||||
}
|
||||
|
||||
.board-overlay.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.overlay-title {
|
||||
font-size: clamp(1.4rem, 4vw, 2.1rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
.overlay-copy {
|
||||
color: var(--muted);
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #212529;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #0088cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
.stat-card,
|
||||
.history-detail,
|
||||
.preview-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(14, 18, 24, 0.92);
|
||||
}
|
||||
|
||||
.webhook-url {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.history-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.history-table {
|
||||
.stat-label,
|
||||
.summary-label,
|
||||
.history-detail-header,
|
||||
.detail-grid dt {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: clamp(1.2rem, 2vw, 1.55rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 176px;
|
||||
}
|
||||
|
||||
#next-piece {
|
||||
width: 144px;
|
||||
height: 144px;
|
||||
}
|
||||
|
||||
.multiplayer-block .form-control {
|
||||
background: var(--surface-soft);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.multiplayer-block .form-control:focus {
|
||||
background: var(--surface-soft);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 0.2rem rgba(210, 176, 98, 0.15);
|
||||
}
|
||||
|
||||
.room-code-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(14, 18, 24, 0.6);
|
||||
}
|
||||
|
||||
.room-code-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.room-code-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.room-status .status-badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.opponent-wrap {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
#opponent-board {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1 / 2;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: #0e1218;
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
.opponent-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.detail-grid.compact {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
.detail-grid .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
margin: 0.28rem 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.controls-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.controls-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text);
|
||||
font-size: 0.92rem;
|
||||
padding-bottom: 0.65rem;
|
||||
border-bottom: 1px solid rgba(39, 48, 58, 0.7);
|
||||
}
|
||||
|
||||
.controls-list li:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.28rem 0.5rem;
|
||||
margin-left: 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 0.95rem 1rem;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--muted);
|
||||
background: rgba(14, 18, 24, 0.5);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(14, 18, 24, 0.88);
|
||||
padding: 0.9rem 1rem;
|
||||
color: var(--text);
|
||||
transition: border-color 140ms ease, transform 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.history-item:hover,
|
||||
.history-item:focus-visible {
|
||||
border-color: var(--accent);
|
||||
background: rgba(17, 22, 29, 1);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.history-item.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(210, 176, 98, 0.08);
|
||||
}
|
||||
|
||||
.history-topline,
|
||||
.history-subline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.history-score {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.history-meta,
|
||||
.history-date {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.history-detail {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.modal-copy,
|
||||
.toast-body,
|
||||
.text-secondary {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background: #f5f7f8;
|
||||
border-color: #f5f7f8;
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-outline-light:hover,
|
||||
.btn-outline-light:focus-visible {
|
||||
background: var(--surface-strong);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
.history-item:focus-visible {
|
||||
box-shadow: 0 0 0 0.2rem rgba(210, 176, 98, 0.18);
|
||||
}
|
||||
|
||||
.toast,
|
||||
.modal-content {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.app-header {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
#tetris-board {
|
||||
height: min(52vh, 460px);
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.detail-grid,
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.controls-list li {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-topline,
|
||||
.history-subline {
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.field-help {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.scoreboard-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scoreboard-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.875rem;
|
||||
align-items: start;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.scoreboard-rank {
|
||||
min-width: 2.75rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(210, 176, 98, 0.14);
|
||||
color: #d2b062;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
.scoreboard-item-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scoreboard-topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.scoreboard-name,
|
||||
.scoreboard-score {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.scoreboard-score {
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scoreboard-meta {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.scoreboard-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scoreboard-topline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
1277
assets/js/main.js
1277
assets/js/main.js
File diff suppressed because it is too large
Load Diff
51
db/migrate.php
Normal file
51
db/migrate.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function run_migrations(): void
|
||||
{
|
||||
static $hasRun = false;
|
||||
|
||||
if ($hasRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasRun = true;
|
||||
$pdo = db();
|
||||
|
||||
$pdo->exec(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
"
|
||||
. " filename VARCHAR(190) NOT NULL PRIMARY KEY,
|
||||
"
|
||||
. " applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
"
|
||||
. ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$files = glob(__DIR__ . '/migrations/*.sql') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
|
||||
$checkStmt = $pdo->prepare('SELECT 1 FROM schema_migrations WHERE filename = :filename LIMIT 1');
|
||||
$insertStmt = $pdo->prepare('INSERT INTO schema_migrations (filename) VALUES (:filename)');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
$checkStmt->bindValue(':filename', $filename, PDO::PARAM_STR);
|
||||
$checkStmt->execute();
|
||||
|
||||
if ($checkStmt->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = trim((string) file_get_contents($file));
|
||||
|
||||
if ($sql !== '') {
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
$insertStmt->bindValue(':filename', $filename, PDO::PARAM_STR);
|
||||
$insertStmt->execute();
|
||||
}
|
||||
}
|
||||
41
db/migrations/20260325_tetris_multiplayer_rooms.sql
Normal file
41
db/migrations/20260325_tetris_multiplayer_rooms.sql
Normal file
@ -0,0 +1,41 @@
|
||||
-- Multiplayer room support for the browser Tetris game.
|
||||
CREATE TABLE IF NOT EXISTS tetris_rooms (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
room_code VARCHAR(8) NOT NULL,
|
||||
status ENUM('waiting', 'active', 'closed') NOT NULL DEFAULT 'waiting',
|
||||
host_player_id BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_tetris_rooms_code (room_code),
|
||||
KEY idx_tetris_rooms_status (status),
|
||||
KEY idx_tetris_rooms_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tetris_room_players (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
room_id BIGINT UNSIGNED NOT NULL,
|
||||
player_token CHAR(48) NOT NULL,
|
||||
player_slot TINYINT UNSIGNED NOT NULL,
|
||||
display_name VARCHAR(48) NOT NULL DEFAULT 'Player',
|
||||
connection_status ENUM('connected', 'disconnected') NOT NULL DEFAULT 'connected',
|
||||
game_status ENUM('ready', 'playing', 'paused', 'game_over') NOT NULL DEFAULT 'ready',
|
||||
score INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lines_cleared INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
level INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
board_state_json JSON NULL,
|
||||
piece_state_json JSON NULL,
|
||||
meta_json JSON NULL,
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_tetris_room_players_token (player_token),
|
||||
UNIQUE KEY uniq_tetris_room_players_slot (room_id, player_slot),
|
||||
KEY idx_tetris_room_players_room (room_id),
|
||||
KEY idx_tetris_room_players_seen (last_seen_at),
|
||||
CONSTRAINT fk_tetris_room_players_room
|
||||
FOREIGN KEY (room_id) REFERENCES tetris_rooms(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
18
db/migrations/20260325_tetris_scoreboard.sql
Normal file
18
db/migrations/20260325_tetris_scoreboard.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- Persistent scoreboard entries for completed Tetris runs.
|
||||
CREATE TABLE IF NOT EXISTS tetris_scores (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
player_name VARCHAR(48) NOT NULL DEFAULT 'Player',
|
||||
score INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lines_cleared INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
level INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
pieces_placed INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
duration_seconds INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
mode ENUM('solo', 'multiplayer') NOT NULL DEFAULT 'solo',
|
||||
room_code VARCHAR(8) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_tetris_scores_rank (score, lines_cleared, level, duration_seconds),
|
||||
KEY idx_tetris_scores_created (created_at),
|
||||
KEY idx_tetris_scores_mode (mode)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
415
index.php
415
index.php
@ -1,150 +1,299 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$projectName = trim((string)($_SERVER['PROJECT_NAME'] ?? ''));
|
||||
$projectDescription = trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? ''));
|
||||
$projectImageUrl = trim((string)($_SERVER['PROJECT_IMAGE_URL'] ?? ''));
|
||||
|
||||
$appName = $projectName !== '' ? $projectName : 'Classic Tetris';
|
||||
$pageTitle = $appName . ' — Classic Tetris';
|
||||
$metaDescription = $projectDescription !== ''
|
||||
? $projectDescription
|
||||
: 'Play a focused browser-based Tetris clone with keyboard controls, scoring, levels, next piece preview, pause, restart, and local best score tracking.';
|
||||
|
||||
$cssVersion = (string)@filemtime(__DIR__ . '/assets/css/custom.css');
|
||||
$jsVersion = (string)@filemtime(__DIR__ . '/assets/js/main.js');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||
<meta name="author" content="Flatlogic">
|
||||
<meta property="og:title" content="<?= htmlspecialchars($pageTitle) ?>">
|
||||
<meta property="og:description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||
<meta property="twitter:title" content="<?= htmlspecialchars($pageTitle) ?>">
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($metaDescription) ?>">
|
||||
|
||||
<?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>
|
||||
<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($cssVersion !== '' ? $cssVersion : (string)time()) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<body class="tetris-body">
|
||||
<noscript>
|
||||
<div class="alert alert-danger rounded-0 mb-0 text-center">JavaScript is required to play this Tetris app.</div>
|
||||
</noscript>
|
||||
|
||||
<div class="page-shell">
|
||||
<header class="app-header border-bottom border-secondary-subtle">
|
||||
<div class="container-xxl d-flex flex-wrap align-items-center justify-content-between gap-3 py-3">
|
||||
<div>
|
||||
<p class="eyebrow mb-1">Arcade game web app</p>
|
||||
<h1 class="app-title mb-0">Classic Tetris</h1>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span id="game-state-badge" class="badge status-badge state-ready">Ready</span>
|
||||
<button id="pause-button" class="btn btn-outline-light btn-sm control-btn" type="button">Pause</button>
|
||||
<button id="restart-button" class="btn btn-light btn-sm control-btn" type="button">Restart</button>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<main class="container-xxl py-3 py-lg-4">
|
||||
<div class="row g-3 g-xl-4 align-items-start">
|
||||
<div class="col-xl-8">
|
||||
<section class="panel game-panel h-100" aria-labelledby="playfield-title">
|
||||
<div class="panel-header d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div>
|
||||
<p class="panel-label mb-1">Playfield</p>
|
||||
<h2 id="playfield-title" class="panel-title mb-0">Pure gameplay, zero filler</h2>
|
||||
</div>
|
||||
<div class="panel-inline-stats d-flex flex-wrap gap-2">
|
||||
<span class="inline-chip">Best <strong id="best-score-inline">0</strong></span>
|
||||
<span class="inline-chip">Time <strong id="elapsed-time-inline">00:00</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board-layout">
|
||||
<div class="board-frame">
|
||||
<canvas id="tetris-board" width="300" height="600" aria-label="Tetris playfield" role="img"></canvas>
|
||||
<div class="board-overlay" id="board-overlay">
|
||||
<div>
|
||||
<p class="overlay-label mb-2">Press Enter to start</p>
|
||||
<h3 class="overlay-title mb-3">Stack clean. Clear fast.</h3>
|
||||
<p class="overlay-copy mb-0">Controls: move with arrow keys, rotate with ↑ or X, hard drop with Space.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" aria-label="Game statistics">
|
||||
<article class="stat-card">
|
||||
<span class="stat-label">Score</span>
|
||||
<strong id="score-value" class="stat-value">0</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span class="stat-label">Lines</span>
|
||||
<strong id="lines-value" class="stat-value">0</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span class="stat-label">Level</span>
|
||||
<strong id="level-value" class="stat-value">1</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span class="stat-label">Drop speed</span>
|
||||
<strong id="speed-value" class="stat-value">0.90s</strong>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<div class="sidebar-stack d-grid gap-3 gap-xl-4">
|
||||
<section class="panel compact-panel" aria-labelledby="next-piece-title">
|
||||
<div class="panel-header">
|
||||
<p class="panel-label mb-1">Preview</p>
|
||||
<h2 id="next-piece-title" class="panel-title mb-0">Next piece</h2>
|
||||
</div>
|
||||
<div class="preview-wrap">
|
||||
<canvas id="next-piece" width="144" height="144" aria-label="Next tetromino preview" role="img"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel" aria-labelledby="multiplayer-title">
|
||||
<div class="panel-header">
|
||||
<p class="panel-label mb-1">Multiplayer</p>
|
||||
<h2 id="multiplayer-title" class="panel-title mb-0">Play with a friend</h2>
|
||||
</div>
|
||||
<div class="multiplayer-block">
|
||||
<div class="form-floating mb-1">
|
||||
<input id="player-name" class="form-control form-control-sm bg-dark text-light border-secondary-subtle" placeholder="Your name">
|
||||
<label for="player-name">Your name</label>
|
||||
</div>
|
||||
<div class="field-help mb-2">Used for room play and the database scoreboard.</div>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button id="create-room-btn" class="btn btn-light btn-sm flex-grow-1" type="button">Create room</button>
|
||||
<button id="leave-room-btn" class="btn btn-outline-light btn-sm flex-grow-1 d-none" type="button">Leave</button>
|
||||
</div>
|
||||
<div id="room-code-card" class="room-code-card mb-2 d-none">
|
||||
<div>
|
||||
<span class="room-code-label">Room code</span>
|
||||
<div class="room-code-value" id="room-code-value">—</div>
|
||||
</div>
|
||||
<button id="copy-room-btn" class="btn btn-outline-light btn-sm" type="button">Copy</button>
|
||||
</div>
|
||||
<div class="join-row d-flex gap-2">
|
||||
<input id="room-code-input" class="form-control form-control-sm bg-dark text-light border-secondary-subtle" placeholder="Enter code">
|
||||
<button id="join-room-btn" class="btn btn-outline-light btn-sm" type="button">Join</button>
|
||||
</div>
|
||||
<div class="room-status mt-2 d-flex align-items-center gap-2">
|
||||
<span id="room-status-badge" class="badge status-badge state-ready">Offline</span>
|
||||
<span id="room-status-text" class="small text-secondary">Create a room to start.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel" aria-labelledby="opponent-title">
|
||||
<div class="panel-header">
|
||||
<p class="panel-label mb-1">Opponent</p>
|
||||
<h2 id="opponent-title" class="panel-title mb-0">Live board</h2>
|
||||
</div>
|
||||
<div class="opponent-wrap">
|
||||
<canvas id="opponent-board" width="200" height="400" aria-label="Opponent playfield" role="img"></canvas>
|
||||
<div class="opponent-meta">
|
||||
<div id="opponent-name" class="opponent-name">Waiting for opponent…</div>
|
||||
<div id="opponent-stats" class="opponent-stats small text-secondary">Score — · Level — · Lines —</div>
|
||||
<div id="opponent-status" class="opponent-status small text-secondary">Room idle</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel" aria-labelledby="session-title">
|
||||
<div class="panel-header">
|
||||
<p class="panel-label mb-1">Current run</p>
|
||||
<h2 id="session-title" class="panel-title mb-0">Session detail</h2>
|
||||
</div>
|
||||
<dl class="detail-grid mb-0">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd id="detail-status">Ready to start</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pieces placed</dt>
|
||||
<dd id="detail-pieces">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lines cleared</dt>
|
||||
<dd id="detail-lines">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duration</dt>
|
||||
<dd id="detail-duration">00:00</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel" aria-labelledby="controls-title">
|
||||
<div class="panel-header">
|
||||
<p class="panel-label mb-1">Keyboard</p>
|
||||
<h2 id="controls-title" class="panel-title mb-0">Controls</h2>
|
||||
</div>
|
||||
<ul class="controls-list list-unstyled mb-0">
|
||||
<li><span>Move</span><kbd>←</kbd><kbd>→</kbd></li>
|
||||
<li><span>Soft drop</span><kbd>↓</kbd></li>
|
||||
<li><span>Rotate</span><kbd>↑</kbd><kbd>X</kbd><kbd>Z</kbd></li>
|
||||
<li><span>Hard drop</span><kbd>Space</kbd></li>
|
||||
<li><span>Pause</span><kbd>P</kbd></li>
|
||||
<li><span>Restart</span><kbd>R</kbd></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel" aria-labelledby="scoreboard-title">
|
||||
<div class="panel-header d-flex justify-content-between align-items-end gap-2">
|
||||
<div>
|
||||
<p class="panel-label mb-1">Database records</p>
|
||||
<h2 id="scoreboard-title" class="panel-title mb-0">Scoreboard</h2>
|
||||
</div>
|
||||
<span id="scoreboard-status" class="small text-secondary">Loading…</span>
|
||||
</div>
|
||||
<div id="scoreboard-empty" class="empty-state">No database scores yet. Finish a game to post the first result.</div>
|
||||
<div id="scoreboard-list" class="scoreboard-list" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel" aria-labelledby="history-title">
|
||||
<div class="panel-header d-flex justify-content-between align-items-end gap-2">
|
||||
<div>
|
||||
<p class="panel-label mb-1">Local records</p>
|
||||
<h2 id="history-title" class="panel-title mb-0">Recent runs</h2>
|
||||
</div>
|
||||
<span class="small text-secondary">Saved in this browser</span>
|
||||
</div>
|
||||
<div id="history-empty" class="empty-state">No completed runs yet. Finish a game and your latest result will appear here.</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
<div id="history-detail" class="history-detail d-none">
|
||||
<div class="history-detail-header">Selected run</div>
|
||||
<dl class="detail-grid compact mb-0">
|
||||
<div><dt>Score</dt><dd id="selected-score">0</dd></div>
|
||||
<div><dt>Level</dt><dd id="selected-level">1</dd></div>
|
||||
<div><dt>Lines</dt><dd id="selected-lines">0</dd></div>
|
||||
<div><dt>Duration</dt><dd id="selected-duration">00:00</dd></div>
|
||||
<div class="full"><dt>Finished</dt><dd id="selected-finished">—</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
<div id="game-toast" class="toast text-bg-dark border border-secondary-subtle" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="toast-header bg-dark text-light border-bottom border-secondary-subtle">
|
||||
<strong class="me-auto">Tetris</strong>
|
||||
<small id="toast-timestamp">now</small>
|
||||
<button type="button" class="btn-close btn-close-white ms-2 mb-1" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body" id="toast-body">Ready.</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="gameOverModal" tabindex="-1" aria-labelledby="gameOverTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light border border-secondary-subtle">
|
||||
<div class="modal-header border-secondary-subtle">
|
||||
<div>
|
||||
<p class="panel-label mb-1">Run complete</p>
|
||||
<h2 class="modal-title fs-5" id="gameOverTitle">Game over</h2>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="summary-grid">
|
||||
<div>
|
||||
<span class="summary-label">Score</span>
|
||||
<strong id="final-score">0</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">Lines</span>
|
||||
<strong id="final-lines">0</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">Level</span>
|
||||
<strong id="final-level">1</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">Duration</span>
|
||||
<strong id="final-duration">00:00</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="modal-copy mb-0">Press restart or hit Enter to jump straight into a new run.</p>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary-subtle">
|
||||
<button type="button" class="btn btn-outline-light btn-sm" data-bs-dismiss="modal">Review board</button>
|
||||
<button type="button" class="btn btn-light btn-sm" id="modal-restart-button">Play again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
<script src="assets/js/main.js?v=<?= urlencode($jsVersion !== '' ? $jsVersion : (string)time()) ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user