import { G } from './globals.js'; import { CFG } from './config.js'; // Cache HUD element refs and last values to minimize DOM churn const HUD = { waveEl: /** @type {HTMLElement|null} */(document.getElementById('wave')), scoreEl: /** @type {HTMLElement|null} */(document.getElementById('score')), enemiesEl: /** @type {HTMLElement|null} */(document.getElementById('enemies')), ammoEl: /** @type {HTMLElement|null} */(document.getElementById('ammo')), grenadesEl: /** @type {HTMLElement|null} */(document.getElementById('grenades')), healthText: /** @type {HTMLElement|null} */(document.getElementById('health-text')), healthFill: /** @type {HTMLElement|null} */(document.getElementById('health-fill')), powerupsEl: /** @type {HTMLElement|null} */(document.getElementById('powerup-chips')), ch: { root: /** @type {HTMLElement|null} */(document.getElementById('crosshair')), left: /** @type {HTMLElement|null} */(document.getElementById('ch-left')), right: /** @type {HTMLElement|null} */(document.getElementById('ch-right')), top: /** @type {HTMLElement|null} */(document.getElementById('ch-top')), bottom: /** @type {HTMLElement|null} */(document.getElementById('ch-bottom')), hitA1: /** @type {HTMLElement|null} */(document.getElementById('ch-hit-a1')), hitA2: /** @type {HTMLElement|null} */(document.getElementById('ch-hit-a2')), hitB1: /** @type {HTMLElement|null} */(document.getElementById('ch-hit-b1')), hitB2: /** @type {HTMLElement|null} */(document.getElementById('ch-hit-b2')), lastGap: -1, lastLen: -1, hitLen: -1, lastHitOpacity: -1, hitGap: -1 }, last: { hp: -1, hpPct: -1, wave: -1, score: -1, enemies: -1, ammo: '', grenades: -1, powerupsKey: '' } }; export function updateHUD() { const hp = Math.ceil(G.player.health); const hpPct = Math.max(0, Math.min(1, G.player.health / CFG.player.health)); if (hp !== HUD.last.hp) { HUD.last.hp = hp; if (HUD.healthText) HUD.healthText.textContent = String(hp); } if (Math.abs(hpPct - HUD.last.hpPct) > 0.005) { HUD.last.hpPct = hpPct; if (HUD.healthFill) HUD.healthFill.style.width = ((hpPct * 100) | 0) + '%'; } if (G.waves.current !== HUD.last.wave) { HUD.last.wave = G.waves.current; if (HUD.waveEl) HUD.waveEl.textContent = String(G.waves.current); } if (G.player.score !== HUD.last.score) { HUD.last.score = G.player.score; if (HUD.scoreEl) HUD.scoreEl.textContent = String(G.player.score); } if (G.waves.aliveCount !== HUD.last.enemies) { HUD.last.enemies = G.waves.aliveCount; if (HUD.enemiesEl) HUD.enemiesEl.textContent = String(G.waves.aliveCount); } let ammoText; if (G.weapon.infiniteAmmoTimer > 0) { ammoText = '∞/∞'; } else { const reserveText = G.weapon.reserve === Infinity ? '∞' : String(G.weapon.reserve); ammoText = `${G.weapon.ammo}/${reserveText}`; } if (ammoText !== HUD.last.ammo) { HUD.last.ammo = ammoText; if (HUD.ammoEl) HUD.ammoEl.textContent = ammoText; } if (G.grenadeCount !== HUD.last.grenades) { HUD.last.grenades = G.grenadeCount; if (HUD.grenadesEl) HUD.grenadesEl.textContent = String(G.grenadeCount); } // Powerup chips next to health const active = []; if (G.weapon.rofBuffTimer > 0) active.push({ id: 'accelerator', name: 'ACCELERATE', color: 0xffd84d, time: G.weapon.rofBuffTimer }); if (G.weapon.infiniteAmmoTimer > 0) active.push({ id: 'infinite', name: 'INFINITE AMMO', color: 0x6366f1, time: G.weapon.infiniteAmmoTimer }); const key = active.map(a => a.id).join(','); if (key !== HUD.last.powerupsKey) { HUD.last.powerupsKey = key; const el = HUD.powerupsEl; if (el) { // Clear and rebuild chips el.innerHTML = ''; for (const p of active) { const chip = document.createElement('div'); chip.className = 'pu-chip'; chip.dataset.id = p.id; const r = (p.color >> 16) & 255; const g = (p.color >> 8) & 255; const b = p.color & 255; chip.style.borderColor = `rgba(${r},${g},${b},0.75)`; chip.style.boxShadow = `0 0 10px rgba(${r},${g},${b},0.35)`; chip.style.backgroundColor = `rgba(${r},${g},${b},0.12)`; const fill = document.createElement('div'); fill.className = 'pu-fill'; fill.style.backgroundColor = `rgba(${r},${g},${b},0.35)`; fill.style.width = '100%'; const text = document.createElement('span'); text.className = 'pu-text'; text.textContent = p.name; chip.appendChild(fill); chip.appendChild(text); el.appendChild(chip); } } } // Update blink state even if set didn't change const el = HUD.powerupsEl; if (el) { for (const p of active) { const chip = el.querySelector(`.pu-chip[data-id="${p.id}"]`); if (chip) { const shouldBlink = p.time != null && p.time <= 3; if (shouldBlink) chip.classList.add('blink'); else chip.classList.remove('blink'); // Update progress fill width if total known const total = (p.id === 'accelerator') ? (G.weapon.rofBuffTotal || 0) : (p.id === 'infinite' ? (G.weapon.infiniteAmmoTotal || 0) : 0); const fill = chip.querySelector('.pu-fill'); if (fill && total > 0 && p.time != null) { const t = Math.max(0, Math.min(1, p.time / total)); fill.style.width = (t * 100).toFixed(1) + '%'; } } } // Also remove blink from any chips not active const nodes = el.querySelectorAll('.pu-chip'); nodes.forEach(node => { const id = node.dataset.id; if (!active.find(a => a.id === id)) node.classList.remove('blink'); }); } } export function showWaveBanner(text) { const banner = document.getElementById('wave-banner'); if (!banner) return; banner.textContent = text; banner.classList.add('show'); setTimeout(() => banner.classList.remove('show'), 2000); } export function showOverlay(type) { const overlay = document.getElementById('overlay'); const content = document.getElementById('overlay-content'); if (!overlay || !content) return; overlay.classList.remove('hidden'); if (type === 'paused') { content.innerHTML = `

Paused

Click to Resume

`; } else if (type === 'gameover') { content.innerHTML = `

You Died

Score: ${G.player.score}

Wave: ${G.waves.current}

Click to Restart

`; } } // Simple red damage overlay that fades over time export function updateDamageEffect(delta) { const el = document.getElementById('damage-overlay'); if (!el) return; // Fade G.damageFlash = Math.max(0, G.damageFlash - CFG.hud.damageFadeSpeed * delta); const opacity = Math.min(CFG.hud.damageMaxOpacity, G.damageFlash * CFG.hud.damageMaxOpacity); el.style.opacity = String(opacity); } // Green heal overlay that fades over time export function updateHealEffect(delta) { const el = document.getElementById('heal-overlay'); if (!el) return; G.healFlash = Math.max(0, G.healFlash - CFG.hud.healFadeSpeed * delta); const opacity = Math.min(CFG.hud.healMaxOpacity, G.healFlash * CFG.hud.healMaxOpacity); el.style.opacity = String(opacity); } // Crosshair widening based on current spread export function updateCrosshair(delta) { const { ch } = HUD; if (!ch.root || !ch.left || !ch.right || !ch.top || !ch.bottom) return; // Convert NDC spread to pixel gap (approximate using viewport width) // When crouched, the effective minimum spread is reduced const baseMin = (CFG.gun.spreadMin || CFG.gun.bloom || 0); const crouchMin = G.input.crouch ? baseMin * (CFG.gun.spreadCrouchMult || 1) : baseMin; const ndc = Math.max(G.weapon.spread, crouchMin); const baseGap = 6; // px baseline gap const gapPx = baseGap + ndc * 0.5 * window.innerWidth; // half-width maps NDC to px const armLen = 10 + Math.min(20, ndc * window.innerWidth * 0.4); // grow a bit with spread if (Math.abs(gapPx - ch.lastGap) < 0.5 && Math.abs(armLen - ch.lastLen) < 0.5) return; ch.lastGap = gapPx; ch.lastLen = armLen; // Horizontal arms ch.left.style.width = armLen + 'px'; ch.left.style.left = -(gapPx + armLen) + 'px'; ch.left.style.top = '-1px'; ch.right.style.width = armLen + 'px'; ch.right.style.left = gapPx + 'px'; ch.right.style.top = '-1px'; // Vertical arms ch.top.style.height = armLen + 'px'; ch.top.style.top = -(gapPx + armLen) + 'px'; ch.top.style.left = '-1px'; ch.bottom.style.height = armLen + 'px'; ch.bottom.style.top = gapPx + 'px'; ch.bottom.style.left = '-1px'; } // Small white X that flashes briefly when hitting an enemy export function updateHitMarker(delta) { const { ch } = HUD; if (!ch.root || !ch.hitA1 || !ch.hitA2 || !ch.hitB1 || !ch.hitB2) return; // Fade out hit flash G.hitFlash = Math.max(0, G.hitFlash - (CFG.hud.hitFadeSpeed || 12) * delta); const maxOp = (CFG.hud.hitMaxOpacity != null ? CFG.hud.hitMaxOpacity : 0.3); const op = Math.max(0, Math.min(maxOp, G.hitFlash)); // Only touch DOM when something changed if (Math.abs(op - ch.lastHitOpacity) > 0.01) { ch.lastHitOpacity = op; ch.hitA1.style.opacity = String(op); ch.hitA2.style.opacity = String(op); ch.hitB1.style.opacity = String(op); ch.hitB2.style.opacity = String(op); } // Size and placement: four segments with a center gap const L = (CFG.hud.hitSize || 16) | 0; // length of each segment in px const extra = (CFG.hud.hitGapExtra || 6) | 0; const baseGap = ch.lastGap >= 0 ? ch.lastGap : 10; // from crosshair const gap = baseGap + extra; // ensure larger than crosshair gap if (L !== ch.hitLen || Math.abs(gap - ch.hitGap) > 0.5) { ch.hitLen = L; ch.hitGap = gap; // Distance from center to each segment center along the diagonal const d = gap + L * 0.5; const s = Math.SQRT1_2; // 1 / sqrt(2) const dx = d * s; const dy = d * s; const leftBase = (v) => (v - L * 0.5) + 'px'; const topBase = (v) => (v - 1) + 'px'; // thickness ~2px // A diagonal (+45deg): NE and SW ch.hitA1.style.width = L + 'px'; ch.hitA1.style.left = leftBase(dx); ch.hitA1.style.top = topBase(dy); ch.hitA1.style.transform = 'rotate(45deg)'; ch.hitA2.style.width = L + 'px'; ch.hitA2.style.left = leftBase(-dx); ch.hitA2.style.top = topBase(-dy); ch.hitA2.style.transform = 'rotate(45deg)'; // B diagonal (-45deg): NW and SE ch.hitB1.style.width = L + 'px'; ch.hitB1.style.left = leftBase(-dx); ch.hitB1.style.top = topBase(dy); ch.hitB1.style.transform = 'rotate(-45deg)'; ch.hitB2.style.width = L + 'px'; ch.hitB2.style.left = leftBase(dx); ch.hitB2.style.top = topBase(-dy); ch.hitB2.style.transform = 'rotate(-45deg)'; } }