diff --git a/server.js b/server.js index 1063c9d..d75c4e4 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,7 @@ const { WebSocketServer } = require("ws"); const PORT = Number(process.env.PORT || 8080); const MAX_PLAYERS = 4; const ROOT = __dirname; +const HIGHSCORE_FILE = path.join(ROOT, "highscores.json"); const CONTENT_TYPES = { ".html": "text/html; charset=utf-8", @@ -22,6 +23,7 @@ const CONTENT_TYPES = { const clientsBySocket = new Map(); const clientsById = new Map(); +const highscoreByName = new Map(); const lobby = { phase: "lobby", // lobby | game @@ -53,6 +55,159 @@ function normalizeName(value, fallback) { return normalized || fallback; } +function highscoreKey(name) { + const normalized = normalizeName(name, ""); + return normalized ? normalized.toLowerCase() : ""; +} + +function normalizeHighscores(entries) { + return [...entries] + .filter((entry) => entry && typeof entry.name === "string") + .map((entry) => ({ + name: normalizeName(entry.name, "Unknown"), + kills: Math.max(0, Math.floor(Number(entry.kills) || 0)), + wins: Math.max(0, Math.floor(Number(entry.wins) || 0)), + longestAlive: Math.max(0, Number(entry.longestAlive) || 0), + updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : 0, + })) + .sort( + (a, b) => + b.wins - a.wins || + b.kills - a.kills || + b.longestAlive - a.longestAlive || + b.updatedAt - a.updatedAt || + a.name.localeCompare(b.name), + ) + .slice(0, 12) + .map((entry) => ({ + name: entry.name, + kills: entry.kills, + wins: entry.wins, + longestAlive: Math.round(entry.longestAlive * 10) / 10, + })); +} + +function getHighscores() { + return normalizeHighscores(Array.from(highscoreByName.values())); +} + +function loadHighscores() { + try { + if (!fs.existsSync(HIGHSCORE_FILE)) { + return; + } + const raw = fs.readFileSync(HIGHSCORE_FILE, "utf8"); + const parsed = JSON.parse(raw); + const list = Array.isArray(parsed) ? normalizeHighscores(parsed) : []; + for (const row of list) { + const key = highscoreKey(row.name); + if (key) { + highscoreByName.set(key, { ...row, updatedAt: now() }); + } + } + } catch { + // ignore load failures + } +} + +function saveHighscores() { + try { + fs.writeFileSync(HIGHSCORE_FILE, `${JSON.stringify(getHighscores(), null, 2)}\n`); + } catch { + // ignore write failures + } +} + +function resolvePlayerName(clientId) { + const active = clientsById.get(clientId); + if (active && active.name) { + return active.name; + } + const lobbyPlayer = lobby.players.find((player) => player.id === clientId); + if (lobbyPlayer && lobbyPlayer.name) { + return lobbyPlayer.name; + } + const rosterPlayer = lobby.roster.find((player) => player.id === clientId); + if (rosterPlayer && rosterPlayer.name) { + return rosterPlayer.name; + } + return defaultName(clientId); +} + +function applyRoundReport(report) { + if (!report || typeof report !== "object") { + return false; + } + + const killsByClientId = report.kills && typeof report.kills === "object" ? report.kills : {}; + const winnerClientId = typeof report.winnerId === "string" ? report.winnerId : ""; + const aliveTimesByClientId = report.aliveTimes && typeof report.aliveTimes === "object" ? report.aliveTimes : {}; + + let changed = false; + + const updateEntry = (clientId, mutator) => { + if (!clientId || typeof mutator !== "function") { + return; + } + + const name = normalizeName(resolvePlayerName(clientId), ""); + const key = highscoreKey(name); + if (!key) { + return; + } + + const existing = highscoreByName.get(key) || { name, kills: 0, wins: 0, longestAlive: 0, updatedAt: 0 }; + existing.name = name; + existing.kills = Math.max(0, Math.floor(Number(existing.kills) || 0)); + existing.wins = Math.max(0, Math.floor(Number(existing.wins) || 0)); + existing.longestAlive = Math.max(0, Number(existing.longestAlive) || 0); + + if (!mutator(existing)) { + return; + } + + existing.updatedAt = now(); + highscoreByName.set(key, existing); + changed = true; + }; + + for (const [clientId, value] of Object.entries(killsByClientId)) { + const kills = Math.floor(Number(value)); + if (!clientId || !Number.isFinite(kills) || kills <= 0 || kills > 99) { + continue; + } + + updateEntry(clientId, (entry) => { + entry.kills += kills; + return true; + }); + } + + if (winnerClientId) { + updateEntry(winnerClientId, (entry) => { + entry.wins += 1; + return true; + }); + } + + for (const [clientId, value] of Object.entries(aliveTimesByClientId)) { + const aliveSeconds = Number(value); + if (!clientId || !Number.isFinite(aliveSeconds) || aliveSeconds < 0 || aliveSeconds > 3600) { + continue; + } + + updateEntry(clientId, (entry) => { + if (aliveSeconds <= entry.longestAlive) { + return false; + } + entry.longestAlive = aliveSeconds; + return true; + }); + } + + return changed; +} + function safePath(urlPath) { const clean = decodeURIComponent(urlPath.split("?")[0]); const target = clean === "/" ? "/index.html" : clean; @@ -102,10 +257,18 @@ function broadcastLobbyState(reason = null) { phase: lobby.phase, hostId: lobby.hostId, players: normalizePlayers(lobby.players), + highscores: getHighscores(), reason, }); } +function broadcastHighscoreUpdate() { + broadcast({ + type: "highscore_update", + highscores: getHighscores(), + }); +} + function endMatch(reason = "Match ended") { lobby.phase = "lobby"; lobby.roster = []; @@ -114,7 +277,7 @@ function endMatch(reason = "Match ended") { function startMatch() { const ordered = normalizePlayers(lobby.players); - if (ordered.length < 1) { + if (ordered.length < 2) { return; } lobby.phase = "game"; @@ -123,6 +286,7 @@ function startMatch() { type: "lobby_start", hostId: lobby.hostId, roster: lobby.roster, + highscores: getHighscores(), }); } @@ -166,7 +330,7 @@ function handleMessage(client, msg) { if (msg.type === "hello") { client.name = normalizeName(msg.name, client.name); - send(client.ws, { type: "welcome", clientId: client.id, name: client.name }); + send(client.ws, { type: "welcome", clientId: client.id, name: client.name, highscores: getHighscores() }); return; } @@ -184,7 +348,7 @@ function handleMessage(client, msg) { } broadcastLobbyState(); } - send(client.ws, { type: "welcome", clientId: client.id, name: client.name }); + send(client.ws, { type: "welcome", clientId: client.id, name: client.name, highscores: getHighscores() }); return; } @@ -207,6 +371,10 @@ function handleMessage(client, msg) { send(client.ws, { type: "error", message: "Only the host can start the match." }); return; } + if (lobby.players.length < 2) { + send(client.ws, { type: "error", message: "At least 2 players are required to start a match." }); + return; + } startMatch(); return; } @@ -219,10 +387,29 @@ function handleMessage(client, msg) { send(client.ws, { type: "error", message: "Only the host can start the next round." }); return; } + if (lobby.players.length < 2) { + send(client.ws, { type: "error", message: "At least 2 players are required to start a match." }); + return; + } startMatch(); return; } + if (msg.type === "lobby_kill_report") { + if (lobby.phase !== "game") { + return; + } + if (client.id !== lobby.hostId) { + send(client.ws, { type: "error", message: "Only the host can submit round stats." }); + return; + } + if (applyRoundReport(msg)) { + saveHighscores(); + broadcastHighscoreUpdate(); + } + return; + } + if (msg.type === "player_input") { if (lobby.phase !== "game") { return; @@ -267,6 +454,8 @@ function handleMessage(client, msg) { } } +loadHighscores(); + const server = http.createServer((req, res) => { const filePath = safePath(req.url || "/"); if (!filePath) { @@ -311,7 +500,7 @@ wss.on("connection", (ws) => { clientsBySocket.set(ws, client); clientsById.set(client.id, client); - send(ws, { type: "welcome", clientId: client.id, name: client.name }); + send(ws, { type: "welcome", clientId: client.id, name: client.name, highscores: getHighscores() }); ws.on("message", (data) => { let msg; diff --git a/src/game.js b/src/game.js index a95fbcb..346063d 100644 --- a/src/game.js +++ b/src/game.js @@ -73,6 +73,9 @@ const state = { pendingDetonationSet: new Set(), lastFrame: 0, outcomePlayed: false, + multiplayerRoundKills: {}, + multiplayerRoundDeathTimes: {}, + multiplayerRoundTime: 0, menu: { open: false, selectedIndex: 0, @@ -120,6 +123,7 @@ const network = { hostId: null, localName: "", lobbyPlayers: [], + highscores: [], remoteInputs: new Map(), remoteBombPrev: new Map(), lastSnapshotSentAt: 0, @@ -340,6 +344,9 @@ function resetRoundSingle() { state.status = "running"; state.message = "Fight"; state.outcomePlayed = false; + state.multiplayerRoundKills = {}; + state.multiplayerRoundDeathTimes = {}; + state.multiplayerRoundTime = 0; closeMenu(); } @@ -370,6 +377,9 @@ function resetRoundMultiplayer(roster) { state.status = "running"; state.message = "Multiplayer match live"; state.outcomePlayed = false; + state.multiplayerRoundKills = {}; + state.multiplayerRoundDeathTimes = {}; + state.multiplayerRoundTime = 0; closeMenu(); inputState.bombQueued = false; network.remoteBombPrev.clear(); @@ -508,11 +518,21 @@ function getMainMenuItems() { } function getLobbyMenuItems() { - const canStart = network.lobbyPhase === "lobby" && network.isHost && network.lobbyPlayers.length >= 1; + const enoughPlayers = network.lobbyPlayers.length >= 2; + const canStart = network.lobbyPhase === "lobby" && network.isHost && enoughPlayers; + const startLabel = + network.lobbyPhase === "game" + ? "Game In Progress" + : network.isHost + ? enoughPlayers + ? "Start Match" + : "Need 2 Players" + : "Waiting For Host"; + return [ { id: "start", - label: canStart ? "Start Match" : network.lobbyPhase === "game" ? "Game In Progress" : "Waiting For Host", + label: startLabel, disabled: !canStart, }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, @@ -570,6 +590,37 @@ function normalizeLobbyPlayers(players) { })); } +function normalizeHighscores(entries) { + if (!Array.isArray(entries)) { + return []; + } + return [...entries] + .filter((entry) => entry && typeof entry.name === "string") + .map((entry) => ({ + name: normalizePlayerName(entry.name) || "Unknown", + wins: Math.max(0, Math.floor(Number(entry.wins) || 0)), + kills: Math.max(0, Math.floor(Number(entry.kills) || 0)), + longestAlive: Math.max(0, Number(entry.longestAlive) || 0), + })) + .sort( + (a, b) => + b.wins - a.wins || + b.kills - a.kills || + b.longestAlive - a.longestAlive || + a.name.localeCompare(b.name), + ) + .slice(0, 8) + .map((entry) => ({ + ...entry, + longestAlive: Math.round(entry.longestAlive * 10) / 10, + })); +} + +function formatAliveSeconds(value) { + const seconds = Math.max(0, Number(value) || 0); + return String(Math.round(seconds)) + "s"; +} + function sendSocketMessage(type, payload = {}) { if (!network.socket || network.socket.readyState !== WebSocket.OPEN) { return; @@ -595,6 +646,7 @@ function ensureSocketConnection() { 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; @@ -652,6 +704,9 @@ function handleSocketMessage(raw) { } else if (!network.localName) { network.localName = `P-${shortId(network.clientId)}`; } + if (Array.isArray(msg.highscores)) { + network.highscores = normalizeHighscores(msg.highscores); + } if (state.screen === "lobby") { sendSocketMessage("lobby_join"); network.inLobby = true; @@ -674,6 +729,9 @@ function handleSocketMessage(raw) { network.isHost = network.hostId === network.clientId; network.lobbyPhase = msg.phase === "game" ? "game" : "lobby"; network.lobbyPlayers = nextLobbyPlayers; + if (Array.isArray(msg.highscores)) { + network.highscores = normalizeHighscores(msg.highscores); + } if (state.mode === "multiplayer" && state.screen === "game" && msg.phase === "game") { const activeMatchIds = new Set(network.activeRoster.map((player) => player.id)); @@ -705,10 +763,20 @@ function handleSocketMessage(raw) { network.isHost = msg.hostId === network.clientId; network.lobbyPhase = "game"; network.activeRoster = normalizeLobbyPlayers(msg.roster || []); + if (Array.isArray(msg.highscores)) { + network.highscores = normalizeHighscores(msg.highscores); + } startMultiplayerMatch(network.activeRoster); return; } + if (msg.type === "highscore_update") { + if (Array.isArray(msg.highscores)) { + network.highscores = normalizeHighscores(msg.highscores); + } + return; + } + if (msg.type === "player_input") { if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { return; @@ -780,6 +848,9 @@ function leaveLobbyInternals() { network.activeRoster = []; state.notification.text = ""; state.notification.expiresAt = 0; + state.multiplayerRoundKills = {}; + state.multiplayerRoundDeathTimes = {}; + state.multiplayerRoundTime = 0; network.inputStateAtClient = { dir: null, bomb: false, bombCell: null }; network.lastInputSentAt = 0; network.hasReceivedSnapshot = false; @@ -797,7 +868,7 @@ function leaveMultiplayerToMainMenu() { } function hostStartMatchFromLobby() { - if (!network.isHost || network.lobbyPhase !== "lobby" || network.lobbyPlayers.length < 1) { + if (!network.isHost || network.lobbyPhase !== "lobby" || network.lobbyPlayers.length < 2) { return; } sendSocketMessage("lobby_start"); @@ -843,6 +914,7 @@ function serializeGameState() { })), explosions: state.explosions.map((explosion) => ({ timer: explosion.timer, + ownerId: explosion.ownerId, cells: explosion.cells.map((cell) => ({ x: cell.x, y: cell.y, type: cell.type, rot: cell.rot })), })), nextBombId: state.nextBombId, @@ -941,6 +1013,7 @@ function applySnapshot(snapshot) { })); state.explosions = snapshot.explosions.map((explosion) => ({ timer: explosion.timer, + ownerId: explosion.ownerId, cells: explosion.cells.map((cell) => ({ ...cell })), })); state.nextBombId = snapshot.nextBombId; @@ -1347,9 +1420,54 @@ function updateBot(player, dt) { player.ai.desiredDir = options.length > 0 ? options[0].dir : null; } -function killPlayer(player) { +function registerMultiplayerKill(killerPlayerId, victimPlayerId) { + if (state.mode !== "multiplayer" || !network.isHost) { + return; + } + const killer = state.players.find((player) => player.id === killerPlayerId); + const victim = state.players.find((player) => player.id === victimPlayerId); + if (!killer || !victim || !killer.ownerId || !victim.ownerId) { + return; + } + if (killer.ownerId === victim.ownerId) { + return; + } + const previous = state.multiplayerRoundKills[killer.ownerId] || 0; + state.multiplayerRoundKills[killer.ownerId] = previous + 1; +} + +function findKillerForHit(tiles, victimPlayerId) { + for (const explosion of state.explosions) { + const ownerId = Number.isInteger(explosion.ownerId) ? explosion.ownerId : null; + if (ownerId === null || ownerId === victimPlayerId) { + continue; + } + for (const cell of explosion.cells) { + if (tiles.includes(tileKey(cell.x, cell.y))) { + return ownerId; + } + } + } + return null; +} + +function killPlayer(player, killerPlayerId = null) { + if (!player.alive) { + return false; + } player.alive = false; player.ai.desiredDir = null; + + if (state.mode === "multiplayer" && network.isHost && player.ownerId) { + if (!Object.prototype.hasOwnProperty.call(state.multiplayerRoundDeathTimes, player.ownerId)) { + state.multiplayerRoundDeathTimes[player.ownerId] = state.multiplayerRoundTime; + } + } + + if (Number.isInteger(killerPlayerId) && killerPlayerId !== player.id) { + registerMultiplayerKill(killerPlayerId, player.id); + } + return true; } function collectPowerup(player) { @@ -1498,7 +1616,7 @@ function detonateBomb(bomb) { } } - state.explosions.push({ cells, timer: CONFIG.explosionDuration }); + state.explosions.push({ cells, timer: CONFIG.explosionDuration, ownerId: bomb.ownerId }); audio.playExplosion(); } @@ -1529,7 +1647,8 @@ function checkDeaths() { } const tiles = occupiedTiles(player); if (tiles.some((key) => state.fireLookup.has(key))) { - killPlayer(player); + const killerPlayerId = findKillerForHit(tiles, player.id); + killPlayer(player, killerPlayerId); } } } @@ -1578,6 +1697,50 @@ function evaluateOutcome() { return endedThisFrame; } +function reportRoundKillsToServer() { + if (state.mode !== "multiplayer" || !network.isHost) { + return; + } + + const kills = {}; + let hasKills = false; + for (const [ownerId, count] of Object.entries(state.multiplayerRoundKills)) { + const cleanCount = Math.floor(Number(count)); + if (!ownerId || !Number.isFinite(cleanCount) || cleanCount <= 0) { + continue; + } + kills[ownerId] = cleanCount; + hasKills = true; + } + + const roundDuration = Math.max(0, state.multiplayerRoundTime); + const aliveTimes = {}; + let hasAliveTimes = false; + for (const player of state.players) { + if (!player.ownerId) { + continue; + } + const deathAt = state.multiplayerRoundDeathTimes[player.ownerId]; + const aliveSeconds = Number.isFinite(deathAt) + ? clamp(deathAt, 0, roundDuration) + : roundDuration; + aliveTimes[player.ownerId] = Math.round(aliveSeconds * 10) / 10; + hasAliveTimes = true; + } + + const alivePlayers = state.players.filter((player) => player.alive && player.ownerId); + const winnerId = alivePlayers.length === 1 ? alivePlayers[0].ownerId : null; + + if (!hasKills && !hasAliveTimes && !winnerId) { + return; + } + + sendSocketMessage("lobby_kill_report", { kills, winnerId, aliveTimes }); + state.multiplayerRoundKills = {}; + state.multiplayerRoundDeathTimes = {}; + state.multiplayerRoundTime = 0; +} + function updateNonHostClient(dt) { for (const player of state.players) { if (!player.alive) { @@ -1625,6 +1788,10 @@ function updateGame(dt) { return; } + if (state.mode === "multiplayer") { + state.multiplayerRoundTime += dt; + } + if (state.mode === "multiplayer" && !network.isHost) { updateNonHostClient(dt); return; @@ -1692,6 +1859,9 @@ function updateGame(dt) { const endedThisFrame = evaluateOutcome(); if (state.mode === "multiplayer" && network.isHost) { + if (endedThisFrame) { + reportRoundKillsToServer(); + } broadcastSnapshot(endedThisFrame); } } @@ -1924,8 +2094,8 @@ function drawMainMenu() { function drawLobby() { drawStaticBackdrop(); - const panelWidth = 460; - const panelHeight = 400; + const panelWidth = 560; + const panelHeight = 430; const panelX = (canvas.width - panelWidth) / 2; const panelY = (canvas.height - panelHeight) / 2; @@ -1946,24 +2116,59 @@ function drawLobby() { const lobbyHint = network.lobbyPhase === "game" ? "Match currently running. You will join next round." - : "Host can start with any number of players (1-4)."; + : "Host can start with 2-4 players."; ctx.fillText(lobbyHint, canvas.width / 2, panelY + 74); ctx.textAlign = "left"; ctx.font = "bold 17px Trebuchet MS"; + ctx.fillStyle = "#ffdca3"; + ctx.fillText("Lobby Players", panelX + 34, panelY + 102); for (let i = 0; i < MAX_PLAYERS; i += 1) { const player = network.lobbyPlayers[i]; - const y = panelY + 112 + i * 34; + const y = panelY + 136 + i * 34; const line = player - ? `${i + 1}. ${player.name}${player.id === network.hostId ? " (Host)" : ""}` - : `${i + 1}. Waiting...`; + ? String(i + 1) + ". " + player.name + (player.id === network.hostId ? " (Host)" : "") + : String(i + 1) + ". Waiting..."; ctx.fillStyle = player ? "#e9f6ff" : "#8fb6d4"; ctx.fillText(line, panelX + 34, y); } + ctx.strokeStyle = "#8acfff44"; + ctx.beginPath(); + ctx.moveTo(panelX + panelWidth / 2, panelY + 98); + ctx.lineTo(panelX + panelWidth / 2, panelY + 306); + ctx.stroke(); + + ctx.fillStyle = "#ffdca3"; + ctx.font = "bold 17px Trebuchet MS"; + ctx.fillText("Server Highscores", panelX + panelWidth / 2 + 24, panelY + 102); + + if (network.highscores.length === 0) { + ctx.fillStyle = "#8fb6d4"; + ctx.font = "bold 15px Trebuchet MS"; + ctx.fillText("No rounds recorded yet.", panelX + panelWidth / 2 + 24, panelY + 136); + } else { + ctx.font = "bold 14px Trebuchet MS"; + network.highscores.forEach((entry, index) => { + const y = panelY + 136 + index * 22; + const rank = String(index + 1).padStart(2, "0"); + const line = + rank + + ". " + + entry.name + + " - " + + entry.kills + + " (" + + formatAliveSeconds(entry.longestAlive) + + ")"; + ctx.fillStyle = "#e9f6ff"; + ctx.fillText(line, panelX + panelWidth / 2 + 24, y); + }); + } + const items = getLobbyMenuItems(); items.forEach((item, index) => { - const y = panelY + 260 + index * 34; + const y = panelY + 324 + index * 34; const selected = index === state.lobbyMenu.selectedIndex; if (selected && !item.disabled) { ctx.fillStyle = "#ffd166"; @@ -2411,10 +2616,22 @@ class AudioSystem { 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], + bpm: 130, + stepsPerBar: 16, + progression: [ + { root: 45, chord: [0, 3, 7, 10] }, + { root: 50, chord: [0, 3, 7, 10] }, + { root: 43, chord: [0, 4, 7, 11] }, + { root: 48, chord: [0, 4, 7, 11] }, + ], + bassPattern: [0, null, 0, null, 7, null, 10, null, 0, null, 7, null, 3, null, 5, null], + arpPattern: [12, 19, 22, 19, 15, 19, 22, 19, 12, 19, 22, 24, 15, 19, 22, 19], + leadPatternA: [null, 24, null, 26, null, 27, 26, null, null, 31, null, 29, 27, null, 26, null], + leadPatternB: [null, 24, null, 24, null, 26, 27, null, null, 29, null, 31, 29, null, 27, null], + kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0], + snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], + hat: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + openHat: [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], }; } @@ -2445,7 +2662,7 @@ class AudioSystem { this.masterGain.connect(this.ctx.destination); this.musicGain = this.ctx.createGain(); - this.musicGain.gain.value = 0.26; + this.musicGain.gain.value = 0.24; this.musicGain.connect(this.masterGain); this.sfxGain = this.ctx.createGain(); @@ -2462,7 +2679,10 @@ class AudioSystem { return; } this.musicEnabled = !this.musicEnabled; - this.musicGain.gain.setTargetAtTime(this.musicEnabled ? 0.26 : 0, this.ctx.currentTime, 0.05); + this.musicGain.gain.setTargetAtTime(this.musicEnabled ? 0.24 : 0, this.ctx.currentTime, 0.05); + if (this.musicEnabled) { + this.nextStepTime = this.ctx.currentTime + 0.03; + } } midiToFreq(note) { @@ -2475,30 +2695,71 @@ class AudioSystem { } const stepDuration = (60 / this.music.bpm) / 2; - while (this.nextStepTime < this.ctx.currentTime + 0.4) { + const totalSteps = this.music.stepsPerBar * this.music.progression.length; + while (this.nextStepTime < this.ctx.currentTime + 0.35) { this.playMusicStep(this.stepIndex, this.nextStepTime); - this.stepIndex = (this.stepIndex + 1) % this.music.lead.length; + this.stepIndex = (this.stepIndex + 1) % totalSteps; 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]; + const step = index % this.music.stepsPerBar; + const barIndex = Math.floor(index / this.music.stepsPerBar) % this.music.progression.length; + const bar = this.music.progression[barIndex]; + const leadPattern = barIndex % 2 === 0 ? this.music.leadPatternA : this.music.leadPatternB; + const swingTime = step % 2 === 1 ? time + 0.008 : time; - 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); + const bassInterval = this.music.bassPattern[step]; + if (bassInterval !== null) { + this.playTone(this.midiToFreq(bar.root + bassInterval), 0.19, "triangle", 0.17, swingTime, this.musicGain); + if (step % 4 === 0) { + this.playTone(this.midiToFreq(bar.root + bassInterval - 12), 0.1, "sine", 0.08, swingTime, this.musicGain); + } } - if (index % 4 === 0) { - this.playNoise(0.045, 0.07, time, this.musicGain); + const arpInterval = this.music.arpPattern[(step + barIndex * 2) % this.music.arpPattern.length]; + if (arpInterval !== null) { + this.playTone(this.midiToFreq(bar.root + arpInterval), 0.07, "sawtooth", 0.05, swingTime, this.musicGain); + } + + const leadInterval = leadPattern[step]; + if (leadInterval !== null) { + const accent = step % 4 === 1 ? 1.15 : 1; + this.playTone( + this.midiToFreq(bar.root + leadInterval), + 0.12, + "square", + 0.095 * accent, + swingTime, + this.musicGain, + 0.004, + 0.07, + ); + } + + if (step % 8 === 0) { + this.playTone(this.midiToFreq(bar.root + bar.chord[0] + 12), 0.42, "triangle", 0.045, time, this.musicGain); + this.playTone(this.midiToFreq(bar.root + bar.chord[1] + 12), 0.42, "triangle", 0.037, time, this.musicGain); + this.playTone(this.midiToFreq(bar.root + bar.chord[2] + 12), 0.42, "triangle", 0.04, time, this.musicGain); + } + + if (this.music.kick[step]) { + this.playTone(58, 0.05, "sine", 0.16, time, this.musicGain, 0.0015, 0.04); + this.playTone(42, 0.08, "triangle", 0.11, time, this.musicGain, 0.0015, 0.05); + } + + if (this.music.snare[step]) { + this.playNoise(0.06, 0.04, time, this.musicGain); + this.playTone(188, 0.045, "triangle", 0.07, time, this.musicGain, 0.002, 0.035); + } + + if (this.music.hat[step]) { + this.playNoise(0.018, 0.014, swingTime, this.musicGain); + } + + if (this.music.openHat[step]) { + this.playNoise(0.025, 0.06, swingTime, this.musicGain); } }