hoel/js/ui.js
2026-02-01 22:11:20 +01:00

548 lines
22 KiB
JavaScript
Raw Permalink 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'),
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 = `
<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 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 = `
<span class="${'rate-' + (state.rateShortTrend || 'same')}">${rateShortText}</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';
}
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 =
'<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 = (1 / gameState.currentAsteroidSpawnInterval * 1000).toFixed(2);
var bonusPercent = (gameState.asteroidUpgradeLevel * 10);
var tooltipText = 'Rate: ' + rate + '/sec <br>Bonus: ' + bonusPercent + '%';
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 = (1 / gameState.currentCometSpawnInterval * 60000).toFixed(2);
var bonusPercent = (gameState.cometUpgradeLevel * 10);
var tooltipText = 'Rate: ' + rate + '/min <br>Bonus: ' + bonusPercent + '%';
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 = (3600000 / gameState.currentPlanetSpawnInterval).toFixed(2);
var bonusPercent = (gameState.planetUpgradeLevel * 10);
var tooltipText = 'Rate: ' + rate + '/hour <br>Bonus: ' + bonusPercent + '%';
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 = (21600000 / gameState.currentGiantSpawnInterval).toFixed(2);
var bonusPercent = (gameState.giantUpgradeLevel * 10);
var tooltipText = 'Spawn Rate: ' + rate + '/6hours <br>Bonus: ' + bonusPercent + '%';
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 = (86400000 / gameState.currentMtypeSpawnInterval).toFixed(2);
var bonusPercent = (gameState.mtypeUpgradeLevel * 0.5);
var tooltipText = 'Spawn Rate: ' + rate + '/day <br>Bonus: ' + bonusPercent + '%';
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;
},
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;');
}
};