modified: js/config.js modified: js/entities.js modified: js/game.js modified: js/helpers.js modified: js/ui.js
680 lines
29 KiB
JavaScript
680 lines
29 KiB
JavaScript
// 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'),
|
||
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'),
|
||
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);
|
||
},
|
||
|
||
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}"> ${deltaText}</span>
|
||
</br><span> ${rateMediumText}</span>
|
||
</br><span> ${rateLongText}</span>
|
||
`;
|
||
|
||
this.elements.massRate.innerHTML = `
|
||
<span> 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';
|
||
}
|
||
|
||
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);
|
||
|
||
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;
|
||
|
||
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>Unlocks Comets at level 20';
|
||
|
||
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>Unlocks Planets at level 15';
|
||
|
||
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>Unlocks Giants at level 10';
|
||
|
||
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; Jupiter and Saturn are examples.<br>Ice giants are similar but contain higher amounts of frozen materials like water, ammonia, and methane beneath their atmospheres; Uranus and Neptune fall into this category.<br><br>Rate: ' + rate + '/6hours<br>Bonus: ' + bonusPercent + '%';
|
||
if (!gameState.mtypeUnlocked) tooltipText += '<br>Unlocks M-Type at level 5';
|
||
|
||
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>Unlocks K-Type at level 5';
|
||
|
||
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 + '%';
|
||
|
||
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;
|
||
},
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
},
|
||
|
||
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';
|
||
}
|
||
};
|