Files
siege-protocol/js/main.js
T
44r0n7 626879ed0c Add freshness bar, enhance overlays and renderers
- Add enemy freshness tracking (novelty bonus for repeated deploys)
- Add freshness bar to sidepanel enemy cards with penalty indicator
- Major overhaul of renderer-overlays.js (790+ lines for UI polish)
- Enhanced combat log, shop overlays, and inventory UI
- Improved weapon/upgrade display with partial ownership colors
- Added element icons and weakness/resistance indicators to cards
- Enhanced radial menu and tooltip system
- Add "stale/%" penalty text when freshness depleted
- Update play link to ffazeshift.net in index.html
2026-06-17 11:58:17 -04:00

359 lines
13 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.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 = `
<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 = [];
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);