Allow waiting players in the lobby to join next round
This commit is contained in:
parent
c2025e0ca2
commit
21f9807482
@ -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
|
||||||
|
|
||||||
|
|||||||
12
server.js
12
server.js
@ -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;
|
||||||
|
|||||||
105
src/game.js
105
src/game.js
@ -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";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user