473 lines
16 KiB
JavaScript
473 lines
16 KiB
JavaScript
// 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();
|
|
});
|