From 99fe6d26819f7e82447698636ae7820108bbae32 Mon Sep 17 00:00:00 2001 From: xbl Date: Thu, 22 Jan 2026 19:21:59 +0100 Subject: [PATCH] hoel --- .gitignore | 6 + index.html | 128 ++++++++++++++ js/config.js | 100 +++++++++++ js/entities.js | 469 ++++++++++++++++++++++++++++++++++++++++++++++++ js/game.js | 472 +++++++++++++++++++++++++++++++++++++++++++++++++ js/helpers.js | 125 +++++++++++++ js/server.js | 202 +++++++++++++++++++++ js/storage.js | 106 +++++++++++ js/ui.js | 338 +++++++++++++++++++++++++++++++++++ style.css | 316 +++++++++++++++++++++++++++++++++ 10 files changed, 2262 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 js/config.js create mode 100644 js/entities.js create mode 100644 js/game.js create mode 100644 js/helpers.js create mode 100644 js/server.js create mode 100644 js/storage.js create mode 100644 js/ui.js create mode 100644 style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d42f190 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +dev/ + +leaderboard.json +node_modules/ +package-lock.json +package.json diff --git a/index.html b/index.html new file mode 100644 index 0000000..29f4583 --- /dev/null +++ b/index.html @@ -0,0 +1,128 @@ + + + + + + hoel + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+
+ +
+
+
+
Available to Spend: 0 kg
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..0df1115 --- /dev/null +++ b/js/config.js @@ -0,0 +1,100 @@ +// Game configuration and constants + +var CONFIG = { + // Physical constants + SOLAR_MASS_KG: 1.989e30, + + // Initial values + INITIAL_BLACK_HOLE_MASS: 42, // in solar masses + INITIAL_BLACK_HOLE_RADIUS: 42, + + // Performance + LOGIC_INTERVAL: 10, + + // Star configuration + STAR_COUNT: 1312, + STAR_UPDATE_INTERVAL: 1312, + + // Spawn intervals (in milliseconds) + BASE_ASTEROID_SPAWN_INTERVAL: 1000, + BASE_COMET_SPAWN_INTERVAL: 120000, // 2 minutes + BASE_PLANET_SPAWN_INTERVAL: 3600000, // 1 hour + BASE_GIANT_SPAWN_INTERVAL: 21600000, // 6 hours + + // Rate tracking windows (in milliseconds) + RATE_WINDOWS: { + SHORT: 1000, // 1 second + MEDIUM: 3600000, // 1 hour + LONG: 86400000 // 1 day + }, + + // Upgrade costs (in kg) + ASTEROID_BASE_COST: 1e3, + COMET_BASE_COST: 1e4, + PLANET_BASE_COST: 1e5, + GIANT_BASE_COST: 1e6, + + // Upgrade scaling + UPGRADE_COST_MULTIPLIER: 1.5, + UPGRADE_COST_MULTIPLIER_ASTER: 1.25, + UPGRADE_BONUS_PER_LEVEL: 0.1, // 10% per level + + // Asteroid spawn patterns + ASTEROID_SPAWN_PATTERNS: { + LARGE_EVERY: 900, + MEDIUM_EVERY: 30 + }, + + 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 + }, + + // Planet color schemes + PLANET_COLORS: [ + { light: '#6b7c87', mid: '#3d4d5a', dark: '#1f2a33' }, + { light: '#7a6347', mid: '#4d3d28', dark: '#2a2015' }, + { light: '#5a3838', mid: '#3d2525', dark: '#1f1212' }, + { light: '#5a5a5a', mid: '#3d3d3d', dark: '#1f1f1f' }, + { light: '#5a4d3d', mid: '#3d342a', dark: '#251f18' }, + { light: '#3d3540', mid: '#2a232d', dark: '#16111a' } + ], + + // Giant (gas giant) color schemes + GIANT_COLORS: [ + { light: '#d4a574', mid: '#b87d4a', dark: '#8b5a3c' }, + { light: '#c4b896', mid: '#9d8c6e', dark: '#6b5d47' }, + { light: '#5a7ca8', mid: '#3d5577', dark: '#2a3b52' }, + { light: '#7a9da8', mid: '#567880', dark: '#3d5559' } + ], + + // Giant ring colors + GIANT_RING_COLORS: [ + '#f5e27c', + '#c0c0c0', + '#fff2cc', + '#fff2cc', + '#d0e0e3', + '#f4cccc' + ], + + // Star color distribution + STAR_COLORS: [ + { threshold: 0.6, rgb: '255, 255, 255' }, + { threshold: 0.75, rgb: '150, 180, 255' }, + { threshold: 0.85, rgb: '255, 240, 200' }, + { threshold: 0.95, rgb: '255, 200, 100' }, + { threshold: 1.0, rgb: '255, 150, 150' } + ], + + // Asteroid colors + ASTEROID_COLORS: { + small: '#444', + medium: '#555', + large: '#666' + } +}; diff --git a/js/entities.js b/js/entities.js new file mode 100644 index 0000000..5b3359a --- /dev/null +++ b/js/entities.js @@ -0,0 +1,469 @@ +// Entity classes for game objects + +function Star(canvas) { + this.canvas = canvas + this.reset(); + this.baseBrightness = 0.6 + Math.random() * 0.4; + this.brightness = this.baseBrightness; + this.twinkleSpeed = Math.random() * 0.003 + 0.001; + this.twinkleAmount = 0.1 + Math.random() * 0.1; + + var colorRand = Math.random(); + for (var i = 0; i < CONFIG.STAR_COLORS.length; i++) { + if (colorRand < CONFIG.STAR_COLORS[i].threshold) { + this.color = CONFIG.STAR_COLORS[i].rgb; + break; + } + } +} + +Star.prototype.reset = function() { + this.x = Math.random() * this.canvas.width; + this.y = Math.random() * this.canvas.height; + this.size = Math.random() * 1; +}; + +Star.prototype.update = function() { + this.brightness += this.twinkleSpeed; + if (this.brightness > this.baseBrightness + this.twinkleAmount || + this.brightness < this.baseBrightness - this.twinkleAmount) { + this.twinkleSpeed = -this.twinkleSpeed; + } +}; + +Star.prototype.draw = function(ctx) { + ctx.fillStyle = 'rgba(' + this.color + ', ' + this.brightness + ')'; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); + ctx.fill(); +}; + +function Asteroid(type, blackHole, canvas) { + this.canvas = canvas + if (!type) type = 'small'; + this.type = type; + + var angle = Math.random() * Math.PI * 2; + var distance = Math.max(canvas.width, canvas.height) * 0.7; + + this.x = this.canvas.width / 2 + Math.cos(angle) * distance; + this.y = this.canvas.height / 2 + Math.sin(angle) * distance; + + this.angle = Math.atan2(this.y - blackHole.y, this.x - blackHole.x); + this.distance = Math.sqrt( + Math.pow(this.x - blackHole.x, 2) + + Math.pow(this.y - blackHole.y, 2) + ); + this.orbitSpeed = 0.005 + Math.random() * 0.003; + this.decayRate = 0.08 + Math.random() * 0.05; + + this.blackHole = blackHole; + + this.initializeTypeSpecificProperties(); + + this.rotation = Math.random() * Math.PI * 2; + this.rotationSpeed = (Math.random() - 0.5) * 0.1; +} + +Asteroid.prototype.initializeTypeSpecificProperties = function() { + // Visual size (unchanged) + if (this.type === 'comet') { + this.size = 5 + Math.random() * 3; + } else if (this.type === 'giant') { + this.size = 15 + Math.random() * 10; + this.orbitSpeed *= 0.3; + this.decayRate *= 0.4; + this.planetColors = CONFIG.GIANT_COLORS[Math.floor(Math.random() * CONFIG.GIANT_COLORS.length)]; + // Initialize rings + this.rings = []; + var ringCount = Math.floor(Math.random() * 4); // 0-3 rings + for (var i = 0; i < ringCount; i++) { + this.rings.push({ + color: CONFIG.GIANT_RING_COLORS[Math.floor(Math.random() * CONFIG.GIANT_RING_COLORS.length)], + thickness: 1 + Math.random() * 9, // random thickness + radiusOffset: 10 + i * 5 + Math.random() * 5, // spread rings outward + alpha: 0.3 + Math.random() * 0.4 // stored permanently + }); + } + } else if (this.type === 'planet') { + this.size = 10 + Math.random() * 5; + 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; + } else if (this.type === 'medium') { + this.size = 3 + Math.random() * 2; + } else { + this.size = 1 + Math.random() * 2; + } + + // Physical mass (kg) + var range = CONFIG.ASTEROID_MASS_RANGES[this.type] || CONFIG.ASTEROID_MASS_RANGES.small; + this.massKg = range[0] + Math.random() * (range[1] - range[0]); +}; + +Asteroid.prototype.update = function() { + this.angle += this.orbitSpeed; + this.distance -= this.decayRate; + this.x = this.blackHole.x + Math.cos(this.angle) * this.distance; + this.y = this.blackHole.y + Math.sin(this.angle) * this.distance; + this.rotation += this.rotationSpeed; +}; + +Asteroid.prototype.draw = function(ctx) { + if (this.type === 'comet') { + this.drawComet(ctx); + } else if (this.type === 'giant') { + // Initialize per-giant tilt if not already done + if (this._ringTiltBase === undefined) { + this._ringTiltBase = (Math.random() - 0.5) * 0.3; // ±0.15 rad + this._tiltOscillationSpeed = 0.001 + Math.random() * 0.002; + this._tiltScale = 0.1 + Math.random() * 0.69; // vertical squash + } + + const tilt = this._ringTiltBase + Math.sin(Date.now() * this._tiltOscillationSpeed) * 0.05; + + if (this.rings && this.rings.length > 0) { + // Draw back halves of rings + this.rings.forEach((ring) => { + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(tilt); + ctx.scale(1, this._tiltScale); + + ctx.strokeStyle = ring.color.replace(/;?\s*$/, '') // just in case + ctx.strokeStyle = ring.color.startsWith('rgba') + ? ring.color.replace(/rgba\(([^)]+),[^)]+\)/, `rgba($1,${ring.alpha})`) + : `rgba(${parseInt(ring.color.slice(1,3),16)},${parseInt(ring.color.slice(3,5),16)},${parseInt(ring.color.slice(5,7),16)},${ring.alpha})`; + + ctx.lineWidth = ring.thickness; + ctx.beginPath(); + ctx.arc(0, 0, this.size + ring.radiusOffset, Math.PI, 2 * Math.PI); // back half + ctx.stroke(); + ctx.restore(); + }); + } + + // Draw the giant planet + this.drawGiant(ctx); + + if (this.rings && this.rings.length > 0) { + // Draw front halves of rings + this.rings.forEach((ring) => { + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(tilt); + ctx.scale(1, this._tiltScale); + + ctx.strokeStyle = ring.color.startsWith('rgba') + ? ring.color.replace(/rgba\(([^)]+),[^)]+\)/, `rgba($1,${rings.alpha})`) + : `rgba(${parseInt(ring.color.slice(1,3),16)},${parseInt(ring.color.slice(3,5),16)},${parseInt(ring.color.slice(5,7),16)},${ring.alpha})`; + + ctx.lineWidth = ring.thickness; + ctx.beginPath(); + ctx.arc(0, 0, this.size + ring.radiusOffset, 0, Math.PI); // front half + 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') { +// // 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; + + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(orbitTangentAngle + Math.PI); + + var gradient = ctx.createLinearGradient(0, 0, tailLength, 0); + gradient.addColorStop(0, 'rgba(80, 140, 200, 0.5)'); + gradient.addColorStop(0.5, 'rgba(60, 100, 150, 0.25)'); + gradient.addColorStop(1, 'rgba(40, 80, 120, 0)'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, -this.size * 0.4, tailLength, this.size * 0.8); + ctx.restore(); + + var nucleusSize = this.size * 0.5; + ctx.save(); + ctx.translate(this.x, this.y); + ctx.fillStyle = '#5a7a8a'; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.arc(0, 0, nucleusSize, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = 'rgba(80, 120, 140, 0.2)'; + ctx.beginPath(); + ctx.arc(0, 0, nucleusSize * 1.3, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +}; + +Asteroid.prototype.drawGiant = function(ctx) { + ctx.save(); + ctx.translate(this.x, this.y); + + var gradient = ctx.createRadialGradient(-this.size * 0.25, -this.size * 0.25, 0, 0, 0, this.size); + gradient.addColorStop(0, this.planetColors.light); + gradient.addColorStop(0.5, this.planetColors.mid); + gradient.addColorStop(1, this.planetColors.dark); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)'; + ctx.lineWidth = 1; + for (var band = -this.size * 0.6; band < this.size * 0.6; band += this.size * 0.15) { + ctx.beginPath(); + var bandWidth = Math.sqrt(this.size * this.size - band * band); + ctx.moveTo(-bandWidth, band); + ctx.lineTo(bandWidth, band); + ctx.stroke(); + } + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.stroke(); + + ctx.restore(); +}; + +Asteroid.prototype.drawPlanet = function(ctx) { + ctx.save(); + ctx.translate(this.x, this.y); + + var gradient = ctx.createRadialGradient(-this.size * 0.2, -this.size * 0.2, 0, 0, 0, this.size); + gradient.addColorStop(0, this.planetColors.light); + gradient.addColorStop(0.5, this.planetColors.mid); + gradient.addColorStop(1, this.planetColors.dark); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, this.size, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineWidth = 0.5; + ctx.stroke(); + + ctx.restore(); +}; + +Asteroid.prototype.drawAsteroid = function(ctx) { + ctx.save(); + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + + var asteroidColor = CONFIG.ASTEROID_COLORS[this.type] || CONFIG.ASTEROID_COLORS.small; + + ctx.fillStyle = asteroidColor; + ctx.beginPath(); + ctx.moveTo(0, -this.size); + ctx.lineTo(this.size * 0.8, this.size * 0.6); + ctx.lineTo(-this.size * 0.8, this.size * 0.6); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +}; + +Asteroid.prototype.isDestroyed = function() { + return this.distance < this.blackHole.radius; +}; + +function BlackHole(x, y, radius) { + this.x = x; + this.y = y; + this.radius = radius; + this.pulse = 0; + this.pulseColor = '#000000'; +} + +BlackHole.prototype.draw = function(ctx) { + if (this.pulse > 0) { + this.pulse -= 0.02; + if (this.pulse < 0) this.pulse = 0; + } + if (this.pulse === 0) { + this.pulseColor = '#000000'; + } + ctx.fillStyle = '#000000'; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); + ctx.fill(); + + var visualPulse = Math.min(this.pulse, 1); + var glowIntensity = 0.2 + (visualPulse * 0.3); + var glowRadius = this.radius * (1.01 + visualPulse * 0.1); + + var gradient = ctx.createRadialGradient( + this.x, this.y, this.radius * 0.97, + this.x, this.y, glowRadius + ); + + var r, g, b; + if (this.pulseColor.startsWith('#')) { + var hex = this.pulseColor; + r = parseInt(hex.substr(1, 2), 16); + g = parseInt(hex.substr(3, 2), 16); + b = parseInt(hex.substr(5, 2), 16); + } else { + r = 60; g = 60; b = 80; + } + + // Increase saturation by 3x when pulsing + var saturated = saturateColor(r, g, b, 3); + r = saturated.r; + g = saturated.g; + b = saturated.b; + + gradient.addColorStop(0, 'rgba(' + r + ', ' + g + ', ' + b + ', 0)'); + gradient.addColorStop(1, 'rgba(' + r + ', ' + g + ', ' + b + ', ' + glowIntensity + ')'); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(this.x, this.y, glowRadius, 0, Math.PI * 2); + ctx.fill(); +}; + +BlackHole.prototype.consumeAsteroid = function(asteroid) { + // Calculate pulse contribution (same as before) + var normalized = (Math.log10(asteroid.massKg) - 3) / 6; + var pulseContribution = Math.max(0.1, normalized); + this.pulse = Math.min(this.pulse + pulseContribution, 9); + + // Determine new pulse color based on asteroid type/size + var newColor; + if (asteroid.planetColors) { + newColor = asteroid.planetColors.mid; + } else if (asteroid.type === 'comet') { + newColor = '#5a7a8a'; + } else if (asteroid.size === 'large') { + newColor = '#f8f8f8'; + } else if (asteroid.size === 'medium') { + newColor = '#888888'; + } else { + newColor = '#444444'; + } + + // Blend with existing pulseColor instead of replacing + if (!this.pulseColor) this.pulseColor = newColor; + else this.pulseColor = this.blendColors(this.pulseColor, newColor, 0.3); +}; + +// Helper function to blend two hex colors by weight (0–1) +BlackHole.prototype.blendColors = function(c1, c2, weight) { + var d2h = d => d.toString(16).padStart(2, '0'); + var h2d = h => parseInt(h, 16); + + // Remove # + c1 = c1.replace('#',''); + c2 = c2.replace('#',''); + + var r = Math.round(h2d(c1.substring(0,2)) * (1-weight) + h2d(c2.substring(0,2)) * weight); + var g = Math.round(h2d(c1.substring(2,4)) * (1-weight) + h2d(c2.substring(2,4)) * weight); + var b = Math.round(h2d(c1.substring(4,6)) * (1-weight) + h2d(c2.substring(4,6)) * weight); + + return '#' + d2h(r) + d2h(g) + d2h(b); +}; + + +//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'; +// } +//}; + diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..ebdbabd --- /dev/null +++ b/js/game.js @@ -0,0 +1,472 @@ +// 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(); +}); diff --git a/js/helpers.js b/js/helpers.js new file mode 100644 index 0000000..3b78f38 --- /dev/null +++ b/js/helpers.js @@ -0,0 +1,125 @@ +// Helper functions for the game + +// Helper function to increase saturation of RGB color +function saturateColor(r, g, b, saturationBoost) { + // Normalize RGB to 0-1 + r /= 255; g /= 255; b /= 255; + + // Convert to HSL + var max = Math.max(r, g, b); + var min = Math.min(r, g, b); + var h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; // achromatic + } else { + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + + // Boost saturation + s = Math.min(1, s * saturationBoost); + + // Convert back to RGB + function hue2rgb(p, q, t) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + } + + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + + r = Math.round(hue2rgb(p, q, h + 1/3) * 255); + g = Math.round(hue2rgb(p, q, h) * 255); + b = Math.round(hue2rgb(p, q, h - 1/3) * 255); + + return { r: r, g: g, b: b }; +} + +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; + } + + state.rateShort = updateWindow("sM", "sT", "prevRateShort"); + state.rateMedium = updateWindow("mM", "mT", "prevRateMedium"); + state.rateLong = updateWindow("lM", "lT", "prevRateLong"); +} + +function formatOfflineTime(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 + ' day' + (days > 1 ? 's' : '') + ', ' + (hours % 24) + ' hour' + ((hours % 24) !== 1 ? 's' : ''); + } else if (hours > 0) { + return hours + ' hour' + (hours > 1 ? 's' : '') + ', ' + (minutes % 60) + ' min'; + } else if (minutes > 0) { + return minutes + ' minute' + (minutes > 1 ? 's' : ''); + } else { + return seconds + ' second' + (seconds > 1 ? 's' : ''); + } +} + +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'; + + notification.innerHTML = + '
Inactive for: ' + timeAway + '
' + + '
Mass gained: ' + massGained + '
'; + + document.body.appendChild(notification); + + document.getElementById('space').addEventListener('click', function() { + notification.remove(); + }); +} diff --git a/js/server.js b/js/server.js new file mode 100644 index 0000000..5eafe66 --- /dev/null +++ b/js/server.js @@ -0,0 +1,202 @@ +const express = require('express'); +const fs = require('fs'); +const cors = require('cors'); + +const app = express(); +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 + +// Load leaderboard from file +function loadLeaderboard() { + try { + return JSON.parse(fs.readFileSync(LEADERBOARD_FILE, 'utf8')); + } catch (e) { + return []; + } +} + +// Save leaderboard to file +function saveLeaderboard(data) { + fs.writeFileSync(LEADERBOARD_FILE, JSON.stringify(data, null, 2)); +} + +// Clean up inactive players (remove from JSON after 30 days) +function cleanupInactive(leaderboard) { + const thirtyDaysAgo = Date.now() - (INACTIVE_DAYS * 24 * 60 * 60 * 1000); + const beforeCount = leaderboard.length; + + const active = leaderboard.filter(player => player.lastSeen > thirtyDaysAgo); + + if (active.length < beforeCount) { + console.log(`Permanently removed ${beforeCount - active.length} players (>30 days inactive)`); + } + + return active; +} + +// 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); + return visible; +} + +// Submit checkpoint +app.post('/api/checkpoint', (req, res) => { + const { playerId, name, mass, playTime, levels } = req.body; + + // Basic validation + if (!playerId || mass < 0 || playTime < 0) { + return res.status(400).json({ error: 'Invalid data' }); + } + + let leaderboard = loadLeaderboard(); + + // Clean up players inactive for 30+ days + leaderboard = cleanupInactive(leaderboard); + + const now = Date.now(); + + // 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; + player.lastSeen = now; + + // Calculate hole age (time since first seen) + player.holeAge = now - player.firstSeen; + } else { + // New player + leaderboard.push({ + id: playerId, + name: name || 'Anonymous', + mass: mass, + playTime: playTime, + level: levels?.total || 0, + firstSeen: now, + lastSeen: now, + holeAge: 0 + }); + } + + // Sort by mass and keep top 100 (in JSON file) + leaderboard.sort((a, b) => b.mass - a.mass); + leaderboard = leaderboard.slice(0, 100); + + saveLeaderboard(leaderboard); + + res.json({ success: true }); +}); + +// Get leaderboard +app.get('/api/leaderboard', (req, res) => { + const type = req.query.type || 'mass'; + const requesterId = req.query.playerId; // Get player ID from query parameter + + let leaderboard = loadLeaderboard(); + + // Clean up players inactive for 30+ days + leaderboard = cleanupInactive(leaderboard); + + // Update hole ages and online status + const now = Date.now(); + const fifteenMinutes = 15 * 60 * 1000; + + leaderboard.forEach(player => { + player.holeAge = now - player.firstSeen; + player.isOnline = (now - player.lastSeen) < fifteenMinutes; + }); + + // Filter to only show players active within 5 days + let visiblePlayers = getVisiblePlayers(leaderboard); + + // Sort by requested type + if (type === 'age') { + visiblePlayers.sort((a, b) => b.holeAge - a.holeAge); + } else if (type === 'time') { + visiblePlayers.sort((a, b) => b.playTime - a.playTime); + } else if (type === 'level') { + visiblePlayers.sort((a, b) => b.level - a.level); + } else { + visiblePlayers.sort((a, b) => b.mass - a.mass); + } + + // Log who opened the leaderboard + if (requesterId) { + const requester = leaderboard.find(p => p.id === requesterId); + const requesterName = requester ? requester.name : 'Unknown'; + const timestamp = new Date().toLocaleString(); + console.log(`${timestamp} Score requested by: ${requesterName} (${requesterId.slice(-5)})`); + } + + // Save the full leaderboard (including hidden players) + saveLeaderboard(leaderboard); + + // Return only visible players (top 50) + res.json(visiblePlayers.slice(0, 50)); +}); + +// Get player info (works even if not visible on leaderboard) +app.get('/api/player/:playerId', (req, res) => { + const playerId = req.params.playerId; + let leaderboard = loadLeaderboard(); + + const player = leaderboard.find(p => p.id === playerId); + + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + // Update hole age + player.holeAge = Date.now() - player.firstSeen; + + // Calculate days since last seen + player.daysSinceLastSeen = Math.floor((Date.now() - player.lastSeen) / (24 * 60 * 60 * 1000)); + + // Check if visible on leaderboard + const fiveDaysAgo = Date.now() - (LEADERBOARD_VISIBLE_DAYS * 24 * 60 * 60 * 1000); + player.visibleOnLeaderboard = player.lastSeen > fiveDaysAgo; + + res.json(player); +}); + +// Manual cleanup endpoint (optional - for maintenance) +app.post('/api/cleanup', (req, res) => { + let leaderboard = loadLeaderboard(); + const beforeCount = leaderboard.length; + + leaderboard = cleanupInactive(leaderboard); + saveLeaderboard(leaderboard); + + res.json({ + removed: beforeCount - leaderboard.length, + remaining: leaderboard.length + }); +}); + +// Stats endpoint (optional - see how many are hidden vs visible) +app.get('/api/stats', (req, res) => { + let leaderboard = loadLeaderboard(); + const visiblePlayers = getVisiblePlayers(leaderboard); + + res.json({ + totalPlayers: leaderboard.length, + visiblePlayers: visiblePlayers.length, + hiddenPlayers: leaderboard.length - visiblePlayers.length + }); +}); + +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`); +}); diff --git a/js/storage.js b/js/storage.js new file mode 100644 index 0000000..2e807db --- /dev/null +++ b/js/storage.js @@ -0,0 +1,106 @@ +var Storage = { + setCookie: function(name, value, days) { + var expires = ""; + if (days) { + var date = new Date(); + date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Strict"; + }, + + getCookie: function(name) { + var nameEQ = name + "="; + var ca = document.cookie.split(';'); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length); + } + return null; + }, + + deleteCookie: function(name) { + 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(); + } +}; diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..9803ebb --- /dev/null +++ b/js/ui.js @@ -0,0 +1,338 @@ +// UI management and event handlers + +var UI = { + elements: {}, + + init: function() { + // Cache DOM elements + this.elements = { + totalMass: document.getElementById('total-mass'), + spendableMass: document.getElementById('spendable-mass'), + totalLevel: document.getElementById('total-level'), + asteroidLevel: document.getElementById('asteroid-level'), + cometLevel: document.getElementById('comet-level'), + planetLevel: document.getElementById('planet-level'), + giantLevel: document.getElementById('giant-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'), + gearIcon: document.getElementById('gear-icon'), + settingsMenu: document.getElementById('settings-menu'), + resetBtn: document.getElementById('reset-btn'), + leaderboardModal: document.getElementById('leaderboardModal'), + leaderboardToggle: document.getElementById('leaderboardToggle'), + leaderboardPanel: document.getElementById('leaderboardPanel'), + leaderboardList: document.getElementById('leaderboardList'), + sortMass: document.getElementById('sortMass'), + sortAge: document.getElementById('sortAge') + }; + + // Sorting buttons + this.elements.sortMass.addEventListener('click', () => this.openLeaderboard('mass')); + this.elements.sortAge.addEventListener('click', () => this.openLeaderboard('age')); + + this.setupEventListeners(); + }, + + setupEventListeners: function() { + const self = this; + const gear = self.elements.gearIcon; + 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'); + }); + + // Prevent clicks inside menu from closing it + settingsMenu.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + // ---------- Leaderboard ---------- + if (leaderboardToggle && leaderboardPanel) { + // Toggle leaderboard panel + leaderboardToggle.addEventListener('click', (e) => { + e.stopPropagation(); // prevent document click + leaderboardPanel.classList.toggle('show'); + + if (leaderboardPanel.classList.contains('show')) { + self.openLeaderboard('mass'); // load leaderboard by mass immediately + } + }); + + // Prevent clicks inside the panel from closing it + leaderboardPanel.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + // ---------- Click outside to close ---------- + document.addEventListener('click', () => { + // Close settings menu + if (settingsMenu) { + settingsMenu.classList.remove('open'); + } + + // Close leaderboard panel + if (leaderboardPanel) { + leaderboardPanel.classList.remove('show'); + } + }); + }, + + setUpgradeHandlers: function(handlers) { + this.elements.asteroidUpgradeBtn.addEventListener('click', handlers.asteroid); + this.elements.cometUpgradeBtn.addEventListener('click', handlers.comet); + this.elements.planetUpgradeBtn.addEventListener('click', handlers.planet); + this.elements.giantUpgradeBtn.addEventListener('click', handlers.giant); + }, + + update: function(gameState, config) { + if (this.elements.spendableMass) { + this.elements.spendableMass.textContent = + 'Available to Spend: ' + this.formatMass(gameState.totalMassConsumed); + } + this.updateMassDisplay(gameState, config); + this.updateUpgradeDisplay(gameState); + this.updateTotalLevel(gameState); + }, + + 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); + + this.elements.totalMass.innerHTML = ` + + Black Hole Mass: ${bhMassText} + + The mass of your black hole in solar masses.
+ 1 Solar Mass (M☉) ≈ 1.989 × 10³⁰ kg.

+ Total mass absorbed:
+ ${consumedText} +
+

+ Rate: ${rateText} + `; + }, + + updateUpgradeDisplay: function(gameState) { + this.updateAsteroidUpgrade(gameState); + this.updateCometUpgrade(gameState); + this.updatePlanetUpgrade(gameState); + this.updateGiantUpgrade(gameState); + }, + updateTotalLevel: function(gameState) { + var el = this.elements.totalLevel; + if (!el) return; + + var totalLevel = + gameState.asteroidUpgradeLevel + + gameState.cometUpgradeLevel + + gameState.planetUpgradeLevel + + gameState.giantUpgradeLevel; + + el.innerHTML = + '' + + 'Total Level: ' + totalLevel + + '' + + 'Sum of all upgrades.
' + '
' + + '
'; + }, + + + updateAsteroidUpgrade: function(gameState) { + var rate = (1 / gameState.currentAsteroidSpawnInterval * 1000).toFixed(2); + var bonusPercent = (gameState.asteroidUpgradeLevel * 10); + var tooltipText = 'Rate: ' + rate + '/sec
Bonus: ' + bonusPercent + '%'; + + this.elements.asteroidLevel.innerHTML = 'Asteroids: Level ' + + gameState.asteroidUpgradeLevel + + '' + tooltipText + ''; + + this.elements.asteroidUpgradeBtn.textContent = + 'Upgrade (Cost: ' + this.formatMass(gameState.asteroidUpgradeCost) + ')'; + + this.elements.asteroidUpgradeBtn.disabled = + gameState.totalMassConsumed < gameState.asteroidUpgradeCost; + }, + + updateCometUpgrade: function(gameState) { + var rate = (1 / gameState.currentCometSpawnInterval * 60000).toFixed(2); + var bonusPercent = (gameState.cometUpgradeLevel * 10); + var tooltipText = 'Rate: ' + rate + '/min
Bonus: ' + bonusPercent + '%'; + + this.elements.cometLevel.innerHTML = 'Comets: Level ' + + gameState.cometUpgradeLevel + + '' + tooltipText + ''; + + this.elements.cometUpgradeBtn.textContent = + 'Upgrade (Cost: ' + this.formatMass(gameState.cometUpgradeCost) + ')'; + + this.elements.cometUpgradeBtn.disabled = + gameState.totalMassConsumed < gameState.cometUpgradeCost; + }, + + updatePlanetUpgrade: function(gameState) { + var rate = (3600000 / gameState.currentPlanetSpawnInterval).toFixed(2); + var bonusPercent = (gameState.planetUpgradeLevel * 10); + var tooltipText = 'Rate: ' + rate + '/hour
Bonus: ' + bonusPercent + '%'; + + this.elements.planetLevel.innerHTML = 'Planets: Level ' + + gameState.planetUpgradeLevel + + '' + tooltipText + ''; + + this.elements.planetUpgradeBtn.textContent = + 'Upgrade (Cost: ' + this.formatMass(gameState.planetUpgradeCost) + ')'; + + this.elements.planetUpgradeBtn.disabled = + gameState.totalMassConsumed < gameState.planetUpgradeCost; + }, + + updateGiantUpgrade: function(gameState) { + var rate = (21600000 / gameState.currentGiantSpawnInterval).toFixed(2); + var bonusPercent = (gameState.giantUpgradeLevel * 10); + var tooltipText = 'Spawn Rate: ' + rate + '/6hours
Bonus: ' + bonusPercent + '%'; + + this.elements.giantLevel.innerHTML = 'Giants: Level ' + + gameState.giantUpgradeLevel + + '' + tooltipText + ''; + + this.elements.giantUpgradeBtn.textContent = + 'Upgrade (Cost: ' + this.formatMass(gameState.giantUpgradeCost) + ')'; + + 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 + }, + + 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'; + } else if (hours > 0) { + return hours + 'h ' + (minutes % 60) + 'm'; + } else if (minutes > 0) { + return minutes + 'm ' + (seconds % 60) + 's'; + } else { + 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'; + } else if (hours > 0) { + return hours + 'h old'; + } else { + return 'New'; + } + }, + + + openLeaderboard: async function(sortBy = 'mass') { + const container = this.elements.leaderboardList; + 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; + }, + + escapeHtml: function(text) { + if (!text) return ''; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +}; diff --git a/style.css b/style.css new file mode 100644 index 0000000..43bf36d --- /dev/null +++ b/style.css @@ -0,0 +1,316 @@ +body, html { + margin: 0; + padding: 0; + overflow: hidden; + background: #000; + font-family: 'Courier New', monospace; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + touch-action: pan-x pan-y; +} + +* { + touch-action: manipulation; +} + +canvas { + display: block; +} + +#gear-icon { + position: absolute; + top: 20px; + right: 20px; + width: 20px; + height: 20px; + cursor: pointer; + opacity: 0.3; + transition: opacity 0.2s; + z-index: 1000; +} + +#gear-icon:hover { + opacity: 0.6; +} + +#settings-menu { + display: none; + position: absolute; + top: 50px; + right: 20px; + background: rgba(20, 20, 30, 0.95); + 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; + line-height: 1.5; + z-index: 999; +} + +#settings-menu.open { + display: block; +} + +.menu-title { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.menu-text { + margin-bottom: 12px; +} + +.menu-btn { + background: rgba(60, 60, 80, 0.5); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.5); + padding: 6px 12px; + margin-top: 8px; + margin-right: 8px; + cursor: pointer; + font-family: 'Courier New', monospace; + font-size: 11px; + 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); +} + +#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; +} + +#upgrade-panel { + position: absolute; + top: 90px; + left: 20px; + color: rgba(255, 255, 255, 0.3); + font-size: 12px; +} + +.upgrade-row { + margin-bottom: 8px; +} + +.upgrade-btn { + background: rgba(60, 60, 80, 0.5); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.5); + padding: 4px 8px; + margin-top: 4px; + cursor: pointer; + font-family: 'Courier New', monospace; + font-size: 11px; +} + +.upgrade-btn:hover { + background: rgba(80, 80, 100, 0.6); +} + +.upgrade-btn:disabled { + cursor: not-allowed; + opacity: 0.3; +} + +.modal { + position: absolute; + top: 80px; + right: 20px; + display: none; + width: 360px; + z-index: 5000; + background: transparent; +} + +.modal.show { + display: block; +} + +.modal-content { + background: rgba(20, 20, 30, 0.95); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 16px; + border-radius: 4px; + color: rgba(255, 255, 255, 0.7); + max-height: 80vh; + overflow-y: auto; +} + +.leaderboard-entry { + display: flex; + 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 .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; + white-space: nowrap; + font-size: 11px; +} + +.leaderboard-entry .value { + color: rgba(255, 255, 255, 0.7); + min-width: 80px; + text-align: right; + font-size: 11px; +} + +.leaderboard-entry .value.mass { + color: rgba(255, 255, 255, 0.7); +} + +.leaderboard-entry .value.age { + color: rgba(255, 255, 255, 0.7); +} + +.value.mass.sorted { + font-weight: bold; +} + +.value.age.sorted { + font-weight: bold; +} + +.leaderboard-entry.local-player { + border: 1px solid rgba(255, 100, 150, 0.3); +} + +.menu-title { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.menu-text { + margin-bottom: 12px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + 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; + height: 6px; + background: #00ff00; + border-radius: 50%; + margin-left: 6px; + margin-right: 6px; + box-shadow: 0 0 1px #00ff00, 0 0 3px #00ff00; + animation: pulse 3s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 6px #00ff00, 0 0 6px #00ff00; + } + 50% { + opacity: 0.7; + box-shadow: 0 0 4px #00ff00, 0 0 6px #00ff00; + } +}