Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3cf9df956 |
134
about.php
Normal file
134
about.php
Normal 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">© <?= 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
199
api/multiplayer.php
Normal 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()]);
|
||||
}
|
||||
@ -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 {
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
background-color: #0f172a; /* Darker background for neon effects */
|
||||
color: #f8fafc;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-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 {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background-color: rgba(15, 23, 42, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: var(--border-width) solid transparent;
|
||||
transition: all 0.3s;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #1e293b !important;
|
||||
}
|
||||
|
||||
.navbar.scrolled {
|
||||
border-bottom-color: #000;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
.navbar-brand, .nav-link, .text-muted {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
.navbar-brand {
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-left: 1rem;
|
||||
position: relative;
|
||||
.game-container {
|
||||
display: inline-block;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
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 {
|
||||
color: var(--color-primary);
|
||||
#tetris, #opponent-tetris {
|
||||
display: block;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
font-weight: 700;
|
||||
font-family: var(--font-heading);
|
||||
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;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
border-color: #000;
|
||||
color: #fff;
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
border-color: #000;
|
||||
color: #fff;
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 15px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
background-color: #0f172a;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.btn-outline-dark {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
color: #94a3b8;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.btn-cta {
|
||||
background-color: var(--color-accent);
|
||||
color: #000;
|
||||
.btn-outline-dark:hover {
|
||||
background-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.btn-cta:hover {
|
||||
background-color: #8cc629;
|
||||
color: #000;
|
||||
.btn-outline-info {
|
||||
color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
min-height: 100vh;
|
||||
padding-top: 80px;
|
||||
.btn-outline-info:hover {
|
||||
background-color: #3b82f6;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.background-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.6;
|
||||
z-index: 1;
|
||||
.next-piece-container {
|
||||
background-color: #0f172a !important;
|
||||
border: 1px solid #334155 !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -10%;
|
||||
right: -10%;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, var(--color-accent), transparent);
|
||||
#next-piece {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
bottom: 10%;
|
||||
left: -10%;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, var(--color-primary), transparent);
|
||||
.list-group-item {
|
||||
background-color: transparent;
|
||||
border-color: #334155;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
font-weight: 500;
|
||||
background: #f8f9fa;
|
||||
background-color: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
box-shadow: 4px 4px 0 var(--color-primary);
|
||||
border-color: #000;
|
||||
background: #fff;
|
||||
background-color: #0f172a;
|
||||
border-color: #3b82f6;
|
||||
color: #f8fafc;
|
||||
box-shadow: 0 0 0 0.25rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.animate-up {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
animation: fadeUp 0.8s ease forwards;
|
||||
.form-control::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.delay-100 { animation-delay: 0.1s; }
|
||||
.delay-200 { animation-delay: 0.2s; }
|
||||
|
||||
@keyframes fadeUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
/* Multiplayer specific */
|
||||
#multiplayer-status-bar {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Social */
|
||||
.social-links a {
|
||||
transition: transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
.social-links a:hover {
|
||||
transform: scale(1.2) rotate(10deg);
|
||||
color: var(--color-accent) !important;
|
||||
#opponent-column {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 991px) {
|
||||
.rotate-divider {
|
||||
transform: rotate(0);
|
||||
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%; }
|
||||
#player-label, #opponent-label {
|
||||
letter-spacing: 0.15em;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
@ -1,73 +1,775 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canvas = document.getElementById('tetris');
|
||||
const context = canvas.getContext('2d');
|
||||
const nextCanvas = document.getElementById('next-piece');
|
||||
const nextContext = nextCanvas.getContext('2d');
|
||||
const opponentCanvas = document.getElementById('opponent-tetris');
|
||||
const opponentContext = opponentCanvas ? opponentCanvas.getContext('2d') : null;
|
||||
|
||||
// Smooth scrolling for navigation links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('href');
|
||||
if (targetId === '#') return;
|
||||
const mpSetup = document.getElementById('mp-setup');
|
||||
const mpActive = document.getElementById('mp-active');
|
||||
const mpStatusBar = document.getElementById('multiplayer-status-bar');
|
||||
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');
|
||||
|
||||
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();
|
||||
context.scale(20, 20);
|
||||
nextContext.scale(20, 20);
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
function shakeScreen(intensity) {
|
||||
screenShake = intensity;
|
||||
}
|
||||
|
||||
function createExplosion(x, y, color) {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
particles.push(new Particle(x, y, color));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll with offset
|
||||
const offset = 80;
|
||||
const elementPosition = targetElement.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
const row = arena.splice(y, 1)[0];
|
||||
arena.unshift(new Array(row.length).fill(0));
|
||||
++y;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth"
|
||||
rowCount++;
|
||||
|
||||
row.forEach((value, x) => {
|
||||
if (value !== 0) {
|
||||
createExplosion(x, y - 1, colors[value]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (rowCount > 0) {
|
||||
player.score += rowCount * 10;
|
||||
shakeScreen(rowCount * 0.1);
|
||||
addFloatingText(`+${rowCount * 10}`, player.pos.x + 2, player.pos.y, '#fff');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar scroll effect
|
||||
const navbar = document.querySelector('.navbar');
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.scrollY > 50) {
|
||||
navbar.classList.add('scrolled', 'shadow-sm', 'bg-white');
|
||||
navbar.classList.remove('bg-transparent');
|
||||
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 {
|
||||
navbar.classList.remove('scrolled', 'shadow-sm', 'bg-white');
|
||||
navbar.classList.add('bg-transparent');
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Intersection Observer for fade-up animations
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: "0px 0px -50px 0px"
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('animate-up');
|
||||
entry.target.style.opacity = "1";
|
||||
observer.unobserve(entry.target); // Only animate once
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
});
|
||||
}, observerOptions);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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);
|
||||
};
|
||||
|
||||
const projectCards = document.querySelectorAll('.project-card');
|
||||
projectCards.forEach((card, index) => {
|
||||
card.style.opacity = "0";
|
||||
card.style.animationDelay = `${index * 0.1}s`;
|
||||
observer.observe(card);
|
||||
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();
|
||||
BIN
assets/vm-shot-2026-01-22T16-03-28-792Z.jpg
Normal file
BIN
assets/vm-shot-2026-01-22T16-03-28-792Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
BIN
assets/vm-shot-2026-01-22T16-04-14-164Z.jpg
Normal file
BIN
assets/vm-shot-2026-01-22T16-04-14-164Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
1
db/migrations/001_create_high_scores.sql
Normal file
1
db/migrations/001_create_high_scores.sql
Normal 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);
|
||||
20
db/migrations/002_multiplayer_setup.sql
Normal file
20
db/migrations/002_multiplayer_setup.sql
Normal 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
|
||||
);
|
||||
3
db/migrations/003_add_debuffs.sql
Normal file
3
db/migrations/003_add_debuffs.sql
Normal 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;
|
||||
18
db/migrations/004_presence_and_invites.sql
Normal file
18
db/migrations/004_presence_and_invites.sql
Normal 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
|
||||
);
|
||||
297
index.php
297
index.php
@ -1,150 +1,211 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
require_once 'db/config.php';
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
// Fetch top high scores
|
||||
$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>
|
||||
<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 -->
|
||||
<title>Classic Tetris</title>
|
||||
|
||||
<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; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<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.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>
|
||||
: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);
|
||||
#debuff-notifications {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
}
|
||||
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;
|
||||
.debuff-toast {
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
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 slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@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;
|
||||
.online-badge {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #28a745;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
@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;
|
||||
.invite-toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
z-index: 1060;
|
||||
background: #1e293b;
|
||||
border-left: 5px solid #3b82f6;
|
||||
padding: 15px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.5);
|
||||
border-radius: 5px;
|
||||
min-width: 250px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
</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>
|
||||
<div id="debuff-notifications"></div>
|
||||
<div id="invite-container"></div>
|
||||
|
||||
<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" 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>
|
||||
<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>
|
||||
</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">© <?= 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>
|
||||
|
||||
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user