Files
siege-protocol/js/main.js
T
44r0n7 622a9fd170 Initial commit: Siege Protocol
Inverted tower-defense browser game — deploy enemies yourself, tower auto-kills them, pocket credits, upgrade weapons. HTML + Canvas + vanilla JS, no build step.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 11:36:53 -04:00

237 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══ 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 = `
<div class="perf-title">PERF</div>
<div class="perf-row"><span>FPS</span><span>${PERF_METRICS.fps.toFixed(1)}</span></div>
<div class="perf-row"><span>Frame</span><span>${PERF_METRICS.frameMs.toFixed(2)} ms</span></div>
<div class="perf-row"><span>Logic</span><span>${PERF_METRICS.updateMs.toFixed(2)} ms</span></div>
<div class="perf-row"><span>Render</span><span>${PERF_METRICS.renderMs.toFixed(2)} ms</span></div>
<div class="perf-row"><span>Enemies</span><span>${enemiesAlive}</span></div>
<div class="perf-row"><span>Projectiles</span><span>${G.projectiles.length}</span></div>
<div class="perf-row"><span>Portals</span><span>${G.portals.length}</span></div>
`;
}
// ── 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('[10] 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('[10] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info');
gameLoop();
}
window.addEventListener('load', init);