Compare commits

...

2 Commits

3 changed files with 187 additions and 29 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 = {};
@ -123,10 +127,12 @@ const network = {
inputStateAtClient: { inputStateAtClient: {
dir: null, dir: null,
bomb: false, bomb: false,
bombCell: null,
}, },
lastInputSentAt: 0, lastInputSentAt: 0,
activeRoster: [], activeRoster: [],
lastPingAt: 0, lastPingAt: 0,
lobbyPhase: "lobby",
hasReceivedSnapshot: false, hasReceivedSnapshot: false,
}; };
@ -203,6 +209,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 +445,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 +508,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 +612,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 +667,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 +703,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;
@ -687,7 +713,7 @@ function handleSocketMessage(raw) {
if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") {
return; return;
} }
network.remoteInputs.set(msg.playerId, msg.input || { dir: null, bomb: false }); network.remoteInputs.set(msg.playerId, msg.input || { dir: null, bomb: false, bombCell: null });
return; return;
} }
@ -710,6 +736,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 +759,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,11 +773,14 @@ 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 = [];
network.inputStateAtClient = { dir: null, bomb: false }; state.notification.text = "";
state.notification.expiresAt = 0;
network.inputStateAtClient = { dir: null, bomb: false, bombCell: null };
network.lastInputSentAt = 0; network.lastInputSentAt = 0;
network.hasReceivedSnapshot = false; network.hasReceivedSnapshot = false;
} }
@ -766,7 +797,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");
@ -945,22 +976,94 @@ function updateLobbyNetwork(dt) {
} }
} }
function sameBombCell(a, b) {
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
return a.x === b.x && a.y === b.y;
}
function resolveBombDropCell(player, preferredCell = null) {
const candidates = [];
if (player.moveProgress < 1) {
candidates.push({ x: player.moveFromX, y: player.moveFromY });
if (player.moveToX !== player.moveFromX || player.moveToY !== player.moveFromY) {
candidates.push({ x: player.moveToX, y: player.moveToY });
}
} else {
candidates.push({ x: player.x, y: player.y });
}
const canPlaceAt = (x, y) => {
if (!inBounds(x, y)) {
return false;
}
const tile = state.map[y]?.[x];
if (!tile || tile.type !== "floor") {
return false;
}
return !getBombAt(x, y);
};
const isCandidateCell = (cell) =>
candidates.some((candidate) => candidate.x === cell.x && candidate.y === cell.y);
if (preferredCell && Number.isInteger(preferredCell.x) && Number.isInteger(preferredCell.y)) {
if (isCandidateCell(preferredCell) && canPlaceAt(preferredCell.x, preferredCell.y)) {
return { x: preferredCell.x, y: preferredCell.y };
}
}
const primary =
player.moveProgress < 1 && player.moveProgress >= 0.5
? { x: player.moveToX, y: player.moveToY }
: candidates[0];
if (primary && canPlaceAt(primary.x, primary.y)) {
return { x: primary.x, y: primary.y };
}
for (const candidate of candidates) {
if (canPlaceAt(candidate.x, candidate.y)) {
return { x: candidate.x, y: candidate.y };
}
}
return null;
}
function sendLocalMultiplayerInput(force = false) { function sendLocalMultiplayerInput(force = false) {
if (state.mode !== "multiplayer" || state.screen !== "game" || network.isHost) { if (state.mode !== "multiplayer" || state.screen !== "game" || network.isHost) {
return; return;
} }
const t = now(); const t = now();
const localPlayer = state.players.find((player) => player.ownerId === network.clientId) || null;
const nextInput = { const nextInput = {
dir: currentHumanDirection(), dir: currentHumanDirection(),
bomb: inputState.bombHeld, bomb: inputState.bombHeld,
bombCell: null,
}; };
if (nextInput.bomb && localPlayer) {
nextInput.bombCell = resolveBombDropCell(localPlayer);
}
const changed = const changed =
nextInput.dir !== network.inputStateAtClient.dir || nextInput.bomb !== network.inputStateAtClient.bomb; nextInput.dir !== network.inputStateAtClient.dir ||
nextInput.bomb !== network.inputStateAtClient.bomb ||
!sameBombCell(nextInput.bombCell, network.inputStateAtClient.bombCell);
const dueHeartbeat = t - network.lastInputSentAt > 0.12; const dueHeartbeat = t - network.lastInputSentAt > 0.12;
if (!changed && !force && !dueHeartbeat) { if (!changed && !force && !dueHeartbeat) {
return; return;
} }
network.inputStateAtClient = nextInput;
network.inputStateAtClient = {
dir: nextInput.dir,
bomb: nextInput.bomb,
bombCell: nextInput.bombCell ? { ...nextInput.bombCell } : null,
};
network.lastInputSentAt = t; network.lastInputSentAt = t;
sendSocketMessage("player_input", { input: nextInput, ts: t }); sendSocketMessage("player_input", { input: nextInput, ts: t });
} }
@ -1002,18 +1105,20 @@ function queueDetonation(bombId) {
state.pendingDetonations.push(bombId); state.pendingDetonations.push(bombId);
} }
function dropBomb(player) { function dropBomb(player, preferredCell = null) {
if (!player.alive || player.bombsPlaced >= player.bombCapacity) { if (!player.alive || player.bombsPlaced >= player.bombCapacity) {
return false; return false;
} }
if (getBombAt(player.x, player.y)) {
const dropCell = resolveBombDropCell(player, preferredCell);
if (!dropCell) {
return false; return false;
} }
const bomb = { const bomb = {
id: state.nextBombId, id: state.nextBombId,
x: player.x, x: dropCell.x,
y: player.y, y: dropCell.y,
timer: CONFIG.bombFuse, timer: CONFIG.bombFuse,
range: player.flameRange, range: player.flameRange,
ownerId: player.id, ownerId: player.id,
@ -1299,7 +1404,7 @@ function occupiedTiles(player) {
} }
function getNetworkInput(ownerId) { function getNetworkInput(ownerId) {
return network.remoteInputs.get(ownerId) || { dir: null, bomb: false }; return network.remoteInputs.get(ownerId) || { dir: null, bomb: false, bombCell: null };
} }
function getDesiredDirection(player) { function getDesiredDirection(player) {
@ -1341,8 +1446,9 @@ function updatePlayerMovement(player, dt) {
player.renderY = player.y; player.renderY = player.y;
} }
const occupied = new Set(occupiedTiles(player));
for (const bomb of state.bombs) { for (const bomb of state.bombs) {
if (bomb.passThrough.has(player.id) && (player.x !== bomb.x || player.y !== bomb.y)) { if (bomb.passThrough.has(player.id) && !occupied.has(tileKey(bomb.x, bomb.y))) {
bomb.passThrough.delete(player.id); bomb.passThrough.delete(player.id);
} }
} }
@ -1555,7 +1661,7 @@ function updateGame(dt) {
const remote = getNetworkInput(player.ownerId); const remote = getNetworkInput(player.ownerId);
const prevBomb = network.remoteBombPrev.get(player.ownerId) || false; const prevBomb = network.remoteBombPrev.get(player.ownerId) || false;
if (remote.bomb && !prevBomb) { if (remote.bomb && !prevBomb) {
dropBomb(player); dropBomb(player, remote.bombCell || null);
} }
network.remoteBombPrev.set(player.ownerId, remote.bomb); network.remoteBombPrev.set(player.ownerId, remote.bomb);
} }
@ -1837,7 +1943,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 +1976,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 +2027,7 @@ function drawFrame() {
drawBombs(); drawBombs();
drawExplosions(); drawExplosions();
drawPlayers(); drawPlayers();
drawMatchNotification();
drawMenu(); drawMenu();
ctx.strokeStyle = "#86cef91f"; ctx.strokeStyle = "#86cef91f";