Spaces:
Running
Running
| 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 = ` | |
| <h1>Paused</h1> | |
| <p>Click to Resume</p> | |
| `; | |
| } else if (type === 'gameover') { | |
| content.innerHTML = ` | |
| <h1>You Died</h1> | |
| <p>Score: ${G.player.score}</p> | |
| <p>Wave: ${G.waves.current}</p> | |
| <p>Click to Restart</p> | |
| `; | |
| } | |
| } | |
| // 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)'; | |
| } | |
| } | |