// ═══ 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.shopOpen &&
!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 = [];
G.logLines.unshift({ text: msg, type });
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.shopOpen) closeShop();
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.shopOpen) closeShop();
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() {
G = makeGameState();
setPaused(false);
_sidePanelScrollY = 0;
_logScrollY = 0;
updateHUD();
addLog('System online. Deploy enemies to earn credits.', 'info');
addLog('[1–0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info');
}
// ── INIT ──────────────────────────────────────────────────────
function init() {
resize(); // set canvas size after full page load — guaranteed correct dimensions
updateHUD();
initInput();
addLog('SIEGE PROTOCOL initialized.', 'info');
addLog('[1–0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info');
gameLoop();
}
window.addEventListener('load', init);