hoel
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
dev/
|
||||||
|
|
||||||
|
leaderboard.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
package.json
|
||||||
128
index.html
Normal file
128
index.html
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>hoel</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<svg id="gear-icon" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15
|
||||||
|
a1.7 1.7 0 0 0 .3 1.8l.1.1
|
||||||
|
a2 2 0 0 1-2.8 2.8l-.1-.1
|
||||||
|
a1.7 1.7 0 0 0-1.8-.3
|
||||||
|
a1.7 1.7 0 0 0-1 1.5V21
|
||||||
|
a2 2 0 0 1-4 0v-.1
|
||||||
|
a1.7 1.7 0 0 0-1-1.5
|
||||||
|
a1.7 1.7 0 0 0-1.8.3l-.1.1
|
||||||
|
a2 2 0 0 1-2.8-2.8l.1-.1
|
||||||
|
a1.7 1.7 0 0 0 .3-1.8
|
||||||
|
a1.7 1.7 0 0 0-1.5-1H3
|
||||||
|
a2 2 0 0 1 0-4h.1
|
||||||
|
a1.7 1.7 0 0 0 1.5-1
|
||||||
|
a1.7 1.7 0 0 0-.3-1.8l-.1-.1
|
||||||
|
a2 2 0 0 1 2.8-2.8l.1.1
|
||||||
|
a1.7 1.7 0 0 0 1.8.3
|
||||||
|
a1.7 1.7 0 0 0 1-1.5V3
|
||||||
|
a2 2 0 0 1 4 0v.1
|
||||||
|
a1.7 1.7 0 0 0 1 1.5
|
||||||
|
a1.7 1.7 0 0 0 1.8-.3l.1-.1
|
||||||
|
a2 2 0 0 1 2.8 2.8l-.1.1
|
||||||
|
a1.7 1.7 0 0 0-.3 1.8
|
||||||
|
a1.7 1.7 0 0 0 1.5 1H21
|
||||||
|
a2 2 0 0 1 0 4h-.1
|
||||||
|
a1.7 1.7 0 0 0-1.5 1z"></path>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div id="settings-menu">
|
||||||
|
<div class="menu-title">Game Settings</div>
|
||||||
|
<div class="menu-text">
|
||||||
|
This game uses a browser cookie to save your progress across sessions.
|
||||||
|
The cookie stores your black hole mass, consumed resources, upgrade levels and spawn timers.
|
||||||
|
</div>
|
||||||
|
<div class="menu-text">
|
||||||
|
By using this website, you consent to storing a cookie on your device.
|
||||||
|
</div>
|
||||||
|
<button id="reset-btn" class="menu-btn danger">Reset</button>
|
||||||
|
<div id="save-status" style="margin-top: 12px; font-size: 10px; color: rgba(100, 200, 100, 0.6);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats-panel">
|
||||||
|
<div id="total-mass"></div>
|
||||||
|
</br>
|
||||||
|
<div id="total-level" class="stat-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="upgrade-panel">
|
||||||
|
<div class="upgrade-row">
|
||||||
|
</br>
|
||||||
|
<div id="spendable-mass">Available to Spend: 0 kg</div>
|
||||||
|
</br>
|
||||||
|
<div id="asteroid-level"></div>
|
||||||
|
<button id="asteroid-upgrade-btn" class="upgrade-btn" disabled>Upgrade (Cost: 1 tonne)</button>
|
||||||
|
</div>
|
||||||
|
<div class="upgrade-row">
|
||||||
|
<div id="comet-level"></div>
|
||||||
|
<button id="comet-upgrade-btn" class="upgrade-btn" disabled>Upgrade (Cost: 10 tonnes)</button>
|
||||||
|
</div>
|
||||||
|
<div class="upgrade-row">
|
||||||
|
<div id="planet-level"></div>
|
||||||
|
<button id="planet-upgrade-btn" class="upgrade-btn" disabled>Upgrade (Cost: 100 tonnes)</button>
|
||||||
|
</div>
|
||||||
|
<div class="upgrade-row">
|
||||||
|
<div id="giant-level"></div>
|
||||||
|
<button id="giant-upgrade-btn" class="upgrade-btn" disabled>Upgrade (Cost: 1000 tonnes)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaderboard Toggle -->
|
||||||
|
<div id="leaderboardToggle" class="score-icon">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<!-- Large circle -->
|
||||||
|
<path d="
|
||||||
|
M 6 6
|
||||||
|
a 4 4 0 1 0 8 0
|
||||||
|
a 4 4 0 1 0 -8 0
|
||||||
|
" stroke="#fff"/>
|
||||||
|
|
||||||
|
<!-- Medium circle -->
|
||||||
|
<path d="
|
||||||
|
M 14 13
|
||||||
|
a 3 3 0 1 0 6 0
|
||||||
|
a 3 3 0 1 0 -6 0
|
||||||
|
" stroke="#fff"/>
|
||||||
|
|
||||||
|
<!-- Small circle -->
|
||||||
|
<path d="
|
||||||
|
M 5 16.5
|
||||||
|
a 4 4 0 1 0 8 0
|
||||||
|
a 4 4 0 1 0 -8 0
|
||||||
|
" stroke="#fff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaderboard Panel -->
|
||||||
|
<div id="leaderboardPanel" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="leaderboard-title">
|
||||||
|
<span class="rank" style="width:20px;"></span>
|
||||||
|
<span class="name" style="flex:1; min-width:80px;">Name</span>
|
||||||
|
<span id="sortMass" class="value mass" style="min-width: 115px; cursor:pointer">Mass</span>
|
||||||
|
<span id="sortAge" class="value age" style="padding-right: 30px; cursor:pointer">Age</span>
|
||||||
|
</div>
|
||||||
|
<div id="leaderboardList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas id="space"></canvas>
|
||||||
|
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/helpers.js"></script>
|
||||||
|
<script src="js/entities.js"></script>
|
||||||
|
<script src="js/storage.js"></script>
|
||||||
|
<script src="js/ui.js"></script>
|
||||||
|
<script src="js/game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
js/config.js
Normal file
100
js/config.js
Normal file
@ -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'
|
||||||
|
}
|
||||||
|
};
|
||||||
469
js/entities.js
Normal file
469
js/entities.js
Normal file
@ -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';
|
||||||
|
// }
|
||||||
|
//};
|
||||||
|
|
||||||
472
js/game.js
Normal file
472
js/game.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
125
js/helpers.js
Normal file
125
js/helpers.js
Normal file
@ -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 =
|
||||||
|
'<div>Inactive for: <strong>' + timeAway + '</strong></div>' +
|
||||||
|
'<div>Mass gained: <strong>' + massGained + '</strong></div>';
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
document.getElementById('space').addEventListener('click', function() {
|
||||||
|
notification.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
202
js/server.js
Normal file
202
js/server.js
Normal file
@ -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`);
|
||||||
|
});
|
||||||
106
js/storage.js
Normal file
106
js/storage.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
338
js/ui.js
Normal file
338
js/ui.js
Normal file
@ -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 = `
|
||||||
|
<span class="${'rate-' + (state.prevRateShortTrend || 'same')}">${rateShortText}</span>,
|
||||||
|
<span class="${'rate-' + (state.prevRateMediumTrend || 'same')}">${rateMediumText}</span>,
|
||||||
|
<span class="${'rate-' + (state.prevRateLongTrend || 'same')}">${rateLongText}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const bhMassText = this.formatMass(state.blackHoleTotalMass, { forceSolarMass: true });
|
||||||
|
const consumedText = this.formatMass(state.totalMassConsumedEver);
|
||||||
|
|
||||||
|
this.elements.totalMass.innerHTML = `
|
||||||
|
<span class="tooltip-trigger">
|
||||||
|
Black Hole Mass: ${bhMassText}
|
||||||
|
<span class="tooltip">
|
||||||
|
The mass of your black hole in solar masses.<br>
|
||||||
|
1 Solar Mass (M☉) ≈ 1.989 × 10³⁰ kg.<br><br>
|
||||||
|
Total mass absorbed:<br>
|
||||||
|
${consumedText}
|
||||||
|
</span>
|
||||||
|
</span><br>
|
||||||
|
<span style="font-size:10px; opacity:0.7;">Rate: ${rateText}</span>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<span class="tooltip-trigger">' +
|
||||||
|
'Total Level: ' + totalLevel +
|
||||||
|
'<span class="tooltip">' +
|
||||||
|
'Sum of all upgrades.<br>'
|
||||||
|
'</span>' +
|
||||||
|
'</span>';
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
updateAsteroidUpgrade: function(gameState) {
|
||||||
|
var rate = (1 / gameState.currentAsteroidSpawnInterval * 1000).toFixed(2);
|
||||||
|
var bonusPercent = (gameState.asteroidUpgradeLevel * 10);
|
||||||
|
var tooltipText = 'Rate: ' + rate + '/sec <br>Bonus: ' + bonusPercent + '%';
|
||||||
|
|
||||||
|
this.elements.asteroidLevel.innerHTML = '<span class="tooltip-trigger">Asteroids: Level ' +
|
||||||
|
gameState.asteroidUpgradeLevel +
|
||||||
|
'<span class="tooltip">' + tooltipText + '</span></span>';
|
||||||
|
|
||||||
|
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 <br>Bonus: ' + bonusPercent + '%';
|
||||||
|
|
||||||
|
this.elements.cometLevel.innerHTML = '<span class="tooltip-trigger">Comets: Level ' +
|
||||||
|
gameState.cometUpgradeLevel +
|
||||||
|
'<span class="tooltip">' + tooltipText + '</span></span>';
|
||||||
|
|
||||||
|
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 <br>Bonus: ' + bonusPercent + '%';
|
||||||
|
|
||||||
|
this.elements.planetLevel.innerHTML = '<span class="tooltip-trigger">Planets: Level ' +
|
||||||
|
gameState.planetUpgradeLevel +
|
||||||
|
'<span class="tooltip">' + tooltipText + '</span></span>';
|
||||||
|
|
||||||
|
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 <br>Bonus: ' + bonusPercent + '%';
|
||||||
|
|
||||||
|
this.elements.giantLevel.innerHTML = '<span class="tooltip-trigger">Giants: Level ' +
|
||||||
|
gameState.giantUpgradeLevel +
|
||||||
|
'<span class="tooltip">' + tooltipText + '</span></span>';
|
||||||
|
|
||||||
|
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 = '<div style="text-align:center; padding:20px;">Loading...</div>';
|
||||||
|
|
||||||
|
// Fetch leaderboard
|
||||||
|
let data = await Server.getLeaderboard(sortBy);
|
||||||
|
if (!data.length) {
|
||||||
|
container.innerHTML = '<div style="text-align:center; padding:20px;">No entries yet</div>';
|
||||||
|
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 ? '<span class="online-indicator"></span>' : '';
|
||||||
|
const nameSuffix = isLocal ? '💗' : '';
|
||||||
|
|
||||||
|
// Use last 5 chars of playerId if available, otherwise fallback
|
||||||
|
const displayName = entry.id
|
||||||
|
? entry.id.slice(-5)
|
||||||
|
: 'Anon';
|
||||||
|
|
||||||
|
html += `<div class="leaderboard-entry${isLocal ? ' local-player' : ''}">
|
||||||
|
<span class="rank">${medal}</span>
|
||||||
|
<span class="name">${this.escapeHtml(displayName)}${onlineIndicator}${nameSuffix}</span>
|
||||||
|
<span class="value mass${sortBy==='mass'?' sorted':''}">${this.formatMass(entry.mass,{forceSolarMass:true})}</span>
|
||||||
|
<span class="value age${sortBy==='age'?' sorted':''}">${this.formatAge(entry.holeAge)}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeHtml: function(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
};
|
||||||
316
style.css
Normal file
316
style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user