diff --git a/README.md b/README.md index 2d62e74..892be9d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A fully original Bomberman-style browser game built from scratch. - Startup menu with mode selection (`Single Player` / `Multiplayer`) and music toggle - Multiplayer name setting from menu (`Name: ...`) with local persistence - Multiplayer lobby with host-controlled start (can start with 1-4 players) +- Lobby shows when a match is currently running; waiting players auto-join the next round - After each multiplayer round, host can start the next round directly (no lobby required) - Host-authoritative multiplayer sync via WebSocket backend diff --git a/server.js b/server.js index e074cb6..1063c9d 100644 --- a/server.js +++ b/server.js @@ -211,6 +211,18 @@ function handleMessage(client, msg) { return; } + if (msg.type === "lobby_next_round") { + if (lobby.phase !== "game") { + return; + } + if (client.id !== lobby.hostId) { + send(client.ws, { type: "error", message: "Only the host can start the next round." }); + return; + } + startMatch(); + return; + } + if (msg.type === "player_input") { if (lobby.phase !== "game") { return; diff --git a/src/game.js b/src/game.js index cd55702..2ca84a6 100644 --- a/src/game.js +++ b/src/game.js @@ -86,6 +86,10 @@ const state = { lobbyMenu: { selectedIndex: 0, }, + notification: { + text: "", + expiresAt: 0, + }, }; const images = {}; @@ -127,6 +131,7 @@ const network = { lastInputSentAt: 0, activeRoster: [], lastPingAt: 0, + lobbyPhase: "lobby", hasReceivedSnapshot: false, }; @@ -203,6 +208,14 @@ function now() { return performance.now() / 1000; } +function showMatchNotification(text, duration = 3) { + if (typeof text !== "string" || !text.trim()) { + return; + } + state.notification.text = text.trim(); + state.notification.expiresAt = now() + duration; +} + function loadImage(path) { return new Promise((resolve, reject) => { const img = new Image(); @@ -431,19 +444,8 @@ function restartMultiplayerRoundAsHost() { if (state.mode !== "multiplayer" || state.screen !== "game" || !network.isHost) { return; } - const roster = state.players - .filter((player) => typeof player.ownerId === "string" && player.ownerId) - .map((player) => ({ - id: player.ownerId, - name: player.name, - })); - if (roster.length < 1) { - return; - } - network.activeRoster = roster.slice(0, MAX_PLAYERS); - resetRoundMultiplayer(network.activeRoster); - network.lastSnapshotSentAt = 0; - broadcastSnapshot(); + sendSocketMessage("lobby_next_round"); + state.message = "Starting next round..."; } function activateMenuSelection() { @@ -505,11 +507,11 @@ function getMainMenuItems() { } function getLobbyMenuItems() { - const canStart = network.isHost && network.lobbyPlayers.length >= 1; + const canStart = network.lobbyPhase === "lobby" && network.isHost && network.lobbyPlayers.length >= 1; return [ { id: "start", - label: canStart ? "Start Match" : "Waiting For Host", + label: canStart ? "Start Match" : network.lobbyPhase === "game" ? "Game In Progress" : "Waiting For Host", disabled: !canStart, }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, @@ -609,6 +611,7 @@ function ensureSocketConnection() { network.isHost = false; network.hostId = null; network.lobbyPlayers = []; + network.lobbyPhase = "lobby"; if (state.screen === "lobby") { network.inLobby = true; state.message = "Disconnected. Reconnecting..."; @@ -663,9 +666,30 @@ function handleSocketMessage(raw) { } if (msg.type === "lobby_state") { + const previousLobbyPlayers = network.lobbyPlayers.slice(); + const nextLobbyPlayers = normalizeLobbyPlayers(msg.players || []); + network.hostId = msg.hostId || null; network.isHost = network.hostId === network.clientId; - network.lobbyPlayers = normalizeLobbyPlayers(msg.players || []); + network.lobbyPhase = msg.phase === "game" ? "game" : "lobby"; + network.lobbyPlayers = nextLobbyPlayers; + + if (state.mode === "multiplayer" && state.screen === "game" && msg.phase === "game") { + const activeMatchIds = new Set(network.activeRoster.map((player) => player.id)); + const previousIds = new Set(previousLobbyPlayers.map((player) => player.id)); + const newlyWaiting = nextLobbyPlayers.filter( + (player) => !previousIds.has(player.id) && !activeMatchIds.has(player.id), + ); + if (newlyWaiting.length === 1) { + showMatchNotification(newlyWaiting[0].name + " is waiting in lobby."); + } else if (newlyWaiting.length > 1) { + showMatchNotification(String(newlyWaiting.length) + " players are waiting in lobby."); + } + } + + if (state.screen === "lobby" && network.lobbyPhase === "game") { + state.message = "Match running. You will join next round."; + } if (state.mode === "multiplayer" && state.screen === "game" && msg.phase === "lobby") { state.screen = "lobby"; state.status = "idle"; @@ -678,6 +702,7 @@ function handleSocketMessage(raw) { if (msg.type === "lobby_start") { network.hostId = msg.hostId; network.isHost = msg.hostId === network.clientId; + network.lobbyPhase = "game"; network.activeRoster = normalizeLobbyPlayers(msg.roster || []); startMultiplayerMatch(network.activeRoster); return; @@ -710,6 +735,7 @@ function startSinglePlayerFromMenu() { function startMultiplayerMatch(roster) { network.activeRoster = roster.slice(0, MAX_PLAYERS); network.inLobby = false; + network.lobbyPhase = "game"; network.lastSnapshotAt = now(); network.lastInputSentAt = 0; network.hasReceivedSnapshot = network.isHost; @@ -732,6 +758,7 @@ function enterLobbyScreen() { network.inLobby = true; network.isHost = false; network.hostId = null; + network.lobbyPhase = "lobby"; network.lobbyPlayers = []; network.remoteInputs.clear(); network.remoteBombPrev.clear(); @@ -745,10 +772,13 @@ function leaveLobbyInternals() { network.inLobby = false; network.isHost = false; network.hostId = null; + network.lobbyPhase = "lobby"; network.lobbyPlayers = []; network.remoteInputs.clear(); network.remoteBombPrev.clear(); network.activeRoster = []; + state.notification.text = ""; + state.notification.expiresAt = 0; network.inputStateAtClient = { dir: null, bomb: false }; network.lastInputSentAt = 0; network.hasReceivedSnapshot = false; @@ -766,7 +796,7 @@ function leaveMultiplayerToMainMenu() { } function hostStartMatchFromLobby() { - if (!network.isHost || network.lobbyPlayers.length < 1) { + if (!network.isHost || network.lobbyPhase !== "lobby" || network.lobbyPlayers.length < 1) { return; } sendSocketMessage("lobby_start"); @@ -1837,7 +1867,11 @@ function drawLobby() { ctx.fillText("Multiplayer Lobby", canvas.width / 2, panelY + 48); ctx.fillStyle = "#b6ddff"; ctx.font = "bold 14px Trebuchet MS"; - ctx.fillText("Host can start with any number of players (1-4).", canvas.width / 2, panelY + 74); + const lobbyHint = + network.lobbyPhase === "game" + ? "Match currently running. You will join next round." + : "Host can start with any number of players (1-4)."; + ctx.fillText(lobbyHint, canvas.width / 2, panelY + 74); ctx.textAlign = "left"; ctx.font = "bold 17px Trebuchet MS"; @@ -1866,6 +1900,40 @@ function drawLobby() { }); } +function drawMatchNotification() { + if (state.screen !== "game" || !state.notification.text) { + return; + } + + const remaining = state.notification.expiresAt - now(); + if (remaining <= 0) { + state.notification.text = ""; + state.notification.expiresAt = 0; + return; + } + + const fade = clamp(remaining < 0.35 ? remaining / 0.35 : 1, 0, 1); + const panelWidth = Math.min(canvas.width - 36, 520); + const panelHeight = 34; + const panelX = (canvas.width - panelWidth) / 2; + const panelY = 14; + + ctx.save(); + ctx.globalAlpha = 0.96 * fade; + ctx.fillStyle = "#071421db"; + ctx.strokeStyle = "#8acfff99"; + ctx.lineWidth = 2; + ctx.fillRect(panelX, panelY, panelWidth, panelHeight); + ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); + + ctx.fillStyle = "#e7f4ff"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = "bold 16px Trebuchet MS"; + ctx.fillText(state.notification.text, canvas.width / 2, panelY + panelHeight / 2 + 1); + ctx.restore(); +} + function drawFrame() { if (state.screen === "mainMenu") { drawMainMenu(); @@ -1883,6 +1951,7 @@ function drawFrame() { drawBombs(); drawExplosions(); drawPlayers(); + drawMatchNotification(); drawMenu(); ctx.strokeStyle = "#86cef91f";