Implementing server wide highscore for multiplayer

This commit is contained in:
Mike Müller 2026-03-08 15:19:28 +01:00
parent e9306a2502
commit 25a8826030
2 changed files with 488 additions and 38 deletions

197
server.js
View File

@ -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;

View File

@ -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);
}
}