// 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 = '' + 'Black Hole Mass: ' + bhMassText + '';
},
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 = `
${rateShortText}
${deltaText}
${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';
}
// 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 = '' + 'Total Level: ' + totalLevel + '';
var starBonusEl = document.getElementById('star-bonus');
if (!starBonusEl) return;
var mBonus = gameState.mtypeUpgradeLevel * CONFIG.MTYPE_SPAWN_BOOST_PER_LEVEL * 100;
var kBonus = gameState.ktypeUpgradeLevel * CONFIG.KTYPE_SPAWN_BOOST_PER_LEVEL * 100;
var gBonus = gameState.gtypeUpgradeLevel * CONFIG.GTYPE_SPAWN_BOOST_PER_LEVEL * 100;
var totalStarBonus = (mBonus + kBonus + gBonus).toFixed(0);
var starTooltip =
'*-Type upgrades boost the spawn rate of all objects.';
if (mBonus > 1) {
starTooltip += '
M-Type: +' + mBonus.toFixed(0) + '%
';
}
if (kBonus > 1) {
starTooltip += 'K-Type: +' + kBonus.toFixed(0) + '%
';
}
if (gBonus > 1) {
starTooltip += 'G-Type: +' + gBonus.toFixed(0) + '%';
}
starBonusEl.innerHTML = '' + '
Star Bonus: +' + totalStarBonus + '%' + '';
},
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 = (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.
Rate: ' + rate + '/sec
Bonus: ' + bonusPercent + '%';
if (!gameState.cometUnlocked) tooltipText += '
Unlocks Comets at level 20';
this.elements.asteroidLevel.innerHTML = 'Asteroids: Level ' + gameState.asteroidUpgradeLevel + '';
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.
Rate: ' + rate + '/min
Bonus: ' + bonusPercent + '%';
if (!gameState.planetUnlocked) tooltipText += '
Unlocks Planets at level 15';
this.elements.cometLevel.innerHTML = 'Comets: Level ' + gameState.cometUpgradeLevel + '';
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.
Rate: ' + rate + '/hour
Bonus: ' + bonusPercent + '%';
if (!gameState.giantUnlocked) tooltipText += '
Unlocks Giants at level 10';
this.elements.planetLevel.innerHTML = 'Planets: Level ' + gameState.planetUpgradeLevel + '';
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.
Ice giants are similar but contain higher amounts of frozen materials like water, ammonia, and methane in their atmospheres.
Local examples include Jupiter and Neptune.
Rate: ' + rate + '/6hours
Bonus: ' + bonusPercent + '%';
if (!gameState.mtypeUnlocked) tooltipText += '
Unlocks M-Type at level 5';
this.elements.giantLevel.innerHTML = 'Giants: Level ' + gameState.giantUpgradeLevel + '';
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☉.
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.
Rate: ' + rate + '/day
Bonus: ' + bonusPercent + '%';
if (!gameState.ktypeUnlocked) tooltipText += '
Unlocks K-Type at level 5';
this.elements.mtypeLevel.innerHTML = 'M-Type: Level ' + gameState.mtypeUpgradeLevel + '';
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.
Rate: ' + rate + '/2days
Bonus: ' + bonusPercent + '%';
if (!gameState.gtypeUnlocked) tooltipText += '
Unlocks G-Type at level 5';
this.elements.ktypeLevel.innerHTML = 'K-Type: Level ' + gameState.ktypeUpgradeLevel + '';
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.
Rate: ' + rate + '/3days
Bonus: ' + bonusPercent + '%';
this.elements.gtypeLevel.innerHTML = 'G-Type: Level ' + gameState.gtypeUpgradeLevel + '';
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 = '