Implementing server wide highscore for multiplayer
This commit is contained in:
parent
e9306a2502
commit
25a8826030
197
server.js
197
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;
|
||||
|
||||
329
src/game.js
329
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user