Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
b3cf9df956 123 2026-01-22 16:53:25 +00:00
11 changed files with 1420 additions and 475 deletions

134
about.php Normal file
View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
require_once 'db/config.php';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Learn more about the creator of Classic Tetris.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About the Author - Classic Tetris</title>
<meta name="description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link 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;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<style>
.profile-header {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border: 1px solid #334155;
color: white;
padding: 4rem 0;
border-radius: 12px;
margin-bottom: 3rem;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
}
.profile-img {
width: 150px;
height: 150px;
object-fit: cover;
border: 5px solid rgba(255, 255, 255, 0.2);
}
.profile-icon-container {
background: #1e293b;
display: inline-block;
border-radius: 50%;
padding: 10px;
border: 2px solid #3b82f6;
}
.profile-icon-inner {
background: #0f172a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 140px;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg py-3">
<div class="container">
<a class="navbar-brand fw-bold" href="/">TETRIS</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="about.php">About Author</a>
</li>
</ul>
</div>
<a href="/" class="btn btn-outline-info btn-sm fw-semibold">Back to Game</a>
</div>
</nav>
<main class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="profile-header text-center shadow-sm">
<div class="mb-3">
<div class="profile-icon-container">
<div class="profile-icon-inner">
<span class="fs-1">👨‍💻</span>
</div>
</div>
</div>
<h1 class="fw-bold mb-2">Alex Dev</h1>
<p class="lead text-muted mb-0">Web Developer & Retro Game Enthusiast</p>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4 p-md-5">
<h2 class="fw-bold mb-4">The Story Behind</h2>
<p class="text-muted mb-4">
Hello! I'm Alex, a passionate developer who loves bringing classic arcade experiences to the modern web.
This Tetris clone was built as a demonstration of what can be achieved with pure PHP, JavaScript, and Canvas.
</p>
<div class="row g-4 mt-2">
<div class="col-md-6">
<h5 class="fw-bold text-info"><span class="me-2">🚀</span> My Mission</h5>
<p class="small text-muted">To create simple, engaging, and accessible web applications that remind us of the golden age of gaming.</p>
</div>
<div class="col-md-6">
<h5 class="fw-bold text-info"><span class="me-2">🛠️</span> Tech Stack</h5>
<p class="small text-muted">For this project, I used PHP for the backend persistence, MySQL for high scores, and Canvas API for the game engine.</p>
</div>
</div>
<hr class="my-5 border-secondary opacity-25">
<div class="text-center">
<h3 class="fw-bold mb-4">Get in Touch</h3>
<div class="d-flex justify-content-center gap-3">
<a href="#" class="btn btn-dark border-secondary fw-medium px-4">GitHub</a>
<a href="#" class="btn btn-dark border-secondary fw-medium px-4">Twitter</a>
<a href="#" class="btn btn-primary fw-medium px-4">Contact Me</a>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="container py-4 border-top border-secondary text-center">
<p class="text-muted small mb-1">&copy; <?= date('Y') ?> Classic Tetris. Built with PHP & Canvas.</p>
<a href="/" class="text-decoration-none small text-muted">Back to Game</a>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

199
api/multiplayer.php Normal file
View File

@ -0,0 +1,199 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
$action = $_GET['action'] ?? '';
$input = json_decode(file_get_contents('php://input'), true);
try {
$pdo = db();
switch ($action) {
case 'set_nickname':
$nickname = trim($input['nickname'] ?? '');
$session_id = $input['player_id'] ?? '';
if (!$nickname || !$session_id) {
echo json_encode(['success' => false, 'error' => 'Missing data']);
break;
}
$stmt = $pdo->prepare("INSERT INTO players (nickname, session_id, last_seen) VALUES (?, ?, NOW()) ON DUPLICATE KEY UPDATE nickname = ?, last_seen = NOW()");
$stmt->execute([$nickname, $session_id, $nickname]);
echo json_encode(['success' => true]);
break;
case 'heartbeat':
$session_id = $input['player_id'] ?? '';
if ($session_id) {
$stmt = $pdo->prepare("UPDATE players SET last_seen = NOW() WHERE session_id = ?");
$stmt->execute([$session_id]);
}
echo json_encode(['success' => true]);
break;
case 'get_online':
$session_id = $_GET['player_id'] ?? '';
$stmt = $pdo->prepare("SELECT id, nickname, session_id FROM players WHERE last_seen > DATE_SUB(NOW(), INTERVAL 30 SECOND) AND session_id != ? LIMIT 20");
$stmt->execute([$session_id]);
$players = $stmt->fetchAll();
echo json_encode(['success' => true, 'players' => $players]);
break;
case 'invite':
$from_session = $input['from_player_id'] ?? '';
$to_session = $input['to_player_id'] ?? '';
$room_code = strtoupper(substr(md5(uniqid()), 0, 6));
$stmt = $pdo->prepare("SELECT id FROM players WHERE session_id = ?");
$stmt->execute([$from_session]);
$from = $stmt->fetch();
$stmt = $pdo->prepare("SELECT id FROM players WHERE session_id = ?");
$stmt->execute([$to_session]);
$to = $stmt->fetch();
if ($from && $to) {
$stmt = $pdo->prepare("INSERT INTO invitations (from_player_id, to_player_id, room_code) VALUES (?, ?, ?)");
$stmt->execute([$from['id'], $to['id'], $room_code]);
// Pre-create the room
$stmt = $pdo->prepare("INSERT INTO rooms (room_code, player1_id, status) VALUES (?, ?, 'waiting')");
$stmt->execute([$room_code, $from_session]);
echo json_encode(['success' => true, 'room_code' => $room_code]);
} else {
echo json_encode(['success' => false, 'error' => 'Player not found']);
}
break;
case 'check_invites':
$session_id = $_GET['player_id'] ?? '';
$stmt = $pdo->prepare("
SELECT i.id, p.nickname as from_nickname, i.room_code
FROM invitations i
JOIN players p ON i.from_player_id = p.id
JOIN players target ON i.to_player_id = target.id
WHERE target.session_id = ? AND i.status = 'pending' AND i.created_at > DATE_SUB(NOW(), INTERVAL 1 MINUTE)
");
$stmt->execute([$session_id]);
$invites = $stmt->fetchAll();
echo json_encode(['success' => true, 'invites' => $invites]);
break;
case 'respond_invite':
$invite_id = $input['invite_id'] ?? 0;
$status = $input['status'] ?? 'rejected';
$stmt = $pdo->prepare("UPDATE invitations SET status = ? WHERE id = ?");
$stmt->execute([$status, $invite_id]);
echo json_encode(['success' => true]);
break;
case 'create':
$player_id = $input['player_id'] ?? '';
$room_code = $input['room_code'] ?? strtoupper(substr(md5(uniqid()), 0, 6));
$stmt = $pdo->prepare("INSERT INTO rooms (room_code, player1_id, status) VALUES (?, ?, 'waiting') ON DUPLICATE KEY UPDATE player1_id = VALUES(player1_id)");
$stmt->execute([$room_code, $player_id]);
$room_id = $pdo->lastInsertId();
if (!$room_id) {
$stmt = $pdo->prepare("SELECT id FROM rooms WHERE room_code = ?");
$stmt->execute([$room_code]);
$room_id = $stmt->fetchColumn();
}
echo json_encode(['success' => true, 'room_code' => $room_code, 'room_id' => $room_id]);
break;
case 'join':
$player_id = $input['player_id'] ?? '';
$room_code = strtoupper($input['room_code'] ?? '');
$stmt = $pdo->prepare("SELECT id, player1_id, player2_id FROM rooms WHERE room_code = ? AND (status = 'waiting' OR status = 'playing')");
$stmt->execute([$room_code]);
$room = $stmt->fetch();
if ($room) {
if ($room['player1_id'] === $player_id) {
echo json_encode(['success' => true, 'room_id' => $room['id']]);
} else {
$stmt = $pdo->prepare("UPDATE rooms SET player2_id = ?, status = 'playing' WHERE id = ?");
$stmt->execute([$player_id, $room['id']]);
echo json_encode(['success' => true, 'room_id' => $room['id']]);
}
} else {
echo json_encode(['success' => false, 'error' => 'Room not found or full']);
}
break;
case 'update':
$room_id = $input['room_id'] ?? 0;
$player_id = $input['player_id'] ?? '';
$board = json_encode($input['board'] ?? []);
$score = $input['score'] ?? 0;
$is_game_over = $input['is_game_over'] ? 1 : 0;
$stmt = $pdo->prepare("INSERT INTO game_states (room_id, player_id, board, score, is_game_over)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE board = VALUES(board), score = VALUES(score), is_game_over = VALUES(is_game_over)");
$stmt->execute([$room_id, $player_id, $board, $score, $is_game_over]);
echo json_encode(['success' => true]);
break;
case 'poll':
$room_id = $_GET['room_id'] ?? 0;
$player_id = $_GET['player_id'] ?? '';
$stmt = $pdo->prepare("SELECT player_id, board, score, is_game_over FROM game_states WHERE room_id = ? AND player_id != ?");
$stmt->execute([$room_id, $player_id]);
$opponent = $stmt->fetch();
$stmt = $pdo->prepare("SELECT pending_debuffs FROM game_states WHERE room_id = ? AND player_id = ?");
$stmt->execute([$room_id, $player_id]);
$self = $stmt->fetch();
$debuffs = [];
if ($self && $self['pending_debuffs']) {
$debuffs = explode(',', $self['pending_debuffs']);
$stmt = $pdo->prepare("UPDATE game_states SET pending_debuffs = NULL WHERE room_id = ? AND player_id = ?");
$stmt->execute([$room_id, $player_id]);
}
$stmt = $pdo->prepare("SELECT status FROM rooms WHERE id = ?");
$stmt->execute([$room_id]);
$room = $stmt->fetch();
echo json_encode([
'success' => true,
'opponent' => $opponent ? [
'board' => json_decode($opponent['board']),
'score' => $opponent['score'],
'is_game_over' => (bool)$opponent['is_game_over']
] : null,
'status' => $room['status'] ?? 'unknown',
'debuffs' => $debuffs
]);
break;
case 'send_debuff':
$room_id = $input['room_id'] ?? 0;
$player_id = $input['player_id'] ?? '';
$debuff = $input['debuff'] ?? '';
$stmt = $pdo->prepare("SELECT player_id FROM game_states WHERE room_id = ? AND player_id != ?");
$stmt->execute([$room_id, $player_id]);
$opponent = $stmt->fetch();
if ($opponent) {
$stmt = $pdo->prepare("UPDATE game_states SET pending_debuffs = IF(pending_debuffs IS NULL, ?, CONCAT(pending_debuffs, ',', ?)) WHERE room_id = ? AND player_id = ?");
$stmt->execute([$debuff, $debuff, $room_id, $opponent['player_id']]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Opponent not found']);
}
break;
default:
echo json_encode(['success' => false, 'error' => 'Invalid action']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

View File

@ -1,346 +1,153 @@
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-primary: #2563EB; /* Vibrant Blue */
--color-secondary: #000000;
--color-accent: #A3E635; /* Lime Green */
--color-surface: #f8f9fa;
--font-heading: 'Space Grotesk', sans-serif;
--font-body: 'Inter', sans-serif;
--border-width: 2px;
--shadow-hard: 5px 5px 0px #000;
--shadow-hover: 8px 8px 0px #000;
--radius-pill: 50rem;
--radius-card: 1rem;
}
body { body {
font-family: var(--font-body); background-color: #0f172a; /* Darker background for neon effects */
background-color: var(--color-bg); color: #f8fafc;
color: var(--color-text); font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow-x: hidden; -webkit-font-smoothing: antialiased;
} }
h1, h2, h3, h4, h5, h6, .navbar-brand {
font-family: var(--font-heading);
letter-spacing: -0.03em;
}
/* Utilities */
.text-primary { color: var(--color-primary) !important; }
.bg-black { background-color: #000 !important; }
.text-white { color: #fff !important; }
.shadow-hard { box-shadow: var(--shadow-hard); }
.border-2-black { border: var(--border-width) solid #000; }
.py-section { padding-top: 5rem; padding-bottom: 5rem; }
/* Navbar */
.navbar { .navbar {
background: rgba(255, 255, 255, 0.9); background-color: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-bottom: var(--border-width) solid transparent; border-bottom: 1px solid #1e293b !important;
transition: all 0.3s;
padding-top: 1rem;
padding-bottom: 1rem;
} }
.navbar.scrolled { .navbar-brand, .nav-link, .text-muted {
border-bottom-color: #000; color: #94a3b8 !important;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
} }
.brand-text { .navbar-brand {
font-size: 1.5rem; color: #f8fafc !important;
font-weight: 800;
} }
.nav-link { .game-container {
font-weight: 500; display: inline-block;
color: var(--color-text); border-radius: 8px;
margin-left: 1rem; background: #000;
position: relative; border: 2px solid #1e293b !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 10px rgba(59, 130, 246, 0.2);
overflow: hidden;
} }
.nav-link:hover, .nav-link.active { #tetris, #opponent-tetris {
color: var(--color-primary); display: block;
background-color: #000;
}
.card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
color: #f8fafc;
}
.card-title {
color: #f8fafc;
} }
/* Buttons */
.btn { .btn {
font-weight: 700; border-radius: 6px;
font-family: var(--font-heading); transition: all 0.2s ease-in-out;
padding: 0.8rem 2rem;
border-radius: var(--radius-pill);
border: var(--border-width) solid #000;
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
box-shadow: var(--shadow-hard);
}
.btn:hover {
transform: translate(-2px, -2px);
box-shadow: var(--shadow-hover);
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: 0 0 0 #000;
} }
.btn-primary { .btn-primary {
background-color: var(--color-primary); background-color: #3b82f6;
border-color: #000; border-color: #3b82f6;
color: #fff;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #1d4ed8; background-color: #2563eb;
border-color: #000; border-color: #2563eb;
color: #fff; box-shadow: 0 0 15px rgba(59, 130, 246, 0.5);
}
.btn-dark {
background-color: #0f172a;
border-color: #334155;
} }
.btn-outline-dark { .btn-outline-dark {
background-color: #fff; color: #94a3b8;
color: #000; border-color: #334155;
} }
.btn-cta { .btn-outline-dark:hover {
background-color: var(--color-accent); background-color: #334155;
color: #000; color: #f8fafc;
} }
.btn-cta:hover { .btn-outline-info {
background-color: #8cc629; color: #3b82f6;
color: #000; border-color: #3b82f6;
} }
/* Hero Section */ .btn-outline-info:hover {
.hero-section { background-color: #3b82f6;
min-height: 100vh; color: #f8fafc;
padding-top: 80px;
} }
.background-blob { .next-piece-container {
position: absolute; background-color: #0f172a !important;
border-radius: 50%; border: 1px solid #334155 !important;
filter: blur(80px); border-radius: 6px;
opacity: 0.6;
z-index: 1;
} }
.blob-1 { #next-piece {
top: -10%; background-color: transparent !important;
right: -10%;
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--color-accent), transparent);
} }
.blob-2 { .list-group-item {
bottom: 10%; background-color: transparent;
left: -10%; border-color: #334155;
width: 500px; color: #94a3b8;
height: 500px;
background: radial-gradient(circle, var(--color-primary), transparent);
} }
.highlight-text {
background: linear-gradient(120deg, transparent 0%, transparent 40%, var(--color-accent) 40%, var(--color-accent) 100%);
background-repeat: no-repeat;
background-size: 100% 40%;
background-position: 0 88%;
padding: 0 5px;
}
.dot { color: var(--color-primary); }
.badge-pill {
display: inline-block;
padding: 0.5rem 1rem;
border: 2px solid #000;
border-radius: 50px;
font-weight: 700;
background: #fff;
box-shadow: 4px 4px 0 #000;
font-family: var(--font-heading);
font-size: 0.9rem;
}
/* Marquee */
.marquee-container {
overflow: hidden;
white-space: nowrap;
border-top: 2px solid #000;
border-bottom: 2px solid #000;
}
.rotate-divider {
transform: rotate(-2deg) scale(1.05);
z-index: 10;
position: relative;
margin-top: -50px;
margin-bottom: 30px;
}
.marquee-content {
display: inline-block;
animation: marquee 20s linear infinite;
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.5rem;
letter-spacing: 2px;
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
/* Portfolio Cards */
.project-card {
border: 2px solid #000;
border-radius: var(--radius-card);
overflow: hidden;
background: #fff;
transition: transform 0.3s ease;
box-shadow: var(--shadow-hard);
height: 100%;
display: flex;
flex-direction: column;
}
.project-card:hover {
transform: translateY(-10px);
box-shadow: 8px 8px 0 #000;
}
.card-img-holder {
height: 250px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 2px solid #000;
position: relative;
font-size: 4rem;
}
.placeholder-art {
transition: transform 0.3s ease;
}
.project-card:hover .placeholder-art {
transform: scale(1.2) rotate(10deg);
}
.bg-soft-blue { background-color: #e0f2fe; }
.bg-soft-green { background-color: #dcfce7; }
.bg-soft-purple { background-color: #f3e8ff; }
.bg-soft-yellow { background-color: #fef9c3; }
.category-tag {
position: absolute;
top: 15px;
right: 15px;
background: #000;
color: #fff;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 700;
}
.card-body { padding: 1.5rem; }
.link-arrow {
text-decoration: none;
color: #000;
font-weight: 700;
display: inline-flex;
align-items: center;
margin-top: auto;
}
.link-arrow i { transition: transform 0.2s; margin-left: 5px; }
.link-arrow:hover i { transform: translateX(5px); }
/* About */
.about-image-stack {
position: relative;
height: 400px;
width: 100%;
}
.stack-card {
position: absolute;
width: 80%;
height: 100%;
border-radius: var(--radius-card);
border: 2px solid #000;
box-shadow: var(--shadow-hard);
left: 10%;
transform: rotate(-3deg);
background-size: cover;
}
/* Forms */
.form-control { .form-control {
border: 2px solid #000; background-color: #0f172a;
border-radius: 0.5rem; border: 1px solid #334155;
padding: 1rem; color: #f8fafc;
font-weight: 500;
background: #f8f9fa;
} }
.form-control:focus { .form-control:focus {
box-shadow: 4px 4px 0 var(--color-primary); background-color: #0f172a;
border-color: #000; border-color: #3b82f6;
background: #fff; color: #f8fafc;
box-shadow: 0 0 0 0.25rem rgba(59, 130, 246, 0.25);
} }
/* Animations */ .form-control::placeholder {
.animate-up { color: #64748b;
opacity: 0;
transform: translateY(30px);
animation: fadeUp 0.8s ease forwards;
} }
.delay-100 { animation-delay: 0.1s; } /* Multiplayer specific */
.delay-200 { animation-delay: 0.2s; } #multiplayer-status-bar {
border-radius: 8px;
@keyframes fadeUp { font-weight: 600;
to { background-color: #1e293b;
opacity: 1; border: 1px solid #3b82f6;
transform: translateY(0); color: #3b82f6;
}
} }
/* Social */ #opponent-column {
.social-links a { transition: opacity 0.5s ease;
transition: transform 0.2s;
display: inline-block;
}
.social-links a:hover {
transform: scale(1.2) rotate(10deg);
color: var(--color-accent) !important;
} }
/* Responsive */ #player-label, #opponent-label {
@media (max-width: 991px) { letter-spacing: 0.15em;
.rotate-divider { font-size: 0.7rem;
transform: rotate(0); color: #64748b;
margin-top: 0;
margin-bottom: 2rem;
}
.hero-section {
padding-top: 120px;
text-align: center;
min-height: auto;
padding-bottom: 100px;
}
.display-1 { font-size: 3.5rem; }
.blob-1 { width: 300px; height: 300px; right: -20%; }
.blob-2 { width: 300px; height: 300px; left: -20%; }
} }
#score-val, #level-val, #opponent-score-val {
color: #3b82f6;
font-weight: 700;
}
.badge.bg-dark {
background-color: #0f172a !important;
border: 1px solid #334155;
}
hr {
border-color: #334155;
opacity: 0.5;
}

View File

@ -1,73 +1,775 @@
document.addEventListener('DOMContentLoaded', () => { const canvas = document.getElementById('tetris');
const context = canvas.getContext('2d');
// Smooth scrolling for navigation links const nextCanvas = document.getElementById('next-piece');
document.querySelectorAll('a[href^="#"]').forEach(anchor => { const nextContext = nextCanvas.getContext('2d');
anchor.addEventListener('click', function (e) { const opponentCanvas = document.getElementById('opponent-tetris');
e.preventDefault(); const opponentContext = opponentCanvas ? opponentCanvas.getContext('2d') : null;
const targetId = this.getAttribute('href');
if (targetId === '#') return;
const targetElement = document.querySelector(targetId);
if (targetElement) {
// Close mobile menu if open
const navbarToggler = document.querySelector('.navbar-toggler');
const navbarCollapse = document.querySelector('.navbar-collapse');
if (navbarCollapse.classList.contains('show')) {
navbarToggler.click();
}
// Scroll with offset const mpSetup = document.getElementById('mp-setup');
const offset = 80; const mpActive = document.getElementById('mp-active');
const elementPosition = targetElement.getBoundingClientRect().top; const mpStatusBar = document.getElementById('multiplayer-status-bar');
const offsetPosition = elementPosition + window.pageYOffset - offset; const mpStatusText = document.getElementById('multiplayer-status-text');
const activeRoomCode = document.getElementById('active-room-code');
const displayRoomCode = document.getElementById('display-room-code');
const opponentColumn = document.getElementById('opponent-column');
const debuffNotifications = document.getElementById('debuff-notifications');
const onlinePlayersList = document.getElementById('online-players-list');
const onlineCountBadge = document.getElementById('online-count');
const playerNameInput = document.getElementById('player-name');
const inviteContainer = document.getElementById('invite-container');
window.scrollTo({ context.scale(20, 20);
top: offsetPosition, nextContext.scale(20, 20);
behavior: "smooth" if (opponentContext) opponentContext.scale(10, 10);
});
} let isMultiplayer = false;
}); let roomId = null;
let playerId = localStorage.getItem('tetris_player_id') || Math.random().toString(36).substring(2, 10);
localStorage.setItem('tetris_player_id', playerId);
let roomStatus = 'waiting';
let opponentArena = null;
let opponentScore = 0;
let pollTimer = null;
let updateTimer = null;
let lastDebuffScore = 0;
let isSpeedSurge = false;
let isInputScrambled = false;
let speedSurgeTimer = null;
let scrambleTimer = null;
// Effects
let screenShake = 0;
let particles = [];
let floatingTexts = [];
class Particle {
constructor(x, y, color) {
this.x = x;
this.y = y;
this.color = color;
this.size = Math.random() * 0.15 + 0.05;
this.vx = (Math.random() - 0.5) * 0.4;
this.vy = (Math.random() - 0.5) * 0.4;
this.life = 1.0;
this.decay = Math.random() * 0.03 + 0.02;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += 0.01; // gravity
this.life -= this.decay;
}
draw(ctx) {
ctx.save();
ctx.globalAlpha = this.life;
ctx.fillStyle = this.color;
ctx.shadowBlur = 5;
ctx.shadowColor = this.color;
ctx.fillRect(this.x, this.y, this.size, this.size);
ctx.restore();
}
}
function addFloatingText(text, x, y, color) {
floatingTexts.push({
text, x, y, color,
life: 1.0,
decay: 0.02
}); });
}
// Navbar scroll effect function shakeScreen(intensity) {
const navbar = document.querySelector('.navbar'); screenShake = intensity;
window.addEventListener('scroll', () => { }
if (window.scrollY > 50) {
navbar.classList.add('scrolled', 'shadow-sm', 'bg-white'); function createExplosion(x, y, color) {
navbar.classList.remove('bg-transparent'); for (let i = 0; i < 15; i++) {
} else { particles.push(new Particle(x, y, color));
navbar.classList.remove('scrolled', 'shadow-sm', 'bg-white'); }
navbar.classList.add('bg-transparent'); }
function arenaSweep() {
let rowCount = 0;
outer: for (let y = arena.length - 1; y > 0; --y) {
for (let x = 0; x < arena[y].length; ++x) {
if (arena[y][x] === 0) {
continue outer;
}
} }
});
// Intersection Observer for fade-up animations const row = arena.splice(y, 1)[0];
const observerOptions = { arena.unshift(new Array(row.length).fill(0));
threshold: 0.1, ++y;
rootMargin: "0px 0px -50px 0px"
};
const observer = new IntersectionObserver((entries) => { rowCount++;
entries.forEach(entry => {
if (entry.isIntersecting) { row.forEach((value, x) => {
entry.target.classList.add('animate-up'); if (value !== 0) {
entry.target.style.opacity = "1"; createExplosion(x, y - 1, colors[value]);
observer.unobserve(entry.target); // Only animate once
} }
}); });
}, observerOptions); }
// Select elements to animate (add a class 'reveal' to them in HTML if not already handled by CSS animation)
// For now, let's just make sure the hero animations run.
// If we want scroll animations, we'd add opacity: 0 to elements in CSS and reveal them here.
// Given the request, the CSS animation I added runs on load for Hero.
// Let's make the project cards animate in.
const projectCards = document.querySelectorAll('.project-card'); if (rowCount > 0) {
projectCards.forEach((card, index) => { player.score += rowCount * 10;
card.style.opacity = "0"; shakeScreen(rowCount * 0.1);
card.style.animationDelay = `${index * 0.1}s`; addFloatingText(`+${rowCount * 10}`, player.pos.x + 2, player.pos.y, '#fff');
observer.observe(card); }
}
function collide(arena, player) {
const [m, o] = [player.matrix, player.pos];
for (let y = 0; y < m.length; ++y) {
for (let x = 0; x < m[y].length; ++x) {
if (m[y][x] !== 0 &&
(arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) {
return true;
}
}
}
return false;
}
function createMatrix(w, h) {
const matrix = [];
while (h--) {
matrix.push(new Array(w).fill(0));
}
return matrix;
}
function createPiece(type) {
if (type === 'I') {
return [
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0],
];
} else if (type === 'L') {
return [
[0, 2, 0],
[0, 2, 0],
[0, 2, 2],
];
} else if (type === 'J') {
return [
[0, 3, 0],
[0, 3, 0],
[3, 3, 0],
];
} else if (type === 'O') {
return [
[4, 4],
[4, 4],
];
} else if (type === 'Z') {
return [
[5, 5, 0],
[0, 5, 5],
[0, 0, 0],
];
} else if (type === 'S') {
return [
[0, 6, 6],
[6, 6, 0],
[0, 0, 0],
];
} else if (type === 'T') {
return [
[0, 7, 0],
[7, 7, 7],
[0, 0, 0],
];
}
}
function drawMatrix(matrix, offset, ctx) {
matrix.forEach((row, y) => {
row.forEach((value, x) => {
if (value !== 0) {
ctx.save();
ctx.fillStyle = colors[value];
ctx.shadowBlur = 10;
ctx.shadowColor = colors[value];
ctx.fillRect(x + offset.x, y + offset.y, 1, 1);
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 0.05;
ctx.strokeRect(x + offset.x, y + offset.y, 1, 1);
ctx.restore();
}
});
});
}
function drawBackground(ctx, w, h) {
ctx.strokeStyle = '#111';
ctx.lineWidth = 0.02;
for (let x = 0; x <= w; x++) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = 0; y <= h; y++) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
function draw() {
context.fillStyle = '#000';
context.fillRect(0, 0, canvas.width, canvas.height);
context.save();
if (screenShake > 0) {
context.translate((Math.random() - 0.5) * screenShake, (Math.random() - 0.5) * screenShake);
screenShake *= 0.9;
if (screenShake < 0.01) screenShake = 0;
}
drawBackground(context, 12, 20);
drawMatrix(arena, {x: 0, y: 0}, context);
drawMatrix(player.matrix, player.pos, context);
particles.forEach((p, i) => {
p.update();
p.draw(context);
if (p.life <= 0) particles.splice(i, 1);
}); });
}); floatingTexts.forEach((ft, i) => {
context.save();
context.globalAlpha = ft.life;
context.fillStyle = ft.color;
context.font = "0.8px 'Inter'";
context.fillText(ft.text, ft.x, ft.y);
ft.y -= 0.02;
ft.life -= ft.decay;
context.restore();
if (ft.life <= 0) floatingTexts.splice(i, 1);
});
context.restore();
nextContext.fillStyle = '#f8f9fa';
nextContext.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
if (player.next) {
drawMatrix(player.next, {x: 1, y: 1}, nextContext);
}
if (isMultiplayer && opponentContext) {
opponentContext.fillStyle = '#000';
opponentContext.fillRect(0, 0, opponentCanvas.width, opponentCanvas.height);
drawBackground(opponentContext, 12, 20);
drawMatrix(opponentArena, {x: 0, y: 0}, opponentContext);
document.getElementById('opponent-score-val').innerText = opponentScore;
}
}
const colors = [
null,
'#00f0f0',
'#f0a000',
'#0000f0',
'#f0f000',
'#f00000',
'#00f000',
'#a000f0',
'#808080',
];
function merge(arena, player) {
player.matrix.forEach((row, y) => {
row.forEach((value, x) => {
if (value !== 0) {
arena[y + player.pos.y][x + player.pos.x] = value;
}
});
});
shakeScreen(0.05);
}
function rotate(matrix, dir) {
for (let y = 0; y < matrix.length; ++y) {
for (let x = 0; x < y; ++x) {
[matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
}
}
if (dir > 0) matrix.forEach(row => row.reverse());
else matrix.reverse();
}
function playerDrop() {
player.pos.y++;
if (collide(arena, player)) {
player.pos.y--;
merge(arena, player);
playerReset();
arenaSweep();
updateScore();
}
dropCounter = 0;
}
function playerMove(offset) {
if (isInputScrambled) {
offset *= -1;
}
player.pos.x += offset;
if (collide(arena, player)) {
player.pos.x -= offset;
}
}
function playerReset() {
const pieces = 'TJLOSZI';
player.matrix = player.next;
player.next = createPiece(pieces[pieces.length * Math.random() | 0]);
player.pos.y = 0;
player.pos.x = (arena[0].length / 2 | 0) - (player.matrix[0].length / 2 | 0);
if (collide(arena, player)) {
arena.forEach(row => row.fill(0));
player.isGameOver = true;
if (isMultiplayer) syncState();
alert('Game Over! Your final score: ' + player.score);
isPaused = true;
}
}
function playerRotate(dir) {
const pos = player.pos.x;
let offset = 1;
rotate(player.matrix, dir);
while (collide(arena, player)) {
player.pos.x += offset;
offset = -(offset + (offset > 0 ? 1 : -1));
if (offset > player.matrix[0].length) {
rotate(player.matrix, -dir);
player.pos.x = pos;
return;
}
}
}
let dropCounter = 0;
let dropInterval = 1000;
let lastTime = 0;
let isPaused = true;
function update(time = 0) {
if (isPaused) {
draw();
if (particles.length > 0 || floatingTexts.length > 0) requestAnimationFrame(update);
return;
}
const deltaTime = time - lastTime;
lastTime = time;
dropCounter += deltaTime;
let currentInterval = dropInterval;
if (isSpeedSurge) {
currentInterval /= 2;
}
if (dropCounter > currentInterval) {
playerDrop();
}
draw();
requestAnimationFrame(update);
}
function updateScore() {
document.getElementById('score-val').innerText = player.score;
document.getElementById('level-val').innerText = Math.floor(player.score / 100) + 1;
dropInterval = Math.max(100, 1000 - (Math.floor(player.score / 50) * 50));
if (isMultiplayer && player.score >= lastDebuffScore + 200) {
lastDebuffScore = Math.floor(player.score / 200) * 200;
sendRandomDebuff();
}
}
const arena = createMatrix(12, 20);
const player = {
pos: {x: 0, y: 0},
matrix: null,
next: null,
score: 0,
isGameOver: false,
};
// --- Presence & Online Players ---
async function setNickname() {
const nickname = playerNameInput.value || 'Anonymous';
await fetch('api/multiplayer.php?action=set_nickname', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId, nickname: nickname })
});
}
async function updatePresence() {
await fetch('api/multiplayer.php?action=heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId })
});
}
async function fetchOnlinePlayers() {
const resp = await fetch(`api/multiplayer.php?action=get_online&player_id=${playerId}`);
const data = await resp.json();
if (data.success) {
onlineCountBadge.innerText = data.players.length;
if (data.players.length === 0) {
onlinePlayersList.innerHTML = '<p class="text-muted small p-2">No other players online</p>';
} else {
onlinePlayersList.innerHTML = data.players.map(p => `
<div class="list-group-item d-flex justify-content-between align-items-center py-2 px-1">
<span class="small"><span class="online-badge"></span>${p.nickname}</span>
<button class="btn btn-xs btn-primary py-0 px-2 small" onclick="sendInvite('${p.session_id}')" style="font-size: 10px;">Invite</button>
</div>
`).join('');
}
}
}
async function checkInvites() {
if (isMultiplayer) return;
const resp = await fetch(`api/multiplayer.php?action=check_invites&player_id=${playerId}`);
const data = await resp.json();
if (data.success && data.invites && data.invites.length > 0) {
data.invites.forEach(inv => {
if (!document.getElementById(`invite-${inv.id}`)) {
showInvitePopup(inv);
}
});
}
}
function showInvitePopup(inv) {
const popup = document.createElement('div');
popup.id = `invite-${inv.id}`;
popup.className = 'invite-toast';
popup.innerHTML = `
<div class="fw-bold mb-1">Invitation!</div>
<div class="small mb-2"><strong>${inv.from_nickname}</strong> invited you to play.</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary w-100" onclick="acceptInvite(${inv.id}, '${inv.room_code}')">Accept</button>
<button class="btn btn-sm btn-outline-secondary w-100" onclick="rejectInvite(${inv.id})">Decline</button>
</div>
`;
inviteContainer.appendChild(popup);
setTimeout(() => popup.remove(), 30000);
}
async function sendInvite(toSessionId) {
const resp = await fetch('api/multiplayer.php?action=invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from_player_id: playerId, to_player_id: toSessionId })
});
const data = await resp.json();
if (data.success) {
alert('Invitation sent! Waiting for opponent...');
// The room is already created by 'invite' action.
// We just need to join it ourselves to get the roomId.
joinRoom(data.room_code);
}
}
window.acceptInvite = async (inviteId, roomCode) => {
await fetch('api/multiplayer.php?action=respond_invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_id: inviteId, status: 'accepted' })
});
document.getElementById(`invite-${inviteId}`)?.remove();
joinRoom(roomCode);
};
window.rejectInvite = async (inviteId) => {
await fetch('api/multiplayer.php?action=respond_invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_id: inviteId, status: 'rejected' })
});
document.getElementById(`invite-${inviteId}`)?.remove();
};
window.sendInvite = sendInvite;
playerNameInput.addEventListener('blur', setNickname);
setNickname();
setInterval(updatePresence, 10000);
setInterval(fetchOnlinePlayers, 5000);
setInterval(checkInvites, 3000);
fetchOnlinePlayers();
// --- Multiplayer Functions ---
async function createRoom() {
const resp = await fetch('api/multiplayer.php?action=create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId })
});
const data = await resp.json();
if (data.success) {
roomId = data.room_id;
startMultiplayer(data.room_code);
}
}
async function joinRoom(code) {
const resp = await fetch('api/multiplayer.php?action=join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: playerId, room_code: code })
});
const data = await resp.json();
if (data.success) {
roomId = data.room_id;
startMultiplayer(code);
} else {
alert(data.error);
}
}
function startMultiplayer(code) {
isMultiplayer = true;
mpSetup.classList.add('d-none');
mpActive.classList.remove('d-none');
activeRoomCode.innerText = code;
displayRoomCode.innerText = code;
mpStatusBar.classList.remove('d-none');
opponentColumn.classList.remove('d-none');
if (pollTimer) clearInterval(pollTimer);
if (updateTimer) clearInterval(updateTimer);
pollTimer = setInterval(pollOpponent, 1000);
updateTimer = setInterval(syncState, 500);
}
async function syncState() {
if (!roomId) return;
await fetch('api/multiplayer.php?action=update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
room_id: roomId,
player_id: playerId,
board: arena,
score: player.score,
is_game_over: player.isGameOver
})
});
}
async function pollOpponent() {
if (!roomId) return;
const resp = await fetch(`api/multiplayer.php?action=poll&room_id=${roomId}&player_id=${playerId}`);
const data = await resp.json();
if (data.success) {
if (data.status === 'playing') {
if (roomStatus === 'waiting') {
roomStatus = 'playing';
mpStatusText.innerText = 'Game started!';
setTimeout(() => mpStatusBar.classList.add('d-none'), 3000);
startGame();
}
}
if (data.opponent) {
opponentArena = data.opponent.board;
opponentScore = data.opponent.score;
}
if (data.debuffs && data.debuffs.length > 0) {
data.debuffs.forEach(applyDebuff);
}
}
}
function sendRandomDebuff() {
const debuffs = ['garbage', 'speed', 'scramble'];
const debuff = debuffs[Math.floor(Math.random() * debuffs.length)];
fetch('api/multiplayer.php?action=send_debuff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
room_id: roomId,
player_id: playerId,
debuff: debuff
})
});
showNotification(`Sent debuff: ${debuff.toUpperCase()}!`, 'info');
addFloatingText(`${debuff.toUpperCase()} SENT!`, player.pos.x, player.pos.y - 2, '#00ffff');
}
function applyDebuff(type) {
if (type === 'garbage') {
showNotification('RECEIVED: Garbage Line!', 'danger');
addFloatingText('GARBAGE IN!', 5, 10, '#ff0000');
shakeScreen(0.4);
addGarbageLine();
} else if (type === 'speed') {
showNotification('RECEIVED: Speed Surge (7s)!', 'danger');
addFloatingText('SPEED UP!', 5, 10, '#ff0000');
isSpeedSurge = true;
clearTimeout(speedSurgeTimer);
speedSurgeTimer = setTimeout(() => {
isSpeedSurge = false;
showNotification('Speed normalized.', 'info');
}, 7000);
} else if (type === 'scramble') {
showNotification('RECEIVED: Input Scramble (5s)!', 'danger');
addFloatingText('SCRAMBLED!', 5, 10, '#ff0000');
isInputScrambled = true;
clearTimeout(scrambleTimer);
scrambleTimer = setTimeout(() => {
isInputScrambled = false;
showNotification('Controls normalized.', 'info');
}, 5000);
}
}
function addGarbageLine() {
const row = new Array(arena[0].length).fill(8);
const hole = Math.floor(Math.random() * row.length);
row[hole] = 0;
arena.shift();
arena.push(row);
if (collide(arena, player)) {
player.pos.y--;
if (player.pos.y < 0) {
player.pos.y = 0;
player.isGameOver = true;
syncState();
alert('Game Over by Garbage!');
}
}
}
function showNotification(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `debuff-toast bg-${type}`;
toast.innerText = message;
debuffNotifications.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function startGame() {
arena.forEach(row => row.fill(0));
player.score = 0;
player.isGameOver = false;
lastDebuffScore = 0;
isInputScrambled = false;
isSpeedSurge = false;
particles = [];
floatingTexts = [];
updateScore();
const pieces = 'TJLOSZI';
player.next = createPiece(pieces[pieces.length * Math.random() | 0]);
playerReset();
isPaused = false;
pauseBtn.disabled = isMultiplayer;
lastTime = performance.now();
update();
}
// --- Event Listeners ---
document.addEventListener('keydown', event => {
const keysToPrevent = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'w', 'W', 'a', 'A', 's', 'S', 'd', 'D'];
if (keysToPrevent.includes(event.key)) {
event.preventDefault();
}
if (isPaused && !player.isGameOver) {
if (event.key === 'p' || event.key === 'P') {
if (!isMultiplayer) togglePause();
}
return;
}
if (event.key === 'ArrowLeft' || event.key === 'a' || event.key === 'A') {
playerMove(-1);
} else if (event.key === 'ArrowRight' || event.key === 'd' || event.key === 'D') {
playerMove(1);
} else if (event.key === 'ArrowDown' || event.key === 's' || event.key === 'S') {
playerDrop();
} else if (event.key === 'ArrowUp' || event.key === 'w' || event.key === 'W') {
playerRotate(1);
} else if (event.key === ' ') {
let dropped = 0;
while (!collide(arena, player)) {
player.pos.y++;
dropped++;
}
player.pos.y--;
if (dropped > 0) shakeScreen(0.05);
merge(arena, player);
playerReset();
arenaSweep();
updateScore();
} else if (event.key === 'p' || event.key === 'P') {
if (!isMultiplayer) togglePause();
}
});
const startBtn = document.getElementById('start-btn');
const pauseBtn = document.getElementById('pause-btn');
const createRoomBtn = document.getElementById('create-room-btn');
const joinRoomBtn = document.getElementById('join-room-btn');
const leaveRoomBtn = document.getElementById('leave-room-btn');
function togglePause() {
if (isMultiplayer) return;
isPaused = !isPaused;
pauseBtn.innerText = isPaused ? 'Resume' : 'Pause';
if (!isPaused) {
lastTime = performance.now();
update();
}
}
startBtn.addEventListener('click', () => {
if (isMultiplayer) {
alert("Wait for the game to start automatically when opponent joins.");
return;
}
startGame();
});
pauseBtn.addEventListener('click', togglePause);
createRoomBtn.addEventListener('click', createRoom);
joinRoomBtn.addEventListener('click', () => {
const code = document.getElementById('join-room-code').value.toUpperCase();
if (code) joinRoom(code);
});
leaveRoomBtn.addEventListener('click', () => {
location.reload();
});
player.next = createPiece('T');
draw();

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -0,0 +1 @@
CREATE TABLE IF NOT EXISTS high_scores (id INT AUTO_INCREMENT PRIMARY KEY, player_name VARCHAR(50) DEFAULT 'Player', score INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);

View File

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS rooms (
id INT AUTO_INCREMENT PRIMARY KEY,
room_code VARCHAR(10) UNIQUE NOT NULL,
player1_id VARCHAR(50) NOT NULL,
player2_id VARCHAR(50) DEFAULT NULL,
status ENUM('waiting', 'playing', 'finished') DEFAULT 'waiting',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS game_states (
room_id INT NOT NULL,
player_id VARCHAR(50) NOT NULL,
board TEXT,
score INT DEFAULT 0,
next_piece TEXT,
is_game_over TINYINT(1) DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (room_id, player_id),
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,3 @@
-- Add pending_debuffs column to game_states table
-- This column will store a comma-separated list of debuff codes (e.g., 'garbage,speed,scramble')
ALTER TABLE game_states ADD COLUMN pending_debuffs TEXT NULL;

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS players (
id INT AUTO_INCREMENT PRIMARY KEY,
nickname VARCHAR(50) NOT NULL,
session_id VARCHAR(100) NOT NULL UNIQUE,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (last_seen)
);
CREATE TABLE IF NOT EXISTS invitations (
id INT AUTO_INCREMENT PRIMARY KEY,
from_player_id INT NOT NULL,
to_player_id INT NOT NULL,
room_code VARCHAR(10) NOT NULL,
status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (from_player_id) REFERENCES players(id) ON DELETE CASCADE,
FOREIGN KEY (to_player_id) REFERENCES players(id) ON DELETE CASCADE
);

301
index.php
View File

@ -1,150 +1,211 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1'); require_once 'db/config.php';
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; // Fetch top high scores
$now = date('Y-m-d H:i:s'); $highScores = [];
try {
$stmt = db()->query("SELECT player_name, score FROM high_scores ORDER BY score DESC LIMIT 5");
$highScores = $stmt->fetchAll();
} catch (Exception $e) {
// Fail silently if table doesn't exist yet
}
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Classic Tetris Game - Single and Multiplayer HTML5 game.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title> <title>Classic Tetris</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) ?>' /> <meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?> <?php if ($projectImageUrl): ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?> <?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<style> <style>
:root { #debuff-notifications {
--bg-color-start: #6a11cb; position: fixed;
--bg-color-end: #2575fc; top: 20px;
--text-color: #ffffff; right: 20px;
--card-bg-color: rgba(255, 255, 255, 0.01); z-index: 1050;
--card-border-color: rgba(255, 255, 255, 0.1);
} }
body { .debuff-toast {
margin: 0; background: rgba(220, 53, 69, 0.9);
font-family: 'Inter', sans-serif; color: white;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); padding: 10px 20px;
color: var(--text-color); border-radius: 5px;
display: flex; margin-bottom: 10px;
justify-content: center; box-shadow: 0 4px 6px rgba(0,0,0,0.1);
align-items: center; animation: slideIn 0.3s ease-out;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
} }
body::before { @keyframes slideIn {
content: ''; from { transform: translateX(100%); }
position: absolute; to { transform: translateX(0); }
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 { .online-badge {
0% { background-position: 0% 0%; } width: 8px;
100% { background-position: 100% 100%; } height: 8px;
background: #28a745;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
} }
main { .invite-toast {
padding: 2rem; position: fixed;
} bottom: 20px;
.card { left: 20px;
background: var(--card-bg-color); z-index: 1060;
border: 1px solid var(--card-border-color); background: #1e293b;
border-radius: 16px; border-left: 5px solid #3b82f6;
padding: 2rem; padding: 15px;
backdrop-filter: blur(20px); box-shadow: 0 5px 15px rgba(0,0,0,0.5);
-webkit-backdrop-filter: blur(20px); border-radius: 5px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1); min-width: 250px;
} color: #f8fafc;
.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> </style>
</head> </head>
<body> <body>
<main> <div id="debuff-notifications"></div>
<div class="card"> <div id="invite-container"></div>
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <nav class="navbar navbar-expand-lg py-3">
<span class="sr-only">Loading…</span> <div class="container">
<a class="navbar-brand fw-bold" href="/">TETRIS</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="about.php">About Author</a>
</li>
</ul>
</div>
<div class="d-flex align-items-center">
<span class="text-muted small me-3">Level: <span id="level-val">1</span></span>
<span class="text-muted small">Score: <span id="score-val">0</span></span>
</div>
</div>
</nav>
<main class="container py-5">
<div id="multiplayer-status-bar" class="alert d-none mb-4 text-center">
<span id="multiplayer-status-text">Waiting for opponent...</span>
<strong id="display-room-code" class="ms-2"></strong>
</div>
<div class="row g-4 justify-content-center">
<div id="player-column" class="col-lg-auto text-center">
<h6 id="player-label" class="fw-bold mb-2 text-uppercase">You</h6>
<div class="game-container p-2">
<canvas id="tetris" width="360" height="600"></canvas>
</div>
</div>
<div id="opponent-column" class="col-lg-auto text-center d-none">
<h6 id="opponent-label" class="fw-bold mb-2 text-danger text-uppercase">Opponent</h6>
<div class="game-container p-2">
<canvas id="opponent-tetris" width="240" height="400" style="width: 180px; height: 300px;"></canvas>
</div>
<div class="mt-2 small text-muted">Opponent Score: <span id="opponent-score-val">0</span></div>
</div>
<div class="col-lg-4" id="sidebar">
<!-- Online Players Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title fw-bold mb-3 d-flex align-items-center">
Online Players
<span class="ms-2 badge bg-dark text-muted small fw-normal" id="online-count">0</span>
</h5>
<div id="online-players-list" class="list-group list-group-flush" style="max-height: 200px; overflow-y: auto;">
<p class="text-muted small p-2">No other players online</p>
</div>
</div>
</div>
<!-- Multiplayer Controls -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title fw-bold mb-3">Multiplayer</h5>
<div id="mp-setup">
<div class="mb-3">
<input type="text" id="player-name" class="form-control" placeholder="Your Name" value="Player">
</div>
<div class="d-grid gap-2">
<button id="create-room-btn" class="btn btn-dark">Create Private Room</button>
<div class="input-group">
<input type="text" id="join-room-code" class="form-control" placeholder="Room Code">
<button id="join-room-btn" class="btn btn-outline-info">Join</button>
</div>
</div>
</div>
<div id="mp-active" class="d-none">
<p class="small mb-2">Room: <strong id="active-room-code" class="text-info"></strong></p>
<button id="leave-room-btn" class="btn btn-sm btn-outline-danger w-100">Leave Multiplayer</button>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title fw-bold mb-3">Controls</h5>
<ul class="list-unstyled small text-muted mb-0">
<li class="mb-2"><strong> / </strong> : Move Left/Right</li>
<li class="mb-2"><strong> / W</strong> : Rotate</li>
<li class="mb-2"><strong> / S</strong> : Soft Drop</li>
<li class="mb-2"><strong>Space</strong> : Hard Drop</li>
<li><strong>P</strong> : Pause Game</li>
</ul>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title fw-bold mb-3">Next Piece</h5>
<div class="next-piece-container d-flex align-items-center justify-content-center" style="height: 170px;">
<canvas id="next-piece" width="150" height="150"></canvas>
</div>
</div>
</div>
<div class="d-grid gap-2">
<button id="start-btn" class="btn btn-primary py-2 fw-semibold">Start New Game</button>
<button id="pause-btn" class="btn btn-outline-secondary py-2 fw-semibold" disabled>Pause</button>
</div>
<?php if (!empty($highScores)): ?>
<div class="mt-4" id="high-scores-section">
<h6 class="fw-bold text-uppercase small text-muted mb-3">High Scores</h6>
<div class="list-group list-group-flush border-0">
<?php foreach ($highScores as $s): ?>
<div class="list-group-item d-flex justify-content-between align-items-center small py-2">
<span><?= htmlspecialchars($s['player_name']) ?></span>
<span class="fw-bold text-info"><?= (int)$s['score'] ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div> </div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div> </div>
</main> </main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC) <footer class="container py-4 border-top border-secondary text-center">
<p class="text-muted small mb-1">&copy; <?= date('Y') ?> Classic Tetris. Built with PHP & Canvas.</p>
<a href="about.php" class="text-decoration-none small text-muted">About the Author</a>
</footer> </footer>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
</body> </body>
</html> </html>