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:
+43
-2
@@ -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);
|
||||
}
|
||||
|
||||
+48
-2
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = `
|
||||
<span class="name">${this.escapeHtml(spectator.name)}</span>
|
||||
<span class="status">WATCHING</span>
|
||||
`;
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user