From 8d5b6730446b6ae33266afe66363da7ef8c696eb Mon Sep 17 00:00:00 2001 From: xbl Date: Tue, 17 Feb 2026 19:05:50 +0100 Subject: [PATCH] modified: admin.html modified: js/config.js modified: js/entities.js modified: js/game.js modified: js/helpers.js modified: js/ui.js --- admin.html | 1 + js/config.js | 18 ++++- js/entities.js | 179 ++++++++++++++++++++++++++++++++++++++++++++++++- js/game.js | 45 ++++++------- js/helpers.js | 92 ++++++++++++++++++------- js/ui.js | 49 +++++--------- 6 files changed, 297 insertions(+), 87 deletions(-) diff --git a/admin.html b/admin.html index a564155..647e952 100644 --- a/admin.html +++ b/admin.html @@ -29,6 +29,7 @@ + diff --git a/js/config.js b/js/config.js index e6fc38f..95495e6 100644 --- a/js/config.js +++ b/js/config.js @@ -22,6 +22,7 @@ var CONFIG = { BASE_GIANT_SPAWN_INTERVAL: 21600000, // 6 hours BASE_MTYPE_SPAWN_INTERVAL: 86400000, // 1 day BASE_KTYPE_SPAWN_INTERVAL: 172800000, // 2 days + BASE_GTYPE_SPAWN_INTERVAL: 259200000, // 3 days // Rate tracking windows (in milliseconds) RATE_WINDOWS: { @@ -36,7 +37,8 @@ var CONFIG = { PLANET_BASE_COST: 1e24, GIANT_BASE_COST: 1e27, MTYPE_BASE_COST: 1e29, - KTYPE_BASE_COST: 1e30, + KTYPE_BASE_COST: 1e31, + GTYPE_BASE_COST: 1e33, // Upgrade scaling UPGRADE_COST_MULTIPLIER_ASTER: 1.25, @@ -45,6 +47,7 @@ var CONFIG = { UPGRADE_COST_MULTIPLIER_GIANT: 2, UPGRADE_COST_MULTIPLIER_MTYPE: 2.1, UPGRADE_COST_MULTIPLIER_KTYPE: 2.2, + UPGRADE_COST_MULTIPLIER_GTYPE: 2.3, UPGRADE_BONUS_PER_LEVEL_ASTER: 0.1, UPGRADE_BONUS_PER_LEVEL_COMET: 0.1, @@ -52,6 +55,7 @@ var CONFIG = { UPGRADE_BONUS_PER_LEVEL_GIANT: 0.03, UPGRADE_BONUS_PER_LEVEL_MTYPE: 0.03, UPGRADE_BONUS_PER_LEVEL_KTYPE: 0.03, + UPGRADE_BONUS_PER_LEVEL_GTYPE: 0.03, // Asteroid spawn patterns ASTEROID_SPAWN_PATTERNS: { @@ -67,7 +71,8 @@ var CONFIG = { planet: [1e22, 1e25], // Moons & Planets giant: [1e26, 1e28], // Gas & Ice Giants mtype: [1.589e29, 8.95e29],// M-Type: 0.08-0.45 M☉ - ktype: [8.95e29, 1.59e30] // K-Type: 0.45-0.8 M☉ + ktype: [8.95e29, 1.59e30], // K-Type: 0.45-0.8 M☉ + gtype: [1.59e30, 2.07e30] // K-Type: 0.8-1.04 M☉ }, // Planet color schemes @@ -122,6 +127,15 @@ var CONFIG = { { light: '#ffe8b0', mid: '#ffc055', dark: '#e08820' }, { light: '#ffd070', mid: '#e8952a', dark: '#c87018' } ], + + // G-Type star color schemes (yellow to yellowish-white) + GTYPE_COLORS: [ + { light: '#fff9e6', mid: '#ffe066', dark: '#ffcc33' }, + { light: '#fffbe8', mid: '#ffeb99', dark: '#ffd24d' }, + { light: '#fffde0', mid: '#fff176', dark: '#ffd54f' }, + { light: '#fff8cc', mid: '#ffe57f', dark: '#ffc107' }, + { light: '#fffff0', mid: '#fff59d', dark: '#ffca28' } + ], // Star color distribution STAR_COLORS: [ diff --git a/js/entities.js b/js/entities.js index d172c82..0e674cf 100644 --- a/js/entities.js +++ b/js/entities.js @@ -164,12 +164,12 @@ Asteroid.prototype.initializeTypeSpecificProperties = function() { this.planetColors = CONFIG.GIANT_COLORS[Math.floor(Math.random() * CONFIG.GIANT_COLORS.length)]; // Initialize rings this.rings = []; - var ringCount = Math.floor(Math.random() * 4) + 1; // 1–4 rings + var ringCount = Math.floor(Math.random() * 3) + 1; // 1–3 rings for (var i = 0; i < ringCount; i++) { this.rings.push({ color: CONFIG.GIANT_RING_COLORS[Math.floor(Math.random() * CONFIG.GIANT_RING_COLORS.length)], thickness: 1 + Math.random() * 9, // random thickness - radiusOffset: 10 + i * 5 + Math.random() * 5, // spread rings outward + radiusOffset: 5 + i * 5 + Math.random() * 5, // spread rings outward alpha: 0.3 + Math.random() * 0.4 // stored permanently }); } @@ -614,6 +614,181 @@ Asteroid.prototype.drawGiant = function(ctx) { ctx.restore(); }; +Asteroid.prototype.drawGType = function(ctx, time) { + var massRange = CONFIG.ASTEROID_MASS_RANGES.gtype; + var massNormalized = (this.massKg - massRange[0]) / (massRange[1] - massRange[0]); + + var pulse = 0.06 * Math.sin(time * 1.4 + this.massKg * 0.001) + 1; + var slowPulse = 0.04 * Math.sin(time * 0.4) + 1; + + ctx.save(); + ctx.translate(this.x, this.y); + + // ── Layer 1: tight ambient halo ────────────────────────────────────── + var haloSize = this.size * (2.2 + massNormalized * 0.5) * slowPulse; + var halo = ctx.createRadialGradient(0, 0, this.size * 0.9, 0, 0, haloSize); + halo.addColorStop(0, 'rgba(255, 160, 40, 0.10)'); + halo.addColorStop(0.5, 'rgba(255, 130, 20, 0.04)'); + halo.addColorStop(1, 'rgba(240, 100, 10, 0)'); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(0, 0, haloSize, 0, Math.PI * 2); + ctx.fill(); + + // ── Layer 2: spikes ────────────────────────────────────────────────── + for (var i = 0; i < this.spikes.length; i++) { + var sp = this.spikes[i]; + + var spikeAngle = sp.baseAngle + time * sp.rotSpeed; + var spikeLen = this.size * (sp.lenBase + sp.lenAmp * Math.sin(time * sp.lenSpeed + sp.lenPhase)); + var alpha = sp.alphaBase + sp.alphaAmp * Math.sin(time * sp.alphaSpeed + sp.alphaPhase); + var tipWidth = this.size * sp.widthBase; + + ctx.save(); + ctx.rotate(spikeAngle); + + var spikeGrad = ctx.createLinearGradient( + this.size, 0, + this.size + spikeLen, 0 + ); + spikeGrad.addColorStop(0, 'rgba(255, 210, 110, ' + alpha + ')'); + spikeGrad.addColorStop(0.4, 'rgba(255, 185, 70, ' + (alpha * 0.6) + ')'); + spikeGrad.addColorStop(1, 'rgba(240, 150, 30, 0)'); + + ctx.fillStyle = spikeGrad; + ctx.beginPath(); + ctx.moveTo(this.size * 0.95, -tipWidth); + ctx.quadraticCurveTo( + this.size + spikeLen * 0.5, -tipWidth * 0.3, + this.size + spikeLen, 0 + ); + ctx.quadraticCurveTo( + this.size + spikeLen * 0.5, tipWidth * 0.3, + this.size * 0.95, tipWidth + ); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + // ── Layer 3: inner corona ring ─────────────────────────────────────── + var coronaGrad = ctx.createRadialGradient(0, 0, this.size * 0.9, 0, 0, this.size * 1.3 * pulse); + coronaGrad.addColorStop(0, 'rgba(255, 200, 100, 0.18)'); + coronaGrad.addColorStop(0.5, 'rgba(255, 170, 60, 0.07)'); + coronaGrad.addColorStop(1, 'rgba(255, 140, 30, 0)'); + ctx.fillStyle = coronaGrad; + ctx.beginPath(); + ctx.arc(0, 0, this.size * 1.3 * pulse, 0, Math.PI * 2); + ctx.fill(); + + // ── Layer 4: main body ─────────────────────────────────────────────── + var bodyGrad = ctx.createRadialGradient( + -this.size * 0.15, -this.size * 0.15, 0, + 0, 0, this.size + ); + bodyGrad.addColorStop(0, this.planetColors.light); + bodyGrad.addColorStop(0.45, this.planetColors.mid); + bodyGrad.addColorStop(1, this.planetColors.dark); + ctx.fillStyle = bodyGrad; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fill(); + + // ── Layer 5: surface granulation ──────────────────────────────────── + for (var g = 0; g < this.granules.length; g++) { + var gran = this.granules[g]; + var gAlpha = 0.05 + 0.03 * Math.sin(time * 0.35 + gran.phase); + var granGrad = ctx.createRadialGradient( + gran.x, gran.y, 0, + gran.x, gran.y, gran.radius + ); + granGrad.addColorStop(0, 'rgba(255, 248, 200, ' + gAlpha + ')'); + granGrad.addColorStop(1, 'rgba(255, 200, 100, 0)'); + ctx.fillStyle = granGrad; + ctx.beginPath(); + ctx.arc(gran.x, gran.y, gran.radius, 0, Math.PI * 2); + ctx.fill(); + } + + // ── Layer 6a: limb darkening ────────────────────────────────────────── + var limbGrad = ctx.createRadialGradient(0, 0, this.size * 0.45, 0, 0, this.size * 1.04); + limbGrad.addColorStop(0, 'rgba(0, 0, 0, 0)'); + limbGrad.addColorStop(0.6, 'rgba(0, 0, 0, 0)'); + limbGrad.addColorStop(0.82, 'rgba(0, 0, 0, 0.12)'); + limbGrad.addColorStop(0.93, 'rgba(0, 0, 0, 0.20)'); + limbGrad.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + // ── Layer 6b: sparkles (viewer-facing spikes) ──────────────────────── + for (var k = 0; k < this.sparkles.length; k++) { + var sp = this.sparkles[k]; + + // Drift angle slowly over time + sp.angle += sp.wanderSpeed; + sp.x = Math.cos(sp.angle) * sp.dist; + sp.y = Math.sin(sp.angle) * sp.dist; + + var bright = 0.5 + 0.5 * Math.sin(time * sp.speed + sp.phase); + var sAlpha = bright * bright; // squaring gives snappier flash + + if (sAlpha < 0.05) continue; // skip when nearly invisible + + ctx.save(); + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.clip(); // keep sparkles inside the disc + + ctx.translate(sp.x, sp.y); + + // Four-pointed cross — two perpendicular arms + for (var arm = 0; arm < 2; arm++) { + ctx.save(); + ctx.rotate(arm * Math.PI / 2); + var armGrad = ctx.createLinearGradient(-sp.armLen, 0, sp.armLen, 0); + armGrad.addColorStop(0, 'rgba(255, 255, 240, 0)'); + armGrad.addColorStop(0.35, 'rgba(255, 250, 210, ' + (sAlpha * 0.5) + ')'); + armGrad.addColorStop(0.5, 'rgba(255, 255, 255, ' + sAlpha + ')'); + armGrad.addColorStop(0.65, 'rgba(255, 250, 210, ' + (sAlpha * 0.5) + ')'); + armGrad.addColorStop(1, 'rgba(255, 255, 240, 0)'); + ctx.fillStyle = armGrad; + ctx.beginPath(); + ctx.moveTo(-sp.armLen, 0); + ctx.lineTo(0, -sp.armWidth); + ctx.lineTo( sp.armLen, 0); + ctx.lineTo(0, sp.armWidth); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + // Bright centre dot + var dotGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, sp.size); + dotGrad.addColorStop(0, 'rgba(255, 255, 255, ' + sAlpha + ')'); + dotGrad.addColorStop(0.5, 'rgba(255, 245, 200, ' + (sAlpha * 0.5) + ')'); + dotGrad.addColorStop(1, 'rgba(255, 230, 150, 0)'); + ctx.fillStyle = dotGrad; + ctx.beginPath(); + ctx.arc(0, 0, sp.size, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + // ── Layer 7: bright core ───────────────────────────────────────────── + var coreGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, this.size * 0.45 * pulse); + coreGrad.addColorStop(0, 'rgba(255, 255, 230, 0.55)'); + coreGrad.addColorStop(0.35,'rgba(255, 235, 160, 0.25)'); + coreGrad.addColorStop(0.7, 'rgba(255, 200, 100, 0.08)'); + coreGrad.addColorStop(1, 'rgba(255, 170, 50, 0)'); + ctx.fillStyle = coreGrad; + ctx.beginPath(); + ctx.arc(0, 0, this.size * 0.45 * pulse, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); +}; + + + Asteroid.prototype.drawPlanet = function(ctx) { ctx.save(); ctx.translate(this.x, this.y); diff --git a/js/game.js b/js/game.js index 0b7fb1a..0590ee9 100644 --- a/js/game.js +++ b/js/game.js @@ -207,31 +207,26 @@ var Game = (function() { offlineTime = now - savedState.tabHiddenAt; } - if (offlineTime > 1000) { - var rateToUse = 0; - if (state.rateLong > 0 && (now - state.lT) > 3600000) { - rateToUse = state.rateLong; - } else if (state.rateMedium > 0 && (now - state.mT) > 300000) { - rateToUse = state.rateMedium; - } else { - rateToUse = state.rateShort || 0; - } - - if (rateToUse > 0) { - var offlineMass = rateToUse * (offlineTime / 1000); - state.blackHoleTotalMass += offlineMass; - state.totalMassConsumedEver += offlineMass; - state.totalMassConsumed += offlineMass; - state.sM += offlineMass; - state.mM += offlineMass; - state.lM += offlineMass; - showOfflineNotification( - formatOfflineTime(offlineTime), - UI.formatMass(offlineMass), - 'offline' - ); - } - } + if (offlineTime > 1000) { + var rateToUse = calculateTheoreticalRate(state, CONFIG); + + if (rateToUse > 0) { + var offlineMass = rateToUse * (offlineTime / 1000); + state.blackHoleTotalMass += offlineMass; + state.totalMassConsumedEver += offlineMass; + state.totalMassConsumed += offlineMass; + + state.sM = 0; state.sT = now; + state.mM = 0; state.mT = now; + state.lM = 0; state.lT = now; + + showOfflineNotification( + formatOfflineTime(offlineTime), + UI.formatMass(offlineMass), + 'offline' + ); + } + } state.tabHiddenAt = null; diff --git a/js/helpers.js b/js/helpers.js index 6efee41..fcb5caf 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -46,65 +46,105 @@ function saturateColor(r, g, b, saturationBoost) { return { r: r, g: g, b: b }; } +function calculateTheoreticalRate(state, config, windowMs) { + var rate = 0; + var ranges = config.ASTEROID_MASS_RANGES; + var patterns = config.ASTEROID_SPAWN_PATTERNS; + + var avgLarge = (ranges.large[0] + ranges.large[1]) / 2; + var avgMedium = (ranges.medium[0] + ranges.medium[1]) / 2; + var avgSmall = (ranges.small[0] + ranges.small[1]) / 2; + var mediumCount = (patterns.LARGE_EVERY / patterns.MEDIUM_EVERY) - 1; + var smallCount = patterns.LARGE_EVERY - mediumCount - 1; + var avgMassPerAsteroid = (avgLarge + mediumCount * avgMedium + smallCount * avgSmall) / patterns.LARGE_EVERY; + rate += avgMassPerAsteroid / (state.currentAsteroidSpawnInterval / 1000); + + // Only include object types whose spawn interval fits within the measurement window. + // When windowMs is not provided (display/offline use), limit is Infinity so all types included. + var limit = windowMs || Infinity; + + if (state.cometUnlocked && state.currentCometSpawnInterval && + state.currentCometSpawnInterval <= limit) { + rate += ((ranges.comet[0] + ranges.comet[1]) / 2) / (state.currentCometSpawnInterval / 1000); + } + if (state.planetUnlocked && state.currentPlanetSpawnInterval && + state.currentPlanetSpawnInterval <= limit) { + rate += ((ranges.planet[0] + ranges.planet[1]) / 2) / (state.currentPlanetSpawnInterval / 1000); + } + if (state.giantUnlocked && state.currentGiantSpawnInterval && + state.currentGiantSpawnInterval <= limit) { + rate += ((ranges.giant[0] + ranges.giant[1]) / 2) / (state.currentGiantSpawnInterval / 1000); + } + if (state.mtypeUnlocked && state.currentMtypeSpawnInterval && + state.currentMtypeSpawnInterval <= limit) { + rate += ((ranges.mtype[0] + ranges.mtype[1]) / 2) / (state.currentMtypeSpawnInterval / 1000); + } + + return rate; +} + function calculateConsumptionRates(state, CONFIG, consumedMassKg) { const now = Date.now(); - // Initialize tracking windows if needed if (!state.sM) state.sM = 0; if (!state.sT) state.sT = now; if (!state.mM) state.mM = 0; if (!state.mT) state.mT = now; if (!state.lM) state.lM = 0; if (!state.lT) state.lT = now; - - // Add mass to all windows + // Comparison window for trend coloring + if (!state.cM) state.cM = 0; + if (!state.cT) state.cT = now; + state.sM += consumedMassKg; state.mM += consumedMassKg; state.lM += consumedMassKg; - - // Update per-second rate (with trend coloring) + state.cM += consumedMassKg; + + // Per-second rate (display only — no longer drives trend) const elapsedShort = now - state.sT; if (elapsedShort >= 1000) { - const newRate = state.sM / (elapsedShort / 1000); - - // Compare to previous rate for trend - if (state.rateShort === undefined) { - state.rateShort = newRate; - state.rateShortTrend = 'same'; - } else { - // Determine trend with 5% threshold to avoid jitter - if (newRate > state.rateShort * 1.05) { + state.rateShort = state.sM / (elapsedShort / 1000); + state.sM = 0; + state.sT = now; + } + + // Trend: actual vs theoretical over 60-second window + const elapsedComparison = now - state.cT; + if (elapsedComparison >= 1000) { + const actualRate = state.cM / (elapsedComparison / 1000); + const theoreticalRate = calculateTheoreticalRate(state, CONFIG, elapsedComparison); + + if (theoreticalRate > 0) { + const ratio = actualRate / theoreticalRate; + if (ratio > 1.05) { state.rateShortTrend = 'up'; - } else if (newRate < state.rateShort * 0.95) { + } else if (ratio < 0.95) { state.rateShortTrend = 'down'; } else { state.rateShortTrend = 'same'; } - state.rateShort = newRate; } - - // Reset short window - state.sM = 0; - state.sT = now; + + state.cM = 0; + state.cT = now; } - - // Calculate hourly average (rolling 1-hour window) + + // Hourly average const elapsedMedium = now - state.mT; if (elapsedMedium > 0) { state.rateMedium = state.mM / (elapsedMedium / 1000); } - // Reset hourly window after 1 hour if (elapsedMedium >= CONFIG.RATE_WINDOWS.MEDIUM) { state.mM = 0; state.mT = now; } - - // Calculate daily average (rolling 24-hour window) + + // Daily average const elapsedLong = now - state.lT; if (elapsedLong > 0) { state.rateLong = state.lM / (elapsedLong / 1000); } - // Reset daily window after 24 hours if (elapsedLong >= CONFIG.RATE_WINDOWS.LONG) { state.lM = 0; state.lT = now; diff --git a/js/ui.js b/js/ui.js index f9227a1..b2e5047 100644 --- a/js/ui.js +++ b/js/ui.js @@ -237,38 +237,23 @@ var UI = { }, updateRateDisplay: function(state, config) { - const now = Date.now(); + const theoretical = calculateTheoreticalRate(state, config); + const windowedTheoretical = calculateTheoreticalRate(state, config, 1000); + const trend = state.rateShortTrend || 'same'; - // Format per-second rate (always real) - const rateShortText = this.formatMass(state.rateShort || 0) + '/s'; + const rateShortText = this.formatMass(theoretical) + '/s'; + const rateMediumText = this.formatMass(theoretical * 3600) + '/h'; + const rateLongText = this.formatMass(theoretical * 86400) + '/d'; - // 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'; - } + 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; - // 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} + ${rateShortText} +  ${deltaText} +
         ${rateMediumText}
         ${rateLongText} `; @@ -427,7 +412,7 @@ var UI = { 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, Venus, Earth, and Mars. They may vary greatly in size.

Rate: ' + rate + '/hour
Bonus: ' + bonusPercent + '%'; + 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 ' + @@ -444,7 +429,7 @@ var UI = { 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.
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.

Spawn Rate: ' + rate + '/6hours
Bonus: ' + bonusPercent + '%'; + 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.
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.

Rate: ' + rate + '/6hours
Bonus: ' + bonusPercent + '%'; if (!gameState.mtypeUnlocked) tooltipText += '
Unlocks M-Type at level 5'; this.elements.giantLevel.innerHTML = 'Giants: Level ' + @@ -461,7 +446,7 @@ var UI = { 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.

Spawn Rate: ' + rate + '/day
Bonus: ' + bonusPercent + '%'; + 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 ' + @@ -476,7 +461,7 @@ var UI = { 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.

Spawn Rate: ' + rate + '/2days
Bonus: ' + bonusPercent + '%'; + 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 + '%'; this.elements.ktypeLevel.innerHTML = 'K-Type: Level ' + gameState.ktypeUpgradeLevel +