hoel/js/game.js
2026-01-22 19:21:59 +01:00

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();
});