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