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:
2026-03-20 18:26:48 +00:00
parent 75cd5b0e44
commit a0ab4ff5cd
7 changed files with 281 additions and 21 deletions
+48 -2
View File
@@ -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;