// 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, ktypeUpgradeLevel: 0, ktypeUpgradeCost: CONFIG.KTYPE_BASE_COST, gtypeUpgradeLevel: 0, gtypeUpgradeCost: CONFIG.GTYPE_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, currentKtypeSpawnInterval: CONFIG.BASE_KTYPE_SPAWN_INTERVAL, currentGtypeSpawnInterval: CONFIG.BASE_GTYPE_SPAWN_INTERVAL, asteroidSpawnCount: 0, lastAsteroidSpawn: Date.now(), lastCometSpawn: Date.now(), lastPlanetSpawn: Date.now(), lastGiantSpawn: Date.now(), lastMtypeSpawn: Date.now(), lastKtypeSpawn: Date.now(), lastGtypeSpawn: 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, ktypeUnlocked: false, gtypeUnlocked: 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, ktype: handleKtypeUpgrade, gtype: handleGtypeUpgrade }); 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) { // Restore only what cannot be derived state.blackHoleTotalMass = savedState.blackHoleTotalMass; state.totalMassConsumedEver = savedState.totalMassConsumedEver; state.totalMassConsumed = savedState.totalMassConsumed; state.asteroidSpawnCount = savedState.asteroidSpawnCount; // Upgrade levels — the only upgrade data we store state.asteroidUpgradeLevel = savedState.asteroidUpgradeLevel || 0; state.cometUpgradeLevel = savedState.cometUpgradeLevel || 0; state.planetUpgradeLevel = savedState.planetUpgradeLevel || 0; state.giantUpgradeLevel = savedState.giantUpgradeLevel || 0; state.mtypeUpgradeLevel = savedState.mtypeUpgradeLevel || 0; state.ktypeUpgradeLevel = savedState.ktypeUpgradeLevel || 0; state.gtypeUpgradeLevel = savedState.gtypeUpgradeLevel || 0; // Derive unlock states from levels state.cometUnlocked = state.asteroidUpgradeLevel >= 20; state.planetUnlocked = state.cometUpgradeLevel >= 15; state.giantUnlocked = state.planetUpgradeLevel >= 10; state.mtypeUnlocked = state.giantUpgradeLevel >= 5; state.ktypeUnlocked = state.mtypeUpgradeLevel >= 5; state.gtypeUnlocked = state.ktypeUpgradeLevel >= 5; // Derive costs and intervals from levels + CONFIG recalculateUpgradeCosts(); updateSpawnIntervals(); // Spawn timestamps 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(); state.lastKtypeSpawn = savedState.lastKtypeSpawn || Date.now(); state.lastGtypeSpawn = savedState.lastGtypeSpawn || Date.now(); // 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; // Offline progression var offlineTime = 0; if (savedState.tabHiddenAt) { offlineTime = now - savedState.tabHiddenAt; } if (offlineTime > 1000) { var rateToUse = calculateTheoreticalRate(state, CONFIG); if (rateToUse > 0) { var offlineMass = rateToUse * (offlineTime / 1000); state.blackHoleTotalMass += offlineMass; state.totalMassConsumedEver += offlineMass; state.totalMassConsumed += offlineMass; state.sM = 0; state.sT = now; state.mM = 0; state.mT = now; state.lM = 0; state.lT = now; showOfflineNotification( formatOfflineTime(offlineTime), UI.formatMass(offlineMass), 'offline' ); } } state.tabHiddenAt = null; // Restore black hole size blackHole.radius = 0.5 * (state.blackHoleTotalMass / CONFIG.SOLAR_MASS_KG); Server.checkpoint(state).then(function() { state.isReady = true; }); } 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() { // Calculate star-tier spawn boost (multiplicative) var starBonus = 1 + (state.mtypeUpgradeLevel * CONFIG.MTYPE_SPAWN_BOOST_PER_LEVEL) + (state.ktypeUpgradeLevel * CONFIG.KTYPE_SPAWN_BOOST_PER_LEVEL) + (state.gtypeUpgradeLevel * CONFIG.GTYPE_SPAWN_BOOST_PER_LEVEL); // Asteroids boosted by all stars state.currentAsteroidSpawnInterval = CONFIG.BASE_ASTEROID_SPAWN_INTERVAL / ((1 + state.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_ASTER) * starBonus); // Comets boosted by all stars state.currentCometSpawnInterval = CONFIG.BASE_COMET_SPAWN_INTERVAL / ((1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_COMET) * starBonus); // Planets boosted by all stars state.currentPlanetSpawnInterval = CONFIG.BASE_PLANET_SPAWN_INTERVAL / ((1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_PLANT) * starBonus); // Giants boosted by all stars state.currentGiantSpawnInterval = CONFIG.BASE_GIANT_SPAWN_INTERVAL / ((1 + state.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GIANT) * starBonus); // M-type boosted by all stars state.currentMtypeSpawnInterval = CONFIG.BASE_MTYPE_SPAWN_INTERVAL / ((1 + state.mtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_MTYPE) * starBonus); // K-type boosted by all stars state.currentKtypeSpawnInterval = CONFIG.BASE_KTYPE_SPAWN_INTERVAL / ((1 + state.ktypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_KTYPE) * starBonus); // G-type boosted by all stars state.currentGtypeSpawnInterval = CONFIG.BASE_GTYPE_SPAWN_INTERVAL / ((1 + state.gtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GTYPE) * starBonus); } function recalculateUpgradeCosts() { state.asteroidUpgradeCost = Math.floor(CONFIG.ASTEROID_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_ASTER, state.asteroidUpgradeLevel)); state.cometUpgradeCost = Math.floor(CONFIG.COMET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_COMET, state.cometUpgradeLevel)); state.planetUpgradeCost = Math.floor(CONFIG.PLANET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_PLANT, state.planetUpgradeLevel)); state.giantUpgradeCost = Math.floor(CONFIG.GIANT_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_GIANT, state.giantUpgradeLevel)); state.mtypeUpgradeCost = Math.floor(CONFIG.MTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_MTYPE, state.mtypeUpgradeLevel)); state.ktypeUpgradeCost = Math.floor(CONFIG.KTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_KTYPE, state.ktypeUpgradeLevel)); state.gtypeUpgradeCost = Math.floor(CONFIG.GTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_GTYPE, state.gtypeUpgradeLevel)); } 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) ); // Unlock K-Type at m-type level 5 if (state.mtypeUpgradeLevel >= 5 && !state.ktypeUnlocked) { state.ktypeUnlocked = true; state.lastKtypeSpawn = Date.now(); showUnlockNotification('ktype'); asteroids.push(new Asteroid('ktype', blackHole, canvas)); } updateSpawnIntervals(); UI.update(state, CONFIG); } } function handleKtypeUpgrade() { if (state.totalMassConsumed >= state.ktypeUpgradeCost) { state.totalMassConsumed -= state.ktypeUpgradeCost; state.ktypeUpgradeLevel++; state.ktypeUpgradeCost = Math.floor( CONFIG.KTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_KTYPE, state.ktypeUpgradeLevel) ); // Unlock K-Type at m-type level 5 if (state.ktypeUpgradeLevel >= 5 && !state.gtypeUnlocked) { state.gtypeUnlocked = true; state.lastGtypeSpawn = Date.now(); showUnlockNotification('gtype'); asteroids.push(new Asteroid('gtype', blackHole, canvas)); } updateSpawnIntervals(); UI.update(state, CONFIG); } } function handleGtypeUpgrade() { if (state.totalMassConsumed >= state.gtypeUpgradeCost) { state.totalMassConsumed -= state.gtypeUpgradeCost; state.gtypeUpgradeLevel++; state.gtypeUpgradeCost = Math.floor( CONFIG.GTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_GTYPE, state.gtypeUpgradeLevel) ); 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)); } } if (state.ktypeUnlocked) { if (currentTime - state.lastKtypeSpawn > state.currentKtypeSpawnInterval) { state.lastKtypeSpawn = currentTime; asteroids.push(new Asteroid('ktype', blackHole, canvas)); } } if (state.gtypeUnlocked) { if (currentTime - state.lastGtypeSpawn > state.currentGtypeSpawnInterval) { state.lastGtypeSpawn = currentTime; asteroids.push(new Asteroid('gtype', 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); } // Get or create session token this.sessionToken = Storage.getOrCreateToken(); // 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, secretToken: this.sessionToken, 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 }, createTransferCode: async function() { try { const resp = await fetch('/api/transfer/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playerId: this.playerId, secretToken: this.sessionToken }) }); const data = await resp.json(); if (!resp.ok) return { error: data.error }; return data; }catch (err) { console.error('Failed to create transfer code', err); return null; } }, claimTransferCode: async function(code) { try { const resp = await fetch('/api/transfer/claim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return await resp.json(); } catch (err) { console.error('Failed to claim transfer code', err); return null; } } }; // Start the game when the page loads window.addEventListener('load', function() { Game.init(); });