hoel/js/ui.js
xbl 805dd7b082 modified: js/config.js
modified:   js/entities.js
	modified:   js/ui.js
2026-02-26 20:40:38 +01:00

708 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// UI management and event handlers
var UI = {
elements: {},
init: function() {
// Cache DOM elements
this.elements = {
totalMass: document.getElementById('total-mass'),
spendableMass: document.getElementById('spendable-mass'),
totalLevel: document.getElementById('total-level'),
asteroidLevel: document.getElementById('asteroid-level'),
cometLevel: document.getElementById('comet-level'),
planetLevel: document.getElementById('planet-level'),
giantLevel: document.getElementById('giant-level'),
mtypeLevel: document.getElementById('mtype-level'),
ktypeLevel: document.getElementById('ktype-level'),
gtypeLevel: document.getElementById('gtype-level'),
asteroidUpgradeBtn: document.getElementById('asteroid-upgrade-btn'),
cometUpgradeBtn: document.getElementById('comet-upgrade-btn'),
planetUpgradeBtn: document.getElementById('planet-upgrade-btn'),
giantUpgradeBtn: document.getElementById('giant-upgrade-btn'),
mtypeUpgradeBtn: document.getElementById('mtype-upgrade-btn'),
ktypeUpgradeBtn: document.getElementById('ktype-upgrade-btn'),
gtypeUpgradeBtn: document.getElementById('gtype-upgrade-btn'),
gearIcon: document.getElementById('gear-icon'),
settingsMenu: document.getElementById('settings-menu'),
resetBtn: document.getElementById('reset-btn'),
leaderboardModal: document.getElementById('leaderboardModal'),
leaderboardToggle: document.getElementById('leaderboardToggle'),
leaderboardPanel: document.getElementById('leaderboardPanel'),
leaderboardList: document.getElementById('leaderboardList'),
sortMass: document.getElementById('sortMass'),
sortAge: document.getElementById('sortAge'),
massRate: document.getElementById('massRate')
};
// Sorting buttons
this.elements.sortMass.addEventListener('click', () => this.openLeaderboard('mass'));
this.elements.sortAge.addEventListener('click', () => this.openLeaderboard('age'));
this.setupEventListeners();
},
setupEventListeners: function() {
const self = this;
const gear = self.elements.gearIcon;
const settingsMenu = self.elements.settingsMenu;
const leaderboardToggle = self.elements.leaderboardToggle;
const leaderboardPanel = self.elements.leaderboardPanel;
const resetBtn = self.elements.resetBtn;
const holeIcon = document.getElementById('hole-icon');
const upgradePanel = document.getElementById('upgrade-panel');
// ---------- Upgrade Panel ----------
if (holeIcon && upgradePanel) {
// Toggle upgrade panel
holeIcon.addEventListener('click', (e) => {
e.stopPropagation();
if (settingsMenu) settingsMenu.classList.remove('open');
if (leaderboardPanel) leaderboardPanel.classList.remove('show');
upgradePanel.classList.toggle('open');
});
// Prevent clicks inside panel from closing it
upgradePanel.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// ---------- Leaderboard ----------
if (leaderboardToggle && leaderboardPanel) {
// Toggle leaderboard panel
leaderboardToggle.addEventListener('click', (e) => {
e.stopPropagation(); // prevent document click
if (settingsMenu) settingsMenu.classList.remove('open');
if (upgradePanel) upgradePanel.classList.remove('open');
leaderboardPanel.classList.toggle('show');
if (leaderboardPanel.classList.contains('show')) {
self.openLeaderboard('mass'); // load leaderboard by mass immediately
}
});
// Prevent clicks inside the panel from closing it
leaderboardPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
}
if (resetBtn) {
resetBtn.addEventListener('click', function() {
Storage.resetGame();
});
}
// ---------- Settings Menu ----------
if (gear && settingsMenu) {
// Toggle settings menu
gear.addEventListener('click', (e) => {
e.stopPropagation(); // prevent document click from immediately closing
if (upgradePanel) upgradePanel.classList.remove('open');
if (leaderboardPanel) leaderboardPanel.classList.remove('show');
settingsMenu.classList.toggle('open');
});
// Prevent clicks inside menu from closing it
settingsMenu.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// ---------- Session Transfer ----------
var exportBtn = document.getElementById('export-session-btn');
var claimBtn = document.getElementById('transfer-claim-btn');
if (exportBtn) {
exportBtn.addEventListener('click', function(e) {
e.stopPropagation();
self.exportSession();
});
}
if (claimBtn) {
claimBtn.addEventListener('click', async function(e) {
e.stopPropagation();
var input = document.getElementById('transfer-code-input');
var isVisible = input.style.display !== 'none';
if (!isVisible) {
input.style.display = 'block';
claimBtn.classList.add('active');
input.focus();
return;
}
var code = input.value.trim();
if (!code) {
input.style.display = 'none';
claimBtn.classList.remove('active');
return;
}
if (!/^[0-9a-fA-F]{5}$/.test(code)) {
showErrorNotification('Invalid or expired code');
return;
}
try {
var result = await Server.claimTransferCode(code.toLowerCase());
if (!result || !result.playerId) throw new Error();
Storage.setCookie('playerId', result.playerId, 365);
Storage.setCookie('sessionToken', result.sessionToken, 365);
showSuccessNotification('Session imported — reloading...');
setTimeout(function() { location.reload(); }, 1500);
} catch (err) {
showErrorNotification('Invalid or expired code');
}
});
}
var transferInput = document.getElementById('transfer-code-input');
if (transferInput) {
transferInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.stopPropagation();
claimBtn.click();
}
});
}
// ---------- Click outside to close ----------
document.addEventListener('click', () => {
// Close settings menu
if (settingsMenu) {
settingsMenu.classList.remove('open');
}
// Close leaderboard panel
if (leaderboardPanel) {
leaderboardPanel.classList.remove('show');
// Clear refresh intervals when closing
if (this._leaderboardRefreshInterval) {
clearInterval(this._leaderboardRefreshInterval);
this._leaderboardRefreshInterval = null;
}
if (this._leaderboardIndicatorInterval) {
clearInterval(this._leaderboardIndicatorInterval);
this._leaderboardIndicatorInterval = null;
}
}
if (upgradePanel) {
upgradePanel.classList.remove('open');
}
});
},
setUpgradeHandlers: function(handlers) {
this.elements.asteroidUpgradeBtn.addEventListener('click', handlers.asteroid);
this.elements.cometUpgradeBtn.addEventListener('click', handlers.comet);
this.elements.planetUpgradeBtn.addEventListener('click', handlers.planet);
this.elements.giantUpgradeBtn.addEventListener('click', handlers.giant);
this.elements.mtypeUpgradeBtn.addEventListener('click', handlers.mtype);
this.elements.ktypeUpgradeBtn.addEventListener('click', handlers.ktype);
this.elements.gtypeUpgradeBtn.addEventListener('click', handlers.gtype);
},
update: function(gameState, config) {
if (this.elements.spendableMass) {
this.elements.spendableMass.textContent =
'Available to Spend: ' + this.formatMass(gameState.totalMassConsumed);
}
this.updateMassDisplay(gameState, config);
this.updateRateDisplay(gameState, config);
this.updateUpgradeDisplay(gameState);
this.updateTotalLevel(gameState);
this.updateActiveObjects();
},
updateMassDisplay: function(state, config) {
const bhMassText = this.formatMass(state.blackHoleTotalMass, { forceSolarMass: true });
const consumedText = this.formatMass(state.totalMassConsumedEver);
this.elements.totalMass.innerHTML = `
<span class="tooltip-trigger">
Black Hole Mass: ${bhMassText}
<span class="tooltip">
The mass of your black hole in solar masses.<br>
1 Solar Mass (M☉) ≈ 1.989 × 10³⁰ kg.<br><br>
Total mass absorbed:<br>
${consumedText}
</span>
`;
},
updateRateDisplay: function(state, config) {
const theoretical = calculateTheoreticalRate(state, config);
const windowedTheoretical = calculateTheoreticalRate(state, config, 1000);
const trend = state.rateShortTrend || 'same';
const rateShortText = this.formatMass(theoretical) + '/s';
const rateMediumText = this.formatMass(theoretical * 3600) + '/h';
const rateLongText = this.formatMass(theoretical * 86400) + '/d';
const delta = (state.rateShort || 0) - windowedTheoretical;
const sign = delta >= 0 ? '+' : '-';
const deltaText = sign + this.formatMass(Math.abs(delta)) + '/s';
const deltaClass = trend === 'same' ? 'rate-same' : 'rate-' + trend;
const rateText = `
<span class="rate-same">${rateShortText}</span>
<span class="${deltaClass}">&nbsp;${deltaText}</span>
</br><span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${rateMediumText}</span>
</br><span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${rateLongText}</span>
`;
this.elements.massRate.innerHTML = `
<span>&nbsp;&nbsp;Rates: ${rateText}</span>
`;
},
updateUpgradeDisplay: function(gameState) {
this.updateAsteroidUpgrade(gameState);
// Only show comet upgrade if unlocked
if (gameState.cometUnlocked) {
this.updateCometUpgrade(gameState);
this.elements.cometLevel.parentElement.style.display = '';
} else {
this.elements.cometLevel.parentElement.style.display = 'none';
}
// Only show planet upgrade if unlocked
if (gameState.planetUnlocked) {
this.updatePlanetUpgrade(gameState);
this.elements.planetLevel.parentElement.style.display = '';
} else {
this.elements.planetLevel.parentElement.style.display = 'none';
}
// Only show giant upgrade if unlocked
if (gameState.giantUnlocked) {
this.updateGiantUpgrade(gameState);
this.elements.giantLevel.parentElement.style.display = '';
} else {
this.elements.giantLevel.parentElement.style.display = 'none';
}
// Only show mtype upgrade if unlocked
if (gameState.mtypeUnlocked) {
this.updateMtypeUpgrade(gameState);
this.elements.mtypeLevel.parentElement.style.display = '';
} else {
this.elements.mtypeLevel.parentElement.style.display = 'none';
}
// Only show ktype upgrade if unlocked
if (gameState.ktypeUnlocked) {
this.updateKtypeUpgrade(gameState);
this.elements.ktypeLevel.parentElement.style.display = '';
} else {
this.elements.ktypeLevel.parentElement.style.display = 'none';
}
// Only show gtype upgrade if unlocked
if (gameState.gtypeUnlocked) {
this.updateGtypeUpgrade(gameState);
this.elements.gtypeLevel.parentElement.style.display = '';
} else {
this.elements.gtypeLevel.parentElement.style.display = 'none';
}
var canAffordAny =
gameState.totalMassConsumed >= gameState.asteroidUpgradeCost ||
(gameState.cometUnlocked && gameState.totalMassConsumed >= gameState.cometUpgradeCost) ||
(gameState.planetUnlocked && gameState.totalMassConsumed >= gameState.planetUpgradeCost) ||
(gameState.giantUnlocked && gameState.totalMassConsumed >= gameState.giantUpgradeCost) ||
(gameState.mtypeUnlocked && gameState.totalMassConsumed >= gameState.mtypeUpgradeCost) ||
(gameState.ktypeUnlocked && gameState.totalMassConsumed >= gameState.ktypeUpgradeCost) ||
(gameState.gtypeUnlocked && gameState.totalMassConsumed >= gameState.gtypeUpgradeCost);
var holeIcon = document.getElementById('hole-icon');
if (holeIcon) {
if (canAffordAny) {
holeIcon.classList.add('pulse');
} else {
holeIcon.classList.remove('pulse');
}
}
},
updateTotalLevel: function(gameState) {
var el = this.elements.totalLevel;
if (!el) return;
var totalLevel =
gameState.asteroidUpgradeLevel +
gameState.cometUpgradeLevel +
gameState.planetUpgradeLevel +
gameState.giantUpgradeLevel +
gameState.mtypeUpgradeLevel +
gameState.ktypeUpgradeLevel +
gameState.gtypeUpgradeLevel;
el.innerHTML =
'<span class="tooltip-trigger">' +
'Total Level: ' + totalLevel +
'<span class="tooltip">' +
'Sum of all upgrades.<br>'
'</span>' +
'</span>';
},
updateActiveObjects: function() {
var el = document.getElementById('active-objects');
if (!el) return;
var count = window.gameAsteroids ? window.gameAsteroids.length : 0;
var now = Date.now();
// Initialize tracking
if (this._lastObjectCount === undefined) {
this._lastObjectCount = count;
this._objectTrend = 'same';
this._lastObjectCheck = now;
}
// Only update trend once per second to avoid flicker
if (now - this._lastObjectCheck >= 1000) {
if (count > this._lastObjectCount) {
this._objectTrend = 'up';
} else if (count < this._lastObjectCount) {
this._objectTrend = 'down';
} else {
this._objectTrend = 'same';
}
this._lastObjectCount = count;
this._lastObjectCheck = now;
}
// Apply trend color
el.innerHTML = 'Objects: <span class="rate-' + this._objectTrend + '">' + count + '</span>';
},
updateAsteroidUpgrade: function(gameState) {
var rate = (1000 / gameState.currentAsteroidSpawnInterval).toFixed(2);
var bonusPercent = (gameState.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_ASTER * 100).toFixed(0);
var tooltipText = 'Asteroids are rocky, airless bodies that range in size from tiny pebbles to hundreds of kilometers across. Some asteroids have moons or even binary companions.<br><br>Rate: ' + rate + '/sec<br>Bonus: ' + bonusPercent + '%';
if (!gameState.cometUnlocked) tooltipText += '<br><span style="color:#0FF;">Unlocks Comets at level 20</span>';
this.elements.asteroidLevel.innerHTML = '<span class="tooltip-trigger">Asteroids: Level ' +
gameState.asteroidUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.asteroidUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.asteroidUpgradeCost) + ')';
this.elements.asteroidUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.asteroidUpgradeCost;
},
updateCometUpgrade: function(gameState) {
var rate = (60000 / gameState.currentCometSpawnInterval).toFixed(2);
var bonusPercent = (gameState.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_COMET * 100).toFixed(0);
var tooltipText = 'Comets are small icy bodies that typically release gas and dust, forming a glowing coma and often a tail. They are composed mainly of ice, rock, and organic compounds.<br><br>Rate: ' + rate + '/min<br>Bonus: ' + bonusPercent + '%';
if (!gameState.planetUnlocked) tooltipText += '<br><span style="color:#0FF;">Unlocks Planets at level 15</span>';
this.elements.cometLevel.innerHTML = '<span class="tooltip-trigger">Comets: Level ' +
gameState.cometUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.cometUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.cometUpgradeCost) + ')';
this.elements.cometUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.cometUpgradeCost;
},
updatePlanetUpgrade: function(gameState) {
var rate = (CONFIG.BASE_PLANET_SPAWN_INTERVAL / gameState.currentPlanetSpawnInterval).toFixed(2);
var bonusPercent = (gameState.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_PLANT * 100).toFixed(0);
var tooltipText = 'Planets are roughly spherical accumulations of rock and metal, with solid surfaces and relatively thin atmospheres. Local examples include Mercury, Earth, and Pluto. They may vary greatly in size.<br><br>Rate: ' + rate + '/hour<br>Bonus: ' + bonusPercent + '%';
if (!gameState.giantUnlocked) tooltipText += '<br><span style="color:#0FF;">Unlocks Giants at level 10</span>';
this.elements.planetLevel.innerHTML = '<span class="tooltip-trigger">Planets: Level ' +
gameState.planetUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.planetUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.planetUpgradeCost) + ')';
this.elements.planetUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.planetUpgradeCost;
},
updateGiantUpgrade: function(gameState) {
var rate = (CONFIG.BASE_GIANT_SPAWN_INTERVAL / gameState.currentGiantSpawnInterval).toFixed(2);
var bonusPercent = (gameState.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GIANT * 100).toFixed(0);
var tooltipText = 'Gas giants are large planets made mostly of hydrogen and helium, with thick atmospheres and no solid surface.<br>Ice giants are similar but contain higher amounts of frozen materials like water, ammonia, and methane in their atmospheres.<br>Local examples include Jupiter and Neptune.<br><br>Rate: ' + rate + '/6hours<br>Bonus: ' + bonusPercent + '%';
if (!gameState.mtypeUnlocked) tooltipText += '<br><span style="color:#0FF;">Unlocks M-Type at level 5</span>';
this.elements.giantLevel.innerHTML = '<span class="tooltip-trigger">Giants: Level ' +
gameState.giantUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.giantUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.giantUpgradeCost) + ')';
this.elements.giantUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.giantUpgradeCost;
},
updateMtypeUpgrade: function(gameState) {
var rate = (CONFIG.BASE_MTYPE_SPAWN_INTERVAL / gameState.currentMtypeSpawnInterval).toFixed(2);
var bonusPercent = (gameState.mtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_MTYPE * 100).toFixed(0);
var tooltipText = 'M-types, also known as red dwarfs, are the smallest and coolest stars with masses ranging from about 0.08 to 0.45 M☉.<br>They are the most common star in the universe, making up roughly three quarters of all main-sequence stars, but are not easily visible due to their low luminosity.<br><br>Rate: ' + rate + '/day<br>Bonus: ' + bonusPercent + '%';
if (!gameState.ktypeUnlocked) tooltipText += '<br><span style="color:#0FF;">Unlocks K-Type at level 5</span>';
this.elements.mtypeLevel.innerHTML = '<span class="tooltip-trigger">M-Type: Level ' +
gameState.mtypeUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.mtypeUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.mtypeUpgradeCost) + ')';
this.elements.mtypeUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.mtypeUpgradeCost;
},
updateKtypeUpgrade: function(gameState) {
var rate = (CONFIG.BASE_KTYPE_SPAWN_INTERVAL / gameState.currentKtypeSpawnInterval).toFixed(2);
var bonusPercent = (gameState.ktypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_KTYPE * 100).toFixed(0);
var tooltipText = 'K-types, also known as orange dwarfs, are medium-sized stars that are cooler than the Sun, with masses ranging from about 0.45 to 0.8 M☉. They are known for their stability and long lifespans (20 to 70 billion years), making them potential candidates for supporting inhabited planets.<br><br>Rate: ' + rate + '/2days<br>Bonus: ' + bonusPercent + '%';
if (!gameState.gtypeUnlocked) tooltipText += '<br><span style="color:#0FF;">Unlocks G-Type at level 5</span>';
this.elements.ktypeLevel.innerHTML = '<span class="tooltip-trigger">K-Type: Level ' +
gameState.ktypeUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.ktypeUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.ktypeUpgradeCost) + ')';
this.elements.ktypeUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.ktypeUpgradeCost;
},
updateGtypeUpgrade: function(gameState) {
var rate = (CONFIG.BASE_GTYPE_SPAWN_INTERVAL / gameState.currentGtypeSpawnInterval).toFixed(2);
var bonusPercent = (gameState.gtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GTYPE * 100).toFixed(0);
var tooltipText = 'G-Types, also known as yellow dwarfs, are medium-sized stars that are about the size of our Sun (~1 M☉). The term yellow dwarf is a misnomer, as most G-Types (including the Sun) are in fact white. These stars are stable and long-lived, making them strong candidates for hosting habitable planets.<br><br>Rate: ' + rate + '/3days<br>Bonus: ' + bonusPercent + '%';
this.elements.gtypeLevel.innerHTML = '<span class="tooltip-trigger">G-Type: Level ' +
gameState.gtypeUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.gtypeUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.gtypeUpgradeCost) + ')';
this.elements.gtypeUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.gtypeUpgradeCost;
},
formatMass: function(massKg, options = {}) {
if (massKg == null) return '0'; // fallback
const SOLAR_SWITCH_KG = CONFIG.SOLAR_MASS_KG * 0.01; // ~1% solar mass
// Forced solar mass display (e.g. black holes)
if (options.forceSolarMass || massKg >= SOLAR_SWITCH_KG) {
var solarMasses = massKg / CONFIG.SOLAR_MASS_KG;
// Precision scales nicely with magnitude
if (solarMasses >= 1000) return solarMasses.toFixed(0) + ' M☉';
if (solarMasses >= 1) return solarMasses.toFixed(3) + ' M☉';
return solarMasses.toFixed(5) + ' M☉';
}
// SI mass units (tonnes-based)
if (massKg >= 1e30) return (massKg / 1e30).toFixed(2) + ' Qt'; // Quettatonnes
if (massKg >= 1e27) return (massKg / 1e27).toFixed(2) + ' Rt'; // Ronnatonnes
if (massKg >= 1e24) return (massKg / 1e24).toFixed(2) + ' Yt'; // Yottatonnes
if (massKg >= 1e21) return (massKg / 1e21).toFixed(2) + ' Zt'; // Zettatonnes
if (massKg >= 1e18) return (massKg / 1e18).toFixed(2) + ' Et'; // Exatonnes
if (massKg >= 1e15) return (massKg / 1e15).toFixed(2) + ' Pt'; // Petatonnes
if (massKg >= 1e12) return (massKg / 1e12).toFixed(2) + ' Tt'; // Teratonnes
if (massKg >= 1e9) return (massKg / 1e9).toFixed(2) + ' Gt'; // Gigatonnes
if (massKg >= 1e6) return (massKg / 1e6).toFixed(2) + ' Mt'; // Megatonnes
if (massKg >= 1e3) return (massKg / 1e3).toFixed(2) + ' tonnes'; // Tonnes
return massKg.toFixed(0) + ' kg';
},
formatTime: function(ms) {
var seconds = Math.floor(ms / 1000);
var minutes = Math.floor(seconds / 60);
var hours = Math.floor(minutes / 60);
var days = Math.floor(hours / 24);
if (days > 0) {
return days + 'd ' + (hours % 24) + 'h';
} else if (hours > 0) {
return hours + 'h ' + (minutes % 60) + 'm';
} else if (minutes > 0) {
return minutes + 'm ' + (seconds % 60) + 's';
} else {
return seconds + 's';
}
},
formatAge: function(ms) {
var days = Math.floor(ms / (24 * 60 * 60 * 1000));
var hours = Math.floor((ms % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
if (days > 0) {
return days + 'd ' + hours + 'h';
} else if (hours > 0) {
return hours + 'h';
} else {
return 'New';
}
},
openLeaderboard: async function(sortBy = 'mass') {
const container = this.elements.leaderboardList;
const localPlayerId = Server.playerId;
// Show loading only on first load
if (!this._cachedLeaderboardData) {
container.innerHTML = '<div style="text-align:center; padding:20px;">Loading...</div>';
}
// Fetch leaderboard
let data = await Server.getLeaderboard(sortBy);
if (!data.length) {
container.innerHTML = '<div style="text-align:center; padding:20px;">No entries yet</div>';
return;
}
this._cachedLeaderboardData = data;
this._currentSort = sortBy;
// Render function (can be called independently)
const render = () => {
const now = Date.now();
const fifteenMinutes = 15 * 60 * 1000;
let html = '';
data.forEach((entry, i) => {
const rank = i + 1;
const medal = rank===1?'🥇':rank===2?'🥈':rank===3?'🥉':rank+'.';
const isLocal = entry.id === localPlayerId;
// Calculate online status in real-time
const isOnline = (now - entry.lastSeen) < fifteenMinutes;
const onlineIndicator = isOnline ? '<span class="online-indicator"></span>' : '';
const nameSuffix = isLocal ? '💗' : '';
const displayName = entry.id ? entry.id.slice(-5) : 'Anon';
// Calculate time since last seen
const timeSinceLastSeen = now - entry.lastSeen;
const lastSeenText = formatTimeSince(timeSinceLastSeen);
html += `<div class="leaderboard-entry${isLocal ? ' local-player' : ''}">
<span class="rank">${medal}</span>
<span class="name">${this.escapeHtml(displayName)}${onlineIndicator}${nameSuffix}</span>
<span class="value last-seen">${lastSeenText}</span>
<span class="value mass${sortBy==='mass'?' sorted':''}">${this.formatMass(entry.mass,{forceSolarMass:true})}</span>
<span class="value age${sortBy==='age'?' sorted':''}">${this.formatAge(entry.holeAge)}</span>
</div>`;
});
container.innerHTML = html;
};
render();
// Auto-refresh data every 30 seconds
if (this._leaderboardRefreshInterval) {
clearInterval(this._leaderboardRefreshInterval);
}
this._leaderboardRefreshInterval = setInterval(async () => {
if (this.elements.leaderboardPanel.classList.contains('show')) {
// Re-fetch data
data = await Server.getLeaderboard(sortBy);
this._cachedLeaderboardData = data;
} else {
clearInterval(this._leaderboardRefreshInterval);
this._leaderboardRefreshInterval = null;
}
}, 30000);
// Re-render indicators every 5 seconds (lightweight, no API call)
if (this._leaderboardIndicatorInterval) {
clearInterval(this._leaderboardIndicatorInterval);
}
this._leaderboardIndicatorInterval = setInterval(() => {
if (this.elements.leaderboardPanel.classList.contains('show')) {
render(); // Just re-render with updated time calculations
} else {
clearInterval(this._leaderboardIndicatorInterval);
this._leaderboardIndicatorInterval = null;
}
}, 5000);
},
escapeHtml: function(text) {
if (!text) return '';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
exportSession: async function() {
var btn = document.getElementById('export-session-btn');
var display = document.getElementById('export-code-display');
var codeEl = document.getElementById('transfer-code-value');
var expiryEl = document.getElementById('transfer-code-expiry');
if (!btn || !display || !codeEl) return;
btn.disabled = true;
btn.textContent = 'Export';
display.style.display = 'none';
try {
var result = await Server.createTransferCode();
if (!result || !result.code) {
showErrorNotification(result && result.error ? result.error : 'Failed to generate transfer code');
return;
}
codeEl.textContent = result.code.toUpperCase();
display.style.display = 'block';
btn.classList.add('menu-btn-export-active');
var expiresAt = result.expiresAt;
if (this._transferTimer) clearInterval(this._transferTimer);
var self = this;
var tick = function() {
var remaining = Math.max(0, expiresAt - Date.now());
var mins = Math.floor(remaining / 60000);
var secs = Math.floor((remaining % 60000) / 1000);
if (expiryEl) expiryEl.textContent = 'Expires in ' + mins + ':' + (secs < 10 ? '0' : '') + secs;
if (remaining === 0) {
clearInterval(self._transferTimer);
if (codeEl) codeEl.textContent = 'EXPIRED';
if (expiryEl) expiryEl.textContent = '';
btn.classList.remove('menu-btn-export-active');
}
};
tick(); // run immediately
this._transferTimer = setInterval(tick, 1000);
} catch (e) {
showErrorNotification('Failed to generate transfer code');
}
btn.disabled = false;
btn.textContent = 'Export';
}
};