diff --git a/public/css/style.css b/public/css/style.css index 42d1d88..742919b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -151,6 +151,44 @@ button:active { color: #0f0; } +.player-item .status.spectator-status { + color: #888; + font-style: italic; +} + +/* Spectator list in lobby */ +#spectator-list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 20px 0; + min-height: 0; +} + +#spectator-list:empty { + display: none; +} + +#spectator-list .player-item { + background: #0a0a1a; + border: 2px solid #555; +} + +#spectator-list .player-item .status { + color: #aaa; +} + +/* Spectator mode header */ +#game-room-name.spectator-mode { + color: #ff8800; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + /* Battle Grid - Focused Layout */ #battle-grid { position: relative; @@ -538,7 +576,31 @@ button:active { height: 50px !important; } - .player-board.spectator { + /* Hide spectator boards on mobile when playing (has main board) */ + #battle-grid:has(.main) .player-board.spectator { display: none; } + + /* In spectator mode (no main board), show all boards in a scrollable container */ + #battle-grid:not(:has(.main)) { + transform: none; + height: auto; + flex-direction: column; + overflow-y: auto; + margin-bottom: 0; + padding-bottom: 100px; + } + + #battle-grid:not(:has(.main)) .player-board { + position: relative; + transform: none; + opacity: 1; + margin: 10px 0; + width: 90%; + } + + #battle-grid:not(:has(.main)) .player-board canvas { + width: 100%; + height: auto; + } } diff --git a/public/index.html b/public/index.html index ad1f84f..fc10e87 100644 --- a/public/index.html +++ b/public/index.html @@ -7,7 +7,7 @@ - +
@@ -25,6 +25,7 @@

GLOBAL LOBBY

+
@@ -61,10 +62,10 @@
- + - - + + diff --git a/public/js/app.js b/public/js/app.js index 8863d64..65755fd 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -83,10 +83,45 @@ function setupNetworkListeners() { network.setListener('game-over', (data) => { endGame(data.states); }); + + // Forced spectator - game already in progress + network.setListener('forced-spectator', ({ states, players }) => { + ui.showScreen('game'); + ui.showSpectatorMode(); + + // Clear old boards + renderer.clearAll(); + + // Create boards for all players (spectator can see everyone) + states.forEach((state) => { + const player = network.getPlayer(state.playerId); + renderer.createPlayerBoard(state.playerId, player.name); + }); + + // Set up battle grid layout + updateBattleGridLayout(players.length); + + // Start game loop for viewing + lastTime = performance.now(); + requestAnimationFrame(gameLoop); + }); + + // Spectator joined + network.setListener('spectator-joined', (spectator) => { + ui.updateSpectatorList(network.getAllSpectators()); + }); + + // Spectator left + network.setListener('spectator-left', (spectatorId) => { + ui.updateSpectatorList(network.getAllSpectators()); + }); } function setupKeyboardControls() { document.addEventListener('keydown', (e) => { + // Spectators cannot control the game + if (network.isSpectator) return; + if (ui.screens.game.classList.contains('active') && localGame) { switch (e.key) { case 'ArrowLeft': @@ -129,6 +164,8 @@ function setupKeyboardControls() { const handleTouch = (e, action) => { e.preventDefault(); e.stopPropagation(); + // Spectators cannot control the game + if (network.isSpectator) return; if (ui.screens.game.classList.contains('active')) { action(); } @@ -185,6 +222,10 @@ function endGame(states) { } }); + // Reset spectator mode for next round + network.isSpectator = false; + ui.hideSpectatorMode(); + ui.showGameOver(winner, scores); } @@ -207,8 +248,8 @@ function gameLoop(currentTime) { renderer.renderPlayer(state.playerId, state); }); - // Set active player highlight - renderer.setActivePlayer(network.currentPlayerId); + // Set active player highlight (or null for spectators to show all boards equally) + renderer.setActivePlayer(network.isSpectator ? null : network.currentPlayerId); requestAnimationFrame(gameLoop); } diff --git a/public/js/network.js b/public/js/network.js index bb6a28e..241b47c 100644 --- a/public/js/network.js +++ b/public/js/network.js @@ -4,14 +4,19 @@ class NetworkManager { constructor() { this.socket = null; this.currentPlayerId = null; + this.isSpectator = false; this.players = {}; + this.spectators = {}; this.gameState = {}; this.listeners = { onPlayerJoined: null, onPlayerLeft: null, onGameStarted: null, onStateUpdate: null, - onGameOver: null + onGameOver: null, + onSpectatorJoined: null, + onSpectatorLeft: null, + onForcedSpectator: null }; } @@ -62,6 +67,33 @@ class NetworkManager { this.listeners.onGameOver(data); } }); + + // Forced spectator mode - game already in progress + this.socket.on('forced-spectator', ({ spectatorId, states, players }) => { + this.isSpectator = true; + this.updateStates(states); + this.updatePlayers(players); + if (this.listeners.onForcedSpectator) { + this.listeners.onForcedSpectator({ states, players }); + } + }); + + // Spectator joined + this.socket.on('spectator-joined', ({ spectator, spectators }) => { + this.updateSpectators(spectators); + if (this.listeners.onSpectatorJoined) { + this.listeners.onSpectatorJoined(spectator); + } + }); + + // Spectator left + this.socket.on('spectator-left', ({ spectatorId, spectators }) => { + delete this.spectators[spectatorId]; + this.updateSpectators(spectators); + if (this.listeners.onSpectatorLeft) { + this.listeners.onSpectatorLeft(spectatorId); + } + }); } joinLobby(playerName) { @@ -116,6 +148,13 @@ class NetworkManager { }); } + updateSpectators(spectators) { + this.spectators = {}; + spectators.forEach(s => { + this.spectators[s.id] = s; + }); + } + getPlayer(playerId) { return this.players[playerId]; } @@ -132,13 +171,20 @@ class NetworkManager { return this.gameState; } + getAllSpectators() { + return this.spectators; + } + setListener(event, callback) { const listenerMap = { 'player-joined': 'onPlayerJoined', 'player-left': 'onPlayerLeft', 'game-started': 'onGameStarted', 'state-update': 'onStateUpdate', - 'game-over': 'onGameOver' + 'game-over': 'onGameOver', + 'forced-spectator': 'onForcedSpectator', + 'spectator-joined': 'onSpectatorJoined', + 'spectator-left': 'onSpectatorLeft' }; if (callback) { this.listeners[listenerMap[event]] = callback; diff --git a/public/js/renderer.js b/public/js/renderer.js index 6326244..e010360 100644 --- a/public/js/renderer.js +++ b/public/js/renderer.js @@ -323,12 +323,17 @@ class TetrisRenderer { } setActivePlayer(playerId) { - // Remove main class from all boards + // Remove main/spectator classes from all boards this.boards.forEach((boardData, id) => { boardData.element.classList.remove('active', 'main'); boardData.element.classList.remove('spectator', 'top-left', 'top-right', 'bottom-left', 'bottom-right'); }); + // If playerId is null (spectator mode), show all boards equally without highlighting + if (playerId === null) { + return; + } + // Add main class to current player const mainBoard = this.boards.get(playerId); if (mainBoard) { diff --git a/public/js/ui.js b/public/js/ui.js index a3cf30f..aec214d 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -23,6 +23,7 @@ class UIManager { this.displays = { gameRoomName: document.getElementById('game-room-name'), playerList: document.getElementById('player-list'), + spectatorList: document.getElementById('spectator-list'), battleGrid: document.getElementById('battle-grid'), gameStatus: document.getElementById('game-status'), winnerDisplay: document.getElementById('winner-display'), @@ -90,6 +91,29 @@ class UIManager { }); } + updateSpectatorList(spectators) { + this.displays.spectatorList.innerHTML = ''; + Object.values(spectators).forEach(spectator => { + const item = document.createElement('div'); + item.className = 'player-item spectator'; + item.innerHTML = ` + ${this.escapeHtml(spectator.name)} + WATCHING + `; + this.displays.spectatorList.appendChild(item); + }); + } + + showSpectatorMode() { + // Update UI to indicate spectator mode + this.displays.gameRoomName.textContent = 'SPECTATOR MODE - Waiting for next game'; + this.displays.gameRoomName.classList.add('spectator-mode'); + } + + hideSpectatorMode() { + this.displays.gameRoomName.classList.remove('spectator-mode'); + } + showMessage(message) { this.displays.gameStatus.textContent = message; setTimeout(() => { diff --git a/server/index.js b/server/index.js index abc61a2..75a69ca 100644 --- a/server/index.js +++ b/server/index.js @@ -13,6 +13,7 @@ app.use(express.static(path.join(__dirname, '../public'))); // Single global lobby const lobby = { players: new Map(), + spectators: new Map(), gameStarted: false, gameInterval: null }; @@ -45,6 +46,31 @@ io.on('connection', (socket) => { socket.join(LOBBY_ROOM); socket.data.playerName = playerName; + // If game is in progress, add as spectator instead + if (lobby.gameStarted) { + const spectator = { + id: socket.id, + name: playerName + }; + lobby.spectators.set(socket.id, spectator); + + // Notify all clients about new spectator + io.to(LOBBY_ROOM).emit('spectator-joined', { + spectator: { id: spectator.id, name: spectator.name }, + spectators: getSpectatorsList() + }); + + // Send current game state to spectator only + socket.emit('forced-spectator', { + spectatorId: socket.id, + states: getStates(), + players: getPlayersList() + }); + + console.log(`${playerName} joined as spectator (${lobby.spectators.size} spectators)`); + return; + } + const player = { id: socket.id, name: playerName, @@ -83,7 +109,7 @@ io.on('connection', (socket) => { players: getPlayersList() }); - // Check if all players ready and min 2 players + // Check if all players ready and min 2 players (spectators don't count) if (lobby.players.size >= 2 && lobby.players.size <= 8) { const allReady = Array.from(lobby.players.values()).every(p => p.ready); if (allReady) { @@ -96,7 +122,7 @@ io.on('connection', (socket) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); - if (!player || player.eliminated) return; + if (!player || player.eliminated || !player.currentPiece) return; const newX = player.currentPiece.x + (direction === 'left' ? -1 : 1); if (isValidPosition(player.currentPiece, newX, player.currentPiece.y, player.board)) { @@ -109,7 +135,7 @@ io.on('connection', (socket) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); - if (!player || player.eliminated) return; + if (!player || player.eliminated || !player.currentPiece) return; const originalShape = player.currentPiece.shape; const rows = originalShape.length; @@ -137,7 +163,7 @@ io.on('connection', (socket) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); - if (!player || player.eliminated) return; + if (!player || player.eliminated || !player.currentPiece) return; if (hard) { let dropped = 0; @@ -163,7 +189,7 @@ io.on('connection', (socket) => { if (!lobby.gameStarted) return; const player = lobby.players.get(playerId); - if (!player || player.eliminated) return; + if (!player || player.eliminated || !player.currentPiece) return; if (!player.canHold) return; if (player.holdPiece === null) { @@ -218,6 +244,19 @@ io.on('connection', (socket) => { if (lobby.gameInterval) clearInterval(lobby.gameInterval); lobby.gameStarted = false; } + return; + } + + // Handle spectator disconnect + const spectator = lobby.spectators.get(socket.id); + if (spectator) { + console.log(`${spectator.name} (spectator) disconnected`); + lobby.spectators.delete(socket.id); + + io.to(LOBBY_ROOM).emit('spectator-left', { + spectatorId: socket.id, + spectators: getSpectatorsList() + }); } }); }); @@ -238,6 +277,13 @@ function getPlayersList() { })); } +function getSpectatorsList() { + return Array.from(lobby.spectators.values()).map(s => ({ + id: s.id, + name: s.name + })); +} + function isValidPosition(piece, x, y, board, shape = null) { const s = shape || piece.shape; for (let row = 0; row < s.length; row++) { @@ -245,8 +291,8 @@ function isValidPosition(piece, x, y, board, shape = null) { if (s[row][col]) { const newX = x + col; const newY = y + row; - if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) return false; - if (newY >= 0 && board[newY][newX]) return false; + if (newX < 0 || newX >= BOARD_WIDTH || newY < 0 || newY >= BOARD_HEIGHT) return false; + if (board[newY][newX]) return false; } } } @@ -268,6 +314,7 @@ function getRandomPiece() { function spawnPiece(player) { player.currentPiece = player.nextPiece; player.nextPiece = getRandomPiece(); + if (!player.currentPiece) return false; return isValidPosition(player.currentPiece, player.currentPiece.x, player.currentPiece.y, player.board); } @@ -323,9 +370,11 @@ function sendGarbage(sender, rowsCleared) { const garbageRows = rowsCleared >= 4 ? rowsCleared : Math.max(1, rowsCleared - 1); const opponents = Array.from(lobby.players.values()).filter(p => p.id !== sender.id && !p.eliminated); if (opponents.length === 0) return; - for (let i = 0; i < garbageRows; i++) { - const target = opponents[Math.floor(Math.random() * opponents.length)]; - addGarbageToPlayer(target); + // Send all garbage rows to each opponent + for (const opponent of opponents) { + for (let i = 0; i < garbageRows; i++) { + addGarbageToPlayer(opponent); + } } } @@ -376,7 +425,7 @@ function startGame() { function gameTick() { for (const player of lobby.players.values()) { - if (player.eliminated) continue; + if (player.eliminated || !player.currentPiece) continue; player.dropCounter += 50; if (player.dropCounter >= player.dropInterval) { @@ -421,6 +470,38 @@ function checkGameOver() { lobby.gameInterval = null; } lobby.gameStarted = false; + + // Move spectators to players for next round + for (const [id, spectator] of lobby.spectators.entries()) { + const player = { + id: spectator.id, + name: spectator.name, + score: 0, + lines: 0, + level: 1, + board: createEmptyBoard(), + currentPiece: null, + nextPiece: null, + eliminated: false, + ready: false, + dropCounter: 0, + dropInterval: 1000 + }; + lobby.players.set(id, player); + spectator.socket = io.sockets.sockets.get(id); + if (spectator.socket) { + spectator.socket.data.playerName = spectator.name; + } + } + lobby.spectators.clear(); + + // Broadcast updated player list + io.to(LOBBY_ROOM).emit('player-joined', { + player: null, + players: getPlayersList() + }); + + console.log(`Game over. Moved ${activePlayers.length === 1 ? 0 : lobby.players.size} players to next round`); } }