626879ed0c
- 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
359 lines
13 KiB
JavaScript
359 lines
13 KiB
JavaScript
// ═══ 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);
|