Compare commits
2 Commits
c2025e0ca2
...
e9306a2502
| Author | SHA1 | Date | |
|---|---|---|---|
| e9306a2502 | |||
| 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
|
||||
- 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
|
||||
|
||||
|
||||
12
server.js
12
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;
|
||||
|
||||
203
src/game.js
203
src/game.js
@ -86,6 +86,10 @@ const state = {
|
||||
lobbyMenu: {
|
||||
selectedIndex: 0,
|
||||
},
|
||||
notification: {
|
||||
text: "",
|
||||
expiresAt: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const images = {};
|
||||
@ -123,10 +127,12 @@ const network = {
|
||||
inputStateAtClient: {
|
||||
dir: null,
|
||||
bomb: false,
|
||||
bombCell: null,
|
||||
},
|
||||
lastInputSentAt: 0,
|
||||
activeRoster: [],
|
||||
lastPingAt: 0,
|
||||
lobbyPhase: "lobby",
|
||||
hasReceivedSnapshot: false,
|
||||
};
|
||||
|
||||
@ -203,6 +209,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 +445,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 +508,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 +612,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 +667,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 +703,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;
|
||||
@ -687,7 +713,7 @@ function handleSocketMessage(raw) {
|
||||
if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -710,6 +736,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 +759,7 @@ function enterLobbyScreen() {
|
||||
network.inLobby = true;
|
||||
network.isHost = false;
|
||||
network.hostId = null;
|
||||
network.lobbyPhase = "lobby";
|
||||
network.lobbyPlayers = [];
|
||||
network.remoteInputs.clear();
|
||||
network.remoteBombPrev.clear();
|
||||
@ -745,11 +773,14 @@ function leaveLobbyInternals() {
|
||||
network.inLobby = false;
|
||||
network.isHost = false;
|
||||
network.hostId = null;
|
||||
network.lobbyPhase = "lobby";
|
||||
network.lobbyPlayers = [];
|
||||
network.remoteInputs.clear();
|
||||
network.remoteBombPrev.clear();
|
||||
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.hasReceivedSnapshot = false;
|
||||
}
|
||||
@ -766,7 +797,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");
|
||||
@ -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) {
|
||||
if (state.mode !== "multiplayer" || state.screen !== "game" || network.isHost) {
|
||||
return;
|
||||
}
|
||||
const t = now();
|
||||
const localPlayer = state.players.find((player) => player.ownerId === network.clientId) || null;
|
||||
const nextInput = {
|
||||
dir: currentHumanDirection(),
|
||||
bomb: inputState.bombHeld,
|
||||
bombCell: null,
|
||||
};
|
||||
if (nextInput.bomb && localPlayer) {
|
||||
nextInput.bombCell = resolveBombDropCell(localPlayer);
|
||||
}
|
||||
|
||||
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;
|
||||
if (!changed && !force && !dueHeartbeat) {
|
||||
return;
|
||||
}
|
||||
network.inputStateAtClient = nextInput;
|
||||
|
||||
network.inputStateAtClient = {
|
||||
dir: nextInput.dir,
|
||||
bomb: nextInput.bomb,
|
||||
bombCell: nextInput.bombCell ? { ...nextInput.bombCell } : null,
|
||||
};
|
||||
network.lastInputSentAt = t;
|
||||
sendSocketMessage("player_input", { input: nextInput, ts: t });
|
||||
}
|
||||
@ -1002,18 +1105,20 @@ function queueDetonation(bombId) {
|
||||
state.pendingDetonations.push(bombId);
|
||||
}
|
||||
|
||||
function dropBomb(player) {
|
||||
function dropBomb(player, preferredCell = null) {
|
||||
if (!player.alive || player.bombsPlaced >= player.bombCapacity) {
|
||||
return false;
|
||||
}
|
||||
if (getBombAt(player.x, player.y)) {
|
||||
|
||||
const dropCell = resolveBombDropCell(player, preferredCell);
|
||||
if (!dropCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bomb = {
|
||||
id: state.nextBombId,
|
||||
x: player.x,
|
||||
y: player.y,
|
||||
x: dropCell.x,
|
||||
y: dropCell.y,
|
||||
timer: CONFIG.bombFuse,
|
||||
range: player.flameRange,
|
||||
ownerId: player.id,
|
||||
@ -1299,7 +1404,7 @@ function occupiedTiles(player) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -1341,8 +1446,9 @@ function updatePlayerMovement(player, dt) {
|
||||
player.renderY = player.y;
|
||||
}
|
||||
|
||||
const occupied = new Set(occupiedTiles(player));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1555,7 +1661,7 @@ function updateGame(dt) {
|
||||
const remote = getNetworkInput(player.ownerId);
|
||||
const prevBomb = network.remoteBombPrev.get(player.ownerId) || false;
|
||||
if (remote.bomb && !prevBomb) {
|
||||
dropBomb(player);
|
||||
dropBomb(player, remote.bombCell || null);
|
||||
}
|
||||
network.remoteBombPrev.set(player.ownerId, remote.bomb);
|
||||
}
|
||||
@ -1837,7 +1943,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 +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() {
|
||||
if (state.screen === "mainMenu") {
|
||||
drawMainMenu();
|
||||
@ -1883,6 +2027,7 @@ function drawFrame() {
|
||||
drawBombs();
|
||||
drawExplosions();
|
||||
drawPlayers();
|
||||
drawMatchNotification();
|
||||
drawMenu();
|
||||
|
||||
ctx.strokeStyle = "#86cef91f";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user