diff --git a/admin.css b/admin.css new file mode 100644 index 0000000..9b9f7d6 --- /dev/null +++ b/admin.css @@ -0,0 +1,195 @@ +/* Admin Panel Styles */ +#admin-panel { + position: fixed; + top: 20px; + right: 20px; + width: 320px; + max-height: calc(100vh - 40px); + background: rgba(15, 15, 25, 0.95); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: rgba(255, 255, 255, 0.9); + font-family: 'Courier New', monospace; + font-size: 11px; + z-index: 9999; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +#admin-panel.collapsed #admin-content { + display: none; +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: rgba(30, 30, 50, 0.95); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.admin-header h2 { + margin: 0; + font-size: 13px; + font-weight: bold; + color: rgba(100, 200, 255, 0.9); + letter-spacing: 1px; +} + +#admin-toggle { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + cursor: pointer; + padding: 4px 8px; + transition: color 0.2s; +} + +#admin-toggle:hover { + color: rgba(255, 255, 255, 0.9); +} + +#admin-content { + max-height: calc(100vh - 100px); + overflow-y: auto; + padding: 16px; +} + +.admin-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.admin-section:last-child { + border-bottom: none; +} + +.admin-section h3 { + margin: 0 0 10px 0; + font-size: 12px; + color: rgba(200, 200, 220, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.admin-buttons { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.admin-buttons:last-child { + margin-bottom: 0; +} + +.admin-buttons button { + flex: 1; + min-width: 80px; + background: rgba(60, 60, 80, 0.6); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.8); + padding: 6px 10px; + cursor: pointer; + font-family: 'Courier New', monospace; + font-size: 10px; + border-radius: 4px; + transition: all 0.2s; +} + +.admin-buttons button:hover { + background: rgba(80, 80, 100, 0.8); + color: rgba(255, 255, 255, 1); + border-color: rgba(255, 255, 255, 0.4); +} + +.admin-buttons button:active { + background: rgba(100, 100, 120, 0.8); +} + +.admin-input-group { + display: flex; + gap: 6px; + margin-bottom: 8px; + align-items: center; +} + +.admin-input-group label { + min-width: 70px; + color: rgba(200, 200, 220, 0.8); + font-size: 10px; +} + +.admin-input-group input { + flex: 1; + background: rgba(30, 30, 50, 0.8); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + padding: 6px 8px; + font-family: 'Courier New', monospace; + font-size: 10px; + border-radius: 4px; +} + +.admin-input-group input:focus { + outline: none; + border-color: rgba(100, 200, 255, 0.6); +} + +.admin-input-group button { + min-width: 50px; + background: rgba(60, 60, 80, 0.6); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.8); + padding: 6px 12px; + cursor: pointer; + font-family: 'Courier New', monospace; + font-size: 10px; + border-radius: 4px; + transition: all 0.2s; +} + +.admin-input-group button:hover { + background: rgba(80, 80, 100, 0.8); + border-color: rgba(255, 255, 255, 0.4); +} + +.admin-info { + background: rgba(20, 20, 30, 0.6); + padding: 10px; + border-radius: 4px; + font-size: 10px; + line-height: 1.8; +} + +.admin-info div { + display: flex; + justify-content: space-between; + color: rgba(180, 180, 200, 0.9); +} + +.admin-info span { + color: rgba(100, 200, 255, 0.9); + font-weight: bold; +} + +/* Scrollbar styling */ +#admin-content::-webkit-scrollbar { + width: 8px; +} + +#admin-content::-webkit-scrollbar-track { + background: rgba(20, 20, 30, 0.5); +} + +#admin-content::-webkit-scrollbar-thumb { + background: rgba(100, 100, 120, 0.6); + border-radius: 4px; +} + +#admin-content::-webkit-scrollbar-thumb:hover { + background: rgba(120, 120, 140, 0.8); +} diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..1cbd341 --- /dev/null +++ b/admin.html @@ -0,0 +1,136 @@ + + + + + + hoel - Admin + + + + + +
+
+

ADMIN PANEL

+ +
+ +
+ +
+

Spawn Objects

+
+ + + + + + + +
+
+ + +
+

Set Levels

+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+

Add Mass

+
+ + +
+
+ + + +
+
+ + +
+

Test Notifications

+
+ + + +
+
+ + + +
+
+ + +
+

Toggle Unlocks

+
+ + + +
+
+ + +
+

Game State

+
+ + + +
+
+ + +
+
+ + +
+

Current State

+
+
Player ID: -
+
Mass: -
+
Total Level: -
+
Asteroids: -
+
Active Objects: -
+
+
+
+
+ + + + + + + + + + + + + diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000..4af682c Binary files /dev/null and b/favicon.png differ diff --git a/index.html b/index.html index 29f4583..ed39d2a 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ hoel + @@ -40,22 +41,28 @@ a1.7 1.7 0 0 0-1.5 1z"> +
-
-
-
+
+
+
+
+ +

Available to Spend: 0 kg
@@ -75,6 +82,10 @@ a1.7 1.7 0 0 0-1.5 1z">
+
+
+ +
@@ -106,16 +117,23 @@ a1.7 1.7 0 0 0-1.5 1z"> + + + + + diff --git a/js/admin.js b/js/admin.js new file mode 100644 index 0000000..fd30944 --- /dev/null +++ b/js/admin.js @@ -0,0 +1,309 @@ +// ===== Admin Mode Setup ===== + +// Admin mode flags +window.isAdminMode = true; +window.adminPlayerId = 'admin_dev'; + +// Create complete UI replacement +window.UI = { + elements: {}, + + init: function() { + console.log('Admin: Using stub UI'); + }, + + setUpgradeHandlers: function(handlers) { + // Store handlers for admin to call + window.adminUpgradeHandlers = handlers; + }, + + update: function(state, config) { + // Update admin info display if it exists + if (document.getElementById('admin-mass')) { + updateAdminInfo(); + } + }, + + formatMass: function(mass, options) { + if (options && options.forceSolarMass) { + var solarMasses = mass / CONFIG.SOLAR_MASS_KG; + return solarMasses.toFixed(3) + ' M☉'; + } + if (mass >= 1e18) return (mass / 1e18).toFixed(2) + ' Et'; + if (mass >= 1e15) return (mass / 1e15).toFixed(2) + ' Pt'; + if (mass >= 1e12) return (mass / 1e12).toFixed(2) + ' Tt'; + if (mass >= 1e9) return (mass / 1e9).toFixed(2) + ' Gt'; + if (mass >= 1e6) return (mass / 1e6).toFixed(2) + ' Mt'; + if (mass >= 1e3) return (mass / 1e3).toFixed(2) + ' tonnes'; + return mass.toFixed(0) + ' kg'; + }, + + updateMassDisplay: function() {}, + updateUpgradeDisplay: function() {}, + updateTotalLevel: function() {}, + 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'; + if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm'; + if (minutes > 0) return minutes + 'm'; + 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 old'; + if (hours > 0) return hours + 'h old'; + return 'New'; + } +}; +window.UI.updateActiveObjects = function() { + if (window.gameAsteroids) { + var el = document.getElementById('admin-objects'); + if (el) el.textContent = window.gameAsteroids.length; + } +}; + +// ===== Wait for game to load ===== +window.addEventListener('load', function() { + + // Override Server.init + if (window.Server) { + var originalInit = Server.init; + Server.init = function() { + this.playerId = window.adminPlayerId; + Storage.setCookie('playerId', this.playerId, 365); + console.log('Admin mode: Player ID =', this.playerId); + } + // Override checkpoint to do nothing + Server.checkpoint = function() { + console.log('Admin: Checkpoint skipped'); + return Promise.resolve({ success: true }); + }; + } + + // Toggle admin panel + var toggleBtn = document.getElementById('admin-toggle'); + if (toggleBtn) { + toggleBtn.addEventListener('click', function() { + var panel = document.getElementById('admin-panel'); + panel.classList.toggle('collapsed'); + this.textContent = panel.classList.contains('collapsed') ? '▲' : '▼'; + }); + } + + console.log('%c[ADMIN MODE ENABLED]', 'color: #64c8ff; font-weight: bold; font-size: 14px;'); +}); + +// ===== Admin Functions ===== + +// Spawn objects +function adminSpawn(type) { + if (!window.Game || !Game.getState) { + console.error('Game not initialized'); + return; + } + + var canvas = document.getElementById('space'); + var blackHole = window.gameBlackHole; + + if (!blackHole) { + console.error('Black hole not available'); + return; + } + + window.gameAsteroids.push(new Asteroid(type, blackHole, canvas)); + console.log('Spawned:', type); +} + +// Set upgrade levels +function adminSetLevel(upgradeType) { + if (!window.Game || !Game.getState) return; + + var state = Game.getState(); + var inputId = 'admin-' + upgradeType + '-level'; + var input = document.getElementById(inputId); + if (!input) return; + + var level = parseInt(input.value) || 0; + + switch(upgradeType) { + case 'asteroid': + state.asteroidUpgradeLevel = level; + state.asteroidUpgradeCost = Math.floor( + CONFIG.ASTEROID_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_ASTER, level) + ); + if (level >= 25) state.cometUnlocked = true; + break; + case 'comet': + state.cometUpgradeLevel = level; + state.cometUpgradeCost = Math.floor( + CONFIG.COMET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, level) + ); + if (level >= 20) state.planetUnlocked = true; + break; + case 'planet': + state.planetUpgradeLevel = level; + state.planetUpgradeCost = Math.floor( + CONFIG.PLANET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, level) + ); + if (level >= 15) state.giantUnlocked = true; + break; + case 'giant': + state.giantUpgradeLevel = level; + state.giantUpgradeCost = Math.floor( + CONFIG.GIANT_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, level) + ); + break; + } + + UI.update(state, CONFIG); + console.log('Set', upgradeType, 'level to:', level); +} + +// Add mass +function adminAddMass(amount) { + if (!window.Game || !Game.getState) return; + + var state = Game.getState(); + + if (!amount) { + var input = document.getElementById('admin-mass-input'); + amount = input ? (parseFloat(input.value) || 0) : 0; + } + + state.blackHoleTotalMass += amount; + state.totalMassConsumedEver += amount; + state.totalMassConsumed += amount; + + // Update black hole size + var solarMasses = state.blackHoleTotalMass / CONFIG.SOLAR_MASS_KG; + if (window.gameBlackHole) { + window.gameBlackHole.radius = CONFIG.INITIAL_BLACK_HOLE_RADIUS + + (solarMasses - CONFIG.INITIAL_BLACK_HOLE_MASS); + } + + UI.update(state, CONFIG); + console.log('Added mass:', UI.formatMass(amount)); +} + +// Toggle unlocks +function adminToggleUnlock(type) { + if (!window.Game || !Game.getState) return; + + var state = Game.getState(); + + switch(type) { + case 'comet': + state.cometUnlocked = !state.cometUnlocked; + break; + case 'planet': + state.planetUnlocked = !state.planetUnlocked; + break; + case 'giant': + state.giantUnlocked = !state.giantUnlocked; + break; + } + + UI.update(state, CONFIG); + console.log(type + ' unlocked:', state[type + 'Unlocked']); +} + +// Game state management +function adminSaveGame() { + if (!window.Game || !Game.getState || !window.Server) return; + Server.checkpoint(Game.getState()); + console.log('Game saved'); + showSuccessNotification('Game saved'); +} + +function adminLoadGame() { + location.reload(); +} + +function adminResetGame() { + if (confirm('Reset game state? This will delete all progress.')) { + Storage.resetGame(); + } +} + +function adminExportState() { + if (!window.Game || !Game.getState) return; + + var state = Game.getState(); + var json = JSON.stringify(state, null, 2); + + var blob = new Blob([json], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'game-state-' + Date.now() + '.json'; + a.click(); + URL.revokeObjectURL(url); + + console.log('Exported game state'); +} + +function adminImportState() { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.onchange = function(e) { + var file = e.target.files[0]; + if (!file) return; + + var reader = new FileReader(); + reader.onload = function(event) { + try { + var importedState = JSON.parse(event.target.result); + var currentState = Game.getState(); + + for (var key in importedState) { + currentState[key] = importedState[key]; + } + + UI.update(currentState, CONFIG); + console.log('Imported game state'); + showSuccessNotification('State imported'); + } catch (err) { + console.error('Failed to import:', err); + showErrorNotification('Invalid JSON file'); + } + }; + reader.readAsText(file); + }; + + input.click(); +} + +// Update admin info display +function updateAdminInfo() { + if (!window.Game || !Game.getState) return; + + var state = Game.getState(); + + var el = document.getElementById('admin-player-id'); + if (el && window.Server) el.textContent = Server.playerId; + + el = document.getElementById('admin-mass'); + if (el) el.textContent = UI.formatMass(state.blackHoleTotalMass, {forceSolarMass: true}); + + el = document.getElementById('admin-total-level'); + if (el) { + el.textContent = state.asteroidUpgradeLevel + state.cometUpgradeLevel + + state.planetUpgradeLevel + state.giantUpgradeLevel; + } + + el = document.getElementById('admin-asteroids'); + if (el) el.textContent = state.asteroidSpawnCount; + + el = document.getElementById('admin-objects'); + if (el) el.textContent = window.gameAsteroids ? window.gameAsteroids.length : 0; +} + +// Update info every second +setInterval(updateAdminInfo, 1000); +setTimeout(updateAdminInfo, 500); diff --git a/js/config.js b/js/config.js index 0df1115..1990377 100644 --- a/js/config.js +++ b/js/config.js @@ -6,7 +6,7 @@ var CONFIG = { // Initial values INITIAL_BLACK_HOLE_MASS: 42, // in solar masses - INITIAL_BLACK_HOLE_RADIUS: 42, + INITIAL_BLACK_HOLE_RADIUS: 21, // Performance LOGIC_INTERVAL: 10, @@ -20,6 +20,7 @@ var CONFIG = { BASE_COMET_SPAWN_INTERVAL: 120000, // 2 minutes BASE_PLANET_SPAWN_INTERVAL: 3600000, // 1 hour BASE_GIANT_SPAWN_INTERVAL: 21600000, // 6 hours + ASE_MTYPE_SPAWN_INTERVAL: 86400000, // 1 day // Rate tracking windows (in milliseconds) RATE_WINDOWS: { @@ -29,15 +30,24 @@ var CONFIG = { }, // Upgrade costs (in kg) - ASTEROID_BASE_COST: 1e3, - COMET_BASE_COST: 1e4, - PLANET_BASE_COST: 1e5, - GIANT_BASE_COST: 1e6, + ASTEROID_BASE_COST: 1e5, + COMET_BASE_COST: 1e7, + PLANET_BASE_COST: 1e24, + GIANT_BASE_COST: 1e27, + MTYPE_BASE_COST: 1e28, // Upgrade scaling - UPGRADE_COST_MULTIPLIER: 1.5, UPGRADE_COST_MULTIPLIER_ASTER: 1.25, - UPGRADE_BONUS_PER_LEVEL: 0.1, // 10% per level + UPGRADE_COST_MULTIPLIER_COMET: 1.5, + UPGRADE_COST_MULTIPLIER_PLANT: 1.75, + UPGRADE_COST_MULTIPLIER_GIANT: 2, + UPGRADE_COST_MULTIPLIER_MTYPE: 2.1, + + UPGRADE_BONUS_PER_LEVEL_ASTER: 0.1, + UPGRADE_BONUS_PER_LEVEL_COMET: 0.1, + UPGRADE_BONUS_PER_LEVEL_PLANT: 0.05, + UPGRADE_BONUS_PER_LEVEL_GIANT: 0.03, + UPGRADE_BONUS_PER_LEVEL_MTYPE: 0.03, // Asteroid spawn patterns ASTEROID_SPAWN_PATTERNS: { @@ -46,12 +56,13 @@ var CONFIG = { }, ASTEROID_MASS_RANGES: { - small: [1e3, 5e3], // 1-5 t - medium: [5e3, 5e4], // 5-50 t - large: [5e4, 5e5], // 50-500 t - comet: [1e6, 5e7], // 1000-50000 t - planet: [1e12, 1e13], // Moons / small planets - giant: [5e13, 5e14] // Gas giants / huge bodies + small: [1e3, 1e4], // 1-10 t + medium: [1e4, 1e5], // 10-100 t + large: [1e5, 1e6], // 100-1000 t + comet: [1e7, 1e8], // 10000-100000 t + planet: [1e22, 1e25], // Moons & Planets + giant: [1e26, 1e28], // Gas & Ice Giants + mtype: [1.589e29, 8.95e29] // M-Type: 0.08-0.45 M☉ }, // Planet color schemes @@ -81,6 +92,22 @@ var CONFIG = { '#d0e0e3', '#f4cccc' ], + // M-Type dwarf star color schemes + MTYPE_COLORS: [ + { light: '#e8734a', mid: '#c4522e', dark: '#8b3a1f' }, // Orange-red + { light: '#d4602a', mid: '#a84520', dark: '#7a3015' }, // Deep orange + { light: '#c45a35', mid: '#9e4428', dark: '#6b2e1a' }, // Burnt orange + { light: '#b8533d', mid: '#934030', dark: '#652a1e' }, // Brownish red + { light: '#a84e3a', mid: '#854030', dark: '#5c2c1f' } // Dark brown-red + ], + + // M-Type spot colors (dark brownish spots) + MTYPE_SPOT_COLORS: [ + 'rgba(60, 35, 20, 0.6)', + 'rgba(50, 30, 18, 0.5)', + 'rgba(70, 40, 22, 0.55)', + 'rgba(45, 28, 15, 0.5)' + ], // Star color distribution STAR_COLORS: [ diff --git a/js/entities.js b/js/entities.js index 5b3359a..a0c8033 100644 --- a/js/entities.js +++ b/js/entities.js @@ -68,9 +68,39 @@ function Asteroid(type, blackHole, canvas) { Asteroid.prototype.initializeTypeSpecificProperties = function() { // Visual size (unchanged) if (this.type === 'comet') { - this.size = 5 + Math.random() * 3; + this.size = 3 + Math.random() * 3; + } else if (this.type === 'mtype') { + this.size = 15 + Math.random() * 3; + this.orbitSpeed *= 0.2; + this.decayRate *= 0.3; + this.planetColors = CONFIG.MTYPE_COLORS[Math.floor(Math.random() * CONFIG.MTYPE_COLORS.length)]; + + // Generate spots (dark brownish surface features) + this.spots = []; + var spotCount = 0 + Math.floor(Math.random() * 9); // 0-9 spots + for (var i = 0; i < spotCount; i++) { + var angle = Math.random() * Math.PI * 2; + var dist = Math.random() * this.size * 0.7; + this.spots.push({ + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + radius: 1 + Math.random() * (this.size * 0.3), + color: CONFIG.MTYPE_SPOT_COLORS[Math.floor(Math.random() * CONFIG.MTYPE_SPOT_COLORS.length)] + }); + } + // Generate sparkles + this.sparkles = []; + for (var s = 0; s < 20; s++) { // more sparkles if you want + var angle = Math.random() * Math.PI * 2; + var radius = Math.random() * this.size * 0.9; + this.sparkles.push({ + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + phase: Math.random() * Math.PI * 2 // random phase for variety + }); + } } else if (this.type === 'giant') { - this.size = 15 + Math.random() * 10; + this.size = 10 + Math.random() * 5; this.orbitSpeed *= 0.3; this.decayRate *= 0.4; this.planetColors = CONFIG.GIANT_COLORS[Math.floor(Math.random() * CONFIG.GIANT_COLORS.length)]; @@ -86,16 +116,16 @@ Asteroid.prototype.initializeTypeSpecificProperties = function() { }); } } else if (this.type === 'planet') { - this.size = 10 + Math.random() * 5; + this.size = 6 + Math.random() * 4; this.orbitSpeed *= 0.5; this.decayRate *= 0.6; this.planetColors = CONFIG.PLANET_COLORS[Math.floor(Math.random() * CONFIG.PLANET_COLORS.length)]; } else if (this.type === 'large') { - this.size = 5 + Math.random() * 2; + this.size = 3 + Math.random() * 1; } else if (this.type === 'medium') { - this.size = 3 + Math.random() * 2; + this.size = 2 + Math.random() * 1; } else { - this.size = 1 + Math.random() * 2; + this.size = 1 + Math.random() * 1; } // Physical mass (kg) @@ -114,6 +144,8 @@ Asteroid.prototype.update = function() { Asteroid.prototype.draw = function(ctx) { if (this.type === 'comet') { this.drawComet(ctx); + } else if (this.type === 'mtype') { + this.drawMType(ctx, performance.now() * 0.002); } else if (this.type === 'giant') { // Initialize per-giant tilt if not already done if (this._ringTiltBase === undefined) { @@ -175,68 +207,6 @@ Asteroid.prototype.draw = function(ctx) { } }; - -//Asteroid.prototype.draw = function(ctx) { -// if (this.type === 'comet') { -// this.drawComet(ctx); -// } else if (this.type === 'giant') { -// // Draw the giant at its absolute position -// this.drawGiant(ctx); -// -// // Draw rings if present -// if (this.rings && this.rings.length > 0) { -// this.rings.forEach((ring, index) => { -// ctx.save(); -// -// // Move origin to giant's center -// ctx.translate(this.x, this.y); -// -// // Independent tilt + oscillation per ring -// if (ring._tiltBase === undefined) { -// ring._tiltBase = (Math.random() - 0.5) * 0.4; // ±0.2 rad -// ring._oscillationSpeed = 0.001 + Math.random() * 0.002; -// } -// const tilt = ring._tiltBase + Math.sin(Date.now() * ring._oscillationSpeed) * 0.05; -// -// ctx.rotate(tilt); -// -// ctx.strokeStyle = ring.color; -// ctx.lineWidth = ring.thickness; -// ctx.beginPath(); -// ctx.arc(0, 0, this.size + ring.radiusOffset, 0, Math.PI * 2); -// ctx.stroke(); -// -// ctx.restore(); -// }); -// } -// } else if (this.type === 'planet') { -// this.drawPlanet(ctx); -// } else { -// this.drawAsteroid(ctx); -// } -//}; - -//Asteroid.prototype.draw = function(ctx) { -// if (this.type === 'comet') { -// this.drawComet(ctx); -// } else if (this.type === 'giant') { -// this.drawGiant(ctx); -// if (this.rings && this.rings.length > 0) { -// this.rings.forEach(function(ring) { -// ctx.strokeStyle = ring.color; -// ctx.lineWidth = ring.thickness; -// ctx.beginPath(); -// ctx.arc(0, 0, this.size + ring.radiusOffset, 0, Math.PI * 2); -// ctx.stroke(); -// }, this); -// } -// } else if (this.type === 'planet') { -// this.drawPlanet(ctx); -// } else { -// this.drawAsteroid(ctx); -// } -//}; - Asteroid.prototype.drawComet = function(ctx) { var orbitTangentAngle = this.angle + Math.PI / 2; var tailLength = this.size * 5; @@ -272,6 +242,112 @@ Asteroid.prototype.drawComet = function(ctx) { ctx.restore(); }; +Asteroid.prototype.drawMType = function(ctx, time) { + var massRange = CONFIG.ASTEROID_MASS_RANGES.mtype; + var massNormalized = (this.massKg - massRange[0]) / (massRange[1] - massRange[0]); // 0-1 + + // Pulsing multiplier for glow + var pulse = 0.2 * Math.sin(time * 2 + this.massKg) + 1; + + // Glow + var glowIntensity = (0.5 + massNormalized * 0.8) * pulse; + var glowSize = this.size * (1.5 + massNormalized * 0.8) * pulse; + + ctx.save(); + ctx.translate(this.x, this.y); + + // Outer glow (soft) + var outerGlow = ctx.createRadialGradient(0, 0, this.size * 0.5, 0, 0, glowSize); + outerGlow.addColorStop(0, 'rgba(255, 200, 150,' + (glowIntensity * 0.4) + ')'); + outerGlow.addColorStop(0.5, 'rgba(200, 120, 80,' + (glowIntensity * 0.2) + ')'); + outerGlow.addColorStop(1, 'rgba(140, 80, 50, 0)'); + ctx.fillStyle = outerGlow; + ctx.beginPath(); + ctx.arc(0, 0, glowSize, 0, Math.PI * 2); + ctx.fill(); + + // Main body + var bodyGradient = ctx.createRadialGradient( + -this.size * 0.1, -this.size * 0.1, 0, + 0, 0, this.size + ); + bodyGradient.addColorStop(0, this.planetColors.light); + bodyGradient.addColorStop(0.5, this.planetColors.mid); + bodyGradient.addColorStop(1, this.planetColors.dark); + ctx.fillStyle = bodyGradient; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fill(); + + // Gritty surface texture (tiny semi-random dots) + for (var i = 0; i < this.size * 4; i++) { + ctx.fillStyle = 'rgba(0,0,0,' + (Math.random() * 0.05) + ')'; + var rx = (Math.random() - 0.5) * this.size * 2; + var ry = (Math.random() - 0.5) * this.size * 2; + if (rx*rx + ry*ry <= this.size*this.size) ctx.fillRect(rx, ry, 1, 1); + } + + // Swirling bands (subtle, irregular) + ctx.strokeStyle = 'rgba(0,0,0,0.06)'; + ctx.lineWidth = 1; + var amplitude = this.size * 0.06; + var frequency = 0.25; + for (var band = -this.size * 0.7; band < this.size * 0.7; band += this.size * 0.15) { + ctx.beginPath(); + for (var x = -this.size; x <= this.size; x += 1) { + var y = band + Math.sin(x * frequency + band + time) * amplitude + + (Math.random()-0.5) * 1.5; // gritty randomness + if (x === -this.size) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + // Spots + for (var i = 0; i < this.spots.length; i++) { + var spot = this.spots[i]; + ctx.save(); + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.clip(); + + ctx.fillStyle = spot.color; + ctx.beginPath(); + ctx.arc(spot.x, spot.y, spot.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + // Inner core glow + var coreGlow = ctx.createRadialGradient(0, 0, 0, 0, 0, this.size * 0.5 * pulse); + coreGlow.addColorStop(0, 'rgba(255, 230, 160,' + (glowIntensity * 0.5) + ')'); + coreGlow.addColorStop(1, 'rgba(255, 150, 80, 0)'); + ctx.fillStyle = coreGlow; + ctx.beginPath(); + ctx.arc(0, 0, this.size * 0.5 * pulse, 0, Math.PI * 2); + ctx.fill(); + + // Subtle edge glow instead of hard stroke + var edgeGlow = ctx.createRadialGradient(0, 0, this.size * 0.9, 0, 0, this.size); + edgeGlow.addColorStop(0, 'rgba(255,200,120,0.15)'); + edgeGlow.addColorStop(1, 'rgba(255,200,120,0)'); + ctx.fillStyle = edgeGlow; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fill(); + + // Sparkles (tiny random twinkles) + for (var i = 0; i < this.sparkles.length; i++) { + var s = this.sparkles[i]; + var sparkleFrequency = 0.1; + var sparkleIntensity = 0.4 + 0.4 * Math.sin(time * sparkleFrequency + s.phase); + ctx.fillStyle = 'rgba(255,255,200,' + sparkleIntensity + ')'; + ctx.fillRect(s.x, s.y, 1.5, 1.5); + } + + ctx.restore(); +}; + Asteroid.prototype.drawGiant = function(ctx) { ctx.save(); ctx.translate(this.x, this.y); @@ -353,16 +429,59 @@ function BlackHole(x, y, radius) { this.radius = radius; this.pulse = 0; this.pulseColor = '#000000'; -} +}; -BlackHole.prototype.draw = function(ctx) { +BlackHole.prototype.draw = function(ctx, consumptionRate) { + // Calculate jet intensity based on consumption rate + var jetMin = 1e7; // Start triggering at 10 Mt/s (Comets) + var jetMax = 1e22; // max intensity + var minVisible = 0.1; // 10% intensity floor + + var targetIntensity = 0; + + if (consumptionRate && consumptionRate > jetMin) { + var logMin = 10; // log10(1e10) + var logMax = 22; // log10(1e22) + var logRate = Math.log10(consumptionRate); + + // Log-scaled 0..1 + targetIntensity = Math.max(0, Math.min( + (logRate - logMin) / (logMax - logMin), + 1 + )); + + // Perceptual tweaks + targetIntensity = Math.pow(targetIntensity, 0.35); // boost low end + targetIntensity = minVisible + (1 - minVisible) * targetIntensity; + } + + // Smooth transition for jet intensity + if (!this.jetIntensity) this.jetIntensity = 0; + + var transitionSpeed = 0.05; // Lower = slower fade (0.01 = very slow, 0.05 = fast) + + if (targetIntensity > this.jetIntensity) { + // Fade in + this.jetIntensity = Math.min(this.jetIntensity + transitionSpeed, targetIntensity); + } else if (targetIntensity < this.jetIntensity) { + // Fade out + this.jetIntensity = Math.max(this.jetIntensity - transitionSpeed, targetIntensity); + } + + // Draw jets first (behind the black hole) + if (this.jetIntensity > 0) { + this.drawJets(ctx, this.jetIntensity); + } + + // Existing black hole drawing code if (this.pulse > 0) { this.pulse -= 0.02; if (this.pulse < 0) this.pulse = 0; } if (this.pulse === 0) { - this.pulseColor = '#000000'; + this.pulseColor = '#000000'; } + ctx.fillStyle = '#000000'; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); @@ -443,27 +562,86 @@ BlackHole.prototype.blendColors = function(c1, c2, weight) { return '#' + d2h(r) + d2h(g) + d2h(b); }; +BlackHole.prototype.drawJets = function(ctx, intensity) { + // Jet configuration (easily adjustable) + var config = { + color: 'rgba(100, 150, 255, 0.8)', // Blue-white jet color + coreColor: 'rgba(200, 220, 255, 0.9)', // Bright core + length: this.radius * 8, // Jet length (8x black hole radius) + width: this.radius * 0.2, // Jet width at base + coreWidth: this.radius * 0.05, // Bright core width + particleCount: 15, // Number of particles per jet + particleSize: 2, // Size of individual particles + rotation: 0, // Rotation angle (for spinning jets) + flickerSpeed: 0.1 // How fast jets flicker + }; -//BlackHole.prototype.consumeAsteroid = function(asteroid) { -// // Calculate pulse contribution based on mass -// // Using log scale: small asteroids (1e3 kg) give ~0.1, giants (5e14 kg) give ~2.0 -// var normalized = (Math.log10(asteroid.massKg) - 3) / 6; // Range: 0 to ~2 -// var pulseContribution = Math.max(0.1, normalized); // Minimum 0.05 for visibility -// -// // Add to existing pulse (cumulative effect) -// this.pulse = Math.min(this.pulse + pulseContribution, 9); // Cap at 9 for very large accumulations -// -// // Update color - blend with existing color if already glowing -// if (asteroid.planetColors) { -// this.pulseColor = asteroid.planetColors.mid; -// } else if (asteroid.type === 'comet') { -// this.pulseColor = '#5a7a8a'; -// } else if (asteroid.size === 'large') { -// this.pulseColor = '#ffffff'; -// } else if (asteroid.size === 'medium') { -// this.pulseColor = '#888888'; -// } else { -// this.pulseColor = '#444444'; -// } -//}; + // Intensity affects opacity and size (0-1 scale) + var alpha = Math.min(intensity, 1); + var sizeMultiplier = 0.5 + (intensity * 0.5); // 50% to 100% size based on intensity + // Draw both jets (top and bottom) + for (var direction = -1; direction <= 1; direction += 2) { + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(config.rotation); + + // Outer jet cone (gradient) + var jetGradient = ctx.createLinearGradient( + 0, 0, + 0, direction * config.length * sizeMultiplier + ); + + var jetColor = config.color.replace(/[\d.]+\)/, (alpha * 0.6) + ')'); + jetGradient.addColorStop(0, jetColor); + jetGradient.addColorStop(0.3, jetColor.replace(/[\d.]+\)/, (alpha * 0.4) + ')')); + jetGradient.addColorStop(1, 'rgba(100, 150, 255, 0)'); + + ctx.fillStyle = jetGradient; + ctx.beginPath(); + ctx.moveTo(-config.width * sizeMultiplier, 0); + ctx.lineTo(0, direction * config.length * sizeMultiplier); + ctx.lineTo(config.width * sizeMultiplier, 0); + ctx.closePath(); + ctx.fill(); + + // Inner bright core + var coreGradient = ctx.createLinearGradient( + 0, 0, + 0, direction * config.length * sizeMultiplier * 0.7 + ); + + var coreColor = config.coreColor.replace(/[\d.]+\)/, alpha + ')'); + coreGradient.addColorStop(0, coreColor); + coreGradient.addColorStop(0.5, coreColor.replace(/[\d.]+\)/, (alpha * 0.5) + ')')); + coreGradient.addColorStop(1, 'rgba(200, 220, 255, 0)'); + + ctx.fillStyle = coreGradient; + ctx.beginPath(); + ctx.moveTo(-config.coreWidth * sizeMultiplier, 0); + ctx.lineTo(0, direction * config.length * sizeMultiplier * 0.7); + ctx.lineTo(config.coreWidth * sizeMultiplier, 0); + ctx.closePath(); + ctx.fill(); + + // Particles along the jet (for dynamic effect) + ctx.fillStyle = config.coreColor.replace(/[\d.]+\)/, alpha + ')'); + for (var i = 0; i < config.particleCount; i++) { + var particlePos = (i / config.particleCount) * config.length * sizeMultiplier; + var particleOffset = (Math.sin(Date.now() * 0.01 + i) * config.width * 0.3) * sizeMultiplier; + var particleSize = config.particleSize * (1 - i / config.particleCount); // Smaller as they go out + + ctx.beginPath(); + ctx.arc( + particleOffset, + direction * particlePos, + particleSize * alpha, + 0, + Math.PI * 2 + ); + ctx.fill(); + } + + ctx.restore(); + } +}; diff --git a/js/game.js b/js/game.js index ebdbabd..b57d865 100644 --- a/js/game.js +++ b/js/game.js @@ -16,21 +16,31 @@ var Game = (function() { planetUpgradeCost: CONFIG.PLANET_BASE_COST, giantUpgradeLevel: 0, giantUpgradeCost: CONFIG.GIANT_BASE_COST, + mtypeUpgradeLevel: 0, + mtypeUpgradeCost: CONFIG.MTYPE_BASE_COST, currentAsteroidSpawnInterval: CONFIG.BASE_ASTEROID_SPAWN_INTERVAL, currentCometSpawnInterval: CONFIG.BASE_COMET_SPAWN_INTERVAL, currentPlanetSpawnInterval: CONFIG.BASE_PLANET_SPAWN_INTERVAL, currentGiantSpawnInterval: CONFIG.BASE_GIANT_SPAWN_INTERVAL, + currentMtypeSpawnInterval: CONFIG.BASE_MTYPE_SPAWN_INTERVAL, asteroidSpawnCount: 0, lastAsteroidSpawn: Date.now(), lastCometSpawn: Date.now(), - lastPlanetSpawn: Date.now() - ((0 + Math.random() * 30) * 60 * 1000), - lastGiantSpawn: Date.now() - ((60 + Math.random() * 240) * 60 * 1000), + lastPlanetSpawn: Date.now(), + lastGiantSpawn: Date.now(), + lastMtypeSpawn: Date.now(), sM: 0, sT: Date.now(), mM: 0, mT: Date.now(), lM: 0, lT: Date.now(), rateShort: 0, // 1 second average rateMedium: 0, // 1 hour average - rateLong: 0 // 1 day average + rateLong: 0, // 1 day average + isReady: false, + tabHiddenAt: null, + cometUnlocked: false, + planetUnlocked: false, + giantUnlocked: false, + mtypeUnlocked: false }; var blackHole; @@ -41,7 +51,11 @@ var Game = (function() { var needsRender = true; var currentTime = Date.now(); var lastLogicUpdate = Date.now(); - + + // Expose to admin interface + window.gameBlackHole = null; + window.gameAsteroids = asteroids; + function resizeCanvas() { // Set canvas to actual viewport size canvas.width = window.innerWidth @@ -57,9 +71,14 @@ var Game = (function() { CONFIG.INITIAL_BLACK_HOLE_RADIUS ); + window.gameBlackHole = blackHole; + for (var i = 0; i < CONFIG.STAR_COUNT; i++) { stars.push(new Star(canvas)); } + setInterval(function() { + UI.updateActiveObjects(); + }, 1000); UI.init(); @@ -67,9 +86,11 @@ var Game = (function() { asteroid: handleAsteroidUpgrade, comet: handleCometUpgrade, planet: handlePlanetUpgrade, - giant: handleGiantUpgrade + giant: handleGiantUpgrade, + mtype: handleMtypeUpgrade }); + Server.init() loadGameState(); window.addEventListener('resize', function() { @@ -81,7 +102,6 @@ var Game = (function() { }); }); - // ADD THIS BLOCK - Prevent zoom on desktop window.addEventListener('wheel', function(e) { if (e.ctrlKey) { e.preventDefault(); @@ -96,141 +116,152 @@ var Game = (function() { animate(currentTime); UI.update(state, CONFIG); - Server.init() // Save when page becomes hidden (tab switch, minimize, close) - document.addEventListener('visibilitychange', function() { - if (document.hidden) { - Storage.saveGame(state); - Server.checkpoint(state); - } else if (!document.hidden) { - loadGameState(); - } - }); - - // Also save on beforeunload as backup + document.addEventListener('visibilitychange', function() { + if (document.hidden) { + state.tabHiddenAt = Date.now(); // NEW: Record when hidden + Server.checkpoint(state); + } else if (!document.hidden) { + loadGameState(); + Server.checkpoint(state); + } + }); + + // Save when page is about to unload (close tab, navigate away) window.addEventListener('beforeunload', function() { - Storage.saveGame(state); + state.tabHiddenAt = Date.now(); + Server.checkpoint(state); }); - - // Periodic auto-save every 30 seconds as final safety net - setInterval(function() { - Storage.saveGame(state); - }, 15000); - } - - function loadGameState() { - var savedState = Storage.loadGame(); - if (savedState) { - state.blackHoleTotalMass = savedState.blackHoleTotalMass; - state.totalMassConsumedEver = savedState.totalMassConsumedEver; - state.totalMassConsumed = savedState.totalMassConsumed; - state.asteroidUpgradeLevel = savedState.asteroidUpgradeLevel; - state.asteroidUpgradeCost = savedState.asteroidUpgradeCost; - state.cometUpgradeLevel = savedState.cometUpgradeLevel; - state.cometUpgradeCost = savedState.cometUpgradeCost; - state.planetUpgradeLevel = savedState.planetUpgradeLevel; - state.planetUpgradeCost = savedState.planetUpgradeCost; - state.giantUpgradeLevel = savedState.giantUpgradeLevel; - state.giantUpgradeCost = savedState.giantUpgradeCost; - state.asteroidSpawnCount = savedState.asteroidSpawnCount; - - // Load spawn timers if they exist, otherwise generate from save timestamp - state.lastAsteroidSpawn = savedState.lastAsteroidSpawn || Date.now(); - state.lastCometSpawn = savedState.lastCometSpawn || Date.now(); - - // For old saves without spawn timers, use savedAt timestamp to generate consistent times - if (savedState.lastPlanetSpawn) { - state.lastPlanetSpawn = savedState.lastPlanetSpawn; - } else { - // Use savedAt to create a consistent offset (not random) - var planetOffset = (savedState.savedAt % 30) * 60 * 1000; // 0-30 minutes - state.lastPlanetSpawn = Date.now() - planetOffset; - } - - if (savedState.lastGiantSpawn) { - state.lastGiantSpawn = savedState.lastGiantSpawn; - } else { - // Use savedAt to create a consistent offset (not random) - var giantOffset = (60 + (savedState.savedAt % 240)) * 60 * 1000; // 60-300 minutes - state.lastGiantSpawn = Date.now() - giantOffset; - } - // Load consumption rates - state.sM = savedState.sM || 0; - state.mM = savedState.mM || 0; - state.lM = savedState.lM || 0; - - var now = Date.now(); - - state.sT = savedState.sT || (now - CONFIG.RATE_WINDOWS.SHORT); - state.mT = savedState.mT || (now - CONFIG.RATE_WINDOWS.MEDIUM); - state.lT = savedState.lT || (now - CONFIG.RATE_WINDOWS.LONG); - - state.rateShort = savedState.rateShort || 0; - state.rateMedium = savedState.rateMedium || 0; - state.rateLong = savedState.rateLong || 0; - - // Calculate offline progression - var now = Date.now(); - var offlineTime = now - savedState.savedAt; // milliseconds offline - - if (offlineTime > 1000) { - // Pick the longest non-zero rate - var shortRate = state.sM / ((now - state.sT) / 1000); - var mediumRate = state.mM / ((now - state.mT) / 1000); - var longRate = state.lM / ((now - state.lT) / 1000); - var rateToUse = longRate || mediumRate || shortRate || 0; - - if (rateToUse > 0) { - var offlineMass = rateToUse * (offlineTime / 1000); - - // Update totals - state.blackHoleTotalMass += offlineMass; - state.totalMassConsumedEver += offlineMass; - state.totalMassConsumed += offlineMass; - - // Add to rolling windows so rates are meaningful - state.sM += offlineMass; - state.mM += offlineMass; - state.lM += offlineMass; - - - // Optional notification - showOfflineNotification( - formatOfflineTime(offlineTime), - UI.formatMass(offlineMass), - 'offline' - ); - } - } - - updateSpawnIntervals(); - - // Restore black hole size - var solarMasses = state.blackHoleTotalMass / CONFIG.SOLAR_MASS_KG; - blackHole.radius = CONFIG.INITIAL_BLACK_HOLE_RADIUS + (solarMasses - CONFIG.INITIAL_BLACK_HOLE_MASS); - - // Save state - Storage.saveGame(state); - - } else { - // No save exists - initialize spawn times with random values - state.lastAsteroidSpawn = Date.now(); - state.lastCometSpawn = Date.now(); - state.lastPlanetSpawn = Date.now() - ((0 + Math.random() * 30) * 60 * 1000); - state.lastGiantSpawn = Date.now() - ((60 + Math.random() * 240) * 60 * 1000); + // Save and update scores when window gains focus + window.addEventListener('focus', function() { + if (window.Server && window.Game && Game.getState) { + Server.checkpoint(Game.getState()); } - } + }); + } + + function loadGameState() { + Server.loadGameState().then(function(savedState) { + if (savedState) { + state.blackHoleTotalMass = savedState.blackHoleTotalMass; + state.totalMassConsumedEver = savedState.totalMassConsumedEver; + state.totalMassConsumed = savedState.totalMassConsumed; + state.asteroidUpgradeLevel = savedState.asteroidUpgradeLevel; + state.asteroidUpgradeCost = savedState.asteroidUpgradeCost; + state.cometUpgradeLevel = savedState.cometUpgradeLevel; + state.cometUpgradeCost = savedState.cometUpgradeCost; + state.planetUpgradeLevel = savedState.planetUpgradeLevel; + state.planetUpgradeCost = savedState.planetUpgradeCost; + state.giantUpgradeLevel = savedState.giantUpgradeLevel; + state.giantUpgradeCost = savedState.giantUpgradeCost; + state.mtypeUpgradeLevel = savedState.mtypeUpgradeLevel || 0; + state.mtypeUpgradeCost = savedState.mtypeUpgradeCost || CONFIG.MTYPE_BASE_COST; + state.asteroidSpawnCount = savedState.asteroidSpawnCount; + + // Load unlock states (with backward compatibility) + state.cometUnlocked = savedState.cometUnlocked !== undefined ? savedState.cometUnlocked : (savedState.asteroidUpgradeLevel >= 20); + state.planetUnlocked = savedState.planetUnlocked !== undefined ? savedState.planetUnlocked : (savedState.cometUpgradeLevel >= 15); + state.giantUnlocked = savedState.giantUnlocked !== undefined ? savedState.giantUnlocked : (savedState.planetUpgradeLevel >= 10); + state.mtypeUnlocked = savedState.mtypeUnlocked !== undefined ? savedState.mtypeUnlocked : (savedState.giantUpgradeLevel >= 5); + + state.lastAsteroidSpawn = savedState.lastAsteroidSpawn || Date.now(); + state.lastCometSpawn = savedState.lastCometSpawn || Date.now(); + state.lastPlanetSpawn = savedState.lastPlanetSpawn || Date.now(); + state.lastGiantSpawn = savedState.lastGiantSpawn || Date.now(); + state.lastMtypeSpawn = savedState.lastMtypeSpawn || Date.now(); + + // Load consumption rates + state.sM = savedState.sM || 0; + state.mM = savedState.mM || 0; + state.lM = savedState.lM || 0; + + var now = Date.now(); + + state.sT = savedState.sT || now; + state.mT = savedState.mT || now; + state.lT = savedState.lT || now; + + state.rateShort = savedState.rateShort || 0; + state.rateShortTrend = savedState.rateShortTrend || 'same'; + state.rateMedium = savedState.rateMedium || 0; + state.rateLong = savedState.rateLong || 0; + + state.tabHiddenAt = savedState.tabHiddenAt || null; + + // Calculate offline progression ONLY if tab was actually hidden + var offlineTime = 0; + + if (savedState.tabHiddenAt) { + // Tab was hidden - calculate time between hiding and now + 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' + ); + } + } + + // Clear the hidden timestamp after processing + state.tabHiddenAt = null; + + updateSpawnIntervals(); + + // Restore black hole size + blackHole.radius = 0.5 * (state.blackHoleTotalMass / CONFIG.SOLAR_MASS_KG) + + // Send checkpoint after loading + Server.checkpoint(state).then(function() { + state.isReady = true; // Mark as ready after checkpoint + }); + } else { + // No saved state - send initial checkpoint for new player + Server.checkpoint(state).then(function() { + state.isReady = true; // Mark as ready after checkpoint + }); + } + + UI.update(state, CONFIG); + }); + } + function updateSpawnIntervals() { state.currentAsteroidSpawnInterval = CONFIG.BASE_ASTEROID_SPAWN_INTERVAL / - (1 + state.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); + (1 + state.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_ASTER); state.currentCometSpawnInterval = CONFIG.BASE_COMET_SPAWN_INTERVAL / - (1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); + (1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_COMET); state.currentPlanetSpawnInterval = CONFIG.BASE_PLANET_SPAWN_INTERVAL / - (1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); + (1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_PLANT); state.currentGiantSpawnInterval = CONFIG.BASE_GIANT_SPAWN_INTERVAL / - (1 + state.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); + (1 + state.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GIANT); + state.currentMtypeSpawnInterval = CONFIG.BASE_MTYPE_SPAWN_INTERVAL / + (1 + state.mtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_MTYPE); } function handleAsteroidUpgrade() { @@ -240,9 +271,15 @@ var Game = (function() { state.asteroidUpgradeCost = Math.floor( CONFIG.ASTEROID_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_ASTER, state.asteroidUpgradeLevel) ); + if (state.asteroidUpgradeLevel >= 20 && !state.cometUnlocked) { + state.cometUnlocked = true; + showUnlockNotification('comet'); + asteroids.push(new Asteroid('comet', blackHole, canvas)); + state.lastCometSpawn = Date.now(); + } + updateSpawnIntervals(); UI.update(state, CONFIG); - Storage.saveGame(state); } } @@ -251,11 +288,16 @@ var Game = (function() { state.totalMassConsumed -= state.cometUpgradeCost; state.cometUpgradeLevel++; state.cometUpgradeCost = Math.floor( - CONFIG.COMET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, state.cometUpgradeLevel) + CONFIG.COMET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_COMET, state.cometUpgradeLevel) ); + if (state.cometUpgradeLevel >= 15 && !state.planetUnlocked) { + state.planetUnlocked = true; + showUnlockNotification('planet'); + asteroids.push(new Asteroid('planet', blackHole, canvas)); + state.lastPlanetSpawn = Date.now(); + } updateSpawnIntervals(); UI.update(state, CONFIG); - Storage.saveGame(state); } } @@ -264,11 +306,16 @@ var Game = (function() { state.totalMassConsumed -= state.planetUpgradeCost; state.planetUpgradeLevel++; state.planetUpgradeCost = Math.floor( - CONFIG.PLANET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, state.planetUpgradeLevel) + CONFIG.PLANET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_PLANT, state.planetUpgradeLevel) ); + if (state.planetUpgradeLevel >= 10 && !state.giantUnlocked) { + state.giantUnlocked = true; + showUnlockNotification('giant'); + asteroids.push(new Asteroid('giant', blackHole, canvas)); + state.lastGiantSpawn = Date.now(); + } updateSpawnIntervals(); UI.update(state, CONFIG); - Storage.saveGame(state); } } @@ -277,11 +324,31 @@ var Game = (function() { state.totalMassConsumed -= state.giantUpgradeCost; state.giantUpgradeLevel++; state.giantUpgradeCost = Math.floor( - CONFIG.GIANT_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, state.giantUpgradeLevel) + CONFIG.GIANT_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_GIANT, state.giantUpgradeLevel) + ); + + // Unlock M-Type at giant level 5 + if (state.giantUpgradeLevel >= 5 && !state.mtypeUnlocked) { + state.mtypeUnlocked = true; + state.lastMtypeSpawn = Date.now(); + showUnlockNotification('mtype'); + asteroids.push(new Asteroid('mtype', blackHole, canvas)); + } + + updateSpawnIntervals(); + UI.update(state, CONFIG); + } + } + + function handleMtypeUpgrade() { + if (state.totalMassConsumed >= state.mtypeUpgradeCost) { + state.totalMassConsumed -= state.mtypeUpgradeCost; + state.mtypeUpgradeLevel++; + state.mtypeUpgradeCost = Math.floor( + CONFIG.MTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_MTYPE, state.mtypeUpgradeLevel) ); updateSpawnIntervals(); UI.update(state, CONFIG); - Storage.saveGame(state); } } @@ -303,24 +370,34 @@ var Game = (function() { asteroids.push(new Asteroid(asteroidType, blackHole, canvas)); state.lastAsteroidSpawn = currentTime; } - - if (currentTime - state.lastCometSpawn > state.currentCometSpawnInterval) { - asteroids.push(new Asteroid('comet', blackHole, canvas)); - state.lastCometSpawn = currentTime; - //Storage.saveGame(state); + + if (state.cometUnlocked && state.cometUpgradeLevel >= 0) { + if (currentTime - state.lastCometSpawn > state.currentCometSpawnInterval) { + state.lastCometSpawn = currentTime; + asteroids.push(new Asteroid('comet', blackHole, canvas)); + } } - if (currentTime - state.lastPlanetSpawn > state.currentPlanetSpawnInterval) { - asteroids.push(new Asteroid('planet', blackHole, canvas)); - state.lastPlanetSpawn = currentTime; - //Storage.saveGame(state); + if (state.planetUnlocked && state.planetUpgradeLevel >= 0) { + if (currentTime - state.lastPlanetSpawn > state.currentPlanetSpawnInterval) { + state.lastPlanetSpawn = currentTime; + asteroids.push(new Asteroid('planet', blackHole, canvas)); + } } - if (currentTime - state.lastGiantSpawn > state.currentGiantSpawnInterval) { - asteroids.push(new Asteroid('giant', blackHole, canvas)); - state.lastGiantSpawn = currentTime; - //Storage.saveGame(state); + if (state.giantUnlocked && state.giantUpgradeLevel >= 0) { + if (currentTime - state.lastGiantSpawn > state.currentGiantSpawnInterval) { + state.lastGiantSpawn = currentTime; + asteroids.push(new Asteroid('giant', blackHole, canvas)); + } } + if (state.mtypeUnlocked) { + if (currentTime - state.lastMtypeSpawn > state.currentMtypeSpawnInterval) { + state.lastMtypeSpawn = currentTime; + asteroids.push(new Asteroid('mtype', blackHole, canvas)); + } + } + } function updateAsteroids() { @@ -364,7 +441,7 @@ var Game = (function() { asteroids[i].draw(ctx); } - blackHole.draw(ctx); + blackHole.draw(ctx, state.rateShort || 0); } function animate(currentTime) { @@ -406,7 +483,6 @@ var Game = (function() { }()); var Server = { - // baseURL removed; now all requests are relative playerId: null, init: function() { @@ -423,47 +499,81 @@ var Server = { checkpoint: async function(gameState) { try { - await fetch('/api/checkpoint', { // relative URL + // Add savedAt timestamp + gameState.savedAt = Date.now(); + + const response = await fetch('/api/checkpoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playerId: this.playerId, - name: Storage.getCookie('playerName') || 'Anonymous', - mass: gameState.blackHoleTotalMass, - playTime: gameState.totalPlayTime + (Date.now() - gameState.gameStartTime), - levels: { - total: gameState.asteroidUpgradeLevel + - gameState.cometUpgradeLevel + - gameState.planetUpgradeLevel + - gameState.giantUpgradeLevel - } + gameState: gameState }) }); + + return await response.json(); } catch (err) { console.error('Checkpoint failed', err); + return { success: false }; + } + }, + + loadGameState: async function() { + try { + const resp = await fetch(`/api/player/${encodeURIComponent(this.playerId)}/state`); + if (!resp.ok) return null; + return await resp.json(); + } catch (err) { + console.error('Failed to load game state', err); + return null; } }, getLeaderboard: async function(sortBy) { try { - const resp = await fetch(`/api/leaderboard?sort=${encodeURIComponent(sortBy)}&playerId=${encodeURIComponent(this.playerId)}`); // relative URL + const resp = await fetch(`/api/leaderboard?sort=${encodeURIComponent(sortBy)}&playerId=${encodeURIComponent(this.playerId)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); - return data; // should be array of { name, mass, holeAge } + return data; } catch (err) { console.error('Failed to fetch leaderboard', err); return []; } }, + deletePlayer: async function(playerId) { + try { + const resp = await fetch(`/api/player/${encodeURIComponent(playerId)}`, { + method: 'DELETE' + }); + return await resp.json(); + } catch (err) { + console.error('Failed to delete player', err); + return { success: false }; + } + }, + startCheckpoints: function() { - // Example: autosave every 15s + let lastCheckpointData = null; + setInterval(() => { - if (window.Game && Game.state) { - this.checkpoint(Game.state); + if (window.Game && Game.getState) { + const state = Game.getState(); + + // Only checkpoint if significant changes + const currentData = JSON.stringify({ + mass: Math.floor(state.blackHoleTotalMass / 1e27), // Round to reduce noise + levels: state.asteroidUpgradeLevel + state.cometUpgradeLevel + + state.planetUpgradeLevel + state.giantUpgradeLevel + }); + + if (currentData !== lastCheckpointData) { + this.checkpoint(state); + lastCheckpointData = currentData; + } } - }, 15000); - } + }, 15000); // Check every 15s, but only send if changed + }, }; // Start the game when the page loads diff --git a/js/helpers.js b/js/helpers.js index 3b78f38..2cbf23b 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -49,35 +49,66 @@ function saturateColor(r, g, b, saturationBoost) { function calculateConsumptionRates(state, CONFIG, consumedMassKg) { const now = Date.now(); - // Only update rates once per second - if (!state._lastRateUpdate) state._lastRateUpdate = 0; - if (now - state._lastRateUpdate < 1000) return; // skip if less than 1s - state._lastRateUpdate = now; - - function updateWindow(massKey, timeKey, prevRateKey) { - const start = state[timeKey] || now; - - // Add new mass - state[massKey] = (state[massKey] || 0) + consumedMassKg; - - const elapsed = now - start; - const rate = elapsed > 0 ? state[massKey] / (elapsed / 1000) : 0; - - // Compare to previous rate to get trend - if (state[prevRateKey] === undefined) state[prevRateKey] = rate; - state[prevRateKey + 'Trend'] = - rate > state[prevRateKey] ? 'up' : - rate < state[prevRateKey] ? 'down' : 'same'; - - // Save current rate for next tick - state[prevRateKey] = rate; - - return rate; + // 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 + state.sM += consumedMassKg; + state.mM += consumedMassKg; + state.lM += consumedMassKg; + + // Update per-second rate (with trend coloring) + 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.rateShortTrend = 'up'; + } else if (newRate < state.rateShort * 0.95) { + state.rateShortTrend = 'down'; + } else { + state.rateShortTrend = 'same'; + } + state.rateShort = newRate; + } + + // Reset short window + state.sM = 0; + state.sT = now; + } + + // Calculate hourly average (rolling 1-hour window) + 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) + 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; } - - state.rateShort = updateWindow("sM", "sT", "prevRateShort"); - state.rateMedium = updateWindow("mM", "mT", "prevRateMedium"); - state.rateLong = updateWindow("lM", "lT", "prevRateLong"); } function formatOfflineTime(ms) { @@ -97,29 +128,110 @@ function formatOfflineTime(ms) { } } -function showOfflineNotification(timeAway, massGained, rateName) { - var notification = document.createElement('div'); - notification.style.position = 'fixed'; - notification.style.top = '50%'; - notification.style.left = '50%'; - notification.style.transform = 'translate(-50%, -50%)'; - notification.style.background = 'rgba(20, 20, 30, 0.95)'; - notification.style.border = '2px solid rgba(161, 161, 161, 0.5)'; - notification.style.padding = '20px 30px'; - notification.style.borderRadius = '8px'; - notification.style.color = 'rgba(255, 255, 255, 0.9)'; - notification.style.fontSize = '12px'; - notification.style.zIndex = '10000'; - notification.style.textAlign = 'center'; - notification.style.lineHeight = '1.6'; +// Unified notification system +function showNotification(options) { + // Default options + var defaults = { + message: '', + type: 'info', // 'unlock', 'offline', 'error', 'success', 'info' + html: false // if true, message is HTML; if false, it's text + }; - notification.innerHTML = + // Merge options with defaults + var config = {}; + for (var key in defaults) { + config[key] = options[key] !== undefined ? options[key] : defaults[key]; + } + + // Create notification element + var notification = document.createElement('div'); + notification.className = 'notification ' + config.type; + + // Set content + if (config.html) { + notification.innerHTML = config.message; + } else { + notification.textContent = config.message; + } + + // Add to DOM + document.body.appendChild(notification); + + // Dismiss on canvas click + var canvas = document.getElementById('space'); + var dismissHandler = function(e) { + // Only dismiss if clicking canvas, not the notification + if (e.target === canvas) { + notification.remove(); + canvas.removeEventListener('click', dismissHandler); + } + }; + + // Small delay to prevent immediate dismissal if notification was triggered by a click + setTimeout(function() { + canvas.addEventListener('click', dismissHandler); + }, 100); + + return notification; +} + +// Helper functions for specific notification types +function showUnlockNotification(type) { + var messages = { + comet: 'Comets Unlocked', + planet: 'Planets Unlocked', + giant: 'Gas & Ice Giants Unlocked', + mtype: 'M-Type Stars Unlocked' + }; + + showNotification({ + message: messages[type] || 'New content unlocked!', + type: 'unlock', + duration: 4000 + }); +} + +function showOfflineNotification(timeAway, massGained) { + var message = '
Inactive for: ' + timeAway + '
' + '
Mass gained: ' + massGained + '
'; - document.body.appendChild(notification); - - document.getElementById('space').addEventListener('click', function() { - notification.remove(); + showNotification({ + message: message, + type: 'offline', + duration: 5000, + dismissable: true, + html: true }); } + +// Additional helper for errors (useful for debugging/user feedback) +function showErrorNotification(message) { + showNotification({ + message: message, + type: 'error', + duration: 5000, + dismissable: true + }); +} + +// Additional helper for success messages +function showSuccessNotification(message) { + showNotification({ + message: message, + type: 'success', + duration: 3000 + }); +} + +function formatTimeSince(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 (seconds < 60) return 'Just now'; + if (minutes < 60) return minutes + 'm ago'; + if (hours < 24) return hours + 'h ago'; + return days + 'd ago'; +} diff --git a/js/server.js b/js/server.js index 5eafe66..93dd471 100644 --- a/js/server.js +++ b/js/server.js @@ -7,8 +7,14 @@ app.use(cors()); app.use(express.json()); const LEADERBOARD_FILE = './leaderboard.json'; -const INACTIVE_DAYS = 30; // Completely remove from JSON after 30 days -const LEADERBOARD_VISIBLE_DAYS = 5; // Only show on leaderboard if active within 5 days +const INACTIVE_DAYS = 30; // Completely remove from JSON after 5 days +const LEADERBOARD_VISIBLE_DAYS = 3; // Only show on leaderboard if active within 1 day + +const BOT_USER_AGENTS = [ + 'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider', + 'yandexbot', 'facebookexternalhit', 'twitterbot', 'bot', 'crawler', + 'spider', 'archive', 'wget', 'curl', 'python-requests' +]; // Load leaderboard from file function loadLeaderboard() { @@ -32,7 +38,8 @@ function cleanupInactive(leaderboard) { const active = leaderboard.filter(player => player.lastSeen > thirtyDaysAgo); if (active.length < beforeCount) { - console.log(`Permanently removed ${beforeCount - active.length} players (>30 days inactive)`); + const timestamp = new Date().toLocaleString(); + console.log(`${timestamp} Permanently removed ${beforeCount - active.length} players (>30 days inactive)`); } return active; @@ -41,16 +48,40 @@ function cleanupInactive(leaderboard) { // Filter players visible on leaderboard (active within 5 days) function getVisiblePlayers(leaderboard) { const fiveDaysAgo = Date.now() - (LEADERBOARD_VISIBLE_DAYS * 24 * 60 * 60 * 1000); - const visible = leaderboard.filter(player => player.lastSeen > fiveDaysAgo); + const visible = leaderboard.filter(player => + player.lastSeen > fiveDaysAgo && + !player.id.startsWith('admin_') + ); return visible; } // Submit checkpoint app.post('/api/checkpoint', (req, res) => { - const { playerId, name, mass, playTime, levels } = req.body; + const userAgent = (req.headers['user-agent'] || '').toLowerCase(); + + // Reject known bots + if (BOT_USER_AGENTS.some(bot => userAgent.includes(bot))) { + return res.status(403).json({ error: 'Bot traffic not allowed' }); + } + + const { playerId, gameState } = req.body; + // Ignore players who haven't consumed any mass + if (gameState.totalMassConsumedEver === 0) { + return res.json({ success: true }); // Accept but don't save + } + + // Only save if they've upgraded at least once + const totalLevels = (gameState.asteroidUpgradeLevel || 0) + + (gameState.cometUpgradeLevel || 0) + + (gameState.planetUpgradeLevel || 0) + + (gameState.giantUpgradeLevel || 0); + + if (totalLevels === 0 && gameState.totalMassConsumedEver < 1e6) { + return res.json({ success: true }); // Accept but don't save + } // Basic validation - if (!playerId || mass < 0 || playTime < 0) { + if (!playerId || !gameState) { return res.status(400).json({ error: 'Invalid data' }); } @@ -64,35 +95,43 @@ app.post('/api/checkpoint', (req, res) => { // Find or create player let player = leaderboard.find(p => p.id === playerId); if (player) { - // Update existing player - player.mass = Math.max(player.mass, mass); - player.playTime = Math.max(player.playTime, playTime); - player.level = Math.max(player.level, levels?.total || 0); - player.name = name || player.name; + // Update existing player - store full game state + player.gameState = gameState; + player.mass = gameState.blackHoleTotalMass || 0; + player.level = (gameState.asteroidUpgradeLevel || 0) + + (gameState.cometUpgradeLevel || 0) + + (gameState.planetUpgradeLevel || 0) + + (gameState.giantUpgradeLevel || 0); player.lastSeen = now; - - // Calculate hole age (time since first seen) player.holeAge = now - player.firstSeen; + } else { // New player - leaderboard.push({ + const timestamp = new Date().toLocaleString(); + const newPlayer = { id: playerId, - name: name || 'Anonymous', - mass: mass, - playTime: playTime, - level: levels?.total || 0, + gameState: gameState, + mass: gameState.blackHoleTotalMass || 0, + level: (gameState.asteroidUpgradeLevel || 0) + + (gameState.cometUpgradeLevel || 0) + + (gameState.planetUpgradeLevel || 0) + + (gameState.giantUpgradeLevel || 0), firstSeen: now, lastSeen: now, holeAge: 0 - }); + }; + + console.log(`${timestamp} Created new player: `, newPlayer.id); + + leaderboard.push(newPlayer); } - // Sort by mass and keep top 100 (in JSON file) + // Sort by mass and keep top 100 leaderboard.sort((a, b) => b.mass - a.mass); leaderboard = leaderboard.slice(0, 100); saveLeaderboard(leaderboard); - + res.json({ success: true }); }); @@ -168,6 +207,20 @@ app.get('/api/player/:playerId', (req, res) => { res.json(player); }); +// Get player's full game state +app.get('/api/player/:playerId/state', (req, res) => { + const playerId = req.params.playerId; + let leaderboard = loadLeaderboard(); + + const player = leaderboard.find(p => p.id === playerId); + + if (!player || !player.gameState) { + return res.status(404).json({ error: 'Player not found' }); + } + + res.json(player.gameState); +}); + // Manual cleanup endpoint (optional - for maintenance) app.post('/api/cleanup', (req, res) => { let leaderboard = loadLeaderboard(); @@ -194,9 +247,35 @@ app.get('/api/stats', (req, res) => { }); }); +// Cleanup bot accounts function +function cleanupBotAccounts() { + let leaderboard = loadLeaderboard(); + const beforeCount = leaderboard.length; + + // Remove accounts with zero progress + leaderboard = leaderboard.filter(player => { + const hasProgress = player.gameState && + (player.gameState.totalMassConsumedEver > 0 || + player.level > 0); + return hasProgress; + }); + + if (leaderboard.length < beforeCount) { + console.log(`Cleaned up ${beforeCount - leaderboard.length} bot/inactive accounts`); + saveLeaderboard(leaderboard); + } +} + +// Run cleanup daily (24 hours) +setInterval(cleanupBotAccounts, 24 * 60 * 60 * 1000); + +// Run cleanup on startup too +cleanupBotAccounts(); + const PORT = process.env.PORT || 3000; app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - console.log(`Players hidden from leaderboard after ${LEADERBOARD_VISIBLE_DAYS} days inactive`); - console.log(`Players permanently removed after ${INACTIVE_DAYS} days inactive`); + const timestamp = new Date().toLocaleString(); + console.log(`${timestamp} Server running on port ${PORT}`); + console.log(`${timestamp} Players hidden from leaderboard after ${LEADERBOARD_VISIBLE_DAYS} days inactive`); + console.log(`${timestamp} Players permanently removed after ${INACTIVE_DAYS} days inactive`); }); diff --git a/js/storage.js b/js/storage.js index 2e807db..4f8b0cd 100644 --- a/js/storage.js +++ b/js/storage.js @@ -24,83 +24,24 @@ var Storage = { document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict'; }, - // Simple checksum: sum of char codes - checksum: function(str) { - var sum = 0; - for (var i = 0; i < str.length; i++) { - sum = (sum + str.charCodeAt(i)) % 65536; - } - return sum.toString(16).padStart(4, '0'); - }, - - saveGame: function(gameState) { - var saveData = { - blackHoleTotalMass: gameState.blackHoleTotalMass, - totalMassConsumedEver: gameState.totalMassConsumedEver, - totalMassConsumed: gameState.totalMassConsumed, - asteroidUpgradeLevel: gameState.asteroidUpgradeLevel, - asteroidUpgradeCost: gameState.asteroidUpgradeCost, - cometUpgradeLevel: gameState.cometUpgradeLevel, - cometUpgradeCost: gameState.cometUpgradeCost, - planetUpgradeLevel: gameState.planetUpgradeLevel, - planetUpgradeCost: gameState.planetUpgradeCost, - giantUpgradeLevel: gameState.giantUpgradeLevel, - giantUpgradeCost: gameState.giantUpgradeCost, - asteroidSpawnCount: gameState.asteroidSpawnCount, - lastAsteroidSpawn: gameState.lastAsteroidSpawn, - lastCometSpawn: gameState.lastCometSpawn, - lastPlanetSpawn: gameState.lastPlanetSpawn, - lastGiantSpawn: gameState.lastGiantSpawn, - sM: gameState.sM, - sT: gameState.sT, - mM: gameState.mM, - mT: gameState.mT, - lM: gameState.lM, - lT: gameState.lT, - rateShort: gameState.rateShort, - rateMedium: gameState.rateMedium, - rateLong: gameState.rateLong, - savedAt: Date.now() - }; - - var json = JSON.stringify(saveData); - var chksum = this.checksum(json); - var packaged = btoa(chksum + json); // prepend checksum, then base64 - this.setCookie('blackHoleSave', packaged, 365); - - var saveStatus = document.getElementById('save-status'); - saveStatus.textContent = 'Game saved successfully!'; - setTimeout(function() { saveStatus.textContent = ''; }, 3000); - }, - - loadGame: function() { - var savedData = this.getCookie('blackHoleSave'); - if (!savedData) return null; - - try { - // try base64 decode - var decoded = atob(savedData); - - // new format: first 4 chars = checksum - var chksum = decoded.slice(0, 4); - var json = decoded.slice(4); - - // verify checksum - if (this.checksum(json) !== chksum) { - console.warn('Save data integrity check failed.'); - return null; - } - - return JSON.parse(json); - } catch (e) { - // old save format (plain JSON) - try { return JSON.parse(savedData); } - catch { return null; } - } - }, - resetGame: function() { - this.deleteCookie('blackHoleSave'); - location.reload(); + if (confirm('Are you sure you want to reset? This will delete all your progress permanently.')) { + var playerId = this.getCookie('playerId'); + + if (playerId && window.Server) { + Server.deletePlayer(playerId).then(function() { + Storage.deleteCookie('playerId'); + + setTimeout(function() { + window.location.href = window.location.href.split('?')[0] + '?nocache=' + Date.now(); + }, 200); + }); + } else { + Storage.deleteCookie('playerId'); + setTimeout(function() { + window.location.reload(true); + }, 100); + } + } } }; diff --git a/js/ui.js b/js/ui.js index 9803ebb..73c14f5 100644 --- a/js/ui.js +++ b/js/ui.js @@ -13,10 +13,12 @@ var UI = { 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'), @@ -25,7 +27,8 @@ var UI = { leaderboardPanel: document.getElementById('leaderboardPanel'), leaderboardList: document.getElementById('leaderboardList'), sortMass: document.getElementById('sortMass'), - sortAge: document.getElementById('sortAge') + sortAge: document.getElementById('sortAge'), + massRate: document.getElementById('massRate') }; // Sorting buttons @@ -41,26 +44,34 @@ var UI = { const settingsMenu = self.elements.settingsMenu; const leaderboardToggle = self.elements.leaderboardToggle; const leaderboardPanel = self.elements.leaderboardPanel; - - // ---------- Settings Menu ---------- - if (gear && settingsMenu) { - // Toggle settings menu - gear.addEventListener('click', (e) => { - e.stopPropagation(); // prevent document click from immediately closing - settingsMenu.classList.toggle('open'); + 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 menu from closing it - settingsMenu.addEventListener('click', (e) => { + // 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')) { @@ -73,6 +84,27 @@ var UI = { 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', () => { @@ -81,9 +113,22 @@ var UI = { settingsMenu.classList.remove('open'); } - // Close leaderboard panel - if (leaderboardPanel) { - leaderboardPanel.classList.remove('show'); + // 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'); } }); }, @@ -93,6 +138,7 @@ var UI = { 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) { @@ -101,22 +147,13 @@ var UI = { '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) { - // Format rates - const rateShortText = this.formatMass(state.rateShort) + '/s'; - const rateMediumText = this.formatMass(state.rateMedium * 3600) + '/h'; - const rateLongText = this.formatMass(state.rateLong * 86400) + '/d'; - - // Use span with class based on trend - const rateText = ` - ${rateShortText}, - ${rateMediumText}, - ${rateLongText} - `; const bhMassText = this.formatMass(state.blackHoleTotalMass, { forceSolarMass: true }); const consumedText = this.formatMass(state.totalMassConsumedEver); @@ -130,17 +167,102 @@ var UI = { Total mass absorbed:
${consumedText} -
- Rate: ${rateText} + `; + }, + + 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); - this.updateCometUpgrade(gameState); - this.updatePlanetUpgrade(gameState); - this.updateGiantUpgrade(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; @@ -160,6 +282,36 @@ var UI = { ''; }, + 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); @@ -224,36 +376,49 @@ var UI = { this.elements.giantUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.giantUpgradeCost; }, - - formatMass: function(massKg, options = {}) { - // If forcing solar mass display (for black hole) - if (options.forceSolarMass) { - var solarMasses = massKg / CONFIG.SOLAR_MASS_KG; - return solarMasses.toFixed(3) + ' M☉'; - } - - // Traditional engineering/astronomical units - 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'; // Kilograms + + 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); @@ -276,9 +441,9 @@ var UI = { var hours = Math.floor((ms % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); if (days > 0) { - return days + 'd ' + hours + 'h old'; + return days + 'd ' + hours + 'h'; } else if (hours > 0) { - return hours + 'h old'; + return hours + 'h'; } else { return 'New'; } @@ -287,43 +452,87 @@ var UI = { openLeaderboard: async function(sortBy = 'mass') { const container = this.elements.leaderboardList; - container.innerHTML = '
Loading...
'; - + 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; } - - // Sort data server-side should already be sorted; else fallback: - data.sort((a,b) => sortBy==='mass'?b.mass-a.mass:b.holeAge-a.holeAge); - const localPlayerId = Server.playerId; // your local player ID - - // Build entries - let html = ''; - data.forEach((entry, i) => { - const rank = i + 1; - const medal = rank===1?'🥇':rank===2?'🥈':rank===3?'🥉':rank+'.'; - const isLocal = entry.id === localPlayerId; - - // Online indicator (green dot if active in last 15 min) - const onlineIndicator = entry.isOnline ? '' : ''; - const nameSuffix = isLocal ? '💗' : ''; - - // Use last 5 chars of playerId if available, otherwise fallback - const displayName = entry.id - ? entry.id.slice(-5) - : 'Anon'; - - html += `
- ${medal} - ${this.escapeHtml(displayName)}${onlineIndicator}${nameSuffix} - ${this.formatMass(entry.mass,{forceSolarMass:true})} - ${this.formatAge(entry.holeAge)} -
`; - }); - container.innerHTML = html; + + 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) { diff --git a/style.css b/style.css index 43bf36d..638302b 100644 --- a/style.css +++ b/style.css @@ -19,28 +19,127 @@ canvas { display: block; } -#gear-icon { +/* STATS */ +#stats-panel { + position: absolute; + top: 20px; + left: 20px; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + text-shadow: + 0 0 1px rgba(0, 0, 0, 1), + 0 0 2px rgba(0, 0, 0, 1), + 0 0 3px rgba(0, 0, 0, 1), + 0 0 4px rgba(0, 0, 0, 1), + 0 0 8px rgba(0, 0, 0, 1), + 0 0 16px rgba(0, 0, 0, 1); + pointer-events: none; + line-height: 1.6; +} + +.tooltip-trigger { + cursor: help; + pointer-events: auto; + border-bottom: 1px dotted rgba(255, 255, 255, 0.3); + position: relative; +} + +.tooltip { + display: none; + position: absolute; + background: rgba(20, 20, 30, 0.3); + backdrop-filter: blur(5px); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 8px 12px; + border-radius: 4px; + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + width: 200px; + z-index: 1000; + line-height: 1.4; + left: 0; + top: 100%; + margin-top: 5px; +} + +.tooltip-trigger:hover .tooltip { + display: block; +} + +.rate-up { + color: rgba(100, 255, 100, 0.9); + transition: color 0.5s ease; +} + +.rate-down { + color: rgba(255, 100, 100, 0.9); + transition: color 0.5s ease; +} + +.rate-same { + color: rgba(200, 200, 200, 0.8); + transition: color 0.5s ease; +} + +/* ICONS */ +#gear-icon, +.score-icon, +#hole-icon { position: absolute; top: 20px; - right: 20px; width: 20px; height: 20px; cursor: pointer; - opacity: 0.3; + opacity: 0.5; transition: opacity 0.2s; z-index: 1000; } -#gear-icon:hover { - opacity: 0.6; +#gear-icon { + right: 20px; } +.score-icon { + right: 50px; +} + +#hole-icon { + top: 16px; + right: 80px; + width: 26px; + height: 26px; +} + +#hole-icon.pulse { + animation: holepulse 3s ease-in-out infinite; +} + +@keyframes holepulse { + 0%, 100% { + } + 50% { + opacity: 0.8; + } +} + +#hole-icon.pulse:hover { +} + +#gear-icon:hover, +.score-icon:hover, +#hole-icon:hover { + opacity: 0.69; +} + + +/* SETTINGS */ #settings-menu { display: none; position: absolute; top: 50px; right: 20px; - background: rgba(20, 20, 30, 0.95); + background: rgba(20, 20, 30, 0.3); + backdrop-filter: blur(2px); border: 1px solid rgba(255, 255, 255, 0.2); padding: 16px; border-radius: 4px; @@ -69,7 +168,7 @@ canvas { .menu-btn { background: rgba(60, 60, 80, 0.5); - border: 1px solid rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 0, 0, 0.42); color: rgba(255, 255, 255, 0.5); padding: 6px 12px; margin-top: 8px; @@ -80,69 +179,39 @@ canvas { transition: all 0.2s; } -.menu-btn:hover { - background: rgba(80, 80, 100, 0.6); - color: rgba(255, 255, 255, 0.7); -} - .menu-btn.danger:hover { - background: rgba(120, 40, 40, 0.6); - border-color: rgba(255, 100, 100, 0.3); + background: rgba(255, 0, 0, 0.42); + border-color: rgba(255, 0, 0, 0.69); } -#stats-panel { - position: absolute; - top: 20px; - left: 20px; - color: rgba(255, 255, 255, 0.3); - font-size: 12px; - pointer-events: none; - line-height: 1.6; -} - -.tooltip-trigger { - cursor: help; - pointer-events: auto; - border-bottom: 1px dotted rgba(255, 255, 255, 0.3); - position: relative; -} - -.tooltip { - display: none; - position: absolute; - background: rgba(20, 20, 30, 0.95); - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 8px 12px; - border-radius: 4px; - font-size: 11px; - color: rgba(255, 255, 255, 0.7); - width: 200px; - z-index: 1000; - line-height: 1.4; - left: 0; - top: 100%; - margin-top: 5px; -} - -.tooltip-trigger:hover .tooltip { - display: block; -} +/* UPGRADES*/ #upgrade-panel { position: absolute; - top: 90px; - left: 20px; - color: rgba(255, 255, 255, 0.3); - font-size: 12px; + top: 50px; + right: 20px; + display: none; + background: rgba(20, 20, 30, 0.3); + backdrop-filter: blur(2px); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 16px; + border-radius: 4px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + width: 280px; + z-index: 999; +} + +#upgrade-panel.open { + display: block; } .upgrade-row { margin-bottom: 8px; } - .upgrade-btn { background: rgba(60, 60, 80, 0.5); - border: 1px solid rgba(255, 255, 255, 0.2); + border: 1px solid rgba(0, 255, 0, 0.42); color: rgba(255, 255, 255, 0.5); padding: 4px 8px; margin-top: 4px; @@ -157,16 +226,17 @@ canvas { .upgrade-btn:disabled { cursor: not-allowed; + border: 1px solid rgba(0, 0, 0, 0.69); opacity: 0.3; } +/* SCORES */ .modal { position: absolute; - top: 80px; + top: 50px; right: 20px; display: none; - width: 360px; - z-index: 5000; + z-index: 999; background: transparent; } @@ -175,7 +245,8 @@ canvas { } .modal-content { - background: rgba(20, 20, 30, 0.95); + background: rgba(20, 20, 30, 0.3); + backdrop-filter: blur(2px); border: 1px solid rgba(255, 255, 255, 0.2); padding: 16px; border-radius: 4px; @@ -184,34 +255,30 @@ canvas { overflow-y: auto; } +.leaderboard-title, .leaderboard-entry { - display: flex; + display: grid; + grid-template-columns: 1fr 90px 60px 90px 60px; + column-gap: 8px; align-items: center; padding: 8px; margin-bottom: 4px; - background: rgba(10, 10, 10, 0.3); border-radius: 4px; font-size: 11px; line-height: 1.5; } -.leaderboard-title { - display: flex; - font-size: 12px; - padding: 5px 0; - border-bottom: 1px solid #333; - text-transform: uppercase; +.leaderboard-entry { + background: rgba(255, 255, 255, 0.03); } .leaderboard-entry .rank { - width: 40px; font-weight: bold; color: rgba(255, 255, 255, 0.5); font-size: 11px; } .leaderboard-entry .name { - flex: 1; color: rgba(255, 255, 255, 0.7); overflow: hidden; text-overflow: ellipsis; @@ -221,8 +288,6 @@ canvas { .leaderboard-entry .value { color: rgba(255, 255, 255, 0.7); - min-width: 80px; - text-align: right; font-size: 11px; } @@ -261,37 +326,6 @@ canvas { line-height: 1.5; } -.rate-up { - color: rgba(100, 255, 100, 0.9); - transition: color 0.5s ease; -} - -.rate-down { - color: rgba(255, 100, 100, 0.9); - transition: color 0.5s ease; -} - -.rate-same { - color: rgba(200, 200, 200, 0.8); - transition: color 0.5s ease; -} - -.score-icon { - position: absolute; - top: 50px; - right: 20px; - width: 20px; - height: 20px; - cursor: pointer; - opacity: 0.3; - transition: opacity 0.2s; - z-index: 1000; -} - -.score-icon:hover { - opacity: 0.6; -} - .online-indicator { display: inline-block; width: 6px; @@ -314,3 +348,71 @@ canvas { box-shadow: 0 0 4px #00ff00, 0 0 6px #00ff00; } } + +/* Base notification styles */ +.notification { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 20px 30px; + border-radius: 8px; + font-size: 12px; + z-index: 10000; + text-align: center; + line-height: 1.6; + animation: notificationFadeIn 0.3s ease-out; +} + +/* Notification types */ +.notification.unlock { + background: rgba(20, 20, 30, 0.95); + border: 1px solid rgba(0, 255, 255, 0.8); + color: rgba(255, 255, 255, 0.7); + text-transform: uppercase; + font-size: 12px; + padding: 20px 40px; + box-shadow: 0 0 20px rgba(0, 255, 255, 0.42); +} + +.notification.offline { + background: rgba(20, 20, 30, 0.95); + border: 1px solid rgba(255, 255, 0, 0.8); + color: rgba(255, 255, 255, 0.9); + box-shadow: 0 0 20px rgba(255, 255, 0, 0.42); +} + +.notification.error { + background: rgba(20, 20, 30, 0.95); + border: 1px solid rgba(255, 0, 0, 0.8); + color: rgba(255, 255, 255, 0.7); + text-transform: uppercase; + box-shadow: 0 0 20px rgba(255, 0, 0, 0.42); +} + +.notification.success { + background: rgba(20, 20, 30, 0.95); + border: 1px solid rgba(0, 255, 0, 0.8); + color: rgba(255, 255, 255, 0.7); + text-transform: uppercase; + box-shadow: 0 0 20px rgba(0, 255, 0, 0.42); +} + +.notification.info { + background: rgba(20, 20, 30, 0.95); + border: 1px solid rgba(180, 180, 200, 0.8); + color: rgba(255, 255, 255, 0.7); + text-transform: uppercase; +} + +/* Animation */ +@keyframes notificationFadeIn { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.9); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +}