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
+63 -1
View File
@@ -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;
}
}
+5 -4
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=9">
<link rel="stylesheet" href="css/style.css?v=10">
</head>
<body>
<div id="app">
@@ -25,6 +25,7 @@
<div id="lobby-screen" class="screen">
<h2>GLOBAL LOBBY</h2>
<div id="player-list"></div>
<div id="spectator-list"></div>
<button id="ready-btn">READY</button>
</div>
@@ -61,10 +62,10 @@
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="js/network.js?v=4"></script>
<script src="js/network.js?v=5"></script>
<script src="js/game.js?v=4"></script>
<script src="js/renderer.js?v=5"></script>
<script src="js/ui.js?v=4"></script>
<script src="js/app.js?v=4"></script>
<script src="js/ui.js?v=5"></script>
<script src="js/app.js?v=5"></script>
</body>
</html>
+43 -2
View File
@@ -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
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;
+6 -1
View File
@@ -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) {
+24
View File
@@ -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(() => {