hoel/js/game.js
2026-02-01 22:11:20 +01:00

583 lines
21 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,
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(),
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
isReady: false,
tabHiddenAt: null,
cometUnlocked: false,
planetUnlocked: false,
giantUnlocked: false,
mtypeUnlocked: false
};
var blackHole;
var stars = [];
var asteroids = [];
var lastFrameTime = 0;
var lastStarUpdate = 0;
var needsRender = true;
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
canvas.height = window.innerHeight
}
function init() {
resizeCanvas();
blackHole = new BlackHole(
canvas.width / 2,
canvas.height / 2,
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();
UI.setUpgradeHandlers({
asteroid: handleAsteroidUpgrade,
comet: handleCometUpgrade,
planet: handlePlanetUpgrade,
giant: handleGiantUpgrade,
mtype: handleMtypeUpgrade
});
Server.init()
loadGameState();
window.addEventListener('resize', function() {
resizeCanvas();
blackHole.x = canvas.width / 2;
blackHole.y = canvas.height / 2;
stars.forEach(function(star) {
star.reset();
});
});
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);
// Save when page becomes hidden (tab switch, minimize, close)
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
state.tabHiddenAt = Date.now(); // NEW: Record when hidden
Server.checkpoint(state);
} else if (!document.hidden) {
loadGameState();
Server.checkpoint(state);
}
});
// Save when page is about to unload (close tab, navigate away)
window.addEventListener('beforeunload', function() {
state.tabHiddenAt = Date.now();
Server.checkpoint(state);
});
// 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() {
Server.loadGameState().then(function(savedState) {
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.mtypeUpgradeLevel = savedState.mtypeUpgradeLevel || 0;
state.mtypeUpgradeCost = savedState.mtypeUpgradeCost || CONFIG.MTYPE_BASE_COST;
state.asteroidSpawnCount = savedState.asteroidSpawnCount;
// 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();
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;
state.mM = savedState.mM || 0;
state.lM = savedState.lM || 0;
var now = Date.now();
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;
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) {
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);
state.blackHoleTotalMass += offlineMass;
state.totalMassConsumedEver += offlineMass;
state.totalMassConsumed += offlineMass;
state.sM += offlineMass;
state.mM += offlineMass;
state.lM += offlineMass;
showOfflineNotification(
formatOfflineTime(offlineTime),
UI.formatMass(offlineMass),
'offline'
);
}
}
// Clear the hidden timestamp after processing
state.tabHiddenAt = null;
updateSpawnIntervals();
// Restore black hole size
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 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_ASTER);
state.currentCometSpawnInterval = CONFIG.BASE_COMET_SPAWN_INTERVAL /
(1 + state.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_COMET);
state.currentPlanetSpawnInterval = CONFIG.BASE_PLANET_SPAWN_INTERVAL /
(1 + state.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_PLANT);
state.currentGiantSpawnInterval = CONFIG.BASE_GIANT_SPAWN_INTERVAL /
(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() {
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)
);
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);
}
}
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_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);
}
}
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_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);
}
}
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_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);
}
}
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 (state.cometUnlocked && state.cometUpgradeLevel >= 0) {
if (currentTime - state.lastCometSpawn > state.currentCometSpawnInterval) {
state.lastCometSpawn = currentTime;
asteroids.push(new Asteroid('comet', blackHole, canvas));
}
}
if (state.planetUnlocked && state.planetUpgradeLevel >= 0) {
if (currentTime - state.lastPlanetSpawn > state.currentPlanetSpawnInterval) {
state.lastPlanetSpawn = currentTime;
asteroids.push(new Asteroid('planet', blackHole, canvas));
}
}
if (state.giantUnlocked && state.giantUpgradeLevel >= 0) {
if (currentTime - state.lastGiantSpawn > state.currentGiantSpawnInterval) {
state.lastGiantSpawn = currentTime;
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--) {
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, state.rateShort || 0);
}
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 = {
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 {
// 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,
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)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
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() {
let lastCheckpointData = null;
setInterval(() => {
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); // Check every 15s, but only send if changed
},
};
// Start the game when the page loads
window.addEventListener('load', function() {
Game.init();
});