Compare commits

..

3 Commits

Author SHA1 Message Date
Flatlogic Bot
1a199bfeb2 Autosave: 20260325-174136 2026-03-25 17:41:36 +00:00
Flatlogic Bot
342ba02e74 1 2026-03-25 17:37:01 +00:00
Flatlogic Bot
373f8b6ba0 Autosave: 20260325-171910 2026-03-25 17:19:10 +00:00
8 changed files with 2850 additions and 511 deletions

493
api/multiplayer.php Normal file
View 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
View 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;
}

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

51
db/migrate.php Normal file
View 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();
}
}

View 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;

View 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
View File

@ -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>