// ═══ main.js ═══ // ============================================================ // MAIN.JS — Game loop, init, HUD updates // ============================================================ const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // Fixed logical resolution — all gameplay runs at this size. // The canvas is CSS-scaled to fill the viewport on resize. const GAME_W = 1600; const GAME_H = 900; canvas.width = GAME_W; canvas.height = GAME_H; function resize() { const vw = window.innerWidth; const vh = window.innerHeight; const scale = Math.min(vw / GAME_W, vh / GAME_H); const w = Math.floor(GAME_W * scale); const h = Math.floor(GAME_H * scale); canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; canvas.style.left = Math.floor((vw - w) / 2) + 'px'; canvas.style.top = Math.floor((vh - h) / 2) + 'px'; } window.addEventListener('resize', resize); resize(); // ── HIGH SCORE ──────────────────────────────────────────────── let _best = (() => { try { return JSON.parse(localStorage.getItem('siegeprotocol_best')) || null; } catch(e) { return null; } })(); const PERF_METRICS = { lastStamp: null, fps: 60, frameMs: 16.7, updateMs: 0, renderMs: 0, sampleMs: 0, sampleFrames: 0, uiTick: 0, uiEveryFrames: 6, }; function perfNow() { return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); } function setPaused(paused, showOverlay = true) { G.paused = paused; document.body.classList.toggle('paused', !!paused && showOverlay && !G.armoryOpen && !G.commandOpen && !document.body.classList.contains('inventory-open') && !G.gameOver); } function togglePause() { setPaused(!document.body.classList.contains('paused'), true); } function updatePerfOverlay(frameStamp, updateMs, renderMs) { const overlay = document.getElementById('perf-overlay'); if (!overlay) return; if (!DEV_MODE || !G._showPerfOverlay) { overlay.style.display = 'none'; return; } overlay.style.display = 'block'; const frameDelta = PERF_METRICS.lastStamp == null ? 16.7 : (frameStamp - PERF_METRICS.lastStamp); PERF_METRICS.lastStamp = frameStamp; PERF_METRICS.frameMs = PERF_METRICS.frameMs * 0.85 + frameDelta * 0.15; PERF_METRICS.updateMs = PERF_METRICS.updateMs * 0.8 + updateMs * 0.2; PERF_METRICS.renderMs = PERF_METRICS.renderMs * 0.8 + renderMs * 0.2; PERF_METRICS.sampleMs += frameDelta; PERF_METRICS.sampleFrames += 1; if (PERF_METRICS.sampleMs >= 250) { PERF_METRICS.fps = (PERF_METRICS.sampleFrames * 1000) / PERF_METRICS.sampleMs; PERF_METRICS.sampleMs = 0; PERF_METRICS.sampleFrames = 0; } PERF_METRICS.uiTick += 1; if (PERF_METRICS.uiTick < PERF_METRICS.uiEveryFrames) return; PERF_METRICS.uiTick = 0; let enemiesAlive = 0; for (const e of G.enemies) if (e.alive) enemiesAlive++; overlay.innerHTML = `
PERF
FPS${PERF_METRICS.fps.toFixed(1)}
Frame${PERF_METRICS.frameMs.toFixed(2)} ms
Logic${PERF_METRICS.updateMs.toFixed(2)} ms
Render${PERF_METRICS.renderMs.toFixed(2)} ms
Enemies${enemiesAlive}
Projectiles${G.projectiles.length}
Portals${G.portals.length}
`; } // ── GAME LOOP ───────────────────────────────────────────────── function gameLoop() { requestAnimationFrame(gameLoop); const frameStamp = perfNow(); const updateStart = frameStamp; if (!G.paused && !G.gameOver) { const ticks = (DEV_MODE && G._speedMult && G._speedMult !== 1) ? Math.max(1, Math.round(G._speedMult)) : 1; for (let t = 0; t < ticks; t++) { G.frame++; // Decay freshness every 5 seconds (300 frames) if (G.frame % 300 === 0) { for (const id in G.enemyFreshness) { if (G.enemyFreshness[id] > 0) G.enemyFreshness[id]--; } } updatePortals(); updateEnemies(); updateWeapons(); updateShield(); updateParticles(); updateFloaters(); if (G.paused || G.gameOver) break; } } checkBankruptcy(); const updateEnd = perfNow(); render(); const renderEnd = perfNow(); updatePerfOverlay(frameStamp, updateEnd - updateStart, renderEnd - updateEnd); } // ── RESERVE ─────────────────────────────────────────────────── function adjustReserve(delta) { const cheapest = cheapestEnemyCost(); G.creditReserve = clamp(G.creditReserve + delta, cheapest, G.credits); updateHUD(); } function spendableCredits() { return Math.max(0, G.credits - G.creditReserve); } // ── HUD ─────────────────────────────────────────────────────── function updateHUD() { // Clamp state — canvas renderer reads G.* directly each frame G.credits = Math.floor(Math.max(0, G.credits)); const cheapest = cheapestEnemyCost(); G.creditReserve = clamp(G.creditReserve, cheapest, G.credits); checkBankruptcy(); } // ── COMBAT LOG ──────────────────────────────────────────────── function addLog(msg, type = '') { if (!G.logLines) G.logLines = []; if (G.logLines.length > 0 && G.logLines[0].text === msg && G.logLines[0].type === type) { G.logLines[0].count = (G.logLines[0].count || 1) + 1; } else { G.logLines.unshift({ text: msg, type, count: 1 }); if (G.logLines.length > 40) G.logLines.length = 40; } } // ── BANKRUPTCY CHECK ────────────────────────────────────────── function checkBankruptcy() { if (G.gameOver) return; // Bankrupt = 0 credits AND no enemies alive (no pending rewards coming in) // AND can't afford even the cheapest enemy const cheapest = cheapestEnemyCost(); const activeEnemies = countAliveEnemies(); const pendingPortals = G.portals.length; const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (G.credits < cheapest && activeEnemies === 0 && pendingPortals === 0) { // Give a brief grace window — maybe enemies just died and reward came in. if (!G._bankruptAt) G._bankruptAt = now; if (now - G._bankruptAt > 3000) { endBankrupt(); } } else { G._bankruptAt = null; } } // ── GAME OVER ───────────────────────────────────────────────── function endGame() { if (G.armoryOpen) closeArmory(); if (G.commandOpen) closeCommand(); setPaused(false); G.gameOver = true; G.isBankrupt = false; G._isNewBest = !_best || G.score > _best.score; if (G._isNewBest) { _best = { score: G.score, kills: G.totalKills }; try { localStorage.setItem('siegeprotocol_best', JSON.stringify(_best)); } catch(e) {} } } function endBankrupt() { if (G.armoryOpen) closeArmory(); if (G.commandOpen) closeCommand(); setPaused(false); G.gameOver = true; G.isBankrupt = true; G._isNewBest = !_best || G.score > _best.score; if (G._isNewBest) { _best = { score: G.score, kills: G.totalKills }; try { localStorage.setItem('siegeprotocol_best', JSON.stringify(_best)); } catch(e) {} } addLog('BANKRUPT. No credits, no enemies. Game over.', 'lose'); } function restartGame() { const savedBonuses = G.permanentBonuses ? { ...G.permanentBonuses } : {}; const savedTiers = G.unlockedTiers ? [...G.unlockedTiers] : [0]; const savedPrestige = G.prestigeLevel || 0; G = makeGameState(); G.permanentBonuses = savedBonuses; G.unlockedTiers = savedTiers; G.difficultyTier = savedTiers.includes(0) ? 0 : savedTiers[0]; G.prestigeLevel = savedPrestige; applyPermanentBonuses(); setPaused(false); _sidePanelScrollY = 0; _logScrollY = 0; updateHUD(); addLog('System online. Deploy enemies to earn credits.', 'info'); if (savedPrestige > 0) addLog(`PRESTIGE ${savedPrestige} active — permanent bonuses applied.`, 'win'); } // ── THREAT / PRESTIGE PANEL CONTROLS ───────────────────────── function openThreatPanel() { if (G.gameOver) return; G.threatOpen = true; G.prestigeOpen = false; } function closeThreatPanel() { G.threatOpen = false; } function openPrestigeDialog() { if (G.gameOver) return; G.prestigeOpen = true; G.threatOpen = false; } function closePrestigeDialog() { G.prestigeOpen = false; } // ── THREAT LEVEL ───────────────────────────────────────────── function unlockThreatTier(tierId) { const tierDef = DIFFICULTY_TIERS[tierId]; if (!tierDef) return; if (G.unlockedTiers.includes(tierId)) { // Already unlocked — just switch setThreatTier(tierId); return; } if (spendableCredits() < tierDef.unlockCost) { addLog(`Need ${tierDef.unlockCost}¢ to unlock ${tierDef.name}.`, 'info'); return; } G.credits -= tierDef.unlockCost; G.unlockedTiers.push(tierId); setThreatTier(tierId); addLog(`THREAT LEVEL: ${tierDef.name} unlocked and activated.`, 'win'); updateHUD(); renderShop(); } function setThreatTier(tierId) { if (!G.unlockedTiers.includes(tierId)) return; G.difficultyTier = tierId; const tierDef = DIFFICULTY_TIERS[tierId]; if (tierDef && tierId > 0) { addLog(`THREAT LEVEL: ${tierDef.name} — enemies at ×${tierDef.hpMult} HP, ×${tierDef.rewardMult} rewards.`, 'info'); } else { addLog('THREAT LEVEL: NORMAL — standard difficulty.', 'info'); } updateHUD(); renderShop(); } // ── PRESTIGE ───────────────────────────────────────────────── function prestigeCost() { const base = [5000, 25000, 60000, 120000, 200000]; const lvl = G.prestigeLevel || 0; return base[lvl] ?? (200000 + (lvl - 4) * 100000); } function applyPermanentBonuses() { const b = G.permanentBonuses || {}; if (b.maxHp) { G.tower.maxHp += Math.floor(b.maxHp); G.tower.hp = Math.min(G.tower.hp, G.tower.maxHp); } if (b.armor) { G.tower.armor += Math.floor(b.armor); } if (b.range) { G.tower.range += Math.floor(b.range); } if (b.aimSpeed) { G.tower.aimSpeed += b.aimSpeed; } } function prestige() { const cost = prestigeCost(); if (G.credits < cost) { addLog(`Need ${cost}¢ to prestige.`, 'info'); return; } const FRACTION = 0.25; const newBonuses = { ...(G.permanentBonuses || {}) }; // Convert all owned tower upgrades into fractional permanent bonuses for (const upg of TOWER_UPGRADE_TREE) { if (!G.towerUpgradesBought.includes(upg.id) || upg.repeatable) continue; const e = upg.effect; if (e.maxHp) newBonuses.maxHp = (newBonuses.maxHp || 0) + e.maxHp * FRACTION; if (e.armor) newBonuses.armor = (newBonuses.armor || 0) + e.armor * FRACTION; if (e.aimSpeed) newBonuses.aimSpeed = (newBonuses.aimSpeed || 0) + e.aimSpeed * FRACTION; if (e.range) newBonuses.range = (newBonuses.range || 0) + e.range * FRACTION; } const prevTiers = [...G.unlockedTiers]; const newPrestige = (G.prestigeLevel || 0) + 1; G = makeGameState(); G.permanentBonuses = newBonuses; G.unlockedTiers = prevTiers; G.difficultyTier = prevTiers.includes(0) ? 0 : prevTiers[0]; G.prestigeLevel = newPrestige; applyPermanentBonuses(); setPaused(false); _sidePanelScrollY = 0; _logScrollY = 0; updateHUD(); renderShop(); addLog(`PRESTIGE ${newPrestige} — permanent bonuses banked. New run starts at 150¢.`, 'win'); if (newBonuses.maxHp) addLog(` Hull: +${Math.floor(newBonuses.maxHp)} HP permanent`, 'win'); if (newBonuses.armor) addLog(` Plating: +${Math.floor(newBonuses.armor)} armor permanent`, 'win'); if (newBonuses.range) addLog(` Scanner: +${Math.floor(newBonuses.range)} range permanent`, 'win'); } // ── INIT ────────────────────────────────────────────────────── function init() { resize(); // set canvas size after full page load — guaranteed correct dimensions updateHUD(); initInput(); addLog('SIEGE PROTOCOL initialized.', 'info'); gameLoop(); } window.addEventListener('load', init);