Allow waiting players in the lobby to join next round

This commit is contained in:
Mike Müller 2026-03-08 14:04:46 +01:00
parent c2025e0ca2
commit 21f9807482
3 changed files with 100 additions and 18 deletions

View File

@ -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 - Startup menu with mode selection (`Single Player` / `Multiplayer`) and music toggle
- Multiplayer name setting from menu (`Name: ...`) with local persistence - Multiplayer name setting from menu (`Name: ...`) with local persistence
- Multiplayer lobby with host-controlled start (can start with 1-4 players) - 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) - After each multiplayer round, host can start the next round directly (no lobby required)
- Host-authoritative multiplayer sync via WebSocket backend - Host-authoritative multiplayer sync via WebSocket backend

View File

@ -211,6 +211,18 @@ function handleMessage(client, msg) {
return; 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 (msg.type === "player_input") {
if (lobby.phase !== "game") { if (lobby.phase !== "game") {
return; return;

View File

@ -86,6 +86,10 @@ const state = {
lobbyMenu: { lobbyMenu: {
selectedIndex: 0, selectedIndex: 0,
}, },
notification: {
text: "",
expiresAt: 0,
},
}; };
const images = {}; const images = {};
@ -127,6 +131,7 @@ const network = {
lastInputSentAt: 0, lastInputSentAt: 0,
activeRoster: [], activeRoster: [],
lastPingAt: 0, lastPingAt: 0,
lobbyPhase: "lobby",
hasReceivedSnapshot: false, hasReceivedSnapshot: false,
}; };
@ -203,6 +208,14 @@ function now() {
return performance.now() / 1000; 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) { function loadImage(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@ -431,19 +444,8 @@ function restartMultiplayerRoundAsHost() {
if (state.mode !== "multiplayer" || state.screen !== "game" || !network.isHost) { if (state.mode !== "multiplayer" || state.screen !== "game" || !network.isHost) {
return; return;
} }
const roster = state.players sendSocketMessage("lobby_next_round");
.filter((player) => typeof player.ownerId === "string" && player.ownerId) state.message = "Starting next round...";
.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();
} }
function activateMenuSelection() { function activateMenuSelection() {
@ -505,11 +507,11 @@ function getMainMenuItems() {
} }
function getLobbyMenuItems() { function getLobbyMenuItems() {
const canStart = network.isHost && network.lobbyPlayers.length >= 1; const canStart = network.lobbyPhase === "lobby" && network.isHost && network.lobbyPlayers.length >= 1;
return [ return [
{ {
id: "start", id: "start",
label: canStart ? "Start Match" : "Waiting For Host", label: canStart ? "Start Match" : network.lobbyPhase === "game" ? "Game In Progress" : "Waiting For Host",
disabled: !canStart, disabled: !canStart,
}, },
{ id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` },
@ -609,6 +611,7 @@ function ensureSocketConnection() {
network.isHost = false; network.isHost = false;
network.hostId = null; network.hostId = null;
network.lobbyPlayers = []; network.lobbyPlayers = [];
network.lobbyPhase = "lobby";
if (state.screen === "lobby") { if (state.screen === "lobby") {
network.inLobby = true; network.inLobby = true;
state.message = "Disconnected. Reconnecting..."; state.message = "Disconnected. Reconnecting...";
@ -663,9 +666,30 @@ function handleSocketMessage(raw) {
} }
if (msg.type === "lobby_state") { if (msg.type === "lobby_state") {
const previousLobbyPlayers = network.lobbyPlayers.slice();
const nextLobbyPlayers = normalizeLobbyPlayers(msg.players || []);
network.hostId = msg.hostId || null; network.hostId = msg.hostId || null;
network.isHost = network.hostId === network.clientId; 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") { if (state.mode === "multiplayer" && state.screen === "game" && msg.phase === "lobby") {
state.screen = "lobby"; state.screen = "lobby";
state.status = "idle"; state.status = "idle";
@ -678,6 +702,7 @@ function handleSocketMessage(raw) {
if (msg.type === "lobby_start") { if (msg.type === "lobby_start") {
network.hostId = msg.hostId; network.hostId = msg.hostId;
network.isHost = msg.hostId === network.clientId; network.isHost = msg.hostId === network.clientId;
network.lobbyPhase = "game";
network.activeRoster = normalizeLobbyPlayers(msg.roster || []); network.activeRoster = normalizeLobbyPlayers(msg.roster || []);
startMultiplayerMatch(network.activeRoster); startMultiplayerMatch(network.activeRoster);
return; return;
@ -710,6 +735,7 @@ function startSinglePlayerFromMenu() {
function startMultiplayerMatch(roster) { function startMultiplayerMatch(roster) {
network.activeRoster = roster.slice(0, MAX_PLAYERS); network.activeRoster = roster.slice(0, MAX_PLAYERS);
network.inLobby = false; network.inLobby = false;
network.lobbyPhase = "game";
network.lastSnapshotAt = now(); network.lastSnapshotAt = now();
network.lastInputSentAt = 0; network.lastInputSentAt = 0;
network.hasReceivedSnapshot = network.isHost; network.hasReceivedSnapshot = network.isHost;
@ -732,6 +758,7 @@ function enterLobbyScreen() {
network.inLobby = true; network.inLobby = true;
network.isHost = false; network.isHost = false;
network.hostId = null; network.hostId = null;
network.lobbyPhase = "lobby";
network.lobbyPlayers = []; network.lobbyPlayers = [];
network.remoteInputs.clear(); network.remoteInputs.clear();
network.remoteBombPrev.clear(); network.remoteBombPrev.clear();
@ -745,10 +772,13 @@ function leaveLobbyInternals() {
network.inLobby = false; network.inLobby = false;
network.isHost = false; network.isHost = false;
network.hostId = null; network.hostId = null;
network.lobbyPhase = "lobby";
network.lobbyPlayers = []; network.lobbyPlayers = [];
network.remoteInputs.clear(); network.remoteInputs.clear();
network.remoteBombPrev.clear(); network.remoteBombPrev.clear();
network.activeRoster = []; network.activeRoster = [];
state.notification.text = "";
state.notification.expiresAt = 0;
network.inputStateAtClient = { dir: null, bomb: false }; network.inputStateAtClient = { dir: null, bomb: false };
network.lastInputSentAt = 0; network.lastInputSentAt = 0;
network.hasReceivedSnapshot = false; network.hasReceivedSnapshot = false;
@ -766,7 +796,7 @@ function leaveMultiplayerToMainMenu() {
} }
function hostStartMatchFromLobby() { function hostStartMatchFromLobby() {
if (!network.isHost || network.lobbyPlayers.length < 1) { if (!network.isHost || network.lobbyPhase !== "lobby" || network.lobbyPlayers.length < 1) {
return; return;
} }
sendSocketMessage("lobby_start"); sendSocketMessage("lobby_start");
@ -1837,7 +1867,11 @@ function drawLobby() {
ctx.fillText("Multiplayer Lobby", canvas.width / 2, panelY + 48); ctx.fillText("Multiplayer Lobby", canvas.width / 2, panelY + 48);
ctx.fillStyle = "#b6ddff"; ctx.fillStyle = "#b6ddff";
ctx.font = "bold 14px Trebuchet MS"; 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.textAlign = "left";
ctx.font = "bold 17px Trebuchet MS"; 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() { function drawFrame() {
if (state.screen === "mainMenu") { if (state.screen === "mainMenu") {
drawMainMenu(); drawMainMenu();
@ -1883,6 +1951,7 @@ function drawFrame() {
drawBombs(); drawBombs();
drawExplosions(); drawExplosions();
drawPlayers(); drawPlayers();
drawMatchNotification();
drawMenu(); drawMenu();
ctx.strokeStyle = "#86cef91f"; ctx.strokeStyle = "#86cef91f";