Add spectator mode for late-joining players
- Server: Late joiners are added as spectators instead of players - Server: Send forced-spectator event only to joining spectator (not broadcast) - Server: Track spectators separately and move them to players after game ends - Client: Handle forced-spectator event to show all player boards - Client: Spectators see all boards equally without main/spectator highlighting - Client: Mobile view shows scrollable vertical list of all boards for spectators - Fix: All cleared lines are sent as garbage to each opponent (not randomized) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+92
-11
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user