// 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'), 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'), 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(); }); } // ---------- 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); }, 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 = ` Black Hole Mass: ${bhMassText} The mass of your black hole in solar masses.
1 Solar Mass (M☉) ≈ 1.989 × 10³⁰ kg.

Total mass absorbed:
${consumedText}
`; }, updateRateDisplay: function(state, config) { const now = Date.now(); // Format per-second rate (always real) const rateShortText = this.formatMass(state.rateShort || 0) + '/s'; // Check if we have enough data for hourly average const hourlyElapsed = now - (state.mT || now); let rateMediumText; if (hourlyElapsed < 300000) { // Less than 5 minutes of data // Use estimated value from per-second rate rateMediumText = '~' + this.formatMass((state.rateShort || 0) * 3600) + '/h'; } else { // Use real average rateMediumText = this.formatMass((state.rateMedium || 0) * 3600) + '/h'; } // Check if we have enough data for daily average const dailyElapsed = now - (state.lT || now); let rateLongText; if (dailyElapsed < 3600000) { // Less than 1 hour of data // Use estimated value from best available rate const baseRate = (hourlyElapsed >= 300000) ? state.rateMedium : state.rateShort; rateLongText = '~' + this.formatMass((baseRate || 0) * 86400) + '/d'; } else { // Use real average rateLongText = this.formatMass((state.rateLong || 0) * 86400) + '/d'; } // Only color the per-second rate based on trend const rateText = ` ${rateShortText}
         ${rateMediumText}
         ${rateLongText} `; this.elements.massRate.innerHTML = `   Rates: ${rateText} `; }, 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'; } 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); 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; el.innerHTML = '' + 'Total Level: ' + totalLevel + '' + 'Sum of all upgrades.
' '
' + '
'; }, 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: ' + count + ''; }, updateAsteroidUpgrade: function(gameState) { var rate = (1 / gameState.currentAsteroidSpawnInterval * 1000).toFixed(2); var bonusPercent = (gameState.asteroidUpgradeLevel * 10); var tooltipText = 'Rate: ' + rate + '/sec
Bonus: ' + bonusPercent + '%'; this.elements.asteroidLevel.innerHTML = 'Asteroids: Level ' + gameState.asteroidUpgradeLevel + '' + tooltipText + ''; this.elements.asteroidUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.asteroidUpgradeCost) + ')'; this.elements.asteroidUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.asteroidUpgradeCost; }, updateCometUpgrade: function(gameState) { var rate = (1 / gameState.currentCometSpawnInterval * 60000).toFixed(2); var bonusPercent = (gameState.cometUpgradeLevel * 10); var tooltipText = 'Rate: ' + rate + '/min
Bonus: ' + bonusPercent + '%'; this.elements.cometLevel.innerHTML = 'Comets: Level ' + gameState.cometUpgradeLevel + '' + tooltipText + ''; this.elements.cometUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.cometUpgradeCost) + ')'; this.elements.cometUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.cometUpgradeCost; }, updatePlanetUpgrade: function(gameState) { var rate = (3600000 / gameState.currentPlanetSpawnInterval).toFixed(2); var bonusPercent = (gameState.planetUpgradeLevel * 10); var tooltipText = 'Rate: ' + rate + '/hour
Bonus: ' + bonusPercent + '%'; this.elements.planetLevel.innerHTML = 'Planets: Level ' + gameState.planetUpgradeLevel + '' + tooltipText + ''; this.elements.planetUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.planetUpgradeCost) + ')'; this.elements.planetUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.planetUpgradeCost; }, updateGiantUpgrade: function(gameState) { var rate = (21600000 / gameState.currentGiantSpawnInterval).toFixed(2); var bonusPercent = (gameState.giantUpgradeLevel * 10); var tooltipText = 'Spawn Rate: ' + rate + '/6hours
Bonus: ' + bonusPercent + '%'; this.elements.giantLevel.innerHTML = 'Giants: Level ' + gameState.giantUpgradeLevel + '' + tooltipText + ''; this.elements.giantUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.giantUpgradeCost) + ')'; this.elements.giantUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.giantUpgradeCost; }, updateMtypeUpgrade: function(gameState) { var rate = (86400000 / gameState.currentMtypeSpawnInterval).toFixed(2); var bonusPercent = (gameState.mtypeUpgradeLevel * 0.5); var tooltipText = 'Spawn Rate: ' + rate + '/day
Bonus: ' + bonusPercent + '%'; this.elements.mtypeLevel.innerHTML = 'M-Type: Level ' + gameState.mtypeUpgradeLevel + '' + tooltipText + ''; this.elements.mtypeUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.mtypeUpgradeCost) + ')'; this.elements.mtypeUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.mtypeUpgradeCost; }, 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 = '
Loading...
'; } // Fetch leaderboard let data = await Server.getLeaderboard(sortBy); if (!data.length) { container.innerHTML = '
No entries yet
'; 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 ? '' : ''; 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 += `
${medal} ${this.escapeHtml(displayName)}${onlineIndicator}${nameSuffix} ${lastSeenText} ${this.formatMass(entry.mass,{forceSolarMass:true})} ${this.formatAge(entry.holeAge)}
`; }); 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } };