From 373f8b6ba0bd24f2793ded4880cef79438a649b9 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 25 Mar 2026 17:19:10 +0000 Subject: [PATCH] Autosave: 20260325-171910 --- api/multiplayer.php | 493 +++++++ api/scoreboard.php | 173 +++ assets/css/custom.css | 879 ++++++++----- assets/js/main.js | 1137 ++++++++++++++++- db/migrate.php | 51 + .../20260325_tetris_multiplayer_rooms.sql | 41 + db/migrations/20260325_tetris_scoreboard.sql | 18 + index.php | 415 ++++-- 8 files changed, 2696 insertions(+), 511 deletions(-) create mode 100644 api/multiplayer.php create mode 100644 api/scoreboard.php create mode 100644 db/migrate.php create mode 100644 db/migrations/20260325_tetris_multiplayer_rooms.sql create mode 100644 db/migrations/20260325_tetris_scoreboard.sql diff --git a/api/multiplayer.php b/api/multiplayer.php new file mode 100644 index 0000000..10f26d0 --- /dev/null +++ b/api/multiplayer.php @@ -0,0 +1,493 @@ + 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'; +} diff --git a/api/scoreboard.php b/api/scoreboard.php new file mode 100644 index 0000000..549d822 --- /dev/null +++ b/api/scoreboard.php @@ -0,0 +1,173 @@ + 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): 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'], + ]; + } + + 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; +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..6a3f93f 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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; -} \ No newline at end of file +} + +.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; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..718cc0a 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,1126 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const COLS = 10; + const ROWS = 20; + const BLOCK = 30; + const STORAGE_KEYS = { + best: 'tetris_best_score_v1', + history: 'tetris_run_history_v1' + }; + const MULTI_STORAGE_KEYS = { + room: 'tetris_room_code_v1', + token: 'tetris_player_token_v1', + name: 'tetris_player_name_v1', + slot: 'tetris_player_slot_v1' + }; + const MULTI_API_URL = 'api/multiplayer.php'; + const SCOREBOARD_API_URL = 'api/scoreboard.php'; + const MULTI_POLL_INTERVAL = 800; + const SCOREBOARD_TIMEOUT_MS = 4000; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const PIECE_DEFS = { + I: { + matrix: [ + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0] + ], + color: '#7cb8c7' + }, + J: { + matrix: [ + [2, 0, 0], + [2, 2, 2], + [0, 0, 0] + ], + color: '#7a8db6' + }, + L: { + matrix: [ + [0, 0, 3], + [3, 3, 3], + [0, 0, 0] + ], + color: '#c49a63' + }, + O: { + matrix: [ + [4, 4], + [4, 4] + ], + color: '#d0b768' + }, + S: { + matrix: [ + [0, 5, 5], + [5, 5, 0], + [0, 0, 0] + ], + color: '#86b494' + }, + T: { + matrix: [ + [0, 6, 0], + [6, 6, 6], + [0, 0, 0] + ], + color: '#b18ab8' + }, + Z: { + matrix: [ + [7, 7, 0], + [0, 7, 7], + [0, 0, 0] + ], + color: '#cb7d7d' + } }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const colorLookup = Object.entries(PIECE_DEFS).reduce((acc, [type, def], index) => { + acc[index + 1] = def.color; + return acc; + }, {}); - appendMessage(message, 'visitor'); - chatInput.value = ''; + const boardCanvas = document.getElementById('tetris-board'); + const nextCanvas = document.getElementById('next-piece'); + const opponentCanvas = document.getElementById('opponent-board'); + const boardCtx = boardCanvas.getContext('2d'); + const nextCtx = nextCanvas.getContext('2d'); + boardCtx.scale(BLOCK, BLOCK); + const opponentCtx = opponentCanvas ? opponentCanvas.getContext('2d') : null; + const OPPONENT_BLOCK = 20; + if (opponentCtx) { + opponentCtx.setTransform(1, 0, 0, 1, 0, 0); + opponentCtx.scale(OPPONENT_BLOCK, OPPONENT_BLOCK); + } + const ui = { + score: document.getElementById('score-value'), + lines: document.getElementById('lines-value'), + level: document.getElementById('level-value'), + speed: document.getElementById('speed-value'), + bestInline: document.getElementById('best-score-inline'), + elapsedInline: document.getElementById('elapsed-time-inline'), + badge: document.getElementById('game-state-badge'), + overlay: document.getElementById('board-overlay'), + pauseButton: document.getElementById('pause-button'), + restartButton: document.getElementById('restart-button'), + modalRestartButton: document.getElementById('modal-restart-button'), + detailStatus: document.getElementById('detail-status'), + detailPieces: document.getElementById('detail-pieces'), + detailLines: document.getElementById('detail-lines'), + detailDuration: document.getElementById('detail-duration'), + finalScore: document.getElementById('final-score'), + finalLines: document.getElementById('final-lines'), + finalLevel: document.getElementById('final-level'), + finalDuration: document.getElementById('final-duration'), + historyEmpty: document.getElementById('history-empty'), + historyList: document.getElementById('history-list'), + historyDetail: document.getElementById('history-detail'), + scoreboardStatus: document.getElementById('scoreboard-status'), + scoreboardEmpty: document.getElementById('scoreboard-empty'), + scoreboardList: document.getElementById('scoreboard-list'), + selectedScore: document.getElementById('selected-score'), + selectedLevel: document.getElementById('selected-level'), + selectedLines: document.getElementById('selected-lines'), + selectedDuration: document.getElementById('selected-duration'), + selectedFinished: document.getElementById('selected-finished'), + toastEl: document.getElementById('game-toast'), + toastBody: document.getElementById('toast-body'), + toastTimestamp: document.getElementById('toast-timestamp'), + playerNameInput: document.getElementById('player-name'), + createRoomBtn: document.getElementById('create-room-btn'), + joinRoomBtn: document.getElementById('join-room-btn'), + leaveRoomBtn: document.getElementById('leave-room-btn'), + roomCodeInput: document.getElementById('room-code-input'), + roomCodeCard: document.getElementById('room-code-card'), + roomCodeValue: document.getElementById('room-code-value'), + copyRoomBtn: document.getElementById('copy-room-btn'), + roomStatusBadge: document.getElementById('room-status-badge'), + roomStatusText: document.getElementById('room-status-text'), + opponentName: document.getElementById('opponent-name'), + opponentStats: document.getElementById('opponent-stats'), + opponentStatus: document.getElementById('opponent-status') + }; + + const bootstrapToast = window.bootstrap ? bootstrap.Toast.getOrCreateInstance(ui.toastEl, { delay: 2200 }) : null; + const gameOverModalEl = document.getElementById('gameOverModal'); + const gameOverModal = window.bootstrap ? new bootstrap.Modal(gameOverModalEl) : null; + + function safeOn(element, eventName, handler) { + if (!element) return; + element.addEventListener(eventName, handler); + } + + let board = createBoard(); + let currentPiece = null; + let nextQueue = []; + let bag = []; + let animationFrameId = null; + let lastTime = 0; + let dropCounter = 0; + let dropInterval = 900; + let score = 0; + let lines = 0; + let level = 1; + let piecesPlaced = 0; + let bestScore = Number(localStorage.getItem(STORAGE_KEYS.best) || 0); + let history = loadHistory(); + let scoreboardEntries = []; + let selectedRunId = history[0]?.id || null; + let startedAt = null; + let endedAt = null; + let isRunning = false; + let isPaused = false; + let isGameOver = false; + const multiplayer = { + roomCode: null, + token: null, + slot: null, + displayName: '', + pollTimer: null, + isSyncing: false, + opponent: null, + roomStatus: 'offline', + playerCount: 0 + }; + + function createBoard() { + return Array.from({ length: ROWS }, () => Array(COLS).fill(0)); + } + + function cloneMatrix(matrix) { + return matrix.map((row) => row.slice()); + } + + function shuffle(array) { + const clone = array.slice(); + for (let i = clone.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [clone[i], clone[j]] = [clone[j], clone[i]]; + } + return clone; + } + + function fillQueue() { + while (nextQueue.length < 4) { + if (bag.length === 0) { + bag = shuffle(Object.keys(PIECE_DEFS)); + } + nextQueue.push(bag.pop()); + } + } + + function createPiece(type) { + const definition = PIECE_DEFS[type]; + const matrix = cloneMatrix(definition.matrix); + return { + type, + matrix, + x: Math.floor(COLS / 2) - Math.ceil(matrix[0].length / 2), + y: 0, + color: definition.color + }; + } + + function spawnPiece() { + fillQueue(); + currentPiece = createPiece(nextQueue.shift()); + fillQueue(); + + if (collides(board, currentPiece)) { + finishGame(); + } + } + + function collides(targetBoard, piece, moveX = 0, moveY = 0, testMatrix = piece.matrix) { + for (let y = 0; y < testMatrix.length; y += 1) { + for (let x = 0; x < testMatrix[y].length; x += 1) { + if (testMatrix[y][x] === 0) continue; + const boardX = x + piece.x + moveX; + const boardY = y + piece.y + moveY; + if (boardX < 0 || boardX >= COLS || boardY >= ROWS) return true; + if (boardY >= 0 && targetBoard[boardY][boardX] !== 0) return true; + } + } + return false; + } + + function mergePiece() { + currentPiece.matrix.forEach((row, y) => { + row.forEach((value, x) => { + if (value !== 0 && board[y + currentPiece.y] && board[y + currentPiece.y][x + currentPiece.x] !== undefined) { + board[y + currentPiece.y][x + currentPiece.x] = value; + } + }); + }); + } + + function clearLines() { + let cleared = 0; + outer: for (let y = ROWS - 1; y >= 0; y -= 1) { + for (let x = 0; x < COLS; x += 1) { + if (board[y][x] === 0) { + continue outer; + } + } + const row = board.splice(y, 1)[0].fill(0); + board.unshift(row); + cleared += 1; + y += 1; + } + return cleared; + } + + function rotate(matrix, direction) { + const rotated = matrix.map((_, index) => matrix.map((row) => row[index])); + if (direction > 0) { + rotated.forEach((row) => row.reverse()); + } else { + rotated.reverse(); + } + return rotated; + } + + function attemptRotate(direction) { + if (!currentPiece || isPaused || isGameOver) return; + const rotated = rotate(currentPiece.matrix, direction); + const offsets = [0, -1, 1, -2, 2]; + const originalX = currentPiece.x; + + for (const offset of offsets) { + if (!collides(board, currentPiece, offset, 0, rotated)) { + currentPiece.x += offset; + currentPiece.matrix = rotated; + draw(); + return; + } + } + currentPiece.x = originalX; + } + + function movePiece(deltaX) { + if (!currentPiece || isPaused || isGameOver) return; + if (!collides(board, currentPiece, deltaX, 0)) { + currentPiece.x += deltaX; + draw(); + } + } + + function dropPiece(softDrop = false) { + if (!currentPiece || isPaused || isGameOver) return; + if (!collides(board, currentPiece, 0, 1)) { + currentPiece.y += 1; + if (softDrop) score += 1; + dropCounter = 0; + updateStats(); + draw(); + return true; + } + + mergePiece(); + piecesPlaced += 1; + const cleared = clearLines(); + if (cleared > 0) { + const lineScores = [0, 100, 300, 500, 800]; + score += lineScores[cleared] * level; + lines += cleared; + showToast(cleared === 4 ? 'Tetris! Four lines cleared.' : `${cleared} line${cleared > 1 ? 's' : ''} cleared.`); + } + + level = Math.floor(lines / 10) + 1; + dropInterval = Math.max(120, 900 - ((level - 1) * 65)); + spawnPiece(); + updateStats(); + draw(); + return false; + } + + function hardDrop() { + if (!currentPiece || isPaused || isGameOver) return; + let distance = 0; + while (!collides(board, currentPiece, 0, 1)) { + currentPiece.y += 1; + distance += 1; + } + if (distance > 0) { + score += distance * 2; + } + dropPiece(false); + } + + function calculateGhostY() { + if (!currentPiece) return 0; + let ghostY = currentPiece.y; + while (!collides(board, { ...currentPiece, y: ghostY }, 0, 1)) { + ghostY += 1; + } + return ghostY; + } + + function drawCell(ctx, x, y, color, alpha = 1) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.fillRect(x + 0.04, y + 0.04, 0.92, 0.92); + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 0.05; + ctx.strokeRect(x + 0.06, y + 0.06, 0.88, 0.88); + ctx.restore(); + } + + function drawBoard() { + boardCtx.fillStyle = '#0e1218'; + boardCtx.fillRect(0, 0, COLS, ROWS); + + for (let y = 0; y < ROWS; y += 1) { + for (let x = 0; x < COLS; x += 1) { + if (board[y][x] !== 0) { + drawCell(boardCtx, x, y, colorLookup[board[y][x]]); + } + } + } + + boardCtx.strokeStyle = 'rgba(255,255,255,0.06)'; + boardCtx.lineWidth = 0.03; + for (let x = 0; x <= COLS; x += 1) { + boardCtx.beginPath(); + boardCtx.moveTo(x, 0); + boardCtx.lineTo(x, ROWS); + boardCtx.stroke(); + } + for (let y = 0; y <= ROWS; y += 1) { + boardCtx.beginPath(); + boardCtx.moveTo(0, y); + boardCtx.lineTo(COLS, y); + boardCtx.stroke(); + } + } + + function drawPiece(piece, alpha = 1) { + piece.matrix.forEach((row, y) => { + row.forEach((value, x) => { + if (value !== 0) { + drawCell(boardCtx, x + piece.x, y + piece.y, colorLookup[value], alpha); + } + }); + }); + } + + function drawGhostPiece() { + if (!currentPiece) return; + const ghostY = calculateGhostY(); + drawPiece({ ...currentPiece, y: ghostY }, 0.2); + } + + function drawNext() { + nextCtx.clearRect(0, 0, nextCanvas.width, nextCanvas.height); + nextCtx.fillStyle = '#0e1218'; + nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height); + + const previewType = nextQueue[0]; + if (!previewType) return; + const matrix = PIECE_DEFS[previewType].matrix; + const cell = 28; + const width = matrix[0].length * cell; + const height = matrix.length * cell; + const offsetX = (nextCanvas.width - width) / 2; + const offsetY = (nextCanvas.height - height) / 2; + + matrix.forEach((row, y) => { + row.forEach((value, x) => { + if (value !== 0) { + nextCtx.fillStyle = colorLookup[value]; + nextCtx.fillRect(offsetX + (x * cell) + 2, offsetY + (y * cell) + 2, cell - 4, cell - 4); + nextCtx.strokeStyle = 'rgba(255,255,255,0.15)'; + nextCtx.strokeRect(offsetX + (x * cell) + 2, offsetY + (y * cell) + 2, cell - 4, cell - 4); + } + }); + }); + } + + function formatDuration(ms) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0'); + const seconds = String(totalSeconds % 60).padStart(2, '0'); + return `${minutes}:${seconds}`; + } + + function getElapsedMs() { + if (!startedAt) return 0; + if (endedAt) return endedAt - startedAt; + return Date.now() - startedAt; + } + + function updateStats() { + ui.score.textContent = score.toLocaleString(); + ui.lines.textContent = lines.toString(); + ui.level.textContent = level.toString(); + ui.speed.textContent = `${(dropInterval / 1000).toFixed(2)}s`; + ui.bestInline.textContent = bestScore.toLocaleString(); + ui.elapsedInline.textContent = formatDuration(getElapsedMs()); + ui.detailPieces.textContent = piecesPlaced.toString(); + ui.detailLines.textContent = lines.toString(); + ui.detailDuration.textContent = formatDuration(getElapsedMs()); + ui.detailStatus.textContent = isGameOver ? 'Game over' : (isPaused ? 'Paused' : (isRunning ? 'In progress' : 'Ready to start')); + drawNext(); + } + + function getGameStatus() { + if (isGameOver) return 'game_over'; + if (isPaused) return 'paused'; + if (isRunning) return 'playing'; + return 'ready'; + } + + function getComposedBoard() { + const composed = board.map((row) => row.slice()); + if (currentPiece) { + currentPiece.matrix.forEach((row, y) => { + row.forEach((value, x) => { + if (value === 0) return; + const boardY = y + currentPiece.y; + const boardX = x + currentPiece.x; + if (boardY < 0 || boardY >= ROWS || boardX < 0 || boardX >= COLS) return; + composed[boardY][boardX] = value; + }); + }); + } + return composed; + } + + function drawOpponentBoard(boardState) { + if (!opponentCtx) return; + opponentCtx.fillStyle = '#0e1218'; + opponentCtx.fillRect(0, 0, COLS, ROWS); + + if (Array.isArray(boardState)) { + for (let y = 0; y < ROWS; y += 1) { + for (let x = 0; x < COLS; x += 1) { + if (boardState[y] && boardState[y][x]) { + drawCell(opponentCtx, x, y, colorLookup[boardState[y][x]]); + } + } + } + } + + opponentCtx.strokeStyle = 'rgba(255,255,255,0.06)'; + opponentCtx.lineWidth = 0.03; + for (let x = 0; x <= COLS; x += 1) { + opponentCtx.beginPath(); + opponentCtx.moveTo(x, 0); + opponentCtx.lineTo(x, ROWS); + opponentCtx.stroke(); + } + for (let y = 0; y <= ROWS; y += 1) { + opponentCtx.beginPath(); + opponentCtx.moveTo(0, y); + opponentCtx.lineTo(COLS, y); + opponentCtx.stroke(); + } + } + + function setBadge(label, variant = 'ready') { + ui.badge.className = `badge status-badge state-${variant}`; + ui.badge.textContent = label; + } + + function showOverlay(title, copy, label = 'Press Enter to start') { + ui.overlay.innerHTML = ` +
+

${label}

+

${title}

+

${copy}

+
+ `; + ui.overlay.classList.remove('hidden'); + } + + function hideOverlay() { + ui.overlay.classList.add('hidden'); + } + + function showToast(message) { + ui.toastBody.textContent = message; + ui.toastTimestamp.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (bootstrapToast) bootstrapToast.show(); + } + + function loadHistory() { try { - const response = await fetch('api/chat.php', { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.history) || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + return []; + } + } + + function saveHistory(run) { + history = [run, ...history].slice(0, 8); + localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)); + if (run.score > bestScore) { + bestScore = run.score; + localStorage.setItem(STORAGE_KEYS.best, String(bestScore)); + showToast('New best score saved locally.'); + } + selectedRunId = run.id; + renderHistory(); + } + + function renderHistory() { + ui.historyList.innerHTML = ''; + ui.historyEmpty.classList.toggle('d-none', history.length > 0); + + history.forEach((run) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `history-item${selectedRunId === run.id ? ' active' : ''}`; + button.innerHTML = ` +
+ ${run.score.toLocaleString()} pts + ${new Date(run.finishedAt).toLocaleDateString()} +
+
+ Level ${run.level} · ${run.lines} line${run.lines === 1 ? '' : 's'} + ${run.duration} +
+ `; + button.addEventListener('click', () => { + selectedRunId = run.id; + renderHistory(); + }); + ui.historyList.appendChild(button); + }); + + const selectedRun = history.find((item) => item.id === selectedRunId) || history[0] || null; + if (selectedRun) { + ui.historyDetail.classList.remove('d-none'); + ui.selectedScore.textContent = selectedRun.score.toLocaleString(); + ui.selectedLevel.textContent = String(selectedRun.level); + ui.selectedLines.textContent = String(selectedRun.lines); + ui.selectedDuration.textContent = selectedRun.duration; + ui.selectedFinished.textContent = new Date(selectedRun.finishedAt).toLocaleString(); + } else { + ui.historyDetail.classList.add('d-none'); + } + } + + + function getPreferredPlayerName() { + const typedName = (ui.playerNameInput?.value || '').trim(); + if (typedName) return typedName; + if (multiplayer.displayName) return multiplayer.displayName; + return 'Player'; + } + + function formatDurationFromSeconds(totalSeconds) { + const safe = Math.max(0, Number(totalSeconds) || 0); + const minutes = Math.floor(safe / 60); + const seconds = safe % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + + function renderScoreboard() { + if (!ui.scoreboardList || !ui.scoreboardEmpty || !ui.scoreboardStatus) return; + + const hasEntries = scoreboardEntries.length > 0; + ui.scoreboardEmpty.classList.toggle('d-none', hasEntries); + ui.scoreboardList.innerHTML = ''; + + if (!hasEntries) { + ui.scoreboardStatus.textContent = 'Waiting for scores'; + return; + } + + ui.scoreboardStatus.textContent = `Top ${scoreboardEntries.length}`; + + scoreboardEntries.forEach((entry, index) => { + const item = document.createElement('article'); + item.className = 'scoreboard-item'; + + const rank = document.createElement('div'); + rank.className = 'scoreboard-rank'; + rank.textContent = `#${index + 1}`; + + const body = document.createElement('div'); + body.className = 'scoreboard-item-body'; + + const topRow = document.createElement('div'); + topRow.className = 'scoreboard-topline'; + + const name = document.createElement('strong'); + name.className = 'scoreboard-name'; + name.textContent = entry.player_name || 'Player'; + + const scoreValue = document.createElement('span'); + scoreValue.className = 'scoreboard-score'; + scoreValue.textContent = `${Number(entry.score || 0).toLocaleString()} pts`; + + topRow.appendChild(name); + topRow.appendChild(scoreValue); + + const meta = document.createElement('div'); + meta.className = 'scoreboard-meta'; + const linesLabel = Number(entry.lines || 0) === 1 ? 'line' : 'lines'; + const dateValue = entry.created_at ? new Date(String(entry.created_at).replace(' ', 'T') + 'Z') : null; + const dateLabel = dateValue && !Number.isNaN(dateValue.getTime()) ? dateValue.toLocaleDateString() : 'Today'; + const modeLabel = entry.mode === 'multiplayer' ? 'Multiplayer' : 'Solo'; + meta.textContent = `Level ${entry.level} · ${entry.lines} ${linesLabel} · ${formatDurationFromSeconds(entry.duration_seconds)} · ${modeLabel} · ${dateLabel}`; + + body.appendChild(topRow); + body.appendChild(meta); + item.appendChild(rank); + item.appendChild(body); + ui.scoreboardList.appendChild(item); + }); + } + + async function loadScoreboard() { + if (!ui.scoreboardStatus) return; + ui.scoreboardStatus.textContent = 'Loading…'; + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), SCOREBOARD_TIMEOUT_MS); + try { + const response = await fetch(`${SCOREBOARD_API_URL}?limit=10`, { + cache: 'no-store', + signal: controller.signal + }); + const payload = await response.json(); + if (!response.ok || !payload.success) { + throw new Error(payload.error || 'Unable to load scoreboard.'); + } + scoreboardEntries = Array.isArray(payload.scores) ? payload.scores : []; + renderScoreboard(); + } catch (error) { + console.warn('Scoreboard load error', error); + scoreboardEntries = []; + renderScoreboard(); + ui.scoreboardStatus.textContent = 'Offline'; + } finally { + window.clearTimeout(timeoutId); + } + } + + async function saveScoreToDatabase(run) { + try { + const response = await fetch(SCOREBOARD_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) + body: JSON.stringify({ + action: 'save_score', + player_name: getPreferredPlayerName(), + score: run.score, + lines: run.lines, + level: run.level, + pieces_placed: run.piecesPlaced, + duration_seconds: run.durationSeconds, + room_code: multiplayer.roomCode || null + }) }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); + const payload = await response.json(); + if (!response.ok || !payload.success) { + throw new Error(payload.error || 'Unable to save scoreboard entry.'); + } + await loadScoreboard(); + if (payload.placement) { + showToast(`Run saved to the database scoreboard. Rank #${payload.placement}.`); + } else { + showToast('Run saved to the database scoreboard.'); + } } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + console.warn('Scoreboard save error', error); + showToast('Run saved locally. Database scoreboard was unavailable.'); + if (ui.scoreboardStatus) { + ui.scoreboardStatus.textContent = 'Offline'; + } + } + } + + function setRoomBadge(label, variant) { + ui.roomStatusBadge.className = `badge status-badge state-${variant}`; + ui.roomStatusBadge.textContent = label; + } + + function updateOpponentUI(opponent) { + if (!opponent) { + ui.opponentName.textContent = 'Waiting for opponent…'; + ui.opponentStats.textContent = 'Score — · Level — · Lines —'; + ui.opponentStatus.textContent = 'Room idle'; + drawOpponentBoard(null); + return; + } + + ui.opponentName.textContent = opponent.display_name || 'Opponent'; + ui.opponentStats.textContent = `Score ${opponent.score.toLocaleString()} · Level ${opponent.level} · Lines ${opponent.lines}`; + ui.opponentStatus.textContent = opponent.game_status === 'playing' + ? 'In play' + : (opponent.game_status === 'paused' ? 'Paused' : (opponent.game_status === 'game_over' ? 'Game over' : 'Ready')); + drawOpponentBoard(opponent.board); + } + + function updateMultiplayerUI() { + const connected = Boolean(multiplayer.roomCode && multiplayer.token); + ui.leaveRoomBtn.classList.toggle('d-none', !connected); + ui.createRoomBtn.disabled = connected; + ui.joinRoomBtn.disabled = connected; + ui.roomCodeInput.disabled = connected; + ui.roomCodeCard.classList.toggle('d-none', !connected); + if (connected) { + ui.roomCodeValue.textContent = multiplayer.roomCode; + } else { + ui.roomCodeValue.textContent = '—'; + } + + if (!connected) { + setRoomBadge('Offline', 'ready'); + ui.roomStatusText.textContent = 'Create a room to start.'; + updateOpponentUI(null); + return; + } + + if (multiplayer.playerCount < 2) { + setRoomBadge('Waiting', 'paused'); + ui.roomStatusText.textContent = 'Share the code so a friend can join.'; + } else { + setRoomBadge('Live', 'playing'); + ui.roomStatusText.textContent = 'Opponent connected.'; + } + } + + function saveMultiplayerSession(roomCode, token, displayName, slot) { + multiplayer.roomCode = roomCode; + multiplayer.token = token; + multiplayer.displayName = displayName; + multiplayer.slot = slot; + sessionStorage.setItem(MULTI_STORAGE_KEYS.room, roomCode); + sessionStorage.setItem(MULTI_STORAGE_KEYS.token, token); + sessionStorage.setItem(MULTI_STORAGE_KEYS.name, displayName); + sessionStorage.setItem(MULTI_STORAGE_KEYS.slot, String(slot)); + updateMultiplayerUI(); + } + + function clearMultiplayerSession() { + multiplayer.roomCode = null; + multiplayer.token = null; + multiplayer.displayName = ''; + multiplayer.slot = null; + multiplayer.playerCount = 0; + multiplayer.opponent = null; + sessionStorage.removeItem(MULTI_STORAGE_KEYS.room); + sessionStorage.removeItem(MULTI_STORAGE_KEYS.token); + sessionStorage.removeItem(MULTI_STORAGE_KEYS.name); + sessionStorage.removeItem(MULTI_STORAGE_KEYS.slot); + stopMultiplayerLoop(); + updateMultiplayerUI(); + } + + async function postMultiplayer(action, data) { + const response = await fetch(MULTI_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, ...data }) + }); + const payload = await response.json(); + if (!response.ok || !payload.success) { + throw new Error(payload.error || 'Multiplayer request failed.'); + } + return payload; + } + + async function syncMultiplayer() { + if (!multiplayer.roomCode || !multiplayer.token || multiplayer.isSyncing) return; + multiplayer.isSyncing = true; + try { + await postMultiplayer('update_state', { + room_code: multiplayer.roomCode, + player_token: multiplayer.token, + board: getComposedBoard(), + piece: currentPiece ? { type: currentPiece.type } : null, + meta: { piecesPlaced, updatedAt: Date.now() }, + score, + lines, + level, + game_status: getGameStatus() + }); + + const state = await postMultiplayer('get_state', { + room_code: multiplayer.roomCode, + player_token: multiplayer.token + }); + + multiplayer.playerCount = state.room?.player_count || 0; + multiplayer.opponent = (state.players || []).find((player) => !player.is_self) || null; + updateOpponentUI(multiplayer.opponent); + updateMultiplayerUI(); + } catch (error) { + console.warn('Multiplayer sync error', error); + ui.roomStatusText.textContent = 'Connection lost. Try rejoining.'; + setRoomBadge('Error', 'game-over'); + } finally { + multiplayer.isSyncing = false; + } + } + + function startMultiplayerLoop() { + stopMultiplayerLoop(); + syncMultiplayer(); + multiplayer.pollTimer = window.setInterval(syncMultiplayer, MULTI_POLL_INTERVAL); + } + + function stopMultiplayerLoop() { + if (multiplayer.pollTimer) { + clearInterval(multiplayer.pollTimer); + multiplayer.pollTimer = null; + } + } + + function resetState() { + board = createBoard(); + nextQueue = []; + bag = []; + score = 0; + lines = 0; + level = 1; + piecesPlaced = 0; + dropInterval = 900; + dropCounter = 0; + lastTime = 0; + startedAt = Date.now(); + endedAt = null; + isRunning = true; + isPaused = false; + isGameOver = false; + fillQueue(); + spawnPiece(); + hideOverlay(); + setBadge('Playing', 'playing'); + ui.pauseButton.textContent = 'Pause'; + updateStats(); + draw(); + } + + function startGame() { + if (gameOverModal) gameOverModal.hide(); + resetState(); + showToast('Game started. Good luck.'); + syncMultiplayer(); + } + + function pauseGame(showNotice = true) { + if (!isRunning || isGameOver) return; + isPaused = !isPaused; + setBadge(isPaused ? 'Paused' : 'Playing', isPaused ? 'paused' : 'playing'); + ui.pauseButton.textContent = isPaused ? 'Resume' : 'Pause'; + ui.detailStatus.textContent = isPaused ? 'Paused' : 'In progress'; + if (isPaused) { + showOverlay('Paused', 'Press P to resume, or restart for a clean board.', 'Game paused'); + } else { + hideOverlay(); + } + if (showNotice) showToast(isPaused ? 'Game paused.' : 'Back in play.'); + syncMultiplayer(); + } + + function finishGame() { + endedAt = Date.now(); + isGameOver = true; + isRunning = false; + isPaused = false; + setBadge('Game over', 'game-over'); + ui.pauseButton.textContent = 'Pause'; + showOverlay('Run complete', 'Restart to jump into a fresh round. This run was saved locally and is being submitted to the scoreboard.', 'Game over'); + + const elapsedMs = getElapsedMs(); + const run = { + id: `${Date.now()}`, + score, + lines, + level, + piecesPlaced, + duration: formatDuration(elapsedMs), + durationSeconds: Math.max(0, Math.round(elapsedMs / 1000)), + finishedAt: endedAt + }; + saveHistory(run); + + ui.finalScore.textContent = score.toLocaleString(); + ui.finalLines.textContent = lines.toString(); + ui.finalLevel.textContent = level.toString(); + ui.finalDuration.textContent = run.duration; + updateStats(); + draw(); + showToast('Game over. Run saved locally.'); + if (gameOverModal) gameOverModal.show(); + void saveScoreToDatabase(run); + syncMultiplayer(); + } + + function draw() { + drawBoard(); + if (currentPiece && !isGameOver) { + drawGhostPiece(); + drawPiece(currentPiece); + } else if (currentPiece && isGameOver) { + drawPiece(currentPiece, 0.8); + } + } + + function update(time = 0) { + const deltaTime = time - lastTime; + lastTime = time; + + if (isRunning && !isPaused && !isGameOver) { + dropCounter += deltaTime; + if (dropCounter > dropInterval) { + dropPiece(false); + } + } + + updateStats(); + draw(); + animationFrameId = requestAnimationFrame(update); + } + + function handleKeydown(event) { + const code = event.code; + const gameKeys = ['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp', 'KeyX', 'KeyZ', 'Space', 'KeyP', 'KeyR', 'Enter']; + if (gameKeys.includes(code)) { + event.preventDefault(); + } + + if ((code === 'Enter' && !isRunning) || (code === 'KeyR')) { + startGame(); + return; + } + + if (!isRunning || isGameOver) { + return; + } + + switch (code) { + case 'ArrowLeft': + movePiece(-1); + break; + case 'ArrowRight': + movePiece(1); + break; + case 'ArrowDown': + dropPiece(true); + break; + case 'ArrowUp': + case 'KeyX': + attemptRotate(1); + break; + case 'KeyZ': + attemptRotate(-1); + break; + case 'Space': + hardDrop(); + break; + case 'KeyP': + pauseGame(true); + break; + default: + break; + } + } + + safeOn(ui.playerNameInput, 'change', () => { + const name = (ui.playerNameInput.value || '').trim(); + if (name) { + sessionStorage.setItem(MULTI_STORAGE_KEYS.name, name); } }); + + safeOn(ui.createRoomBtn, 'click', async () => { + if (multiplayer.roomCode) return; + const displayName = (ui.playerNameInput.value || '').trim() || 'Player 1'; + try { + const result = await postMultiplayer('create_room', { display_name: displayName }); + saveMultiplayerSession(result.room.code, result.self.token, result.self.display_name, result.self.slot); + startMultiplayerLoop(); + showToast(`Room ${result.room.code} created. Share the code.`); + } catch (error) { + showToast(error.message || 'Unable to create room.'); + } + }); + + safeOn(ui.joinRoomBtn, 'click', async () => { + if (multiplayer.roomCode) return; + const roomCode = (ui.roomCodeInput.value || '').trim(); + if (!roomCode) { + showToast('Enter a room code to join.'); + return; + } + const displayName = (ui.playerNameInput.value || '').trim() || 'Player 2'; + try { + const result = await postMultiplayer('join_room', { room_code: roomCode, display_name: displayName }); + saveMultiplayerSession(result.room.code, result.self.token, result.self.display_name, result.self.slot); + startMultiplayerLoop(); + showToast(`Joined room ${result.room.code}.`); + } catch (error) { + showToast(error.message || 'Unable to join room.'); + } + }); + + safeOn(ui.leaveRoomBtn, 'click', async () => { + if (!multiplayer.roomCode || !multiplayer.token) return; + try { + await postMultiplayer('leave_room', { room_code: multiplayer.roomCode, player_token: multiplayer.token }); + } catch (error) { + console.warn('Leave room error', error); + } finally { + clearMultiplayerSession(); + showToast('Left the room.'); + } + }); + + safeOn(ui.copyRoomBtn, 'click', async () => { + if (!multiplayer.roomCode) return; + try { + await navigator.clipboard.writeText(multiplayer.roomCode); + showToast('Room code copied.'); + } catch (error) { + showToast('Copy failed. Select and copy the code manually.'); + } + }); + + safeOn(ui.pauseButton, 'click', () => { + if (!isRunning || isGameOver) { + showToast('Start a run before using pause.'); + return; + } + pauseGame(true); + }); + + safeOn(ui.restartButton, 'click', startGame); + safeOn(ui.modalRestartButton, 'click', startGame); + document.addEventListener('keydown', handleKeydown); + if (gameOverModalEl) { + gameOverModalEl.addEventListener('hidden.bs.modal', () => { + if (isGameOver) { + boardCanvas.focus?.(); + } + }); + } + + const savedName = sessionStorage.getItem(MULTI_STORAGE_KEYS.name); + if (savedName && ui.playerNameInput) { + ui.playerNameInput.value = savedName; + } + const savedRoom = sessionStorage.getItem(MULTI_STORAGE_KEYS.room); + const savedToken = sessionStorage.getItem(MULTI_STORAGE_KEYS.token); + const savedSlot = sessionStorage.getItem(MULTI_STORAGE_KEYS.slot); + if (savedRoom && savedToken) { + saveMultiplayerSession(savedRoom, savedToken, savedName || 'Player', Number(savedSlot || 0)); + startMultiplayerLoop(); + } + + renderHistory(); + void loadScoreboard(); + updateStats(); + updateMultiplayerUI(); + showOverlay('Stack clean. Clear fast.', 'Use the keyboard to play a faithful Tetris loop with local history and a database scoreboard.', 'Press Enter to start'); + setBadge('Ready', 'ready'); + update(); }); diff --git a/db/migrate.php b/db/migrate.php new file mode 100644 index 0000000..bbbbd89 --- /dev/null +++ b/db/migrate.php @@ -0,0 +1,51 @@ +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(); + } +} diff --git a/db/migrations/20260325_tetris_multiplayer_rooms.sql b/db/migrations/20260325_tetris_multiplayer_rooms.sql new file mode 100644 index 0000000..4f4f130 --- /dev/null +++ b/db/migrations/20260325_tetris_multiplayer_rooms.sql @@ -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; diff --git a/db/migrations/20260325_tetris_scoreboard.sql b/db/migrations/20260325_tetris_scoreboard.sql new file mode 100644 index 0000000..2bc22d2 --- /dev/null +++ b/db/migrations/20260325_tetris_scoreboard.sql @@ -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; diff --git a/index.php b/index.php index 7205f3d..1fb5c8d 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,299 @@ - - - New Style - - - - - - - - - + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + - - - - - - + + - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… + + + +
+
+
+
+

Arcade game web app

+

Classic Tetris

+
+
+ Ready + + +
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+
+ +
+
+
+
+
+
+

Playfield

+

Pure gameplay, zero filler

+
+
+ Best 0 + Time 00:00 +
+
+ +
+
+ +
+
+

Press Enter to start

+

Stack clean. Clear fast.

+

Controls: move with arrow keys, rotate with ↑ or X, hard drop with Space.

+
+
+
+ +
+
+ Score + 0 +
+
+ Lines + 0 +
+
+ Level + 1 +
+
+ Drop speed + 0.90s +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ Tetris + now + +
+
Ready.
-
- + + + + + +