// 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, currentAsteroidSpawnInterval: CONFIG.BASE_ASTEROID_SPAWN_INTERVAL, currentCometSpawnInterval: CONFIG.BASE_COMET_SPAWN_INTERVAL, currentPlanetSpawnInterval: CONFIG.BASE_PLANET_SPAWN_INTERVAL, currentGiantSpawnInterval: CONFIG.BASE_GIANT_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), 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 }; var blackHole; var stars = []; var asteroids = []; var lastFrameTime = 0; var lastStarUpdate = 0; var needsRender = true; var currentTime = Date.now(); var lastLogicUpdate = Date.now(); 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 ); for (var i = 0; i < CONFIG.STAR_COUNT; i++) { stars.push(new Star(canvas)); } UI.init(); UI.setUpgradeHandlers({ asteroid: handleAsteroidUpgrade, comet: handleCometUpgrade, planet: handlePlanetUpgrade, giant: handleGiantUpgrade }); loadGameState(); window.addEventListener('resize', function() { resizeCanvas(); blackHole.x = canvas.width / 2; blackHole.y = canvas.height / 2; stars.forEach(function(star) { star.reset(); }); }); // ADD THIS BLOCK - Prevent zoom on desktop 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); 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 window.addEventListener('beforeunload', function() { Storage.saveGame(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); } } function updateSpawnIntervals() { state.currentAsteroidSpawnInterval = CONFIG.BASE_ASTEROID_SPAWN_INTERVAL / (1 + state.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); state.currentCometSpawnInterval = CONFIG.BASE_COMET_SPAWN_INTERVAL / (1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); state.currentPlanetSpawnInterval = CONFIG.BASE_PLANET_SPAWN_INTERVAL / (1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); state.currentGiantSpawnInterval = CONFIG.BASE_GIANT_SPAWN_INTERVAL / (1 + state.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL); } 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) ); updateSpawnIntervals(); UI.update(state, CONFIG); Storage.saveGame(state); } } 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, state.cometUpgradeLevel) ); updateSpawnIntervals(); UI.update(state, CONFIG); Storage.saveGame(state); } } 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, state.planetUpgradeLevel) ); updateSpawnIntervals(); UI.update(state, CONFIG); Storage.saveGame(state); } } 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, state.giantUpgradeLevel) ); updateSpawnIntervals(); UI.update(state, CONFIG); Storage.saveGame(state); } } 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 (currentTime - state.lastCometSpawn > state.currentCometSpawnInterval) { asteroids.push(new Asteroid('comet', blackHole, canvas)); state.lastCometSpawn = currentTime; //Storage.saveGame(state); } if (currentTime - state.lastPlanetSpawn > state.currentPlanetSpawnInterval) { asteroids.push(new Asteroid('planet', blackHole, canvas)); state.lastPlanetSpawn = currentTime; //Storage.saveGame(state); } if (currentTime - state.lastGiantSpawn > state.currentGiantSpawnInterval) { asteroids.push(new Asteroid('giant', blackHole, canvas)); state.lastGiantSpawn = currentTime; //Storage.saveGame(state); } } 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); } 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 = { // baseURL removed; now all requests are relative 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 { await fetch('/api/checkpoint', { // relative URL 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 } }) }); } catch (err) { console.error('Checkpoint failed', err); } }, getLeaderboard: async function(sortBy) { try { const resp = await fetch(`/api/leaderboard?sort=${encodeURIComponent(sortBy)}&playerId=${encodeURIComponent(this.playerId)}`); // relative URL if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); return data; // should be array of { name, mass, holeAge } } catch (err) { console.error('Failed to fetch leaderboard', err); return []; } }, startCheckpoints: function() { // Example: autosave every 15s setInterval(() => { if (window.Game && Game.state) { this.checkpoint(Game.state); } }, 15000); } }; // Start the game when the page loads window.addEventListener('load', function() { Game.init(); });