diff --git a/about.php b/about.php new file mode 100644 index 0000000..77cf5d1 --- /dev/null +++ b/about.php @@ -0,0 +1,134 @@ + + + + + + + About the Author - Classic Tetris + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ 👨‍💻 +
+
+
+

Alex Dev

+

Web Developer & Retro Game Enthusiast

+
+ +
+
+

The Story Behind

+

+ 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. +

+ +
+
+
🚀 My Mission
+

To create simple, engaging, and accessible web applications that remind us of the golden age of gaming.

+
+
+
🛠️ Tech Stack
+

For this project, I used PHP for the backend persistence, MySQL for high scores, and Canvas API for the game engine.

+
+
+ +
+ +
+

Get in Touch

+ +
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/api/multiplayer.php b/api/multiplayer.php new file mode 100644 index 0000000..507d5d6 --- /dev/null +++ b/api/multiplayer.php @@ -0,0 +1,199 @@ + 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()]); +} \ No newline at end of file diff --git a/assets/css/custom.css b/assets/css/custom.css index 65a1626..58c0809 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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; +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index fdf2cfd..d716caf 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,73 +1,775 @@ -document.addEventListener('DOMContentLoaded', () => { - - // 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 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(); - } +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; - // Scroll with offset - const offset = 80; - const elementPosition = targetElement.getBoundingClientRect().top; - const offsetPosition = elementPosition + window.pageYOffset - offset; +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'); - window.scrollTo({ - top: offsetPosition, - behavior: "smooth" - }); - } - }); +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 }); +} - // 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'); - } else { - navbar.classList.remove('scrolled', 'shadow-sm', 'bg-white'); - navbar.classList.add('bg-transparent'); +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; + } } - }); - // Intersection Observer for fade-up animations - const observerOptions = { - threshold: 0.1, - rootMargin: "0px 0px -50px 0px" - }; + const row = arena.splice(y, 1)[0]; + arena.unshift(new Array(row.length).fill(0)); + ++y; - 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 + rowCount++; + + row.forEach((value, x) => { + if (value !== 0) { + createExplosion(x, y - 1, colors[value]); } }); - }, 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'); - projectCards.forEach((card, index) => { - card.style.opacity = "0"; - card.style.animationDelay = `${index * 0.1}s`; - observer.observe(card); + 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(); + } + }); + }); +} + +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); }); -}); \ No newline at end of file + 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 = '

No other players online

'; + } else { + onlinePlayersList.innerHTML = data.players.map(p => ` +
+ ${p.nickname} + +
+ `).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 = ` +
Invitation!
+
${inv.from_nickname} invited you to play.
+
+ + +
+ `; + 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(); \ No newline at end of file diff --git a/assets/vm-shot-2026-01-22T16-03-28-792Z.jpg b/assets/vm-shot-2026-01-22T16-03-28-792Z.jpg new file mode 100644 index 0000000..1328113 Binary files /dev/null and b/assets/vm-shot-2026-01-22T16-03-28-792Z.jpg differ diff --git a/assets/vm-shot-2026-01-22T16-04-14-164Z.jpg b/assets/vm-shot-2026-01-22T16-04-14-164Z.jpg new file mode 100644 index 0000000..1328113 Binary files /dev/null and b/assets/vm-shot-2026-01-22T16-04-14-164Z.jpg differ diff --git a/db/migrations/001_create_high_scores.sql b/db/migrations/001_create_high_scores.sql new file mode 100644 index 0000000..e3eec19 --- /dev/null +++ b/db/migrations/001_create_high_scores.sql @@ -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); diff --git a/db/migrations/002_multiplayer_setup.sql b/db/migrations/002_multiplayer_setup.sql new file mode 100644 index 0000000..cf6a802 --- /dev/null +++ b/db/migrations/002_multiplayer_setup.sql @@ -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 +); diff --git a/db/migrations/003_add_debuffs.sql b/db/migrations/003_add_debuffs.sql new file mode 100644 index 0000000..ef20913 --- /dev/null +++ b/db/migrations/003_add_debuffs.sql @@ -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; diff --git a/db/migrations/004_presence_and_invites.sql b/db/migrations/004_presence_and_invites.sql new file mode 100644 index 0000000..2312161 --- /dev/null +++ b/db/migrations/004_presence_and_invites.sql @@ -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 +); diff --git a/index.php b/index.php index 7205f3d..6d92e11 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,211 @@ 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'] ?? ''; ?> - New Style - - - + Classic Tetris + - - - - - + - - + + - + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +
+
+ + + +
+
+ Waiting for opponent... + +
+ +
+
+
You
+
+ +
+
+ +
+
Opponent
+
+ +
+
Opponent Score: 0
+
+ + -

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-