diff --git a/README.md b/README.md index 34eca27..2d62e74 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ -# gpt-bomber +# GPT Bomber -GPT codex written bomberman clone \ No newline at end of file +A fully original Bomberman-style browser game built from scratch. + +## Features +- Grid-based bomb combat with chain reactions +- Destructible crates and hidden powerups (bomb, flame, speed) +- 3 CPU opponents with survival + attack behavior +- Original visual assets (logo, characters, tiles, icons) in local SVG files +- Original procedural audio (looping music + SFX) synthesized with Web Audio +- Keyboard and controller support +- 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) +- After each multiplayer round, host can start the next round directly (no lobby required) +- Host-authoritative multiplayer sync via WebSocket backend + +## Run +Install dependencies and run the backend server: + +```bash +npm install +npm start +``` + +Then open: + +```text +http://localhost:8080 +``` + +## Controls +- Move: `WASD` or `Arrow Keys` +- Bomb: `Space` or `Enter` +- Menu: `Esc` / `P` +- Menu Select: `Enter` / `Space` +- Controller: `D-pad` move, `A` bomb/select, `Start` open menu + +## Multiplayer Notes +- For LAN/internet play, run the server on a reachable machine and open `http://:8080` from each client. +- The first active lobby player becomes host and can start with fewer than 4 players. diff --git a/assets/logo/neon-bomber.svg b/assets/logo/neon-bomber.svg new file mode 100644 index 0000000..5a4f770 --- /dev/null +++ b/assets/logo/neon-bomber.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NEON + BOMBER + + GRID SHOWDOWN + diff --git a/assets/players/player-blue.svg b/assets/players/player-blue.svg new file mode 100644 index 0000000..c53ab2d --- /dev/null +++ b/assets/players/player-blue.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/players/player-green.svg b/assets/players/player-green.svg new file mode 100644 index 0000000..1c7edd2 --- /dev/null +++ b/assets/players/player-green.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/players/player-red.svg b/assets/players/player-red.svg new file mode 100644 index 0000000..af34177 --- /dev/null +++ b/assets/players/player-red.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/players/player-yellow.svg b/assets/players/player-yellow.svg new file mode 100644 index 0000000..d9d6acb --- /dev/null +++ b/assets/players/player-yellow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/powerups/bomb-up.svg b/assets/powerups/bomb-up.svg new file mode 100644 index 0000000..eccefe8 --- /dev/null +++ b/assets/powerups/bomb-up.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/powerups/flame-up.svg b/assets/powerups/flame-up.svg new file mode 100644 index 0000000..8cc5201 --- /dev/null +++ b/assets/powerups/flame-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/powerups/speed-up.svg b/assets/powerups/speed-up.svg new file mode 100644 index 0000000..c69b4ab --- /dev/null +++ b/assets/powerups/speed-up.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/tiles/bomb.svg b/assets/tiles/bomb.svg new file mode 100644 index 0000000..8444da2 --- /dev/null +++ b/assets/tiles/bomb.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/tiles/crate.svg b/assets/tiles/crate.svg new file mode 100644 index 0000000..916a8ba --- /dev/null +++ b/assets/tiles/crate.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/tiles/flame-center.svg b/assets/tiles/flame-center.svg new file mode 100644 index 0000000..063ccdb --- /dev/null +++ b/assets/tiles/flame-center.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/tiles/flame-end.svg b/assets/tiles/flame-end.svg new file mode 100644 index 0000000..0527dca --- /dev/null +++ b/assets/tiles/flame-end.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/tiles/flame-straight.svg b/assets/tiles/flame-straight.svg new file mode 100644 index 0000000..0a8b2d0 --- /dev/null +++ b/assets/tiles/flame-straight.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/tiles/floor.svg b/assets/tiles/floor.svg new file mode 100644 index 0000000..58e4cec --- /dev/null +++ b/assets/tiles/floor.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/tiles/wall.svg b/assets/tiles/wall.svg new file mode 100644 index 0000000..84d522a --- /dev/null +++ b/assets/tiles/wall.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..314c7df --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + GPT Bomber + + + +
+
+
+ +
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..436d3e8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "gpt-bomber", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gpt-bomber", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bdcb557 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "gpt-bomber", + "version": "1.0.0", + "private": true, + "description": "GPT Bomber browser game with internet multiplayer backend", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..e074cb6 --- /dev/null +++ b/server.js @@ -0,0 +1,328 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { WebSocketServer } = require("ws"); + +const PORT = Number(process.env.PORT || 8080); +const MAX_PLAYERS = 4; +const ROOT = __dirname; + +const CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".ico": "image/x-icon", +}; + +const clientsBySocket = new Map(); +const clientsById = new Map(); + +const lobby = { + phase: "lobby", // lobby | game + players: [], // [{id,name,joinedAt,lastSeenAt}] + hostId: null, + roster: [], // active game roster [{id,name,joinedAt}] +}; + +function now() { + return Date.now(); +} + +function makeId() { + return crypto.randomUUID ? crypto.randomUUID() : `${now()}-${Math.floor(Math.random() * 1e6)}`; +} + +function defaultName(id) { + return `P-${id.slice(0, 4).toUpperCase()}`; +} + +function normalizeName(value, fallback) { + if (typeof value !== "string") { + return fallback; + } + const normalized = value + .replace(/\s+/g, " ") + .trim() + .slice(0, 20); + return normalized || fallback; +} + +function safePath(urlPath) { + const clean = decodeURIComponent(urlPath.split("?")[0]); + const target = clean === "/" ? "/index.html" : clean; + const resolved = path.resolve(ROOT, `.${target}`); + if (!resolved.startsWith(ROOT)) { + return null; + } + return resolved; +} + +function send(ws, payload) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(payload)); + } +} + +function broadcast(payload, filterFn = null) { + for (const [ws, client] of clientsBySocket.entries()) { + if (filterFn && !filterFn(client)) { + continue; + } + send(ws, payload); + } +} + +function normalizePlayers(players) { + return [...players] + .sort((a, b) => a.joinedAt - b.joinedAt) + .map((p) => ({ id: p.id, name: p.name, joinedAt: p.joinedAt })); +} + +function ensureHost() { + if (lobby.players.length === 0) { + lobby.hostId = null; + return; + } + const hasHost = lobby.players.some((p) => p.id === lobby.hostId); + if (!hasHost) { + lobby.hostId = normalizePlayers(lobby.players)[0].id; + } +} + +function broadcastLobbyState(reason = null) { + ensureHost(); + broadcast({ + type: "lobby_state", + phase: lobby.phase, + hostId: lobby.hostId, + players: normalizePlayers(lobby.players), + reason, + }); +} + +function endMatch(reason = "Match ended") { + lobby.phase = "lobby"; + lobby.roster = []; + broadcastLobbyState(reason); +} + +function startMatch() { + const ordered = normalizePlayers(lobby.players); + if (ordered.length < 1) { + return; + } + lobby.phase = "game"; + lobby.roster = ordered.slice(0, MAX_PLAYERS); + broadcast({ + type: "lobby_start", + hostId: lobby.hostId, + roster: lobby.roster, + }); +} + +function removePlayer(clientId) { + lobby.players = lobby.players.filter((p) => p.id !== clientId); + + if (lobby.phase === "game") { + const inRoster = lobby.roster.some((p) => p.id === clientId); + if (inRoster) { + endMatch("A player disconnected. Back to lobby."); + return; + } + } + + ensureHost(); + broadcastLobbyState(); +} + +function addOrRefreshPlayer(client) { + const existing = lobby.players.find((p) => p.id === client.id); + if (existing) { + existing.lastSeenAt = now(); + existing.name = client.name; + return; + } + + lobby.players.push({ + id: client.id, + name: client.name, + joinedAt: now(), + lastSeenAt: now(), + }); + + ensureHost(); +} + +function handleMessage(client, msg) { + if (!msg || typeof msg !== "object" || typeof msg.type !== "string") { + return; + } + + if (msg.type === "hello") { + client.name = normalizeName(msg.name, client.name); + send(client.ws, { type: "welcome", clientId: client.id, name: client.name }); + return; + } + + if (msg.type === "set_name") { + client.name = normalizeName(msg.name, client.name); + const existing = lobby.players.find((p) => p.id === client.id); + if (existing) { + existing.name = client.name; + existing.lastSeenAt = now(); + if (lobby.phase === "game") { + const inRoster = lobby.roster.find((p) => p.id === client.id); + if (inRoster) { + inRoster.name = client.name; + } + } + broadcastLobbyState(); + } + send(client.ws, { type: "welcome", clientId: client.id, name: client.name }); + return; + } + + if (msg.type === "lobby_join") { + addOrRefreshPlayer(client); + broadcastLobbyState(); + return; + } + + if (msg.type === "lobby_leave") { + removePlayer(client.id); + return; + } + + if (msg.type === "lobby_start") { + if (lobby.phase !== "lobby") { + return; + } + if (client.id !== lobby.hostId) { + send(client.ws, { type: "error", message: "Only the host can start the match." }); + return; + } + startMatch(); + return; + } + + if (msg.type === "player_input") { + if (lobby.phase !== "game") { + return; + } + if (client.id === lobby.hostId) { + return; + } + const hostClient = clientsById.get(lobby.hostId); + if (!hostClient) { + return; + } + send(hostClient.ws, { + type: "player_input", + playerId: client.id, + input: msg.input || { dir: null, bomb: false }, + ts: now(), + }); + return; + } + + if (msg.type === "game_snapshot") { + if (lobby.phase !== "game") { + return; + } + if (client.id !== lobby.hostId) { + return; + } + const rosterIds = new Set(lobby.roster.map((p) => p.id)); + broadcast( + { + type: "game_snapshot", + snapshot: msg.snapshot, + ts: now(), + }, + (target) => rosterIds.has(target.id) && target.id !== lobby.hostId, + ); + return; + } + + if (msg.type === "ping") { + addOrRefreshPlayer(client); + } +} + +const server = http.createServer((req, res) => { + const filePath = safePath(req.url || "/"); + if (!filePath) { + res.writeHead(403).end("Forbidden"); + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not Found"); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const type = CONTENT_TYPES[ext] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": type }); + res.end(data); + }); +}); + +const wss = new WebSocketServer({ noServer: true }); + +server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") { + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); +}); + +wss.on("connection", (ws) => { + const id = makeId(); + const client = { + id, + name: defaultName(id), + ws, + }; + + clientsBySocket.set(ws, client); + clientsById.set(client.id, client); + + send(ws, { type: "welcome", clientId: client.id, name: client.name }); + + ws.on("message", (data) => { + let msg; + try { + msg = JSON.parse(data.toString()); + } catch { + return; + } + handleMessage(client, msg); + }); + + ws.on("close", () => { + clientsBySocket.delete(ws); + clientsById.delete(client.id); + removePlayer(client.id); + }); + + ws.on("error", () => { + // no-op + }); +}); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`GPT Bomber server running on http://0.0.0.0:${PORT}`); +}); diff --git a/src/game.js b/src/game.js new file mode 100644 index 0000000..5dbe9c4 --- /dev/null +++ b/src/game.js @@ -0,0 +1,2405 @@ +const CONFIG = { + cols: 15, + rows: 13, + tileSize: 48, + crateChance: 0.56, + powerupChance: 0.3, + bombFuse: 2.2, + explosionDuration: 0.55, + baseSpeed: 3.2, +}; + +const DIRECTIONS = [ + { key: "up", dx: 0, dy: -1, rot: 0 }, + { key: "right", dx: 1, dy: 0, rot: Math.PI / 2 }, + { key: "down", dx: 0, dy: 1, rot: Math.PI }, + { key: "left", dx: -1, dy: 0, rot: -Math.PI / 2 }, +]; + +const ASSET_PATHS = { + floor: "assets/tiles/floor.svg", + wall: "assets/tiles/wall.svg", + crate: "assets/tiles/crate.svg", + bomb: "assets/tiles/bomb.svg", + flameCenter: "assets/tiles/flame-center.svg", + flameStraight: "assets/tiles/flame-straight.svg", + flameEnd: "assets/tiles/flame-end.svg", + powerBomb: "assets/powerups/bomb-up.svg", + powerFlame: "assets/powerups/flame-up.svg", + powerSpeed: "assets/powerups/speed-up.svg", + playerBlue: "assets/players/player-blue.svg", + playerRed: "assets/players/player-red.svg", + playerGreen: "assets/players/player-green.svg", + playerYellow: "assets/players/player-yellow.svg", +}; + +const PLAYER_DEFS = [ + { name: "You", skin: "playerBlue", isHuman: true, color: "#7ac5ff" }, + { name: "Nova", skin: "playerRed", isHuman: false, color: "#ff8d7f" }, + { name: "Moss", skin: "playerGreen", isHuman: false, color: "#8af0ad" }, + { name: "Volt", skin: "playerYellow", isHuman: false, color: "#ffe184" }, +]; + +const SPAWN_POINTS = [ + { x: 1, y: 1 }, + { x: CONFIG.cols - 2, y: 1 }, + { x: 1, y: CONFIG.rows - 2 }, + { x: CONFIG.cols - 2, y: CONFIG.rows - 2 }, +]; + +const POWERUPS = ["bomb", "flame", "speed"]; +const MAX_PLAYERS = 4; +const NETWORK_PATH = "/ws"; +const PLAYER_NAME_STORAGE_KEY = "gptBomberPlayerName"; +const SNAPSHOT_RATE = 24; + +const canvas = document.getElementById("game"); +const ctx = canvas.getContext("2d"); + +canvas.width = CONFIG.cols * CONFIG.tileSize; +canvas.height = CONFIG.rows * CONFIG.tileSize; + +const state = { + map: [], + players: [], + bombs: [], + explosions: [], + fireLookup: new Set(), + status: "loading", + message: "Loading...", + time: 0, + nextBombId: 1, + pendingDetonations: [], + pendingDetonationSet: new Set(), + lastFrame: 0, + outcomePlayed: false, + menu: { + open: false, + selectedIndex: 0, + }, + screen: "mainMenu", + mode: "single", + mainMenu: { + selectedIndex: 0, + mode: "single", + }, + lobbyMenu: { + selectedIndex: 0, + }, +}; + +const images = {}; +const inputState = { + held: new Set(), + movementOrder: [], + bombQueued: false, + bombHeld: false, + gamepadPrev: { + up: false, + down: false, + left: false, + right: false, + bomb: false, + start: false, + }, +}; + +const network = { + clientId: + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `client-${Date.now()}-${Math.floor(Math.random() * 100000)}`, + socket: null, + connected: false, + inLobby: false, + isHost: false, + hostId: null, + localName: "", + lobbyPlayers: [], + remoteInputs: new Map(), + remoteBombPrev: new Map(), + lastSnapshotSentAt: 0, + lastSnapshotAt: 0, + inputStateAtClient: { + dir: null, + bomb: false, + }, + lastInputSentAt: 0, + activeRoster: [], + lastPingAt: 0, +}; + +let audio = null; +network.localName = loadSavedPlayerName() || `P-${shortId(network.clientId)}`; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function tileKey(x, y) { + return `${x},${y}`; +} + +function inBounds(x, y) { + return x >= 0 && y >= 0 && x < CONFIG.cols && y < CONFIG.rows; +} + +function shuffle(array) { + for (let i = array.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} + +function easeOutQuad(t) { + return 1 - (1 - t) * (1 - t); +} + +function shortId(id) { + return id.slice(0, 4).toUpperCase(); +} + +function normalizePlayerName(value) { + if (typeof value !== "string") { + return ""; + } + return value + .replace(/\s+/g, " ") + .trim() + .slice(0, 20); +} + +function loadSavedPlayerName() { + try { + return normalizePlayerName(window.localStorage.getItem(PLAYER_NAME_STORAGE_KEY) || ""); + } catch { + return ""; + } +} + +function savePlayerName(name) { + try { + window.localStorage.setItem(PLAYER_NAME_STORAGE_KEY, name); + } catch { + // Ignore storage failures. + } +} + +function menuNameLabel(name) { + const text = normalizePlayerName(name) || `P-${shortId(network.clientId)}`; + if (text.length <= 12) { + return text; + } + return `${text.slice(0, 9)}...`; +} + +function now() { + return performance.now() / 1000; +} + +function loadImage(path) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load ${path}`)); + img.src = path; + }); +} + +async function loadAssets() { + const entries = Object.entries(ASSET_PATHS); + const loaded = await Promise.all(entries.map(([, path]) => loadImage(path))); + for (let i = 0; i < entries.length; i += 1) { + images[entries[i][0]] = loaded[i]; + } +} + +function makeTile(type, powerup = null) { + return { type, powerup }; +} + +function randomPowerup() { + return POWERUPS[Math.floor(Math.random() * POWERUPS.length)]; +} + +function clearSpawnArea(map, x, y) { + const offsets = [ + [0, 0], + [1, 0], + [0, 1], + [2, 0], + [0, 2], + [-1, 0], + [0, -1], + ]; + + for (const [ox, oy] of offsets) { + const tx = x + ox; + const ty = y + oy; + if (inBounds(tx, ty)) { + map[ty][tx] = makeTile("floor", null); + } + } +} + +function generateMap() { + const map = []; + for (let y = 0; y < CONFIG.rows; y += 1) { + const row = []; + for (let x = 0; x < CONFIG.cols; x += 1) { + const border = x === 0 || y === 0 || x === CONFIG.cols - 1 || y === CONFIG.rows - 1; + const pillar = x % 2 === 0 && y % 2 === 0; + if (border || pillar) { + row.push(makeTile("wall", null)); + } else { + const makeCrate = Math.random() < CONFIG.crateChance; + if (makeCrate) { + const powerup = Math.random() < CONFIG.powerupChance ? randomPowerup() : null; + row.push(makeTile("crate", powerup)); + } else { + row.push(makeTile("floor", null)); + } + } + } + map.push(row); + } + + for (const spawn of SPAWN_POINTS) { + clearSpawnArea(map, spawn.x, spawn.y); + } + + return map; +} + +function createPlayer(id, def, spawn, overrides = {}) { + return { + id, + name: def.name, + skin: def.skin, + color: def.color, + isHuman: def.isHuman, + control: overrides.control || (def.isHuman ? "local" : "bot"), + ownerId: overrides.ownerId || null, + showName: overrides.showName || !def.isHuman, + alive: true, + x: spawn.x, + y: spawn.y, + renderX: spawn.x, + renderY: spawn.y, + moveFromX: spawn.x, + moveFromY: spawn.y, + moveToX: spawn.x, + moveToY: spawn.y, + moveProgress: 1, + speed: CONFIG.baseSpeed, + bombCapacity: 1, + bombsPlaced: 0, + flameRange: 2, + ai: { + thinkTimer: 0, + desiredDir: null, + bombCooldown: 0, + }, + }; +} + +function resetRoundSingle() { + state.map = generateMap(); + state.players = PLAYER_DEFS.map((def, i) => + createPlayer(i, def, SPAWN_POINTS[i], { + control: i === 0 ? "local" : "bot", + ownerId: i === 0 ? network.clientId : null, + showName: i !== 0, + }), + ); + state.bombs = []; + state.explosions = []; + state.fireLookup.clear(); + state.pendingDetonations = []; + state.pendingDetonationSet.clear(); + state.status = "running"; + state.message = "Fight"; + state.outcomePlayed = false; + closeMenu(); +} + +function resetRoundMultiplayer(roster) { + state.map = generateMap(); + state.players = roster.slice(0, MAX_PLAYERS).map((entry, i) => + createPlayer( + i, + { + name: entry.name || `P${i + 1}`, + skin: PLAYER_DEFS[i % PLAYER_DEFS.length].skin, + color: PLAYER_DEFS[i % PLAYER_DEFS.length].color, + isHuman: true, + }, + SPAWN_POINTS[i], + { + control: "network", + ownerId: entry.id, + showName: true, + }, + ), + ); + state.bombs = []; + state.explosions = []; + state.fireLookup.clear(); + state.pendingDetonations = []; + state.pendingDetonationSet.clear(); + state.status = "running"; + state.message = "Multiplayer match live"; + state.outcomePlayed = false; + closeMenu(); + inputState.bombQueued = false; + network.remoteBombPrev.clear(); +} + +function clearMovementInput() { + setMovementHeld("up", false); + setMovementHeld("down", false); + setMovementHeld("left", false); + setMovementHeld("right", false); +} + +function getMenuItems() { + if (state.mode === "multiplayer") { + if (state.status === "ended") { + if (network.isHost) { + return [ + { id: "nextRound", label: "Start Next Round" }, + { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, + { id: "leaveMatch", label: "Leave Match" }, + ]; + } + return [ + { id: "waitHost", label: "Waiting For Host Restart" }, + { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, + { id: "leaveMatch", label: "Leave Match" }, + ]; + } + return [ + { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, + { id: "leaveMatch", label: "Leave Match" }, + { id: "close", label: "Close" }, + ]; + } + + if (state.status === "ended") { + return [ + { id: "playAgain", label: "Play Again" }, + { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, + ]; + } + + return [ + { id: "resume", label: "Resume Match" }, + { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, + { id: "restart", label: "Restart Round" }, + ]; +} + +function openMenu() { + state.menu.open = true; + state.menu.selectedIndex = 0; + clearMovementInput(); +} + +function closeMenu() { + state.menu.open = false; + state.menu.selectedIndex = 0; + clearMovementInput(); +} + +function moveMenuSelection(delta) { + const items = getMenuItems(); + const count = items.length; + if (count === 0) { + state.menu.selectedIndex = 0; + return; + } + state.menu.selectedIndex = (state.menu.selectedIndex + delta + count) % count; +} + +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(); +} + +function activateMenuSelection() { + const items = getMenuItems(); + if (items.length === 0) { + return; + } + const item = items[state.menu.selectedIndex] || items[0]; + if (!item) { + return; + } + + if (item.id === "resume") { + closeMenu(); + return; + } + + if (item.id === "close") { + closeMenu(); + return; + } + + if (item.id === "music") { + toggleMusicPreference(); + return; + } + + if (item.id === "waitHost") { + return; + } + + if (item.id === "nextRound") { + restartMultiplayerRoundAsHost(); + return; + } + + if (item.id === "restart" || item.id === "playAgain") { + resetRoundSingle(); + return; + } + + if (item.id === "leaveMatch") { + closeMenu(); + leaveMultiplayerToMainMenu(); + return; + } +} + +function getMainMenuItems() { + return [ + { + id: "mode", + label: `Mode: ${state.mainMenu.mode === "single" ? "Single Player" : "Multiplayer"}`, + }, + { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, + { id: "name", label: `Name: ${menuNameLabel(network.localName)}` }, + { id: "start", label: state.mainMenu.mode === "single" ? "Start Single Player" : "Enter Lobby" }, + ]; +} + +function getLobbyMenuItems() { + const canStart = network.isHost && network.lobbyPlayers.length >= 1; + return [ + { + id: "start", + label: canStart ? "Start Match" : "Waiting For Host", + disabled: !canStart, + }, + { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, + { id: "name", label: `Name: ${menuNameLabel(network.localName)}` }, + { id: "leave", label: "Leave Lobby" }, + ]; +} + +function toggleMusicPreference() { + if (!audio.hasContext()) { + audio.unlock().then(() => { + if (audio.isMusicEnabled()) { + audio.toggleMusic(); + } + }); + return; + } + audio.toggleMusic(); +} + +function setLocalPlayerName(name) { + const normalized = normalizePlayerName(name); + if (!normalized) { + state.message = "Name must be 1-20 characters."; + return false; + } + network.localName = normalized; + savePlayerName(normalized); + sendSocketMessage("set_name", { name: normalized }); + if (network.inLobby || state.screen === "lobby") { + sendSocketMessage("lobby_join"); + } + state.message = `Name set: ${normalized}`; + return true; +} + +function promptForPlayerName() { + const current = normalizePlayerName(network.localName) || `P-${shortId(network.clientId)}`; + const raw = window.prompt("Enter multiplayer name (1-20 chars):", current); + if (raw === null) { + return; + } + setLocalPlayerName(raw); +} + +function normalizeLobbyPlayers(players) { + return [...players] + .slice(0, MAX_PLAYERS) + .sort((a, b) => a.joinedAt - b.joinedAt) + .map((player) => ({ + id: player.id, + name: player.name, + joinedAt: player.joinedAt, + lastSeenAt: player.lastSeenAt || now(), + })); +} + +function sendSocketMessage(type, payload = {}) { + if (!network.socket || network.socket.readyState !== WebSocket.OPEN) { + return; + } + network.socket.send(JSON.stringify({ type, ...payload })); +} + +function ensureSocketConnection() { + if (typeof WebSocket === "undefined") { + return; + } + if ( + network.socket && + (network.socket.readyState === WebSocket.OPEN || network.socket.readyState === WebSocket.CONNECTING) + ) { + return; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const socketUrl = `${protocol}//${window.location.host}${NETWORK_PATH}`; + network.socket = new WebSocket(socketUrl); + + network.socket.addEventListener("open", () => { + network.connected = true; + sendSocketMessage("hello", { clientId: network.clientId, name: network.localName }); + if (state.screen === "lobby") { + sendSocketMessage("lobby_join"); + network.inLobby = true; + network.lastPingAt = now(); + } + }); + + network.socket.addEventListener("message", (event) => { + handleSocketMessage(event.data); + }); + + network.socket.addEventListener("close", () => { + network.connected = false; + network.socket = null; + network.isHost = false; + network.hostId = null; + network.lobbyPlayers = []; + if (state.screen === "lobby") { + network.inLobby = true; + state.message = "Disconnected. Reconnecting..."; + } else { + network.inLobby = false; + } + if (state.screen === "game" && state.mode === "multiplayer" && state.status === "running") { + state.status = "ended"; + state.message = "Disconnected from server."; + openMenu(); + } + }); + + network.socket.addEventListener("error", () => { + state.message = "Network error."; + }); +} + +function handleSocketMessage(raw) { + let msg; + try { + msg = JSON.parse(raw); + } catch { + return; + } + if (!msg || typeof msg !== "object") { + return; + } + + if (msg.type === "welcome") { + if (msg.clientId && typeof msg.clientId === "string") { + network.clientId = msg.clientId; + } + if (msg.name && typeof msg.name === "string") { + network.localName = normalizePlayerName(msg.name) || `P-${shortId(network.clientId)}`; + savePlayerName(network.localName); + } else if (!network.localName) { + network.localName = `P-${shortId(network.clientId)}`; + } + if (state.screen === "lobby") { + sendSocketMessage("lobby_join"); + network.inLobby = true; + } + return; + } + + if (msg.type === "error") { + if (msg.message) { + state.message = msg.message; + } + return; + } + + if (msg.type === "lobby_state") { + network.hostId = msg.hostId || null; + network.isHost = network.hostId === network.clientId; + network.lobbyPlayers = normalizeLobbyPlayers(msg.players || []); + if (state.mode === "multiplayer" && state.screen === "game" && msg.phase === "lobby") { + state.screen = "lobby"; + state.status = "idle"; + state.message = msg.reason || "Back to lobby."; + network.inLobby = true; + } + return; + } + + if (msg.type === "lobby_start") { + network.hostId = msg.hostId; + network.isHost = msg.hostId === network.clientId; + network.activeRoster = normalizeLobbyPlayers(msg.roster || []); + startMultiplayerMatch(network.activeRoster); + return; + } + + if (msg.type === "player_input") { + if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { + return; + } + network.remoteInputs.set(msg.playerId, msg.input || { dir: null, bomb: false }); + return; + } + + if (msg.type === "game_snapshot") { + if (network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { + return; + } + network.lastSnapshotAt = now(); + applySnapshot(msg.snapshot); + } +} + +function startSinglePlayerFromMenu() { + leaveLobbyInternals(); + state.mode = "single"; + state.screen = "game"; + resetRoundSingle(); +} + +function startMultiplayerMatch(roster) { + network.activeRoster = roster.slice(0, MAX_PLAYERS); + network.inLobby = false; + network.lastSnapshotAt = now(); + network.lastInputSentAt = 0; + state.mode = "multiplayer"; + state.screen = "game"; + resetRoundMultiplayer(network.activeRoster); + network.remoteInputs.clear(); + network.remoteBombPrev.clear(); + if (!network.isHost) { + sendLocalMultiplayerInput(true); + } +} + +function enterLobbyScreen() { + state.screen = "lobby"; + state.mode = "multiplayer"; + state.lobbyMenu.selectedIndex = 0; + state.menu.open = false; + state.message = "Connecting..."; + network.inLobby = true; + network.isHost = false; + network.hostId = null; + network.lobbyPlayers = []; + network.remoteInputs.clear(); + network.remoteBombPrev.clear(); + ensureSocketConnection(); + if (network.connected) { + sendSocketMessage("lobby_join"); + } +} + +function leaveLobbyInternals() { + network.inLobby = false; + network.isHost = false; + network.hostId = null; + network.lobbyPlayers = []; + network.remoteInputs.clear(); + network.remoteBombPrev.clear(); + network.activeRoster = []; + network.inputStateAtClient = { dir: null, bomb: false }; + network.lastInputSentAt = 0; +} + +function leaveMultiplayerToMainMenu() { + if (state.mode === "multiplayer") { + sendSocketMessage("lobby_leave"); + } + leaveLobbyInternals(); + state.mode = "single"; + state.screen = "mainMenu"; + state.status = "idle"; + state.message = "Select mode"; +} + +function hostStartMatchFromLobby() { + if (!network.isHost || network.lobbyPlayers.length < 1) { + return; + } + sendSocketMessage("lobby_start"); +} + +function serializeGameState() { + return { + status: state.status, + message: state.message, + time: state.time, + map: state.map.map((row) => row.map((tile) => ({ type: tile.type, powerup: tile.powerup || null }))), + players: state.players.map((player) => ({ + id: player.id, + name: player.name, + skin: player.skin, + color: player.color, + control: player.control, + ownerId: player.ownerId, + showName: player.showName, + isHuman: player.isHuman, + alive: player.alive, + x: player.x, + y: player.y, + renderX: player.renderX, + renderY: player.renderY, + moveFromX: player.moveFromX, + moveFromY: player.moveFromY, + moveToX: player.moveToX, + moveToY: player.moveToY, + moveProgress: player.moveProgress, + speed: player.speed, + bombCapacity: player.bombCapacity, + bombsPlaced: player.bombsPlaced, + flameRange: player.flameRange, + })), + bombs: state.bombs.map((bomb) => ({ + id: bomb.id, + x: bomb.x, + y: bomb.y, + timer: bomb.timer, + range: bomb.range, + ownerId: bomb.ownerId, + })), + explosions: state.explosions.map((explosion) => ({ + timer: explosion.timer, + cells: explosion.cells.map((cell) => ({ x: cell.x, y: cell.y, type: cell.type, rot: cell.rot })), + })), + nextBombId: state.nextBombId, + outcomePlayed: state.outcomePlayed, + menuOpen: state.menu.open, + }; +} + +function applySnapshot(snapshot) { + const previousPlayers = new Map( + state.players.map((player) => [player.ownerId || `id:${player.id}`, { x: player.renderX, y: player.renderY }]), + ); + state.status = snapshot.status; + state.message = snapshot.message; + state.time = snapshot.time; + state.map = snapshot.map.map((row) => row.map((tile) => ({ type: tile.type, powerup: tile.powerup }))); + state.players = snapshot.players.map((player) => { + const key = player.ownerId || `id:${player.id}`; + const prev = previousPlayers.get(key); + let renderX = player.renderX; + let renderY = player.renderY; + if (prev) { + const dx = player.renderX - prev.x; + const dy = player.renderY - prev.y; + const dist = Math.hypot(dx, dy); + if (dist < 2) { + renderX = lerp(prev.x, player.renderX, 0.55); + renderY = lerp(prev.y, player.renderY, 0.55); + } + } + return { + ...player, + renderX, + renderY, + ai: { thinkTimer: 0, desiredDir: null, bombCooldown: 0 }, + }; + }); + state.bombs = snapshot.bombs.map((bomb) => ({ + ...bomb, + passThrough: new Set(), + })); + state.explosions = snapshot.explosions.map((explosion) => ({ + timer: explosion.timer, + cells: explosion.cells.map((cell) => ({ ...cell })), + })); + state.nextBombId = snapshot.nextBombId; + state.outcomePlayed = snapshot.outcomePlayed; + state.menu.open = snapshot.menuOpen; + rebuildFireLookup(); +} + +function broadcastSnapshot() { + if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { + return; + } + const t = now(); + if (t - network.lastSnapshotSentAt < 1 / SNAPSHOT_RATE) { + return; + } + network.lastSnapshotSentAt = t; + sendSocketMessage("game_snapshot", { snapshot: serializeGameState(), ts: t }); +} + +function updateLobbyNetwork(dt) { + void dt; + if (!network.inLobby || state.screen !== "lobby") { + return; + } + ensureSocketConnection(); + const t = now(); + if (network.connected && t - network.lastPingAt > 2) { + network.lastPingAt = t; + sendSocketMessage("ping"); + sendSocketMessage("lobby_join"); + } +} + +function sendLocalMultiplayerInput(force = false) { + if (state.mode !== "multiplayer" || state.screen !== "game" || network.isHost) { + return; + } + const t = now(); + const nextInput = { + dir: currentHumanDirection(), + bomb: inputState.bombHeld, + }; + const changed = + nextInput.dir !== network.inputStateAtClient.dir || nextInput.bomb !== network.inputStateAtClient.bomb; + const dueHeartbeat = t - network.lastInputSentAt > 0.12; + if (!changed && !force && !dueHeartbeat) { + return; + } + network.inputStateAtClient = nextInput; + network.lastInputSentAt = t; + sendSocketMessage("player_input", { input: nextInput, ts: t }); +} + +function getBombAt(x, y) { + return state.bombs.find((bomb) => bomb.x === x && bomb.y === y) || null; +} + +function isBombBlockingTile(x, y, player = null) { + const bomb = getBombAt(x, y); + if (!bomb) { + return false; + } + if (player && bomb.passThrough.has(player.id)) { + return false; + } + return true; +} + +function canEnterTile(x, y, player = null) { + if (!inBounds(x, y)) { + return false; + } + const tile = state.map[y][x]; + if (tile.type !== "floor") { + return false; + } + if (isBombBlockingTile(x, y, player)) { + return false; + } + return true; +} + +function queueDetonation(bombId) { + if (state.pendingDetonationSet.has(bombId)) { + return; + } + state.pendingDetonationSet.add(bombId); + state.pendingDetonations.push(bombId); +} + +function dropBomb(player) { + if (!player.alive || player.bombsPlaced >= player.bombCapacity) { + return false; + } + if (getBombAt(player.x, player.y)) { + return false; + } + + const bomb = { + id: state.nextBombId, + x: player.x, + y: player.y, + timer: CONFIG.bombFuse, + range: player.flameRange, + ownerId: player.id, + passThrough: new Set([player.id]), + }; + state.nextBombId += 1; + player.bombsPlaced += 1; + state.bombs.push(bomb); + audio.playPlaceBomb(); + return true; +} + +function hasHardBlockBetween(ax, ay, bx, by) { + if (ax !== bx && ay !== by) { + return true; + } + + if (ax === bx) { + const minY = Math.min(ay, by); + const maxY = Math.max(ay, by); + for (let y = minY + 1; y < maxY; y += 1) { + const t = state.map[y][ax]; + if (t.type === "wall" || t.type === "crate") { + return true; + } + } + } else { + const minX = Math.min(ax, bx); + const maxX = Math.max(ax, bx); + for (let x = minX + 1; x < maxX; x += 1) { + const t = state.map[ay][x]; + if (t.type === "wall" || t.type === "crate") { + return true; + } + } + } + + return false; +} + +function bombCanHitCell(bomb, x, y) { + if (bomb.x !== x && bomb.y !== y) { + return false; + } + const distance = Math.abs(bomb.x - x) + Math.abs(bomb.y - y); + if (distance > bomb.range) { + return false; + } + return !hasHardBlockBetween(bomb.x, bomb.y, x, y); +} + +function estimateDangerAt(x, y) { + let danger = state.fireLookup.has(tileKey(x, y)) ? 100 : 0; + for (const bomb of state.bombs) { + if (bombCanHitCell(bomb, x, y)) { + const intensity = clamp(6 - bomb.timer * 3, 0, 6); + danger = Math.max(danger, intensity); + } + } + return danger; +} + +function computeVirtualBlastSet(originX, originY, range) { + const blast = new Set([tileKey(originX, originY)]); + + for (const dir of DIRECTIONS) { + for (let step = 1; step <= range; step += 1) { + const nx = originX + dir.dx * step; + const ny = originY + dir.dy * step; + if (!inBounds(nx, ny)) { + break; + } + const tile = state.map[ny][nx]; + if (tile.type === "wall") { + break; + } + blast.add(tileKey(nx, ny)); + if (tile.type === "crate") { + break; + } + } + } + + return blast; +} + +function hasEscapeRouteAfterBomb(player) { + const dangerBlast = computeVirtualBlastSet(player.x, player.y, player.flameRange); + const queue = [{ x: player.x, y: player.y, depth: 0 }]; + const visited = new Set([tileKey(player.x, player.y)]); + const maxDepth = 6; + + while (queue.length > 0) { + const node = queue.shift(); + const key = tileKey(node.x, node.y); + const safeTile = !dangerBlast.has(key) && estimateDangerAt(node.x, node.y) < 1; + if (node.depth > 0 && safeTile) { + return true; + } + + if (node.depth >= maxDepth) { + continue; + } + + for (const dir of DIRECTIONS) { + const nx = node.x + dir.dx; + const ny = node.y + dir.dy; + const nextKey = tileKey(nx, ny); + if (visited.has(nextKey)) { + continue; + } + if (!inBounds(nx, ny)) { + continue; + } + if (state.map[ny][nx].type !== "floor") { + continue; + } + const bomb = getBombAt(nx, ny); + if (bomb && (nx !== player.x || ny !== player.y)) { + continue; + } + visited.add(nextKey); + queue.push({ x: nx, y: ny, depth: node.depth + 1 }); + } + } + + return false; +} + +function adjacentCrate(x, y) { + for (const dir of DIRECTIONS) { + const nx = x + dir.dx; + const ny = y + dir.dy; + if (inBounds(nx, ny) && state.map[ny][nx].type === "crate") { + return true; + } + } + return false; +} + +function enemyInBlastPotential(player) { + for (const other of state.players) { + if (!other.alive || other.id === player.id) { + continue; + } + if (player.x !== other.x && player.y !== other.y) { + continue; + } + const distance = Math.abs(player.x - other.x) + Math.abs(player.y - other.y); + if (distance > player.flameRange) { + continue; + } + if (!hasHardBlockBetween(player.x, player.y, other.x, other.y)) { + return true; + } + } + return false; +} + +function updateBot(player, dt) { + if (!player.alive || player.moveProgress < 1) { + return; + } + + player.ai.thinkTimer -= dt; + player.ai.bombCooldown -= dt; + if (player.ai.thinkTimer > 0) { + return; + } + player.ai.thinkTimer = 0.13 + Math.random() * 0.2; + + const currentDanger = estimateDangerAt(player.x, player.y); + const liveEnemies = state.players.filter((p) => p.alive && p.id !== player.id); + const targetEnemy = liveEnemies.sort( + (a, b) => + Math.abs(a.x - player.x) + Math.abs(a.y - player.y) - + (Math.abs(b.x - player.x) + Math.abs(b.y - player.y)), + )[0]; + + if ( + player.bombsPlaced < player.bombCapacity && + player.ai.bombCooldown <= 0 && + currentDanger < 0.9 && + (adjacentCrate(player.x, player.y) || enemyInBlastPotential(player)) && + hasEscapeRouteAfterBomb(player) + ) { + if (dropBomb(player)) { + player.ai.bombCooldown = 1.4 + Math.random() * 0.8; + } + } + + const options = []; + const dirs = shuffle([...DIRECTIONS]); + for (const dir of dirs) { + const nx = player.x + dir.dx; + const ny = player.y + dir.dy; + if (!canEnterTile(nx, ny, player)) { + continue; + } + const danger = estimateDangerAt(nx, ny); + let score = Math.random() * 0.35 - danger * 1.65; + + if (danger < currentDanger) { + score += 2.5; + } + + const tile = state.map[ny][nx]; + if (tile.powerup) { + score += 1.6; + } + + if (adjacentCrate(nx, ny)) { + score += 0.3; + } + + if (targetEnemy) { + const nowDist = Math.abs(targetEnemy.x - player.x) + Math.abs(targetEnemy.y - player.y); + const newDist = Math.abs(targetEnemy.x - nx) + Math.abs(targetEnemy.y - ny); + score += (nowDist - newDist) * 0.45; + } + + options.push({ dir: dir.key, score }); + } + + options.sort((a, b) => b.score - a.score); + player.ai.desiredDir = options.length > 0 ? options[0].dir : null; +} + +function killPlayer(player) { + player.alive = false; + player.ai.desiredDir = null; +} + +function collectPowerup(player) { + if (player.moveProgress < 1 || !player.alive) { + return; + } + const tile = state.map[player.y][player.x]; + if (!tile.powerup) { + return; + } + + if (tile.powerup === "bomb") { + player.bombCapacity = clamp(player.bombCapacity + 1, 1, 8); + } else if (tile.powerup === "flame") { + player.flameRange = clamp(player.flameRange + 1, 2, 10); + } else if (tile.powerup === "speed") { + player.speed = clamp(player.speed + 0.42, CONFIG.baseSpeed, 7); + } + + tile.powerup = null; + audio.playPowerup(); +} + +function startMove(player, dirKey) { + const dir = DIRECTIONS.find((d) => d.key === dirKey); + if (!dir) { + return false; + } + const nx = player.x + dir.dx; + const ny = player.y + dir.dy; + if (!canEnterTile(nx, ny, player)) { + return false; + } + player.moveFromX = player.x; + player.moveFromY = player.y; + player.moveToX = nx; + player.moveToY = ny; + player.moveProgress = 0; + return true; +} + +function occupiedTiles(player) { + if (player.moveProgress < 1) { + const from = tileKey(player.moveFromX, player.moveFromY); + const to = tileKey(player.moveToX, player.moveToY); + if (from === to) { + return [from]; + } + return [from, to]; + } + return [tileKey(player.x, player.y)]; +} + +function getNetworkInput(ownerId) { + return network.remoteInputs.get(ownerId) || { dir: null, bomb: false }; +} + +function getDesiredDirection(player) { + if (player.control === "bot") { + return player.ai.desiredDir; + } + if (player.control === "network") { + if (player.ownerId === network.clientId) { + return currentHumanDirection(); + } + return getNetworkInput(player.ownerId).dir; + } + return currentHumanDirection(); +} + +function updatePlayerMovement(player, dt) { + if (!player.alive) { + return; + } + + if (player.moveProgress < 1) { + player.moveProgress = clamp(player.moveProgress + dt * player.speed, 0, 1); + const t = easeOutQuad(player.moveProgress); + player.renderX = lerp(player.moveFromX, player.moveToX, t); + player.renderY = lerp(player.moveFromY, player.moveToY, t); + + if (player.moveProgress >= 1) { + player.x = player.moveToX; + player.y = player.moveToY; + player.renderX = player.x; + player.renderY = player.y; + } + } else { + const dir = getDesiredDirection(player); + if (dir) { + startMove(player, dir); + } + player.renderX = player.x; + player.renderY = player.y; + } + + for (const bomb of state.bombs) { + if (bomb.passThrough.has(player.id) && (player.x !== bomb.x || player.y !== bomb.y)) { + bomb.passThrough.delete(player.id); + } + } +} + +function detonateBomb(bomb) { + const bombIndex = state.bombs.findIndex((entry) => entry.id === bomb.id); + if (bombIndex === -1) { + return; + } + + state.bombs.splice(bombIndex, 1); + const owner = state.players.find((player) => player.id === bomb.ownerId); + if (owner) { + owner.bombsPlaced = Math.max(0, owner.bombsPlaced - 1); + } + + const cells = [{ x: bomb.x, y: bomb.y, type: "center", rot: 0 }]; + + for (const dir of DIRECTIONS) { + for (let step = 1; step <= bomb.range; step += 1) { + const nx = bomb.x + dir.dx * step; + const ny = bomb.y + dir.dy * step; + if (!inBounds(nx, ny)) { + break; + } + + const tile = state.map[ny][nx]; + if (tile.type === "wall") { + break; + } + + const hitCrate = tile.type === "crate"; + const atEnd = step === bomb.range || hitCrate; + const segmentType = atEnd ? "end" : "straight"; + cells.push({ x: nx, y: ny, type: segmentType, rot: dir.rot }); + + const linkedBomb = getBombAt(nx, ny); + if (linkedBomb) { + queueDetonation(linkedBomb.id); + } + + if (hitCrate) { + tile.type = "floor"; + break; + } + } + } + + state.explosions.push({ cells, timer: CONFIG.explosionDuration }); + audio.playExplosion(); +} + +function resolveDetonations() { + while (state.pendingDetonations.length > 0) { + const id = state.pendingDetonations.shift(); + state.pendingDetonationSet.delete(id); + const bomb = state.bombs.find((entry) => entry.id === id); + if (bomb) { + detonateBomb(bomb); + } + } +} + +function rebuildFireLookup() { + state.fireLookup.clear(); + for (const explosion of state.explosions) { + for (const cell of explosion.cells) { + state.fireLookup.add(tileKey(cell.x, cell.y)); + } + } +} + +function checkDeaths() { + for (const player of state.players) { + if (!player.alive) { + continue; + } + const tiles = occupiedTiles(player); + if (tiles.some((key) => state.fireLookup.has(key))) { + killPlayer(player); + } + } +} + +function evaluateOutcome() { + if (state.status !== "running") { + return; + } + + const alivePlayers = state.players.filter((player) => player.alive); + + if (state.mode === "single") { + const human = state.players[0]; + if (!human.alive) { + state.status = "ended"; + state.message = "You were vaporized. Restart to try again."; + if (!state.outcomePlayed) { + audio.playDefeat(); + state.outcomePlayed = true; + } + } else if (alivePlayers.length === 1 && alivePlayers[0].id === human.id) { + state.status = "ended"; + state.message = "Victory. You cleared the arena."; + if (!state.outcomePlayed) { + audio.playVictory(); + state.outcomePlayed = true; + } + } else if (alivePlayers.length === 0) { + state.status = "ended"; + state.message = "Everyone exploded. Restart for a rematch."; + } + } else if (alivePlayers.length <= 1) { + state.status = "ended"; + if (alivePlayers.length === 0) { + state.message = "Multiplayer draw: everyone exploded."; + } else { + state.message = `${alivePlayers[0].name} wins the round.`; + } + } + + if (state.status === "ended") { + openMenu(); + } +} + +function updateNonHostClient(dt) { + for (const player of state.players) { + if (!player.alive) { + continue; + } + if (player.ownerId === network.clientId) { + updatePlayerMovement(player, dt); + continue; + } + if (player.moveProgress < 1) { + player.moveProgress = clamp(player.moveProgress + dt * player.speed, 0, 1); + const t = easeOutQuad(player.moveProgress); + player.renderX = lerp(player.moveFromX, player.moveToX, t); + player.renderY = lerp(player.moveFromY, player.moveToY, t); + if (player.moveProgress >= 1) { + player.x = player.moveToX; + player.y = player.moveToY; + player.renderX = player.x; + player.renderY = player.y; + } + } + } + + const localPlayer = state.players.find((player) => player.ownerId === network.clientId); + if (inputState.bombQueued && localPlayer && localPlayer.alive) { + dropBomb(localPlayer); + } + inputState.bombQueued = false; + + for (const bomb of state.bombs) { + bomb.timer = Math.max(0, bomb.timer - dt); + } + for (const explosion of state.explosions) { + explosion.timer -= dt; + } + state.explosions = state.explosions.filter((explosion) => explosion.timer > 0); +} + +function updateGame(dt) { + if (state.status !== "running") { + return; + } + + if (state.mode === "single" && state.menu.open) { + return; + } + + if (state.mode === "multiplayer" && !network.isHost) { + updateNonHostClient(dt); + return; + } + + if (state.mode === "single") { + for (let i = 1; i < state.players.length; i += 1) { + updateBot(state.players[i], dt); + } + } + + for (const player of state.players) { + updatePlayerMovement(player, dt); + } + + if (state.mode === "single") { + const human = state.players[0]; + if (inputState.bombQueued && human && human.alive) { + dropBomb(human); + } + } else { + for (const player of state.players) { + if (!player.alive) { + continue; + } + + if (player.ownerId === network.clientId) { + if (inputState.bombQueued) { + dropBomb(player); + } + continue; + } + + const remote = getNetworkInput(player.ownerId); + const prevBomb = network.remoteBombPrev.get(player.ownerId) || false; + if (remote.bomb && !prevBomb) { + dropBomb(player); + } + network.remoteBombPrev.set(player.ownerId, remote.bomb); + } + } + inputState.bombQueued = false; + + for (const bomb of state.bombs) { + bomb.timer -= dt; + if (bomb.timer <= 0) { + queueDetonation(bomb.id); + } + } + + resolveDetonations(); + + for (const explosion of state.explosions) { + explosion.timer -= dt; + } + state.explosions = state.explosions.filter((explosion) => explosion.timer > 0); + + rebuildFireLookup(); + checkDeaths(); + + for (const player of state.players) { + collectPowerup(player); + } + + evaluateOutcome(); + + if (state.mode === "multiplayer" && network.isHost) { + broadcastSnapshot(); + } +} + +function drawImageRotated(image, x, y, size, rotation, scale = 1) { + const half = size / 2; + ctx.save(); + ctx.translate(x + half, y + half); + ctx.rotate(rotation); + ctx.scale(scale, scale); + ctx.drawImage(image, -half, -half, size, size); + ctx.restore(); +} + +function drawMap() { + const tileSize = CONFIG.tileSize; + for (let y = 0; y < CONFIG.rows; y += 1) { + for (let x = 0; x < CONFIG.cols; x += 1) { + const px = x * tileSize; + const py = y * tileSize; + const tile = state.map[y][x]; + + ctx.drawImage(images.floor, px, py, tileSize, tileSize); + if (tile.type === "wall") { + ctx.drawImage(images.wall, px, py, tileSize, tileSize); + } else if (tile.type === "crate") { + ctx.drawImage(images.crate, px, py, tileSize, tileSize); + } + } + } +} + +function drawPowerups() { + const tileSize = CONFIG.tileSize; + const pulse = 0.9 + Math.sin(state.time * 5.2) * 0.08; + for (let y = 0; y < CONFIG.rows; y += 1) { + for (let x = 0; x < CONFIG.cols; x += 1) { + const tile = state.map[y][x]; + if (tile.type !== "floor" || !tile.powerup) { + continue; + } + const px = x * tileSize; + const py = y * tileSize; + const icon = + tile.powerup === "bomb" + ? images.powerBomb + : tile.powerup === "flame" + ? images.powerFlame + : images.powerSpeed; + drawImageRotated(icon, px + 8, py + 8, tileSize - 16, 0, pulse); + } + } +} + +function drawBombs() { + const tileSize = CONFIG.tileSize; + for (const bomb of state.bombs) { + const px = bomb.x * tileSize; + const py = bomb.y * tileSize; + const pulse = 1 + Math.sin((CONFIG.bombFuse - bomb.timer) * 15) * 0.06; + drawImageRotated(images.bomb, px + 3, py + 3, tileSize - 6, 0, pulse); + } +} + +function drawExplosions() { + const tileSize = CONFIG.tileSize; + for (const explosion of state.explosions) { + ctx.save(); + ctx.globalAlpha = clamp(explosion.timer / CONFIG.explosionDuration + 0.2, 0.35, 1); + for (const cell of explosion.cells) { + const px = cell.x * tileSize; + const py = cell.y * tileSize; + if (cell.type === "center") { + drawImageRotated(images.flameCenter, px + 2, py + 2, tileSize - 4, 0, 1); + } else if (cell.type === "straight") { + const rotate = Math.abs(cell.rot) === Math.PI / 2 ? Math.PI / 2 : 0; + drawImageRotated(images.flameStraight, px + 2, py + 2, tileSize - 4, rotate, 1); + } else { + drawImageRotated(images.flameEnd, px + 2, py + 2, tileSize - 4, cell.rot, 1); + } + } + ctx.restore(); + } +} + +function drawPlayers() { + const tileSize = CONFIG.tileSize; + const sorted = [...state.players].sort((a, b) => a.renderY - b.renderY); + for (const player of sorted) { + const px = player.renderX * tileSize; + const py = player.renderY * tileSize; + + if (!player.alive) { + ctx.save(); + ctx.globalAlpha = 0.45; + ctx.fillStyle = "#140f14"; + ctx.beginPath(); + ctx.ellipse(px + tileSize / 2, py + tileSize / 2 + 8, 16, 8, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + continue; + } + + const bob = player.moveProgress < 1 ? Math.sin(state.time * 16 + player.id * 0.8) * 1.8 : 0; + ctx.drawImage(images[player.skin], px + 4, py + 4 + bob, tileSize - 8, tileSize - 8); + + if (player.showName) { + ctx.fillStyle = player.color; + ctx.font = "bold 12px Trebuchet MS"; + ctx.textAlign = "center"; + ctx.fillText(player.name, px + tileSize / 2, py - 2); + } + } +} + +function drawMenu() { + if (!state.menu.open) { + return; + } + + const items = getMenuItems(); + const panelWidth = 340; + const panelHeight = 220; + const panelX = (canvas.width - panelWidth) / 2; + const panelY = (canvas.height - panelHeight) / 2; + + ctx.save(); + ctx.fillStyle = "#041321bf"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.fillStyle = "#10283bd8"; + ctx.strokeStyle = "#8acfff88"; + ctx.lineWidth = 2; + ctx.fillRect(panelX, panelY, panelWidth, panelHeight); + ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); + + ctx.textAlign = "center"; + ctx.fillStyle = "#ffe8a8"; + ctx.font = "bold 30px Trebuchet MS"; + ctx.fillText( + state.status === "ended" ? "Round Over" : state.mode === "single" ? "Pause Menu" : "Match Menu", + canvas.width / 2, + panelY + 44, + ); + + if (state.status === "ended") { + ctx.fillStyle = "#cfe8ff"; + ctx.font = "bold 15px Trebuchet MS"; + ctx.fillText(state.message, canvas.width / 2, panelY + 70); + } + + const baseY = state.status === "ended" ? panelY + 102 : panelY + 92; + items.forEach((item, index) => { + const y = baseY + index * 36; + const selected = index === state.menu.selectedIndex; + if (selected) { + ctx.fillStyle = "#ffd166"; + ctx.fillRect(panelX + 36, y - 20, panelWidth - 72, 28); + } + ctx.fillStyle = selected ? "#1a2a3f" : "#e7f4ff"; + ctx.font = "bold 19px Trebuchet MS"; + ctx.fillText(item.label, canvas.width / 2, y); + }); + + ctx.fillStyle = "#9ecdf0"; + ctx.font = "bold 13px Trebuchet MS"; + ctx.fillText("Up/Down + Enter | D-pad Up/Down + A", canvas.width / 2, panelY + panelHeight - 26); + ctx.restore(); +} + +function drawStaticBackdrop() { + ctx.fillStyle = "#0a1622"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = "#8acfff1c"; + for (let x = 0; x <= CONFIG.cols; x += 1) { + ctx.beginPath(); + ctx.moveTo(x * CONFIG.tileSize, 0); + ctx.lineTo(x * CONFIG.tileSize, canvas.height); + ctx.stroke(); + } + for (let y = 0; y <= CONFIG.rows; y += 1) { + ctx.beginPath(); + ctx.moveTo(0, y * CONFIG.tileSize); + ctx.lineTo(canvas.width, y * CONFIG.tileSize); + ctx.stroke(); + } +} + +function drawMainMenu() { + drawStaticBackdrop(); + const panelWidth = 420; + const panelHeight = 340; + const panelX = (canvas.width - panelWidth) / 2; + const panelY = (canvas.height - panelHeight) / 2; + const items = getMainMenuItems(); + + ctx.fillStyle = "#041321c9"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#10283bf2"; + ctx.strokeStyle = "#8acfff88"; + ctx.lineWidth = 2; + ctx.fillRect(panelX, panelY, panelWidth, panelHeight); + ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); + + ctx.textAlign = "center"; + ctx.fillStyle = "#ffe8a8"; + ctx.font = "bold 42px Trebuchet MS"; + ctx.fillText("GPT BOMBER", canvas.width / 2, panelY + 58); + ctx.font = "bold 15px Trebuchet MS"; + ctx.fillStyle = "#b6ddff"; + ctx.fillText("Choose mode and start", canvas.width / 2, panelY + 82); + if (state.message && state.message !== "Select mode") { + ctx.fillStyle = "#ffcf9f"; + ctx.font = "bold 13px Trebuchet MS"; + ctx.fillText(state.message, canvas.width / 2, panelY + 100); + } + + items.forEach((item, index) => { + const y = panelY + 146 + index * 46; + const selected = index === state.mainMenu.selectedIndex; + if (selected) { + ctx.fillStyle = "#ffd166"; + ctx.fillRect(panelX + 36, y - 23, panelWidth - 72, 32); + } + ctx.fillStyle = selected ? "#1a2a3f" : "#e7f4ff"; + ctx.font = "bold 22px Trebuchet MS"; + ctx.fillText(item.label, canvas.width / 2, y); + }); +} + +function drawLobby() { + drawStaticBackdrop(); + const panelWidth = 460; + const panelHeight = 400; + const panelX = (canvas.width - panelWidth) / 2; + const panelY = (canvas.height - panelHeight) / 2; + + ctx.fillStyle = "#041321d4"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#10283bf5"; + ctx.strokeStyle = "#8acfff88"; + ctx.lineWidth = 2; + ctx.fillRect(panelX, panelY, panelWidth, panelHeight); + ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); + + ctx.textAlign = "center"; + ctx.fillStyle = "#ffe8a8"; + ctx.font = "bold 34px Trebuchet MS"; + 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); + + ctx.textAlign = "left"; + ctx.font = "bold 17px Trebuchet MS"; + for (let i = 0; i < MAX_PLAYERS; i += 1) { + const player = network.lobbyPlayers[i]; + const y = panelY + 112 + i * 34; + const line = player + ? `${i + 1}. ${player.name}${player.id === network.hostId ? " (Host)" : ""}` + : `${i + 1}. Waiting...`; + ctx.fillStyle = player ? "#e9f6ff" : "#8fb6d4"; + ctx.fillText(line, panelX + 34, y); + } + + const items = getLobbyMenuItems(); + items.forEach((item, index) => { + const y = panelY + 260 + index * 34; + const selected = index === state.lobbyMenu.selectedIndex; + if (selected && !item.disabled) { + ctx.fillStyle = "#ffd166"; + ctx.fillRect(panelX + 34, y - 21, panelWidth - 68, 28); + } + ctx.fillStyle = item.disabled ? "#7f9bb0" : selected ? "#1a2a3f" : "#e7f4ff"; + ctx.textAlign = "center"; + ctx.font = "bold 18px Trebuchet MS"; + ctx.fillText(item.label, canvas.width / 2, y); + }); +} + +function drawFrame() { + if (state.screen === "mainMenu") { + drawMainMenu(); + return; + } + + if (state.screen === "lobby") { + drawLobby(); + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawMap(); + drawPowerups(); + drawBombs(); + drawExplosions(); + drawPlayers(); + drawMenu(); + + ctx.strokeStyle = "#86cef91f"; + for (let x = 0; x <= CONFIG.cols; x += 1) { + ctx.beginPath(); + ctx.moveTo(x * CONFIG.tileSize, 0); + ctx.lineTo(x * CONFIG.tileSize, canvas.height); + ctx.stroke(); + } + for (let y = 0; y <= CONFIG.rows; y += 1) { + ctx.beginPath(); + ctx.moveTo(0, y * CONFIG.tileSize); + ctx.lineTo(canvas.width, y * CONFIG.tileSize); + ctx.stroke(); + } +} + +function currentHumanDirection() { + if (inputState.movementOrder.length === 0) { + return null; + } + return inputState.movementOrder[inputState.movementOrder.length - 1]; +} + +function setMovementHeld(direction, held) { + if (!held) { + inputState.held.delete(direction); + inputState.movementOrder = inputState.movementOrder.filter((entry) => entry !== direction); + return; + } + + if (!inputState.held.has(direction)) { + inputState.held.add(direction); + inputState.movementOrder = inputState.movementOrder.filter((entry) => entry !== direction); + inputState.movementOrder.push(direction); + } +} + +function keyToAction(code) { + switch (code) { + case "ArrowUp": + case "KeyW": + return { type: "move", value: "up" }; + case "ArrowDown": + case "KeyS": + return { type: "move", value: "down" }; + case "ArrowLeft": + case "KeyA": + return { type: "move", value: "left" }; + case "ArrowRight": + case "KeyD": + return { type: "move", value: "right" }; + case "Space": + case "Enter": + return { type: "bomb" }; + case "Escape": + case "KeyP": + return { type: "menu" }; + default: + return null; + } +} + +function moveMainMenuSelection(delta) { + const count = getMainMenuItems().length; + state.mainMenu.selectedIndex = (state.mainMenu.selectedIndex + delta + count) % count; +} + +function activateMainMenuSelection() { + const items = getMainMenuItems(); + const item = items[state.mainMenu.selectedIndex]; + if (!item) { + return; + } + + if (item.id === "mode") { + state.mainMenu.mode = state.mainMenu.mode === "single" ? "multiplayer" : "single"; + return; + } + if (item.id === "music") { + toggleMusicPreference(); + return; + } + if (item.id === "name") { + promptForPlayerName(); + return; + } + if (item.id === "start") { + if (state.mainMenu.mode === "single") { + startSinglePlayerFromMenu(); + } else { + enterLobbyScreen(); + } + } +} + +function moveLobbySelection(delta) { + const count = getLobbyMenuItems().length; + state.lobbyMenu.selectedIndex = (state.lobbyMenu.selectedIndex + delta + count) % count; +} + +function activateLobbySelection() { + const items = getLobbyMenuItems(); + const item = items[state.lobbyMenu.selectedIndex]; + if (!item || item.disabled) { + return; + } + + if (item.id === "start") { + hostStartMatchFromLobby(); + return; + } + if (item.id === "music") { + toggleMusicPreference(); + return; + } + if (item.id === "name") { + promptForPlayerName(); + return; + } + if (item.id === "leave") { + leaveMultiplayerToMainMenu(); + } +} + +function onActionDown(action, isRepeat = false) { + if (state.screen === "mainMenu") { + if (action.type === "move" && !isRepeat) { + if (action.value === "up") { + moveMainMenuSelection(-1); + } else if (action.value === "down") { + moveMainMenuSelection(1); + } else if (action.value === "left" || action.value === "right") { + activateMainMenuSelection(); + } + } else if (action.type === "bomb" && !isRepeat) { + activateMainMenuSelection(); + } + return; + } + + if (state.screen === "lobby") { + if (action.type === "move" && !isRepeat) { + if (action.value === "up") { + moveLobbySelection(-1); + } else if (action.value === "down") { + moveLobbySelection(1); + } + } else if (action.type === "bomb" && !isRepeat) { + activateLobbySelection(); + } else if (action.type === "menu" && !isRepeat) { + leaveMultiplayerToMainMenu(); + } + return; + } + + if (action.type === "menu" && !isRepeat) { + if (state.mode === "multiplayer" && state.status === "running") { + return; + } + if (state.menu.open) { + closeMenu(); + } else { + openMenu(); + } + return; + } + + if (state.menu.open) { + if (action.type === "move" && !isRepeat) { + if (action.value === "up") { + moveMenuSelection(-1); + } else if (action.value === "down") { + moveMenuSelection(1); + } + } else if (action.type === "bomb" && !isRepeat) { + activateMenuSelection(); + } + return; + } + + if (action.type === "move") { + setMovementHeld(action.value, true); + if (state.mode === "multiplayer" && !network.isHost) { + sendLocalMultiplayerInput(true); + } + } else if (action.type === "bomb" && !isRepeat) { + inputState.bombQueued = true; + inputState.bombHeld = true; + if (state.mode === "multiplayer" && !network.isHost) { + sendLocalMultiplayerInput(true); + } + } +} + +function onActionUp(action) { + if (state.screen !== "game") { + return; + } + + if (state.menu.open && state.mode === "single") { + return; + } + if (action.type === "move") { + setMovementHeld(action.value, false); + if (state.mode === "multiplayer" && !network.isHost) { + sendLocalMultiplayerInput(true); + } + } + if (action.type === "bomb") { + inputState.bombHeld = false; + if (state.mode === "multiplayer" && !network.isHost) { + sendLocalMultiplayerInput(true); + } + } +} + +function setupInput() { + window.addEventListener("keydown", (event) => { + const action = keyToAction(event.code); + if (!action) { + return; + } + event.preventDefault(); + if (action.type !== "menu") { + audio.unlock(); + } + onActionDown(action, event.repeat); + }); + + window.addEventListener("keyup", (event) => { + const action = keyToAction(event.code); + if (!action) { + return; + } + event.preventDefault(); + onActionUp(action); + }); + + window.addEventListener("beforeunload", () => { + if (network.inLobby || state.mode === "multiplayer") { + sendSocketMessage("lobby_leave"); + } + if (network.socket) { + network.socket.close(); + network.socket = null; + } + }); +} + +function buttonPressed(button) { + if (!button) { + return false; + } + return typeof button === "object" ? button.pressed : button === 1; +} + +function pollGamepadInput() { + if (!navigator.getGamepads) { + return; + } + + const pads = navigator.getGamepads(); + const pad = Array.from(pads).find((entry) => entry && entry.connected); + if (!pad) { + for (const dir of ["up", "down", "left", "right"]) { + if (inputState.gamepadPrev[dir]) { + onActionUp({ type: "move", value: dir }); + } + } + if (inputState.gamepadPrev.bomb) { + onActionUp({ type: "bomb" }); + } + inputState.gamepadPrev = { + up: false, + down: false, + left: false, + right: false, + bomb: false, + start: false, + }; + return; + } + + const axisX = pad.axes[0] || 0; + const axisY = pad.axes[1] || 0; + const current = { + up: buttonPressed(pad.buttons[12]) || axisY < -0.6, + down: buttonPressed(pad.buttons[13]) || axisY > 0.6, + left: buttonPressed(pad.buttons[14]) || axisX < -0.6, + right: buttonPressed(pad.buttons[15]) || axisX > 0.6, + bomb: buttonPressed(pad.buttons[0]), + start: buttonPressed(pad.buttons[9]), + }; + const prev = inputState.gamepadPrev; + + if (current.start && !prev.start) { + onActionDown({ type: "menu" }, false); + } + + for (const dir of ["up", "down", "left", "right"]) { + if (current[dir] && !prev[dir]) { + onActionDown({ type: "move", value: dir }, false); + } + if (!current[dir] && prev[dir]) { + onActionUp({ type: "move", value: dir }); + } + } + + if (current.bomb && !prev.bomb) { + audio.unlock(); + onActionDown({ type: "bomb" }, false); + } + if (!current.bomb && prev.bomb) { + onActionUp({ type: "bomb" }); + } + + inputState.gamepadPrev = current; +} + +function updateNetworking(dt) { + if (state.screen === "lobby") { + updateLobbyNetwork(dt); + return; + } + + if (state.screen === "game" && state.mode === "multiplayer") { + if (!network.isHost) { + sendLocalMultiplayerInput(); + if (now() - network.lastSnapshotAt > 4 && state.status === "running") { + state.status = "ended"; + state.message = "Connection to host lost."; + openMenu(); + } + } + } +} + +function gameLoop(timestamp) { + if (state.lastFrame === 0) { + state.lastFrame = timestamp; + } + const dt = clamp((timestamp - state.lastFrame) / 1000, 0, 0.05); + state.lastFrame = timestamp; + state.time += dt; + + pollGamepadInput(); + updateNetworking(dt); + updateGame(dt); + drawFrame(); + + requestAnimationFrame(gameLoop); +} + +async function boot() { + try { + await loadAssets(); + setupInput(); + state.screen = "mainMenu"; + state.mode = "single"; + state.status = "idle"; + state.message = "Select mode"; + requestAnimationFrame(gameLoop); + } catch (error) { + state.status = "error"; + state.message = "Asset load failed. Check console for details."; + console.error(error); + } +} + +class AudioSystem { + constructor() { + this.ctx = null; + this.masterGain = null; + this.musicGain = null; + this.sfxGain = null; + this.noiseBuffer = null; + this.scheduler = null; + this.musicEnabled = true; + this.stepIndex = 0; + this.nextStepTime = 0; + + this.music = { + bpm: 126, + lead: [72, null, 74, null, 76, 74, 72, null, 76, null, 79, 76, 74, null, 72, null], + bass: [48, null, 48, null, 50, null, 47, null, 45, null, 45, null, 47, null, 43, null], + arp: [60, 64, 67, 72, 62, 65, 69, 74, 59, 62, 67, 71, 57, 60, 64, 69], + }; + } + + hasContext() { + return Boolean(this.ctx); + } + + isMusicEnabled() { + return this.musicEnabled; + } + + async unlock() { + if (this.ctx) { + if (this.ctx.state === "suspended") { + await this.ctx.resume(); + } + return; + } + + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) { + return; + } + + this.ctx = new AudioCtx(); + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.value = 0.75; + this.masterGain.connect(this.ctx.destination); + + this.musicGain = this.ctx.createGain(); + this.musicGain.gain.value = 0.26; + this.musicGain.connect(this.masterGain); + + this.sfxGain = this.ctx.createGain(); + this.sfxGain.gain.value = 0.88; + this.sfxGain.connect(this.masterGain); + + this.noiseBuffer = this.createNoiseBuffer(); + this.nextStepTime = this.ctx.currentTime + 0.05; + this.scheduler = window.setInterval(() => this.scheduleMusic(), 90); + } + + toggleMusic() { + if (!this.ctx) { + return; + } + this.musicEnabled = !this.musicEnabled; + this.musicGain.gain.setTargetAtTime(this.musicEnabled ? 0.26 : 0, this.ctx.currentTime, 0.05); + } + + midiToFreq(note) { + return 440 * 2 ** ((note - 69) / 12); + } + + scheduleMusic() { + if (!this.ctx || !this.musicEnabled) { + return; + } + + const stepDuration = (60 / this.music.bpm) / 2; + while (this.nextStepTime < this.ctx.currentTime + 0.4) { + this.playMusicStep(this.stepIndex, this.nextStepTime); + this.stepIndex = (this.stepIndex + 1) % this.music.lead.length; + this.nextStepTime += stepDuration; + } + } + + playMusicStep(index, time) { + const leadNote = this.music.lead[index % this.music.lead.length]; + const bassNote = this.music.bass[index % this.music.bass.length]; + const arpNote = this.music.arp[index % this.music.arp.length]; + + if (bassNote !== null) { + this.playTone(this.midiToFreq(bassNote), 0.2, "triangle", 0.22, time, this.musicGain); + } + if (leadNote !== null) { + this.playTone(this.midiToFreq(leadNote), 0.13, "square", 0.12, time, this.musicGain); + } + if (arpNote !== null) { + this.playTone(this.midiToFreq(arpNote), 0.08, "sawtooth", 0.06, time, this.musicGain); + } + + if (index % 4 === 0) { + this.playNoise(0.045, 0.07, time, this.musicGain); + } + } + + playTone(freq, duration, type, volume, time, targetGain, attack = 0.005, release = 0.08) { + if (!this.ctx) { + return; + } + + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, time); + + gain.gain.setValueAtTime(0.0001, time); + gain.gain.linearRampToValueAtTime(volume, time + attack); + gain.gain.exponentialRampToValueAtTime(0.0001, time + duration + release); + + osc.connect(gain); + gain.connect(targetGain); + + osc.start(time); + osc.stop(time + duration + release + 0.01); + } + + playNoise(volume, duration, time, targetGain) { + if (!this.ctx || !this.noiseBuffer) { + return; + } + + const source = this.ctx.createBufferSource(); + const filter = this.ctx.createBiquadFilter(); + const gain = this.ctx.createGain(); + + source.buffer = this.noiseBuffer; + filter.type = "bandpass"; + filter.frequency.setValueAtTime(1800, time); + filter.Q.setValueAtTime(0.6, time); + + gain.gain.setValueAtTime(volume, time); + gain.gain.exponentialRampToValueAtTime(0.0001, time + duration); + + source.connect(filter); + filter.connect(gain); + gain.connect(targetGain); + + source.start(time); + source.stop(time + duration + 0.02); + } + + createNoiseBuffer() { + const length = this.ctx.sampleRate * 0.5; + const buffer = this.ctx.createBuffer(1, length, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < length; i += 1) { + data[i] = Math.random() * 2 - 1; + } + return buffer; + } + + playPlaceBomb() { + if (!this.ctx) { + return; + } + const t = this.ctx.currentTime; + this.playTone(320, 0.06, "square", 0.12, t, this.sfxGain, 0.001, 0.03); + this.playTone(250, 0.06, "triangle", 0.08, t + 0.01, this.sfxGain, 0.001, 0.03); + } + + playExplosion() { + if (!this.ctx) { + return; + } + const t = this.ctx.currentTime; + this.playTone(140, 0.08, "sawtooth", 0.25, t, this.sfxGain, 0.002, 0.06); + this.playTone(70, 0.18, "triangle", 0.18, t, this.sfxGain, 0.002, 0.1); + this.playNoise(0.22, 0.14, t, this.sfxGain); + } + + playPowerup() { + if (!this.ctx) { + return; + } + const t = this.ctx.currentTime; + this.playTone(660, 0.08, "triangle", 0.14, t, this.sfxGain, 0.002, 0.05); + this.playTone(830, 0.08, "triangle", 0.13, t + 0.07, this.sfxGain, 0.002, 0.05); + this.playTone(990, 0.12, "triangle", 0.12, t + 0.14, this.sfxGain, 0.002, 0.07); + } + + playDefeat() { + if (!this.ctx) { + return; + } + const t = this.ctx.currentTime; + this.playTone(260, 0.12, "square", 0.14, t, this.sfxGain, 0.003, 0.1); + this.playTone(206, 0.18, "square", 0.14, t + 0.14, this.sfxGain, 0.003, 0.1); + this.playTone(174, 0.24, "triangle", 0.14, t + 0.32, this.sfxGain, 0.003, 0.12); + } + + playVictory() { + if (!this.ctx) { + return; + } + const t = this.ctx.currentTime; + this.playTone(523, 0.14, "triangle", 0.13, t, this.sfxGain, 0.002, 0.08); + this.playTone(659, 0.14, "triangle", 0.13, t + 0.13, this.sfxGain, 0.002, 0.08); + this.playTone(784, 0.22, "triangle", 0.16, t + 0.28, this.sfxGain, 0.002, 0.12); + } +} + +audio = new AudioSystem(); +boot(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..86d5b3b --- /dev/null +++ b/styles.css @@ -0,0 +1,75 @@ +:root { + --bg-1: #08131d; + --bg-2: #14293a; + --panel: #102132cc; + --panel-border: #6ec5ff66; + --text: #ecf6ff; + --accent: #ffd166; + --danger: #ff6b6b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + min-height: 100dvh; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + color: var(--text); + background: linear-gradient(145deg, var(--bg-1), var(--bg-2)); + position: relative; + overflow: hidden; +} + +.page-bg { + position: fixed; + inset: 0; + pointer-events: none; + background-image: + radial-gradient(circle at 12% 18%, #3f95d433 0%, transparent 30%), + radial-gradient(circle at 84% 80%, #ffd1662c 0%, transparent 28%), + repeating-linear-gradient( + 0deg, + transparent, + transparent 36px, + #ffffff08 36px, + #ffffff08 37px + ); +} + +.layout { + width: 100%; + min-height: 100vh; + min-height: 100dvh; + margin: 0; + padding: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.game-shell { + margin: 0; + position: relative; + background: #0f1c2a; + border: 2px solid var(--panel-border); + border-radius: 0; + box-shadow: 0 10px 24px #00000055; + width: 100vw; + height: 100vh; + height: 100dvh; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +canvas { + display: block; + width: min(100vw, calc(100dvh * 15 / 13)); + height: min(100dvh, calc(100vw * 13 / 15)); + aspect-ratio: 15 / 13; +}