This commit is contained in:
xbl
2026-02-01 22:11:20 +01:00
parent 99fe6d2681
commit e467c971a3
13 changed files with 2046 additions and 630 deletions

195
admin.css Normal file
View File

@ -0,0 +1,195 @@
/* Admin Panel Styles */
#admin-panel {
position: fixed;
top: 20px;
right: 20px;
width: 320px;
max-height: calc(100vh - 40px);
background: rgba(15, 15, 25, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: rgba(255, 255, 255, 0.9);
font-family: 'Courier New', monospace;
font-size: 11px;
z-index: 9999;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
#admin-panel.collapsed #admin-content {
display: none;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(30, 30, 50, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-header h2 {
margin: 0;
font-size: 13px;
font-weight: bold;
color: rgba(100, 200, 255, 0.9);
letter-spacing: 1px;
}
#admin-toggle {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
cursor: pointer;
padding: 4px 8px;
transition: color 0.2s;
}
#admin-toggle:hover {
color: rgba(255, 255, 255, 0.9);
}
#admin-content {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding: 16px;
}
.admin-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-section:last-child {
border-bottom: none;
}
.admin-section h3 {
margin: 0 0 10px 0;
font-size: 12px;
color: rgba(200, 200, 220, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.admin-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.admin-buttons:last-child {
margin-bottom: 0;
}
.admin-buttons button {
flex: 1;
min-width: 80px;
background: rgba(60, 60, 80, 0.6);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
padding: 6px 10px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 10px;
border-radius: 4px;
transition: all 0.2s;
}
.admin-buttons button:hover {
background: rgba(80, 80, 100, 0.8);
color: rgba(255, 255, 255, 1);
border-color: rgba(255, 255, 255, 0.4);
}
.admin-buttons button:active {
background: rgba(100, 100, 120, 0.8);
}
.admin-input-group {
display: flex;
gap: 6px;
margin-bottom: 8px;
align-items: center;
}
.admin-input-group label {
min-width: 70px;
color: rgba(200, 200, 220, 0.8);
font-size: 10px;
}
.admin-input-group input {
flex: 1;
background: rgba(30, 30, 50, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
padding: 6px 8px;
font-family: 'Courier New', monospace;
font-size: 10px;
border-radius: 4px;
}
.admin-input-group input:focus {
outline: none;
border-color: rgba(100, 200, 255, 0.6);
}
.admin-input-group button {
min-width: 50px;
background: rgba(60, 60, 80, 0.6);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
padding: 6px 12px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 10px;
border-radius: 4px;
transition: all 0.2s;
}
.admin-input-group button:hover {
background: rgba(80, 80, 100, 0.8);
border-color: rgba(255, 255, 255, 0.4);
}
.admin-info {
background: rgba(20, 20, 30, 0.6);
padding: 10px;
border-radius: 4px;
font-size: 10px;
line-height: 1.8;
}
.admin-info div {
display: flex;
justify-content: space-between;
color: rgba(180, 180, 200, 0.9);
}
.admin-info span {
color: rgba(100, 200, 255, 0.9);
font-weight: bold;
}
/* Scrollbar styling */
#admin-content::-webkit-scrollbar {
width: 8px;
}
#admin-content::-webkit-scrollbar-track {
background: rgba(20, 20, 30, 0.5);
}
#admin-content::-webkit-scrollbar-thumb {
background: rgba(100, 100, 120, 0.6);
border-radius: 4px;
}
#admin-content::-webkit-scrollbar-thumb:hover {
background: rgba(120, 120, 140, 0.8);
}

136
admin.html Normal file
View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hoel - Admin</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="admin.css">
</head>
<body>
<!-- Admin Panel -->
<div id="admin-panel">
<div class="admin-header">
<h2>ADMIN PANEL</h2>
<button id="admin-toggle" title="Toggle Panel"></button>
</div>
<div id="admin-content">
<!-- Spawn Objects -->
<div class="admin-section">
<h3>Spawn Objects</h3>
<div class="admin-buttons">
<button onclick="adminSpawn('small')">Small Asteroid</button>
<button onclick="adminSpawn('medium')">Medium Asteroid</button>
<button onclick="adminSpawn('large')">Large Asteroid</button>
<button onclick="adminSpawn('comet')">Comet</button>
<button onclick="adminSpawn('planet')">Planet</button>
<button onclick="adminSpawn('giant')">Giant</button>
<button onclick="adminSpawn('mtype')">M-Type</button>
</div>
</div>
<!-- Set Levels -->
<div class="admin-section">
<h3>Set Levels</h3>
<div class="admin-input-group">
<label>Asteroids:</label>
<input type="number" id="admin-asteroid-level" min="0" value="0">
<button onclick="adminSetLevel('asteroid')">Set</button>
</div>
<div class="admin-input-group">
<label>Comets:</label>
<input type="number" id="admin-comet-level" min="0" value="0">
<button onclick="adminSetLevel('comet')">Set</button>
</div>
<div class="admin-input-group">
<label>Planets:</label>
<input type="number" id="admin-planet-level" min="0" value="0">
<button onclick="adminSetLevel('planet')">Set</button>
</div>
<div class="admin-input-group">
<label>Giants:</label>
<input type="number" id="admin-giant-level" min="0" value="0">
<button onclick="adminSetLevel('giant')">Set</button>
</div>
</div>
<!-- Add Mass -->
<div class="admin-section">
<h3>Add Mass</h3>
<div class="admin-input-group">
<input type="number" id="admin-mass-input" placeholder="Amount in kg" value="1000000">
<button onclick="adminAddMass()">Add Mass</button>
</div>
<div class="admin-buttons">
<button onclick="adminAddMass(1e9)">+1 Gt</button>
<button onclick="adminAddMass(1e12)">+1 Tt</button>
<button onclick="adminAddMass(1e15)">+1 Pt</button>
</div>
</div>
<!-- Notifications -->
<div class="admin-section">
<h3>Test Notifications</h3>
<div class="admin-buttons">
<button onclick="showUnlockNotification('comet')">Unlock: Comet</button>
<button onclick="showUnlockNotification('planet')">Unlock: Planet</button>
<button onclick="showUnlockNotification('giant')">Unlock: Giant</button>
</div>
<div class="admin-buttons">
<button onclick="showOfflineNotification('5 minutes', '1.23 Tt')">Offline Progress</button>
<button onclick="showErrorNotification('Test error message')">Error</button>
<button onclick="showSuccessNotification('Test success message')">Success</button>
</div>
</div>
<!-- Unlocks -->
<div class="admin-section">
<h3>Toggle Unlocks</h3>
<div class="admin-buttons">
<button onclick="adminToggleUnlock('comet')">Toggle Comets</button>
<button onclick="adminToggleUnlock('planet')">Toggle Planets</button>
<button onclick="adminToggleUnlock('giant')">Toggle Giants</button>
</div>
</div>
<!-- Game State -->
<div class="admin-section">
<h3>Game State</h3>
<div class="admin-buttons">
<button onclick="adminSaveGame()">Save Now</button>
<button onclick="adminLoadGame()">Reload State</button>
<button onclick="adminResetGame()">Reset Game</button>
</div>
<div class="admin-buttons">
<button onclick="adminExportState()">Export JSON</button>
<button onclick="adminImportState()">Import JSON</button>
</div>
</div>
<!-- Info Display -->
<div class="admin-section">
<h3>Current State</h3>
<div id="admin-info" class="admin-info">
<div>Player ID: <span id="admin-player-id">-</span></div>
<div>Mass: <span id="admin-mass">-</span></div>
<div>Total Level: <span id="admin-total-level">-</span></div>
<div>Asteroids: <span id="admin-asteroids">-</span></div>
<div>Active Objects: <span id="admin-objects">-</span></div>
</div>
</div>
</div>
</div>
<!-- Game Canvas -->
<canvas id="space"></canvas>
<!-- Same scripts as index.html -->
<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/admin.js"></script>
<script src="js/game.js"></script>
</body>
</html>

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hoel</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.png" type="image/png">
</head>
<body>
<svg id="gear-icon" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1">
@ -40,10 +41,13 @@ a1.7 1.7 0 0 0-1.5 1z"></path>
<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.
By using this website, you consent to storing a cookie on your device.
</div>
<div class="menu-text">
By using this website, you consent to storing a cookie on your device.
The game periodically sends parts of your session to the server to populate the neighbour chart.
</div>
<div class="menu-text">
Sessions are hidden from the chart after 5 days and deleted from the database after 30 days of inactivity.
</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>
@ -51,11 +55,14 @@ a1.7 1.7 0 0 0-1.5 1z"></path>
<div id="stats-panel">
<div id="total-mass"></div>
</br>
<div id="total-level" class="stat-line"></div>
<br>
<div id="active-objects"></div>
<span id="massRate"></span>
</div>
<div id="upgrade-panel">
<div class="menu-title">Upgrades</div>
<div id="total-level" class="stat-line"></div>
<div class="upgrade-row">
</br>
<div id="spendable-mass">Available to Spend: 0 kg</div>
@ -75,6 +82,10 @@ a1.7 1.7 0 0 0-1.5 1z"></path>
<div id="giant-level"></div>
<button id="giant-upgrade-btn" class="upgrade-btn" disabled>Upgrade (Cost: 1000 tonnes)</button>
</div>
<div class="upgrade-row">
<div id="mtype-level"></div>
<button id="mtype-upgrade-btn" class="upgrade-btn" disabled>Upgrade</button>
</div>
</div>
<!-- Leaderboard Toggle -->
@ -106,16 +117,23 @@ a1.7 1.7 0 0 0-1.5 1z"></path>
<!-- 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 class="menu-title">Neighbour Chart</div>
<div class="leaderboard-title" style="">
<span class="rank"></span>
<span class="name">Name</span>
<span class="value last-seen">Seen</span>
<span id="sortMass" class="value mass">Mass</span>
<span id="sortAge" class="value age">Age</span>
</div>
<div id="leaderboardList"></div>
</div>
</div>
<svg id="hole-icon" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="7" stroke="white" stroke-width="1" fill="none" />
<line x1="0" y1="12" x2="24" y2="12" stroke="white" stroke-width="1" />
</svg>
<canvas id="space"></canvas>
<script src="js/config.js"></script>

309
js/admin.js Normal file
View File

@ -0,0 +1,309 @@
// ===== Admin Mode Setup =====
// Admin mode flags
window.isAdminMode = true;
window.adminPlayerId = 'admin_dev';
// Create complete UI replacement
window.UI = {
elements: {},
init: function() {
console.log('Admin: Using stub UI');
},
setUpgradeHandlers: function(handlers) {
// Store handlers for admin to call
window.adminUpgradeHandlers = handlers;
},
update: function(state, config) {
// Update admin info display if it exists
if (document.getElementById('admin-mass')) {
updateAdminInfo();
}
},
formatMass: function(mass, options) {
if (options && options.forceSolarMass) {
var solarMasses = mass / CONFIG.SOLAR_MASS_KG;
return solarMasses.toFixed(3) + ' M☉';
}
if (mass >= 1e18) return (mass / 1e18).toFixed(2) + ' Et';
if (mass >= 1e15) return (mass / 1e15).toFixed(2) + ' Pt';
if (mass >= 1e12) return (mass / 1e12).toFixed(2) + ' Tt';
if (mass >= 1e9) return (mass / 1e9).toFixed(2) + ' Gt';
if (mass >= 1e6) return (mass / 1e6).toFixed(2) + ' Mt';
if (mass >= 1e3) return (mass / 1e3).toFixed(2) + ' tonnes';
return mass.toFixed(0) + ' kg';
},
updateMassDisplay: function() {},
updateUpgradeDisplay: function() {},
updateTotalLevel: function() {},
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';
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
if (minutes > 0) return minutes + 'm';
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';
if (hours > 0) return hours + 'h old';
return 'New';
}
};
window.UI.updateActiveObjects = function() {
if (window.gameAsteroids) {
var el = document.getElementById('admin-objects');
if (el) el.textContent = window.gameAsteroids.length;
}
};
// ===== Wait for game to load =====
window.addEventListener('load', function() {
// Override Server.init
if (window.Server) {
var originalInit = Server.init;
Server.init = function() {
this.playerId = window.adminPlayerId;
Storage.setCookie('playerId', this.playerId, 365);
console.log('Admin mode: Player ID =', this.playerId);
}
// Override checkpoint to do nothing
Server.checkpoint = function() {
console.log('Admin: Checkpoint skipped');
return Promise.resolve({ success: true });
};
}
// Toggle admin panel
var toggleBtn = document.getElementById('admin-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
var panel = document.getElementById('admin-panel');
panel.classList.toggle('collapsed');
this.textContent = panel.classList.contains('collapsed') ? '▲' : '▼';
});
}
console.log('%c[ADMIN MODE ENABLED]', 'color: #64c8ff; font-weight: bold; font-size: 14px;');
});
// ===== Admin Functions =====
// Spawn objects
function adminSpawn(type) {
if (!window.Game || !Game.getState) {
console.error('Game not initialized');
return;
}
var canvas = document.getElementById('space');
var blackHole = window.gameBlackHole;
if (!blackHole) {
console.error('Black hole not available');
return;
}
window.gameAsteroids.push(new Asteroid(type, blackHole, canvas));
console.log('Spawned:', type);
}
// Set upgrade levels
function adminSetLevel(upgradeType) {
if (!window.Game || !Game.getState) return;
var state = Game.getState();
var inputId = 'admin-' + upgradeType + '-level';
var input = document.getElementById(inputId);
if (!input) return;
var level = parseInt(input.value) || 0;
switch(upgradeType) {
case 'asteroid':
state.asteroidUpgradeLevel = level;
state.asteroidUpgradeCost = Math.floor(
CONFIG.ASTEROID_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_ASTER, level)
);
if (level >= 25) state.cometUnlocked = true;
break;
case 'comet':
state.cometUpgradeLevel = level;
state.cometUpgradeCost = Math.floor(
CONFIG.COMET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, level)
);
if (level >= 20) state.planetUnlocked = true;
break;
case 'planet':
state.planetUpgradeLevel = level;
state.planetUpgradeCost = Math.floor(
CONFIG.PLANET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, level)
);
if (level >= 15) state.giantUnlocked = true;
break;
case 'giant':
state.giantUpgradeLevel = level;
state.giantUpgradeCost = Math.floor(
CONFIG.GIANT_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, level)
);
break;
}
UI.update(state, CONFIG);
console.log('Set', upgradeType, 'level to:', level);
}
// Add mass
function adminAddMass(amount) {
if (!window.Game || !Game.getState) return;
var state = Game.getState();
if (!amount) {
var input = document.getElementById('admin-mass-input');
amount = input ? (parseFloat(input.value) || 0) : 0;
}
state.blackHoleTotalMass += amount;
state.totalMassConsumedEver += amount;
state.totalMassConsumed += amount;
// Update black hole size
var solarMasses = state.blackHoleTotalMass / CONFIG.SOLAR_MASS_KG;
if (window.gameBlackHole) {
window.gameBlackHole.radius = CONFIG.INITIAL_BLACK_HOLE_RADIUS +
(solarMasses - CONFIG.INITIAL_BLACK_HOLE_MASS);
}
UI.update(state, CONFIG);
console.log('Added mass:', UI.formatMass(amount));
}
// Toggle unlocks
function adminToggleUnlock(type) {
if (!window.Game || !Game.getState) return;
var state = Game.getState();
switch(type) {
case 'comet':
state.cometUnlocked = !state.cometUnlocked;
break;
case 'planet':
state.planetUnlocked = !state.planetUnlocked;
break;
case 'giant':
state.giantUnlocked = !state.giantUnlocked;
break;
}
UI.update(state, CONFIG);
console.log(type + ' unlocked:', state[type + 'Unlocked']);
}
// Game state management
function adminSaveGame() {
if (!window.Game || !Game.getState || !window.Server) return;
Server.checkpoint(Game.getState());
console.log('Game saved');
showSuccessNotification('Game saved');
}
function adminLoadGame() {
location.reload();
}
function adminResetGame() {
if (confirm('Reset game state? This will delete all progress.')) {
Storage.resetGame();
}
}
function adminExportState() {
if (!window.Game || !Game.getState) return;
var state = Game.getState();
var json = JSON.stringify(state, null, 2);
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'game-state-' + Date.now() + '.json';
a.click();
URL.revokeObjectURL(url);
console.log('Exported game state');
}
function adminImportState() {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(event) {
try {
var importedState = JSON.parse(event.target.result);
var currentState = Game.getState();
for (var key in importedState) {
currentState[key] = importedState[key];
}
UI.update(currentState, CONFIG);
console.log('Imported game state');
showSuccessNotification('State imported');
} catch (err) {
console.error('Failed to import:', err);
showErrorNotification('Invalid JSON file');
}
};
reader.readAsText(file);
};
input.click();
}
// Update admin info display
function updateAdminInfo() {
if (!window.Game || !Game.getState) return;
var state = Game.getState();
var el = document.getElementById('admin-player-id');
if (el && window.Server) el.textContent = Server.playerId;
el = document.getElementById('admin-mass');
if (el) el.textContent = UI.formatMass(state.blackHoleTotalMass, {forceSolarMass: true});
el = document.getElementById('admin-total-level');
if (el) {
el.textContent = state.asteroidUpgradeLevel + state.cometUpgradeLevel +
state.planetUpgradeLevel + state.giantUpgradeLevel;
}
el = document.getElementById('admin-asteroids');
if (el) el.textContent = state.asteroidSpawnCount;
el = document.getElementById('admin-objects');
if (el) el.textContent = window.gameAsteroids ? window.gameAsteroids.length : 0;
}
// Update info every second
setInterval(updateAdminInfo, 1000);
setTimeout(updateAdminInfo, 500);

View File

@ -6,7 +6,7 @@ var CONFIG = {
// Initial values
INITIAL_BLACK_HOLE_MASS: 42, // in solar masses
INITIAL_BLACK_HOLE_RADIUS: 42,
INITIAL_BLACK_HOLE_RADIUS: 21,
// Performance
LOGIC_INTERVAL: 10,
@ -20,6 +20,7 @@ var CONFIG = {
BASE_COMET_SPAWN_INTERVAL: 120000, // 2 minutes
BASE_PLANET_SPAWN_INTERVAL: 3600000, // 1 hour
BASE_GIANT_SPAWN_INTERVAL: 21600000, // 6 hours
ASE_MTYPE_SPAWN_INTERVAL: 86400000, // 1 day
// Rate tracking windows (in milliseconds)
RATE_WINDOWS: {
@ -29,15 +30,24 @@ var CONFIG = {
},
// Upgrade costs (in kg)
ASTEROID_BASE_COST: 1e3,
COMET_BASE_COST: 1e4,
PLANET_BASE_COST: 1e5,
GIANT_BASE_COST: 1e6,
ASTEROID_BASE_COST: 1e5,
COMET_BASE_COST: 1e7,
PLANET_BASE_COST: 1e24,
GIANT_BASE_COST: 1e27,
MTYPE_BASE_COST: 1e28,
// Upgrade scaling
UPGRADE_COST_MULTIPLIER: 1.5,
UPGRADE_COST_MULTIPLIER_ASTER: 1.25,
UPGRADE_BONUS_PER_LEVEL: 0.1, // 10% per level
UPGRADE_COST_MULTIPLIER_COMET: 1.5,
UPGRADE_COST_MULTIPLIER_PLANT: 1.75,
UPGRADE_COST_MULTIPLIER_GIANT: 2,
UPGRADE_COST_MULTIPLIER_MTYPE: 2.1,
UPGRADE_BONUS_PER_LEVEL_ASTER: 0.1,
UPGRADE_BONUS_PER_LEVEL_COMET: 0.1,
UPGRADE_BONUS_PER_LEVEL_PLANT: 0.05,
UPGRADE_BONUS_PER_LEVEL_GIANT: 0.03,
UPGRADE_BONUS_PER_LEVEL_MTYPE: 0.03,
// Asteroid spawn patterns
ASTEROID_SPAWN_PATTERNS: {
@ -46,12 +56,13 @@ var CONFIG = {
},
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
small: [1e3, 1e4], // 1-10 t
medium: [1e4, 1e5], // 10-100 t
large: [1e5, 1e6], // 100-1000 t
comet: [1e7, 1e8], // 10000-100000 t
planet: [1e22, 1e25], // Moons & Planets
giant: [1e26, 1e28], // Gas & Ice Giants
mtype: [1.589e29, 8.95e29] // M-Type: 0.08-0.45 M☉
},
// Planet color schemes
@ -81,6 +92,22 @@ var CONFIG = {
'#d0e0e3',
'#f4cccc'
],
// M-Type dwarf star color schemes
MTYPE_COLORS: [
{ light: '#e8734a', mid: '#c4522e', dark: '#8b3a1f' }, // Orange-red
{ light: '#d4602a', mid: '#a84520', dark: '#7a3015' }, // Deep orange
{ light: '#c45a35', mid: '#9e4428', dark: '#6b2e1a' }, // Burnt orange
{ light: '#b8533d', mid: '#934030', dark: '#652a1e' }, // Brownish red
{ light: '#a84e3a', mid: '#854030', dark: '#5c2c1f' } // Dark brown-red
],
// M-Type spot colors (dark brownish spots)
MTYPE_SPOT_COLORS: [
'rgba(60, 35, 20, 0.6)',
'rgba(50, 30, 18, 0.5)',
'rgba(70, 40, 22, 0.55)',
'rgba(45, 28, 15, 0.5)'
],
// Star color distribution
STAR_COLORS: [

View File

@ -68,9 +68,39 @@ function Asteroid(type, blackHole, canvas) {
Asteroid.prototype.initializeTypeSpecificProperties = function() {
// Visual size (unchanged)
if (this.type === 'comet') {
this.size = 5 + Math.random() * 3;
this.size = 3 + Math.random() * 3;
} else if (this.type === 'mtype') {
this.size = 15 + Math.random() * 3;
this.orbitSpeed *= 0.2;
this.decayRate *= 0.3;
this.planetColors = CONFIG.MTYPE_COLORS[Math.floor(Math.random() * CONFIG.MTYPE_COLORS.length)];
// Generate spots (dark brownish surface features)
this.spots = [];
var spotCount = 0 + Math.floor(Math.random() * 9); // 0-9 spots
for (var i = 0; i < spotCount; i++) {
var angle = Math.random() * Math.PI * 2;
var dist = Math.random() * this.size * 0.7;
this.spots.push({
x: Math.cos(angle) * dist,
y: Math.sin(angle) * dist,
radius: 1 + Math.random() * (this.size * 0.3),
color: CONFIG.MTYPE_SPOT_COLORS[Math.floor(Math.random() * CONFIG.MTYPE_SPOT_COLORS.length)]
});
}
// Generate sparkles
this.sparkles = [];
for (var s = 0; s < 20; s++) { // more sparkles if you want
var angle = Math.random() * Math.PI * 2;
var radius = Math.random() * this.size * 0.9;
this.sparkles.push({
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
phase: Math.random() * Math.PI * 2 // random phase for variety
});
}
} else if (this.type === 'giant') {
this.size = 15 + Math.random() * 10;
this.size = 10 + Math.random() * 5;
this.orbitSpeed *= 0.3;
this.decayRate *= 0.4;
this.planetColors = CONFIG.GIANT_COLORS[Math.floor(Math.random() * CONFIG.GIANT_COLORS.length)];
@ -86,16 +116,16 @@ Asteroid.prototype.initializeTypeSpecificProperties = function() {
});
}
} else if (this.type === 'planet') {
this.size = 10 + Math.random() * 5;
this.size = 6 + Math.random() * 4;
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;
this.size = 3 + Math.random() * 1;
} else if (this.type === 'medium') {
this.size = 3 + Math.random() * 2;
this.size = 2 + Math.random() * 1;
} else {
this.size = 1 + Math.random() * 2;
this.size = 1 + Math.random() * 1;
}
// Physical mass (kg)
@ -114,6 +144,8 @@ Asteroid.prototype.update = function() {
Asteroid.prototype.draw = function(ctx) {
if (this.type === 'comet') {
this.drawComet(ctx);
} else if (this.type === 'mtype') {
this.drawMType(ctx, performance.now() * 0.002);
} else if (this.type === 'giant') {
// Initialize per-giant tilt if not already done
if (this._ringTiltBase === undefined) {
@ -175,68 +207,6 @@ Asteroid.prototype.draw = function(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;
@ -272,6 +242,112 @@ Asteroid.prototype.drawComet = function(ctx) {
ctx.restore();
};
Asteroid.prototype.drawMType = function(ctx, time) {
var massRange = CONFIG.ASTEROID_MASS_RANGES.mtype;
var massNormalized = (this.massKg - massRange[0]) / (massRange[1] - massRange[0]); // 0-1
// Pulsing multiplier for glow
var pulse = 0.2 * Math.sin(time * 2 + this.massKg) + 1;
// Glow
var glowIntensity = (0.5 + massNormalized * 0.8) * pulse;
var glowSize = this.size * (1.5 + massNormalized * 0.8) * pulse;
ctx.save();
ctx.translate(this.x, this.y);
// Outer glow (soft)
var outerGlow = ctx.createRadialGradient(0, 0, this.size * 0.5, 0, 0, glowSize);
outerGlow.addColorStop(0, 'rgba(255, 200, 150,' + (glowIntensity * 0.4) + ')');
outerGlow.addColorStop(0.5, 'rgba(200, 120, 80,' + (glowIntensity * 0.2) + ')');
outerGlow.addColorStop(1, 'rgba(140, 80, 50, 0)');
ctx.fillStyle = outerGlow;
ctx.beginPath();
ctx.arc(0, 0, glowSize, 0, Math.PI * 2);
ctx.fill();
// Main body
var bodyGradient = ctx.createRadialGradient(
-this.size * 0.1, -this.size * 0.1, 0,
0, 0, this.size
);
bodyGradient.addColorStop(0, this.planetColors.light);
bodyGradient.addColorStop(0.5, this.planetColors.mid);
bodyGradient.addColorStop(1, this.planetColors.dark);
ctx.fillStyle = bodyGradient;
ctx.beginPath();
ctx.arc(0, 0, this.size, 0, Math.PI * 2);
ctx.fill();
// Gritty surface texture (tiny semi-random dots)
for (var i = 0; i < this.size * 4; i++) {
ctx.fillStyle = 'rgba(0,0,0,' + (Math.random() * 0.05) + ')';
var rx = (Math.random() - 0.5) * this.size * 2;
var ry = (Math.random() - 0.5) * this.size * 2;
if (rx*rx + ry*ry <= this.size*this.size) ctx.fillRect(rx, ry, 1, 1);
}
// Swirling bands (subtle, irregular)
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
ctx.lineWidth = 1;
var amplitude = this.size * 0.06;
var frequency = 0.25;
for (var band = -this.size * 0.7; band < this.size * 0.7; band += this.size * 0.15) {
ctx.beginPath();
for (var x = -this.size; x <= this.size; x += 1) {
var y = band + Math.sin(x * frequency + band + time) * amplitude
+ (Math.random()-0.5) * 1.5; // gritty randomness
if (x === -this.size) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
// Spots
for (var i = 0; i < this.spots.length; i++) {
var spot = this.spots[i];
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, this.size, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = spot.color;
ctx.beginPath();
ctx.arc(spot.x, spot.y, spot.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// Inner core glow
var coreGlow = ctx.createRadialGradient(0, 0, 0, 0, 0, this.size * 0.5 * pulse);
coreGlow.addColorStop(0, 'rgba(255, 230, 160,' + (glowIntensity * 0.5) + ')');
coreGlow.addColorStop(1, 'rgba(255, 150, 80, 0)');
ctx.fillStyle = coreGlow;
ctx.beginPath();
ctx.arc(0, 0, this.size * 0.5 * pulse, 0, Math.PI * 2);
ctx.fill();
// Subtle edge glow instead of hard stroke
var edgeGlow = ctx.createRadialGradient(0, 0, this.size * 0.9, 0, 0, this.size);
edgeGlow.addColorStop(0, 'rgba(255,200,120,0.15)');
edgeGlow.addColorStop(1, 'rgba(255,200,120,0)');
ctx.fillStyle = edgeGlow;
ctx.beginPath();
ctx.arc(0, 0, this.size, 0, Math.PI * 2);
ctx.fill();
// Sparkles (tiny random twinkles)
for (var i = 0; i < this.sparkles.length; i++) {
var s = this.sparkles[i];
var sparkleFrequency = 0.1;
var sparkleIntensity = 0.4 + 0.4 * Math.sin(time * sparkleFrequency + s.phase);
ctx.fillStyle = 'rgba(255,255,200,' + sparkleIntensity + ')';
ctx.fillRect(s.x, s.y, 1.5, 1.5);
}
ctx.restore();
};
Asteroid.prototype.drawGiant = function(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
@ -353,9 +429,51 @@ function BlackHole(x, y, radius) {
this.radius = radius;
this.pulse = 0;
this.pulseColor = '#000000';
};
BlackHole.prototype.draw = function(ctx, consumptionRate) {
// Calculate jet intensity based on consumption rate
var jetMin = 1e7; // Start triggering at 10 Mt/s (Comets)
var jetMax = 1e22; // max intensity
var minVisible = 0.1; // 10% intensity floor
var targetIntensity = 0;
if (consumptionRate && consumptionRate > jetMin) {
var logMin = 10; // log10(1e10)
var logMax = 22; // log10(1e22)
var logRate = Math.log10(consumptionRate);
// Log-scaled 0..1
targetIntensity = Math.max(0, Math.min(
(logRate - logMin) / (logMax - logMin),
1
));
// Perceptual tweaks
targetIntensity = Math.pow(targetIntensity, 0.35); // boost low end
targetIntensity = minVisible + (1 - minVisible) * targetIntensity;
}
BlackHole.prototype.draw = function(ctx) {
// Smooth transition for jet intensity
if (!this.jetIntensity) this.jetIntensity = 0;
var transitionSpeed = 0.05; // Lower = slower fade (0.01 = very slow, 0.05 = fast)
if (targetIntensity > this.jetIntensity) {
// Fade in
this.jetIntensity = Math.min(this.jetIntensity + transitionSpeed, targetIntensity);
} else if (targetIntensity < this.jetIntensity) {
// Fade out
this.jetIntensity = Math.max(this.jetIntensity - transitionSpeed, targetIntensity);
}
// Draw jets first (behind the black hole)
if (this.jetIntensity > 0) {
this.drawJets(ctx, this.jetIntensity);
}
// Existing black hole drawing code
if (this.pulse > 0) {
this.pulse -= 0.02;
if (this.pulse < 0) this.pulse = 0;
@ -363,6 +481,7 @@ BlackHole.prototype.draw = function(ctx) {
if (this.pulse === 0) {
this.pulseColor = '#000000';
}
ctx.fillStyle = '#000000';
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
@ -443,27 +562,86 @@ BlackHole.prototype.blendColors = function(c1, c2, weight) {
return '#' + d2h(r) + d2h(g) + d2h(b);
};
BlackHole.prototype.drawJets = function(ctx, intensity) {
// Jet configuration (easily adjustable)
var config = {
color: 'rgba(100, 150, 255, 0.8)', // Blue-white jet color
coreColor: 'rgba(200, 220, 255, 0.9)', // Bright core
length: this.radius * 8, // Jet length (8x black hole radius)
width: this.radius * 0.2, // Jet width at base
coreWidth: this.radius * 0.05, // Bright core width
particleCount: 15, // Number of particles per jet
particleSize: 2, // Size of individual particles
rotation: 0, // Rotation angle (for spinning jets)
flickerSpeed: 0.1 // How fast jets flicker
};
//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';
// }
//};
// Intensity affects opacity and size (0-1 scale)
var alpha = Math.min(intensity, 1);
var sizeMultiplier = 0.5 + (intensity * 0.5); // 50% to 100% size based on intensity
// Draw both jets (top and bottom)
for (var direction = -1; direction <= 1; direction += 2) {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(config.rotation);
// Outer jet cone (gradient)
var jetGradient = ctx.createLinearGradient(
0, 0,
0, direction * config.length * sizeMultiplier
);
var jetColor = config.color.replace(/[\d.]+\)/, (alpha * 0.6) + ')');
jetGradient.addColorStop(0, jetColor);
jetGradient.addColorStop(0.3, jetColor.replace(/[\d.]+\)/, (alpha * 0.4) + ')'));
jetGradient.addColorStop(1, 'rgba(100, 150, 255, 0)');
ctx.fillStyle = jetGradient;
ctx.beginPath();
ctx.moveTo(-config.width * sizeMultiplier, 0);
ctx.lineTo(0, direction * config.length * sizeMultiplier);
ctx.lineTo(config.width * sizeMultiplier, 0);
ctx.closePath();
ctx.fill();
// Inner bright core
var coreGradient = ctx.createLinearGradient(
0, 0,
0, direction * config.length * sizeMultiplier * 0.7
);
var coreColor = config.coreColor.replace(/[\d.]+\)/, alpha + ')');
coreGradient.addColorStop(0, coreColor);
coreGradient.addColorStop(0.5, coreColor.replace(/[\d.]+\)/, (alpha * 0.5) + ')'));
coreGradient.addColorStop(1, 'rgba(200, 220, 255, 0)');
ctx.fillStyle = coreGradient;
ctx.beginPath();
ctx.moveTo(-config.coreWidth * sizeMultiplier, 0);
ctx.lineTo(0, direction * config.length * sizeMultiplier * 0.7);
ctx.lineTo(config.coreWidth * sizeMultiplier, 0);
ctx.closePath();
ctx.fill();
// Particles along the jet (for dynamic effect)
ctx.fillStyle = config.coreColor.replace(/[\d.]+\)/, alpha + ')');
for (var i = 0; i < config.particleCount; i++) {
var particlePos = (i / config.particleCount) * config.length * sizeMultiplier;
var particleOffset = (Math.sin(Date.now() * 0.01 + i) * config.width * 0.3) * sizeMultiplier;
var particleSize = config.particleSize * (1 - i / config.particleCount); // Smaller as they go out
ctx.beginPath();
ctx.arc(
particleOffset,
direction * particlePos,
particleSize * alpha,
0,
Math.PI * 2
);
ctx.fill();
}
ctx.restore();
}
};

View File

@ -16,21 +16,31 @@ var Game = (function() {
planetUpgradeCost: CONFIG.PLANET_BASE_COST,
giantUpgradeLevel: 0,
giantUpgradeCost: CONFIG.GIANT_BASE_COST,
mtypeUpgradeLevel: 0,
mtypeUpgradeCost: CONFIG.MTYPE_BASE_COST,
currentAsteroidSpawnInterval: CONFIG.BASE_ASTEROID_SPAWN_INTERVAL,
currentCometSpawnInterval: CONFIG.BASE_COMET_SPAWN_INTERVAL,
currentPlanetSpawnInterval: CONFIG.BASE_PLANET_SPAWN_INTERVAL,
currentGiantSpawnInterval: CONFIG.BASE_GIANT_SPAWN_INTERVAL,
currentMtypeSpawnInterval: CONFIG.BASE_MTYPE_SPAWN_INTERVAL,
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),
lastPlanetSpawn: Date.now(),
lastGiantSpawn: Date.now(),
lastMtypeSpawn: Date.now(),
sM: 0, sT: Date.now(),
mM: 0, mT: Date.now(),
lM: 0, lT: Date.now(),
rateShort: 0, // 1 second average
rateMedium: 0, // 1 hour average
rateLong: 0 // 1 day average
rateLong: 0, // 1 day average
isReady: false,
tabHiddenAt: null,
cometUnlocked: false,
planetUnlocked: false,
giantUnlocked: false,
mtypeUnlocked: false
};
var blackHole;
@ -42,6 +52,10 @@ var Game = (function() {
var currentTime = Date.now();
var lastLogicUpdate = Date.now();
// Expose to admin interface
window.gameBlackHole = null;
window.gameAsteroids = asteroids;
function resizeCanvas() {
// Set canvas to actual viewport size
canvas.width = window.innerWidth
@ -57,9 +71,14 @@ var Game = (function() {
CONFIG.INITIAL_BLACK_HOLE_RADIUS
);
window.gameBlackHole = blackHole;
for (var i = 0; i < CONFIG.STAR_COUNT; i++) {
stars.push(new Star(canvas));
}
setInterval(function() {
UI.updateActiveObjects();
}, 1000);
UI.init();
@ -67,9 +86,11 @@ var Game = (function() {
asteroid: handleAsteroidUpgrade,
comet: handleCometUpgrade,
planet: handlePlanetUpgrade,
giant: handleGiantUpgrade
giant: handleGiantUpgrade,
mtype: handleMtypeUpgrade
});
Server.init()
loadGameState();
window.addEventListener('resize', function() {
@ -81,7 +102,6 @@ var Game = (function() {
});
});
// ADD THIS BLOCK - Prevent zoom on desktop
window.addEventListener('wheel', function(e) {
if (e.ctrlKey) {
e.preventDefault();
@ -96,31 +116,34 @@ var Game = (function() {
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);
state.tabHiddenAt = Date.now(); // NEW: Record when hidden
Server.checkpoint(state);
} else if (!document.hidden) {
loadGameState();
Server.checkpoint(state);
}
});
// Also save on beforeunload as backup
// Save when page is about to unload (close tab, navigate away)
window.addEventListener('beforeunload', function() {
Storage.saveGame(state);
state.tabHiddenAt = Date.now();
Server.checkpoint(state);
});
// Periodic auto-save every 30 seconds as final safety net
setInterval(function() {
Storage.saveGame(state);
}, 15000);
// Save and update scores when window gains focus
window.addEventListener('focus', function() {
if (window.Server && window.Game && Game.getState) {
Server.checkpoint(Game.getState());
}
});
}
function loadGameState() {
var savedState = Storage.loadGame();
Server.loadGameState().then(function(savedState) {
if (savedState) {
state.blackHoleTotalMass = savedState.blackHoleTotalMass;
state.totalMassConsumedEver = savedState.totalMassConsumedEver;
@ -133,28 +156,21 @@ var Game = (function() {
state.planetUpgradeCost = savedState.planetUpgradeCost;
state.giantUpgradeLevel = savedState.giantUpgradeLevel;
state.giantUpgradeCost = savedState.giantUpgradeCost;
state.mtypeUpgradeLevel = savedState.mtypeUpgradeLevel || 0;
state.mtypeUpgradeCost = savedState.mtypeUpgradeCost || CONFIG.MTYPE_BASE_COST;
state.asteroidSpawnCount = savedState.asteroidSpawnCount;
// Load spawn timers if they exist, otherwise generate from save timestamp
// Load unlock states (with backward compatibility)
state.cometUnlocked = savedState.cometUnlocked !== undefined ? savedState.cometUnlocked : (savedState.asteroidUpgradeLevel >= 20);
state.planetUnlocked = savedState.planetUnlocked !== undefined ? savedState.planetUnlocked : (savedState.cometUpgradeLevel >= 15);
state.giantUnlocked = savedState.giantUnlocked !== undefined ? savedState.giantUnlocked : (savedState.planetUpgradeLevel >= 10);
state.mtypeUnlocked = savedState.mtypeUnlocked !== undefined ? savedState.mtypeUnlocked : (savedState.giantUpgradeLevel >= 5);
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;
}
state.lastPlanetSpawn = savedState.lastPlanetSpawn || Date.now();
state.lastGiantSpawn = savedState.lastGiantSpawn || Date.now();
state.lastMtypeSpawn = savedState.lastMtypeSpawn || Date.now();
// Load consumption rates
state.sM = savedState.sM || 0;
@ -163,40 +179,47 @@ var Game = (function() {
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.sT = savedState.sT || now;
state.mT = savedState.mT || now;
state.lT = savedState.lT || now;
state.rateShort = savedState.rateShort || 0;
state.rateShortTrend = savedState.rateShortTrend || 'same';
state.rateMedium = savedState.rateMedium || 0;
state.rateLong = savedState.rateLong || 0;
// Calculate offline progression
var now = Date.now();
var offlineTime = now - savedState.savedAt; // milliseconds offline
state.tabHiddenAt = savedState.tabHiddenAt || null;
// Calculate offline progression ONLY if tab was actually hidden
var offlineTime = 0;
if (savedState.tabHiddenAt) {
// Tab was hidden - calculate time between hiding and now
offlineTime = now - savedState.tabHiddenAt;
}
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;
var rateToUse = 0;
if (state.rateLong > 0 && (now - state.lT) > 3600000) {
rateToUse = state.rateLong;
} else if (state.rateMedium > 0 && (now - state.mT) > 300000) {
rateToUse = state.rateMedium;
} else {
rateToUse = state.rateShort || 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),
@ -205,32 +228,40 @@ var Game = (function() {
}
}
// Clear the hidden timestamp after processing
state.tabHiddenAt = null;
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);
blackHole.radius = 0.5 * (state.blackHoleTotalMass / CONFIG.SOLAR_MASS_KG)
// Send checkpoint after loading
Server.checkpoint(state).then(function() {
state.isReady = true; // Mark as ready after checkpoint
});
} 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);
// No saved state - send initial checkpoint for new player
Server.checkpoint(state).then(function() {
state.isReady = true; // Mark as ready after checkpoint
});
}
UI.update(state, CONFIG);
});
}
function updateSpawnIntervals() {
state.currentAsteroidSpawnInterval = CONFIG.BASE_ASTEROID_SPAWN_INTERVAL /
(1 + state.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL);
(1 + state.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_ASTER);
state.currentCometSpawnInterval = CONFIG.BASE_COMET_SPAWN_INTERVAL /
(1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL);
(1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_COMET);
state.currentPlanetSpawnInterval = CONFIG.BASE_PLANET_SPAWN_INTERVAL /
(1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL);
(1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_PLANT);
state.currentGiantSpawnInterval = CONFIG.BASE_GIANT_SPAWN_INTERVAL /
(1 + state.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL);
(1 + state.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GIANT);
state.currentMtypeSpawnInterval = CONFIG.BASE_MTYPE_SPAWN_INTERVAL /
(1 + state.mtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_MTYPE);
}
function handleAsteroidUpgrade() {
@ -240,9 +271,15 @@ var Game = (function() {
state.asteroidUpgradeCost = Math.floor(
CONFIG.ASTEROID_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_ASTER, state.asteroidUpgradeLevel)
);
if (state.asteroidUpgradeLevel >= 20 && !state.cometUnlocked) {
state.cometUnlocked = true;
showUnlockNotification('comet');
asteroids.push(new Asteroid('comet', blackHole, canvas));
state.lastCometSpawn = Date.now();
}
updateSpawnIntervals();
UI.update(state, CONFIG);
Storage.saveGame(state);
}
}
@ -251,11 +288,16 @@ var Game = (function() {
state.totalMassConsumed -= state.cometUpgradeCost;
state.cometUpgradeLevel++;
state.cometUpgradeCost = Math.floor(
CONFIG.COMET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, state.cometUpgradeLevel)
CONFIG.COMET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_COMET, state.cometUpgradeLevel)
);
if (state.cometUpgradeLevel >= 15 && !state.planetUnlocked) {
state.planetUnlocked = true;
showUnlockNotification('planet');
asteroids.push(new Asteroid('planet', blackHole, canvas));
state.lastPlanetSpawn = Date.now();
}
updateSpawnIntervals();
UI.update(state, CONFIG);
Storage.saveGame(state);
}
}
@ -264,11 +306,16 @@ var Game = (function() {
state.totalMassConsumed -= state.planetUpgradeCost;
state.planetUpgradeLevel++;
state.planetUpgradeCost = Math.floor(
CONFIG.PLANET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, state.planetUpgradeLevel)
CONFIG.PLANET_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_PLANT, state.planetUpgradeLevel)
);
if (state.planetUpgradeLevel >= 10 && !state.giantUnlocked) {
state.giantUnlocked = true;
showUnlockNotification('giant');
asteroids.push(new Asteroid('giant', blackHole, canvas));
state.lastGiantSpawn = Date.now();
}
updateSpawnIntervals();
UI.update(state, CONFIG);
Storage.saveGame(state);
}
}
@ -277,11 +324,31 @@ var Game = (function() {
state.totalMassConsumed -= state.giantUpgradeCost;
state.giantUpgradeLevel++;
state.giantUpgradeCost = Math.floor(
CONFIG.GIANT_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER, state.giantUpgradeLevel)
CONFIG.GIANT_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_GIANT, state.giantUpgradeLevel)
);
// Unlock M-Type at giant level 5
if (state.giantUpgradeLevel >= 5 && !state.mtypeUnlocked) {
state.mtypeUnlocked = true;
state.lastMtypeSpawn = Date.now();
showUnlockNotification('mtype');
asteroids.push(new Asteroid('mtype', blackHole, canvas));
}
updateSpawnIntervals();
UI.update(state, CONFIG);
}
}
function handleMtypeUpgrade() {
if (state.totalMassConsumed >= state.mtypeUpgradeCost) {
state.totalMassConsumed -= state.mtypeUpgradeCost;
state.mtypeUpgradeLevel++;
state.mtypeUpgradeCost = Math.floor(
CONFIG.MTYPE_BASE_COST * Math.pow(CONFIG.UPGRADE_COST_MULTIPLIER_MTYPE, state.mtypeUpgradeLevel)
);
updateSpawnIntervals();
UI.update(state, CONFIG);
Storage.saveGame(state);
}
}
@ -304,24 +371,34 @@ var Game = (function() {
state.lastAsteroidSpawn = currentTime;
}
if (state.cometUnlocked && state.cometUpgradeLevel >= 0) {
if (currentTime - state.lastCometSpawn > state.currentCometSpawnInterval) {
asteroids.push(new Asteroid('comet', blackHole, canvas));
state.lastCometSpawn = currentTime;
//Storage.saveGame(state);
asteroids.push(new Asteroid('comet', blackHole, canvas));
}
}
if (state.planetUnlocked && state.planetUpgradeLevel >= 0) {
if (currentTime - state.lastPlanetSpawn > state.currentPlanetSpawnInterval) {
asteroids.push(new Asteroid('planet', blackHole, canvas));
state.lastPlanetSpawn = currentTime;
//Storage.saveGame(state);
asteroids.push(new Asteroid('planet', blackHole, canvas));
}
}
if (state.giantUnlocked && state.giantUpgradeLevel >= 0) {
if (currentTime - state.lastGiantSpawn > state.currentGiantSpawnInterval) {
asteroids.push(new Asteroid('giant', blackHole, canvas));
state.lastGiantSpawn = currentTime;
//Storage.saveGame(state);
asteroids.push(new Asteroid('giant', blackHole, canvas));
}
}
if (state.mtypeUnlocked) {
if (currentTime - state.lastMtypeSpawn > state.currentMtypeSpawnInterval) {
state.lastMtypeSpawn = currentTime;
asteroids.push(new Asteroid('mtype', blackHole, canvas));
}
}
}
function updateAsteroids() {
for (var i = asteroids.length - 1; i >= 0; i--) {
@ -364,7 +441,7 @@ var Game = (function() {
asteroids[i].draw(ctx);
}
blackHole.draw(ctx);
blackHole.draw(ctx, state.rateShort || 0);
}
function animate(currentTime) {
@ -406,7 +483,6 @@ var Game = (function() {
}());
var Server = {
// baseURL removed; now all requests are relative
playerId: null,
init: function() {
@ -423,47 +499,81 @@ var Server = {
checkpoint: async function(gameState) {
try {
await fetch('/api/checkpoint', { // relative URL
// Add savedAt timestamp
gameState.savedAt = Date.now();
const response = await fetch('/api/checkpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playerId: this.playerId,
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
}
gameState: gameState
})
});
return await response.json();
} catch (err) {
console.error('Checkpoint failed', err);
return { success: false };
}
},
loadGameState: async function() {
try {
const resp = await fetch(`/api/player/${encodeURIComponent(this.playerId)}/state`);
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error('Failed to load game state', err);
return null;
}
},
getLeaderboard: async function(sortBy) {
try {
const resp = await fetch(`/api/leaderboard?sort=${encodeURIComponent(sortBy)}&playerId=${encodeURIComponent(this.playerId)}`); // relative URL
const resp = await fetch(`/api/leaderboard?sort=${encodeURIComponent(sortBy)}&playerId=${encodeURIComponent(this.playerId)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
return data; // should be array of { name, mass, holeAge }
return data;
} catch (err) {
console.error('Failed to fetch leaderboard', err);
return [];
}
},
deletePlayer: async function(playerId) {
try {
const resp = await fetch(`/api/player/${encodeURIComponent(playerId)}`, {
method: 'DELETE'
});
return await resp.json();
} catch (err) {
console.error('Failed to delete player', err);
return { success: false };
}
},
startCheckpoints: function() {
// Example: autosave every 15s
let lastCheckpointData = null;
setInterval(() => {
if (window.Game && Game.state) {
this.checkpoint(Game.state);
if (window.Game && Game.getState) {
const state = Game.getState();
// Only checkpoint if significant changes
const currentData = JSON.stringify({
mass: Math.floor(state.blackHoleTotalMass / 1e27), // Round to reduce noise
levels: state.asteroidUpgradeLevel + state.cometUpgradeLevel +
state.planetUpgradeLevel + state.giantUpgradeLevel
});
if (currentData !== lastCheckpointData) {
this.checkpoint(state);
lastCheckpointData = currentData;
}
}, 15000);
}
}, 15000); // Check every 15s, but only send if changed
},
};
// Start the game when the page loads

View File

@ -49,35 +49,66 @@ function saturateColor(r, g, b, saturationBoost) {
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;
// Initialize tracking windows if needed
if (!state.sM) state.sM = 0;
if (!state.sT) state.sT = now;
if (!state.mM) state.mM = 0;
if (!state.mT) state.mT = now;
if (!state.lM) state.lM = 0;
if (!state.lT) state.lT = now;
function updateWindow(massKey, timeKey, prevRateKey) {
const start = state[timeKey] || now;
// Add mass to all windows
state.sM += consumedMassKg;
state.mM += consumedMassKg;
state.lM += consumedMassKg;
// Add new mass
state[massKey] = (state[massKey] || 0) + consumedMassKg;
// Update per-second rate (with trend coloring)
const elapsedShort = now - state.sT;
if (elapsedShort >= 1000) {
const newRate = state.sM / (elapsedShort / 1000);
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;
// Compare to previous rate for trend
if (state.rateShort === undefined) {
state.rateShort = newRate;
state.rateShortTrend = 'same';
} else {
// Determine trend with 5% threshold to avoid jitter
if (newRate > state.rateShort * 1.05) {
state.rateShortTrend = 'up';
} else if (newRate < state.rateShort * 0.95) {
state.rateShortTrend = 'down';
} else {
state.rateShortTrend = 'same';
}
state.rateShort = newRate;
}
state.rateShort = updateWindow("sM", "sT", "prevRateShort");
state.rateMedium = updateWindow("mM", "mT", "prevRateMedium");
state.rateLong = updateWindow("lM", "lT", "prevRateLong");
// Reset short window
state.sM = 0;
state.sT = now;
}
// Calculate hourly average (rolling 1-hour window)
const elapsedMedium = now - state.mT;
if (elapsedMedium > 0) {
state.rateMedium = state.mM / (elapsedMedium / 1000);
}
// Reset hourly window after 1 hour
if (elapsedMedium >= CONFIG.RATE_WINDOWS.MEDIUM) {
state.mM = 0;
state.mT = now;
}
// Calculate daily average (rolling 24-hour window)
const elapsedLong = now - state.lT;
if (elapsedLong > 0) {
state.rateLong = state.lM / (elapsedLong / 1000);
}
// Reset daily window after 24 hours
if (elapsedLong >= CONFIG.RATE_WINDOWS.LONG) {
state.lM = 0;
state.lT = now;
}
}
function formatOfflineTime(ms) {
@ -97,29 +128,110 @@ function formatOfflineTime(ms) {
}
}
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';
// Unified notification system
function showNotification(options) {
// Default options
var defaults = {
message: '',
type: 'info', // 'unlock', 'offline', 'error', 'success', 'info'
html: false // if true, message is HTML; if false, it's text
};
notification.innerHTML =
// Merge options with defaults
var config = {};
for (var key in defaults) {
config[key] = options[key] !== undefined ? options[key] : defaults[key];
}
// Create notification element
var notification = document.createElement('div');
notification.className = 'notification ' + config.type;
// Set content
if (config.html) {
notification.innerHTML = config.message;
} else {
notification.textContent = config.message;
}
// Add to DOM
document.body.appendChild(notification);
// Dismiss on canvas click
var canvas = document.getElementById('space');
var dismissHandler = function(e) {
// Only dismiss if clicking canvas, not the notification
if (e.target === canvas) {
notification.remove();
canvas.removeEventListener('click', dismissHandler);
}
};
// Small delay to prevent immediate dismissal if notification was triggered by a click
setTimeout(function() {
canvas.addEventListener('click', dismissHandler);
}, 100);
return notification;
}
// Helper functions for specific notification types
function showUnlockNotification(type) {
var messages = {
comet: 'Comets Unlocked',
planet: 'Planets Unlocked',
giant: 'Gas & Ice Giants Unlocked',
mtype: 'M-Type Stars Unlocked'
};
showNotification({
message: messages[type] || 'New content unlocked!',
type: 'unlock',
duration: 4000
});
}
function showOfflineNotification(timeAway, massGained) {
var message =
'<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();
showNotification({
message: message,
type: 'offline',
duration: 5000,
dismissable: true,
html: true
});
}
// Additional helper for errors (useful for debugging/user feedback)
function showErrorNotification(message) {
showNotification({
message: message,
type: 'error',
duration: 5000,
dismissable: true
});
}
// Additional helper for success messages
function showSuccessNotification(message) {
showNotification({
message: message,
type: 'success',
duration: 3000
});
}
function formatTimeSince(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 (seconds < 60) return 'Just now';
if (minutes < 60) return minutes + 'm ago';
if (hours < 24) return hours + 'h ago';
return days + 'd ago';
}

View File

@ -7,8 +7,14 @@ 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
const INACTIVE_DAYS = 30; // Completely remove from JSON after 5 days
const LEADERBOARD_VISIBLE_DAYS = 3; // Only show on leaderboard if active within 1 day
const BOT_USER_AGENTS = [
'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
'yandexbot', 'facebookexternalhit', 'twitterbot', 'bot', 'crawler',
'spider', 'archive', 'wget', 'curl', 'python-requests'
];
// Load leaderboard from file
function loadLeaderboard() {
@ -32,7 +38,8 @@ function cleanupInactive(leaderboard) {
const active = leaderboard.filter(player => player.lastSeen > thirtyDaysAgo);
if (active.length < beforeCount) {
console.log(`Permanently removed ${beforeCount - active.length} players (>30 days inactive)`);
const timestamp = new Date().toLocaleString();
console.log(`${timestamp} Permanently removed ${beforeCount - active.length} players (>30 days inactive)`);
}
return active;
@ -41,16 +48,40 @@ function cleanupInactive(leaderboard) {
// 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);
const visible = leaderboard.filter(player =>
player.lastSeen > fiveDaysAgo &&
!player.id.startsWith('admin_')
);
return visible;
}
// Submit checkpoint
app.post('/api/checkpoint', (req, res) => {
const { playerId, name, mass, playTime, levels } = req.body;
const userAgent = (req.headers['user-agent'] || '').toLowerCase();
// Reject known bots
if (BOT_USER_AGENTS.some(bot => userAgent.includes(bot))) {
return res.status(403).json({ error: 'Bot traffic not allowed' });
}
const { playerId, gameState } = req.body;
// Ignore players who haven't consumed any mass
if (gameState.totalMassConsumedEver === 0) {
return res.json({ success: true }); // Accept but don't save
}
// Only save if they've upgraded at least once
const totalLevels = (gameState.asteroidUpgradeLevel || 0) +
(gameState.cometUpgradeLevel || 0) +
(gameState.planetUpgradeLevel || 0) +
(gameState.giantUpgradeLevel || 0);
if (totalLevels === 0 && gameState.totalMassConsumedEver < 1e6) {
return res.json({ success: true }); // Accept but don't save
}
// Basic validation
if (!playerId || mass < 0 || playTime < 0) {
if (!playerId || !gameState) {
return res.status(400).json({ error: 'Invalid data' });
}
@ -64,30 +95,38 @@ app.post('/api/checkpoint', (req, res) => {
// 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;
// Update existing player - store full game state
player.gameState = gameState;
player.mass = gameState.blackHoleTotalMass || 0;
player.level = (gameState.asteroidUpgradeLevel || 0) +
(gameState.cometUpgradeLevel || 0) +
(gameState.planetUpgradeLevel || 0) +
(gameState.giantUpgradeLevel || 0);
player.lastSeen = now;
// Calculate hole age (time since first seen)
player.holeAge = now - player.firstSeen;
} else {
// New player
leaderboard.push({
const timestamp = new Date().toLocaleString();
const newPlayer = {
id: playerId,
name: name || 'Anonymous',
mass: mass,
playTime: playTime,
level: levels?.total || 0,
gameState: gameState,
mass: gameState.blackHoleTotalMass || 0,
level: (gameState.asteroidUpgradeLevel || 0) +
(gameState.cometUpgradeLevel || 0) +
(gameState.planetUpgradeLevel || 0) +
(gameState.giantUpgradeLevel || 0),
firstSeen: now,
lastSeen: now,
holeAge: 0
});
};
console.log(`${timestamp} Created new player: `, newPlayer.id);
leaderboard.push(newPlayer);
}
// Sort by mass and keep top 100 (in JSON file)
// Sort by mass and keep top 100
leaderboard.sort((a, b) => b.mass - a.mass);
leaderboard = leaderboard.slice(0, 100);
@ -168,6 +207,20 @@ app.get('/api/player/:playerId', (req, res) => {
res.json(player);
});
// Get player's full game state
app.get('/api/player/:playerId/state', (req, res) => {
const playerId = req.params.playerId;
let leaderboard = loadLeaderboard();
const player = leaderboard.find(p => p.id === playerId);
if (!player || !player.gameState) {
return res.status(404).json({ error: 'Player not found' });
}
res.json(player.gameState);
});
// Manual cleanup endpoint (optional - for maintenance)
app.post('/api/cleanup', (req, res) => {
let leaderboard = loadLeaderboard();
@ -194,9 +247,35 @@ app.get('/api/stats', (req, res) => {
});
});
// Cleanup bot accounts function
function cleanupBotAccounts() {
let leaderboard = loadLeaderboard();
const beforeCount = leaderboard.length;
// Remove accounts with zero progress
leaderboard = leaderboard.filter(player => {
const hasProgress = player.gameState &&
(player.gameState.totalMassConsumedEver > 0 ||
player.level > 0);
return hasProgress;
});
if (leaderboard.length < beforeCount) {
console.log(`Cleaned up ${beforeCount - leaderboard.length} bot/inactive accounts`);
saveLeaderboard(leaderboard);
}
}
// Run cleanup daily (24 hours)
setInterval(cleanupBotAccounts, 24 * 60 * 60 * 1000);
// Run cleanup on startup too
cleanupBotAccounts();
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`);
const timestamp = new Date().toLocaleString();
console.log(`${timestamp} Server running on port ${PORT}`);
console.log(`${timestamp} Players hidden from leaderboard after ${LEADERBOARD_VISIBLE_DAYS} days inactive`);
console.log(`${timestamp} Players permanently removed after ${INACTIVE_DAYS} days inactive`);
});

View File

@ -24,83 +24,24 @@ var Storage = {
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();
if (confirm('Are you sure you want to reset? This will delete all your progress permanently.')) {
var playerId = this.getCookie('playerId');
if (playerId && window.Server) {
Server.deletePlayer(playerId).then(function() {
Storage.deleteCookie('playerId');
setTimeout(function() {
window.location.href = window.location.href.split('?')[0] + '?nocache=' + Date.now();
}, 200);
});
} else {
Storage.deleteCookie('playerId');
setTimeout(function() {
window.location.reload(true);
}, 100);
}
}
}
};

323
js/ui.js
View File

@ -13,10 +13,12 @@ var UI = {
cometLevel: document.getElementById('comet-level'),
planetLevel: document.getElementById('planet-level'),
giantLevel: document.getElementById('giant-level'),
mtypeLevel: document.getElementById('mtype-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'),
mtypeUpgradeBtn: document.getElementById('mtype-upgrade-btn'),
gearIcon: document.getElementById('gear-icon'),
settingsMenu: document.getElementById('settings-menu'),
resetBtn: document.getElementById('reset-btn'),
@ -25,7 +27,8 @@ var UI = {
leaderboardPanel: document.getElementById('leaderboardPanel'),
leaderboardList: document.getElementById('leaderboardList'),
sortMass: document.getElementById('sortMass'),
sortAge: document.getElementById('sortAge')
sortAge: document.getElementById('sortAge'),
massRate: document.getElementById('massRate')
};
// Sorting buttons
@ -41,26 +44,34 @@ var UI = {
const settingsMenu = self.elements.settingsMenu;
const leaderboardToggle = self.elements.leaderboardToggle;
const leaderboardPanel = self.elements.leaderboardPanel;
const resetBtn = self.elements.resetBtn;
const holeIcon = document.getElementById('hole-icon');
const upgradePanel = document.getElementById('upgrade-panel');
// ---------- Settings Menu ----------
if (gear && settingsMenu) {
// Toggle settings menu
gear.addEventListener('click', (e) => {
e.stopPropagation(); // prevent document click from immediately closing
settingsMenu.classList.toggle('open');
// ---------- Upgrade Panel ----------
if (holeIcon && upgradePanel) {
// Toggle upgrade panel
holeIcon.addEventListener('click', (e) => {
e.stopPropagation();
if (settingsMenu) settingsMenu.classList.remove('open');
if (leaderboardPanel) leaderboardPanel.classList.remove('show');
upgradePanel.classList.toggle('open');
});
// Prevent clicks inside menu from closing it
settingsMenu.addEventListener('click', (e) => {
// Prevent clicks inside panel from closing it
upgradePanel.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// ---------- Leaderboard ----------
if (leaderboardToggle && leaderboardPanel) {
// Toggle leaderboard panel
leaderboardToggle.addEventListener('click', (e) => {
e.stopPropagation(); // prevent document click
if (settingsMenu) settingsMenu.classList.remove('open');
if (upgradePanel) upgradePanel.classList.remove('open');
leaderboardPanel.classList.toggle('show');
if (leaderboardPanel.classList.contains('show')) {
@ -73,6 +84,27 @@ var UI = {
e.stopPropagation();
});
}
if (resetBtn) {
resetBtn.addEventListener('click', function() {
Storage.resetGame();
});
}
// ---------- Settings Menu ----------
if (gear && settingsMenu) {
// Toggle settings menu
gear.addEventListener('click', (e) => {
e.stopPropagation(); // prevent document click from immediately closing
if (upgradePanel) upgradePanel.classList.remove('open');
if (leaderboardPanel) leaderboardPanel.classList.remove('show');
settingsMenu.classList.toggle('open');
});
// Prevent clicks inside menu from closing it
settingsMenu.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// ---------- Click outside to close ----------
document.addEventListener('click', () => {
@ -84,6 +116,19 @@ var UI = {
// Close leaderboard panel
if (leaderboardPanel) {
leaderboardPanel.classList.remove('show');
// Clear refresh intervals when closing
if (this._leaderboardRefreshInterval) {
clearInterval(this._leaderboardRefreshInterval);
this._leaderboardRefreshInterval = null;
}
if (this._leaderboardIndicatorInterval) {
clearInterval(this._leaderboardIndicatorInterval);
this._leaderboardIndicatorInterval = null;
}
}
if (upgradePanel) {
upgradePanel.classList.remove('open');
}
});
},
@ -93,6 +138,7 @@ var UI = {
this.elements.cometUpgradeBtn.addEventListener('click', handlers.comet);
this.elements.planetUpgradeBtn.addEventListener('click', handlers.planet);
this.elements.giantUpgradeBtn.addEventListener('click', handlers.giant);
this.elements.mtypeUpgradeBtn.addEventListener('click', handlers.mtype);
},
update: function(gameState, config) {
@ -101,22 +147,13 @@ var UI = {
'Available to Spend: ' + this.formatMass(gameState.totalMassConsumed);
}
this.updateMassDisplay(gameState, config);
this.updateRateDisplay(gameState, config);
this.updateUpgradeDisplay(gameState);
this.updateTotalLevel(gameState);
this.updateActiveObjects();
},
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);
@ -130,17 +167,102 @@ var UI = {
Total mass absorbed:<br>
${consumedText}
</span>
</span><br>
<span style="font-size:10px; opacity:0.7;">Rate: ${rateText}</span>
`;
},
updateRateDisplay: function(state, config) {
const now = Date.now();
// Format per-second rate (always real)
const rateShortText = this.formatMass(state.rateShort || 0) + '/s';
// Check if we have enough data for hourly average
const hourlyElapsed = now - (state.mT || now);
let rateMediumText;
if (hourlyElapsed < 300000) { // Less than 5 minutes of data
// Use estimated value from per-second rate
rateMediumText = '~' + this.formatMass((state.rateShort || 0) * 3600) + '/h';
} else {
// Use real average
rateMediumText = this.formatMass((state.rateMedium || 0) * 3600) + '/h';
}
// Check if we have enough data for daily average
const dailyElapsed = now - (state.lT || now);
let rateLongText;
if (dailyElapsed < 3600000) { // Less than 1 hour of data
// Use estimated value from best available rate
const baseRate = (hourlyElapsed >= 300000) ? state.rateMedium : state.rateShort;
rateLongText = '~' + this.formatMass((baseRate || 0) * 86400) + '/d';
} else {
// Use real average
rateLongText = this.formatMass((state.rateLong || 0) * 86400) + '/d';
}
// Only color the per-second rate based on trend
const rateText = `
<span class="${'rate-' + (state.rateShortTrend || 'same')}">${rateShortText}</span>
</br><span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${rateMediumText}</span>
</br><span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${rateLongText}</span>
`;
this.elements.massRate.innerHTML = `
<span>&nbsp;&nbsp;Rates: ${rateText}</span>
`;
},
updateUpgradeDisplay: function(gameState) {
this.updateAsteroidUpgrade(gameState);
// Only show comet upgrade if unlocked
if (gameState.cometUnlocked) {
this.updateCometUpgrade(gameState);
this.elements.cometLevel.parentElement.style.display = '';
} else {
this.elements.cometLevel.parentElement.style.display = 'none';
}
// Only show planet upgrade if unlocked
if (gameState.planetUnlocked) {
this.updatePlanetUpgrade(gameState);
this.elements.planetLevel.parentElement.style.display = '';
} else {
this.elements.planetLevel.parentElement.style.display = 'none';
}
// Only show giant upgrade if unlocked
if (gameState.giantUnlocked) {
this.updateGiantUpgrade(gameState);
this.elements.giantLevel.parentElement.style.display = '';
} else {
this.elements.giantLevel.parentElement.style.display = 'none';
}
// Only show mtype upgrade if unlocked
if (gameState.mtypeUnlocked) {
this.updateMtypeUpgrade(gameState);
this.elements.mtypeLevel.parentElement.style.display = '';
} else {
this.elements.mtypeLevel.parentElement.style.display = 'none';
}
var canAffordAny =
gameState.totalMassConsumed >= gameState.asteroidUpgradeCost ||
(gameState.cometUnlocked && gameState.totalMassConsumed >= gameState.cometUpgradeCost) ||
(gameState.planetUnlocked && gameState.totalMassConsumed >= gameState.planetUpgradeCost) ||
(gameState.giantUnlocked && gameState.totalMassConsumed >= gameState.giantUpgradeCost) ||
(gameState.mtypeUnlocked && gameState.totalMassConsumed >= gameState.mtypeUpgradeCost);
var holeIcon = document.getElementById('hole-icon');
if (holeIcon) {
if (canAffordAny) {
holeIcon.classList.add('pulse');
} else {
holeIcon.classList.remove('pulse');
}
}
},
updateTotalLevel: function(gameState) {
var el = this.elements.totalLevel;
if (!el) return;
@ -160,6 +282,36 @@ var UI = {
'</span>';
},
updateActiveObjects: function() {
var el = document.getElementById('active-objects');
if (!el) return;
var count = window.gameAsteroids ? window.gameAsteroids.length : 0;
var now = Date.now();
// Initialize tracking
if (this._lastObjectCount === undefined) {
this._lastObjectCount = count;
this._objectTrend = 'same';
this._lastObjectCheck = now;
}
// Only update trend once per second to avoid flicker
if (now - this._lastObjectCheck >= 1000) {
if (count > this._lastObjectCount) {
this._objectTrend = 'up';
} else if (count < this._lastObjectCount) {
this._objectTrend = 'down';
} else {
this._objectTrend = 'same';
}
this._lastObjectCount = count;
this._lastObjectCheck = now;
}
// Apply trend color
el.innerHTML = 'Objects: <span class="rate-' + this._objectTrend + '">' + count + '</span>';
},
updateAsteroidUpgrade: function(gameState) {
var rate = (1 / gameState.currentAsteroidSpawnInterval * 1000).toFixed(2);
@ -225,33 +377,46 @@ var UI = {
gameState.totalMassConsumed < gameState.giantUpgradeCost;
},
updateMtypeUpgrade: function(gameState) {
var rate = (86400000 / gameState.currentMtypeSpawnInterval).toFixed(2);
var bonusPercent = (gameState.mtypeUpgradeLevel * 0.5);
var tooltipText = 'Spawn Rate: ' + rate + '/day <br>Bonus: ' + bonusPercent + '%';
this.elements.mtypeLevel.innerHTML = '<span class="tooltip-trigger">M-Type: Level ' +
gameState.mtypeUpgradeLevel +
'<span class="tooltip">' + tooltipText + '</span></span>';
this.elements.mtypeUpgradeBtn.textContent =
'Upgrade (Cost: ' + this.formatMass(gameState.mtypeUpgradeCost) + ')';
this.elements.mtypeUpgradeBtn.disabled =
gameState.totalMassConsumed < gameState.mtypeUpgradeCost;
},
formatMass: function(massKg, options = {}) {
// If forcing solar mass display (for black hole)
if (options.forceSolarMass) {
if (massKg == null) return '0'; // fallback
const SOLAR_SWITCH_KG = CONFIG.SOLAR_MASS_KG * 0.01; // ~1% solar mass
// Forced solar mass display (e.g. black holes)
if (options.forceSolarMass || massKg >= SOLAR_SWITCH_KG) {
var solarMasses = massKg / CONFIG.SOLAR_MASS_KG;
return solarMasses.toFixed(3) + ' M☉';
// Precision scales nicely with magnitude
if (solarMasses >= 1000) return solarMasses.toFixed(0) + ' M☉';
if (solarMasses >= 1) return solarMasses.toFixed(3) + ' M☉';
return solarMasses.toFixed(5) + ' 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
// SI mass units (tonnes-based)
if (massKg >= 1e30) return (massKg / 1e30).toFixed(2) + ' Qt'; // Quettatonnes
if (massKg >= 1e27) return (massKg / 1e27).toFixed(2) + ' Rt'; // Ronnatonnes
if (massKg >= 1e24) return (massKg / 1e24).toFixed(2) + ' Yt'; // Yottatonnes
if (massKg >= 1e21) return (massKg / 1e21).toFixed(2) + ' Zt'; // Zettatonnes
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';
},
formatTime: function(ms) {
@ -276,9 +441,9 @@ var UI = {
var hours = Math.floor((ms % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
if (days > 0) {
return days + 'd ' + hours + 'h old';
return days + 'd ' + hours + 'h';
} else if (hours > 0) {
return hours + 'h old';
return hours + 'h';
} else {
return 'New';
}
@ -287,7 +452,12 @@ var UI = {
openLeaderboard: async function(sortBy = 'mass') {
const container = this.elements.leaderboardList;
const localPlayerId = Server.playerId;
// Show loading only on first load
if (!this._cachedLeaderboardData) {
container.innerHTML = '<div style="text-align:center; padding:20px;">Loading...</div>';
}
// Fetch leaderboard
let data = await Server.getLeaderboard(sortBy);
@ -296,34 +466,73 @@ var UI = {
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
this._cachedLeaderboardData = data;
this._currentSort = sortBy;
// Render function (can be called independently)
const render = () => {
const now = Date.now();
const fifteenMinutes = 15 * 60 * 1000;
// 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>' : '';
// Calculate online status in real-time
const isOnline = (now - entry.lastSeen) < fifteenMinutes;
const onlineIndicator = 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';
const displayName = entry.id ? entry.id.slice(-5) : 'Anon';
// Calculate time since last seen
const timeSinceLastSeen = now - entry.lastSeen;
const lastSeenText = formatTimeSince(timeSinceLastSeen);
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 last-seen">${lastSeenText}</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;
};
render();
// Auto-refresh data every 30 seconds
if (this._leaderboardRefreshInterval) {
clearInterval(this._leaderboardRefreshInterval);
}
this._leaderboardRefreshInterval = setInterval(async () => {
if (this.elements.leaderboardPanel.classList.contains('show')) {
// Re-fetch data
data = await Server.getLeaderboard(sortBy);
this._cachedLeaderboardData = data;
} else {
clearInterval(this._leaderboardRefreshInterval);
this._leaderboardRefreshInterval = null;
}
}, 30000);
// Re-render indicators every 5 seconds (lightweight, no API call)
if (this._leaderboardIndicatorInterval) {
clearInterval(this._leaderboardIndicatorInterval);
}
this._leaderboardIndicatorInterval = setInterval(() => {
if (this.elements.leaderboardPanel.classList.contains('show')) {
render(); // Just re-render with updated time calculations
} else {
clearInterval(this._leaderboardIndicatorInterval);
this._leaderboardIndicatorInterval = null;
}
}, 5000);
},
escapeHtml: function(text) {

310
style.css
View File

@ -19,28 +19,127 @@ canvas {
display: block;
}
#gear-icon {
/* STATS */
#stats-panel {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
text-shadow:
0 0 1px rgba(0, 0, 0, 1),
0 0 2px rgba(0, 0, 0, 1),
0 0 3px rgba(0, 0, 0, 1),
0 0 4px rgba(0, 0, 0, 1),
0 0 8px rgba(0, 0, 0, 1),
0 0 16px rgba(0, 0, 0, 1);
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.3);
backdrop-filter: blur(5px);
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;
}
.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;
}
/* ICONS */
#gear-icon,
.score-icon,
#hole-icon {
position: absolute;
top: 20px;
right: 20px;
width: 20px;
height: 20px;
cursor: pointer;
opacity: 0.3;
opacity: 0.5;
transition: opacity 0.2s;
z-index: 1000;
}
#gear-icon:hover {
opacity: 0.6;
#gear-icon {
right: 20px;
}
.score-icon {
right: 50px;
}
#hole-icon {
top: 16px;
right: 80px;
width: 26px;
height: 26px;
}
#hole-icon.pulse {
animation: holepulse 3s ease-in-out infinite;
}
@keyframes holepulse {
0%, 100% {
}
50% {
opacity: 0.8;
}
}
#hole-icon.pulse:hover {
}
#gear-icon:hover,
.score-icon:hover,
#hole-icon:hover {
opacity: 0.69;
}
/* SETTINGS */
#settings-menu {
display: none;
position: absolute;
top: 50px;
right: 20px;
background: rgba(20, 20, 30, 0.95);
background: rgba(20, 20, 30, 0.3);
backdrop-filter: blur(2px);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 16px;
border-radius: 4px;
@ -69,7 +168,7 @@ canvas {
.menu-btn {
background: rgba(60, 60, 80, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 0, 0, 0.42);
color: rgba(255, 255, 255, 0.5);
padding: 6px 12px;
margin-top: 8px;
@ -80,69 +179,39 @@ canvas {
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);
background: rgba(255, 0, 0, 0.42);
border-color: rgba(255, 0, 0, 0.69);
}
#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;
}
/* UPGRADES*/
#upgrade-panel {
position: absolute;
top: 90px;
left: 20px;
color: rgba(255, 255, 255, 0.3);
font-size: 12px;
top: 50px;
right: 20px;
display: none;
background: rgba(20, 20, 30, 0.3);
backdrop-filter: blur(2px);
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;
z-index: 999;
}
#upgrade-panel.open {
display: block;
}
.upgrade-row {
margin-bottom: 8px;
}
.upgrade-btn {
background: rgba(60, 60, 80, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid rgba(0, 255, 0, 0.42);
color: rgba(255, 255, 255, 0.5);
padding: 4px 8px;
margin-top: 4px;
@ -157,16 +226,17 @@ canvas {
.upgrade-btn:disabled {
cursor: not-allowed;
border: 1px solid rgba(0, 0, 0, 0.69);
opacity: 0.3;
}
/* SCORES */
.modal {
position: absolute;
top: 80px;
top: 50px;
right: 20px;
display: none;
width: 360px;
z-index: 5000;
z-index: 999;
background: transparent;
}
@ -175,7 +245,8 @@ canvas {
}
.modal-content {
background: rgba(20, 20, 30, 0.95);
background: rgba(20, 20, 30, 0.3);
backdrop-filter: blur(2px);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 16px;
border-radius: 4px;
@ -184,34 +255,30 @@ canvas {
overflow-y: auto;
}
.leaderboard-title,
.leaderboard-entry {
display: flex;
display: grid;
grid-template-columns: 1fr 90px 60px 90px 60px;
column-gap: 8px;
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 {
background: rgba(255, 255, 255, 0.03);
}
.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;
@ -221,8 +288,6 @@ canvas {
.leaderboard-entry .value {
color: rgba(255, 255, 255, 0.7);
min-width: 80px;
text-align: right;
font-size: 11px;
}
@ -261,37 +326,6 @@ canvas {
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;
@ -314,3 +348,71 @@ canvas {
box-shadow: 0 0 4px #00ff00, 0 0 6px #00ff00;
}
}
/* Base notification styles */
.notification {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px 30px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
text-align: center;
line-height: 1.6;
animation: notificationFadeIn 0.3s ease-out;
}
/* Notification types */
.notification.unlock {
background: rgba(20, 20, 30, 0.95);
border: 1px solid rgba(0, 255, 255, 0.8);
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
font-size: 12px;
padding: 20px 40px;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.42);
}
.notification.offline {
background: rgba(20, 20, 30, 0.95);
border: 1px solid rgba(255, 255, 0, 0.8);
color: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 20px rgba(255, 255, 0, 0.42);
}
.notification.error {
background: rgba(20, 20, 30, 0.95);
border: 1px solid rgba(255, 0, 0, 0.8);
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
box-shadow: 0 0 20px rgba(255, 0, 0, 0.42);
}
.notification.success {
background: rgba(20, 20, 30, 0.95);
border: 1px solid rgba(0, 255, 0, 0.8);
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.42);
}
.notification.info {
background: rgba(20, 20, 30, 0.95);
border: 1px solid rgba(180, 180, 200, 0.8);
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
}
/* Animation */
@keyframes notificationFadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}