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; 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'); 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; } } const row = arena.splice(y, 1)[0]; arena.unshift(new Array(row.length).fill(0)); ++y; 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(); } }); }); } 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 = '
No other players online
'; } else { onlinePlayersList.innerHTML = data.players.map(p => `