// Main game logic and animation loop var Game = (function() { var canvas = document.getElementById('space'); var ctx = canvas.getContext('2d'); var state = { blackHoleTotalMass: CONFIG.INITIAL_BLACK_HOLE_MASS * CONFIG.SOLAR_MASS_KG, totalMassConsumedEver: 0, totalMassConsumed: 0, asteroidUpgradeLevel: 0, asteroidUpgradeCost: CONFIG.ASTEROID_BASE_COST, cometUpgradeLevel: 0, cometUpgradeCost: CONFIG.COMET_BASE_COST, planetUpgradeLevel: 0, 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(), 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 isReady: false, tabHiddenAt: null, cometUnlocked: false, planetUnlocked: false, giantUnlocked: false, mtypeUnlocked: false }; var blackHole; var stars = []; var asteroids = []; var lastFrameTime = 0; var lastStarUpdate = 0; 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 canvas.height = window.innerHeight } function init() { resizeCanvas(); blackHole = new BlackHole( canvas.width / 2, canvas.height / 2, 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(); UI.setUpgradeHandlers({ asteroid: handleAsteroidUpgrade, comet: handleCometUpgrade, planet: handlePlanetUpgrade, giant: handleGiantUpgrade, mtype: handleMtypeUpgrade }); Server.init() loadGameState(); window.addEventListener('resize', function() { resizeCanvas(); blackHole.x = canvas.width / 2; blackHole.y = canvas.height / 2; stars.forEach(function(star) { star.reset(); }); }); window.addEventListener('wheel', function(e) { if (e.ctrlKey) { e.preventDefault(); } }, { passive: false }); window.addEventListener('keydown', function(e) { if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '-' || e.key === '=')) { e.preventDefault(); } }); animate(currentTime); UI.update(state, CONFIG); // Save when page becomes hidden (tab switch, minimize, close) 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() { state.tabHiddenAt = Date.now(); Server.checkpoint(state); }); // 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_ASTER); state.currentCometSpawnInterval = CONFIG.BASE_COMET_SPAWN_INTERVAL / (1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_COMET); state.currentPlanetSpawnInterval = CONFIG.BASE_PLANET_SPAWN_INTERVAL / (1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_PLANT); state.currentGiantSpawnInterval = CONFIG.BASE_GIANT_SPAWN_INTERVAL / (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() { if (state.totalMassConsumed >= state.asteroidUpgradeCost) { state.totalMassConsumed -= state.asteroidUpgradeCost; state.asteroidUpgradeLevel++; 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); } } function handleCometUpgrade() { if (state.totalMassConsumed >= state.cometUpgradeCost) { state.totalMassConsumed -= state.cometUpgradeCost; state.cometUpgradeLevel++; state.cometUpgradeCost = Math.floor( 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); } } function handlePlanetUpgrade() { if (state.totalMassConsumed >= state.planetUpgradeCost) { state.totalMassConsumed -= state.planetUpgradeCost; state.planetUpgradeLevel++; state.planetUpgradeCost = Math.floor( 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); } } function handleGiantUpgrade() { if (state.totalMassConsumed >= state.giantUpgradeCost) { state.totalMassConsumed -= state.giantUpgradeCost; state.giantUpgradeLevel++; state.giantUpgradeCost = Math.floor( 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); } } function spawnAsteroids() { var currentTime = Date.now(); if (currentTime - state.lastAsteroidSpawn > state.currentAsteroidSpawnInterval) { state.asteroidSpawnCount++; var asteroidType; if (state.asteroidSpawnCount % CONFIG.ASTEROID_SPAWN_PATTERNS.LARGE_EVERY === 0) { asteroidType = 'large'; } else if (state.asteroidSpawnCount % CONFIG.ASTEROID_SPAWN_PATTERNS.MEDIUM_EVERY === 0) { asteroidType = 'medium'; } else { asteroidType = 'small'; } asteroids.push(new Asteroid(asteroidType, blackHole, canvas)); state.lastAsteroidSpawn = currentTime; } if (state.cometUnlocked && state.cometUpgradeLevel >= 0) { if (currentTime - state.lastCometSpawn > state.currentCometSpawnInterval) { state.lastCometSpawn = currentTime; asteroids.push(new Asteroid('comet', blackHole, canvas)); } } if (state.planetUnlocked && state.planetUpgradeLevel >= 0) { if (currentTime - state.lastPlanetSpawn > state.currentPlanetSpawnInterval) { state.lastPlanetSpawn = currentTime; asteroids.push(new Asteroid('planet', blackHole, canvas)); } } 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() { for (var i = asteroids.length - 1; i >= 0; i--) { asteroids[i].update(); if (asteroids[i].isDestroyed()) { var consumedMassKg = asteroids[i].massKg; state.blackHoleTotalMass += consumedMassKg; state.totalMassConsumedEver += consumedMassKg; state.totalMassConsumed += consumedMassKg; calculateConsumptionRates(state, CONFIG, consumedMassKg); // Grow black hole radius var solarMasses = state.blackHoleTotalMass / CONFIG.SOLAR_MASS_KG; blackHole.radius = CONFIG.INITIAL_BLACK_HOLE_RADIUS + (solarMasses - CONFIG.INITIAL_BLACK_HOLE_MASS); blackHole.consumeAsteroid(asteroids[i]); asteroids.splice(i, 1); UI.update(state, CONFIG); } } } function render() { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw stars (don't update them here - that happens in animate()) for (var i = 0; i < stars.length; i++) { stars[i].draw(ctx); } // Draw asteroids for (var i = 0; i < asteroids.length; i++) { asteroids[i].draw(ctx); } blackHole.draw(ctx, state.rateShort || 0); } function animate(currentTime) { requestAnimationFrame(animate); // Logic Update (Low Frequency) if (!lastLogicUpdate) lastLogicUpdate = currentTime; var elapsedTime = Date.now() - lastLogicUpdate if (elapsedTime >= CONFIG.LOGIC_INTERVAL) { spawnAsteroids(); updateAsteroids(); lastLogicUpdate = Date.now(); needsRender = true; } // Update stars less frequently (they twinkle slowly) if (currentTime - lastStarUpdate > CONFIG.STAR_UPDATE_INTERVAL) { for (var i = 0; i < stars.length; i++) { stars[i].update(); } lastStarUpdate = currentTime; needsRender = true; } if (needsRender) { render(); needsRender = false; } } return { init: init, getState: function() { return state; } } }()); var Server = { playerId: null, init: function() { // Get or create player ID this.playerId = Storage.getCookie('playerId'); if (!this.playerId) { this.playerId = 'p_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); Storage.setCookie('playerId', this.playerId, 365); } // Start checkpoints this.startCheckpoints(); }, checkpoint: async function(gameState) { try { // 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, 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)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); 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() { let lastCheckpointData = null; setInterval(() => { 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); // Check every 15s, but only send if changed }, }; // Start the game when the page loads window.addEventListener('load', function() { Game.init(); });