This commit is contained in:
xbl
2026-01-22 19:21:59 +01:00
commit 99fe6d2681
10 changed files with 2262 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
dev/
leaderboard.json
node_modules/
package-lock.json
package.json

128
index.html Normal file
View 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
View 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
View 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 (01)
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
View 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
View 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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
};

316
style.css Normal file
View 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;
}
}