diff --git a/client/webapp/js/ui/hud_orbs.js b/client/webapp/js/ui/hud_orbs.js index 4f37bfb..0b6fda2 100644 --- a/client/webapp/js/ui/hud_orbs.js +++ b/client/webapp/js/ui/hud_orbs.js @@ -86,21 +86,31 @@ export class HudOrbs { } /** - * Main game loop integration point. + * Main game loop integration point with NaN/Infinity resilience and safe clamping. */ static update(hp, maxHp, mana, maxMana, dt = 0.016) { if (!this.initialized) this.init(); - const safeMaxHp = Math.max(1, maxHp || 100); - const safeHp = Math.max(0, Math.min(safeMaxHp, hp ?? safeMaxHp)); - const safeMaxMana = Math.max(1, maxMana || 50); - const safeMana = Math.max(0, Math.min(safeMaxMana, mana ?? safeMaxMana)); + const safeMaxHp = Math.max(1, (typeof maxHp === 'number' && Number.isFinite(maxHp)) ? maxHp : 100); + const rawHp = (typeof hp === 'number' && Number.isFinite(hp)) ? hp : safeMaxHp; + const safeHp = Math.max(0, Math.min(safeMaxHp, rawHp)); - const safeDt = Math.min(0.1, Math.max(0.001, dt)); + const safeMaxMana = Math.max(1, (typeof maxMana === 'number' && Number.isFinite(maxMana)) ? maxMana : 50); + const rawMana = (typeof mana === 'number' && Number.isFinite(mana)) ? mana : safeMaxMana; + const safeMana = Math.max(0, Math.min(safeMaxMana, rawMana)); + + const rawDt = (typeof dt === 'number' && Number.isFinite(dt)) ? dt : 0.016; + const safeDt = Math.min(0.1, Math.max(0.001, rawDt)); const lerpFactor = Math.min(1.0, safeDt * this.LERP_SPEED); + + if (!Number.isFinite(this.displayedHp)) this.displayedHp = safeHp; this.displayedHp += (safeHp - this.displayedHp) * lerpFactor; + if (!Number.isFinite(this.displayedHp)) this.displayedHp = safeHp; this.displayedMaxHp = safeMaxHp; + + if (!Number.isFinite(this.displayedMana)) this.displayedMana = safeMana; this.displayedMana += (safeMana - this.displayedMana) * lerpFactor; + if (!Number.isFinite(this.displayedMana)) this.displayedMana = safeMana; this.displayedMaxMana = safeMaxMana; this.time += safeDt; @@ -131,6 +141,7 @@ export class HudOrbs { /** * Renders a fluid glass orb with sinusoidal wave crest, depth shading, and specular sheen. + * Static gradients are cached per canvas context to eliminate 120 FPS GC allocations. */ static renderOrb(ctx, fillRatio, type, time, isLowHp) { if (!ctx) return; @@ -148,16 +159,22 @@ export class HudOrbs { ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); - // 2. Empty Cavity Background - const cavityGrad = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, r); - if (type === 'life') { - cavityGrad.addColorStop(0, '#1c0505'); - cavityGrad.addColorStop(0.7, '#0d0202'); - cavityGrad.addColorStop(1, '#050101'); - } else { - cavityGrad.addColorStop(0, '#040d1a'); - cavityGrad.addColorStop(0.7, '#020712'); - cavityGrad.addColorStop(1, '#010308'); + // 2. Empty Cavity Background (Cached per context) + if (!ctx._cachedGrads) ctx._cachedGrads = {}; + const cavityKey = `cavity_${type}`; + let cavityGrad = ctx._cachedGrads[cavityKey]; + if (!cavityGrad) { + cavityGrad = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, r); + if (type === 'life') { + cavityGrad.addColorStop(0, '#1c0505'); + cavityGrad.addColorStop(0.7, '#0d0202'); + cavityGrad.addColorStop(1, '#050101'); + } else { + cavityGrad.addColorStop(0, '#040d1a'); + cavityGrad.addColorStop(0.7, '#020712'); + cavityGrad.addColorStop(1, '#010308'); + } + ctx._cachedGrads[cavityKey] = cavityGrad; } ctx.fillStyle = cavityGrad; ctx.fillRect(0, 0, w, h); @@ -220,24 +237,32 @@ export class HudOrbs { ctx.fill(); } - // 4. Spherical Depth Vignette - const depthGrad = ctx.createRadialGradient(cx, cy, r * 0.55, cx, cy, r); - depthGrad.addColorStop(0, 'rgba(0, 0, 0, 0)'); - depthGrad.addColorStop(0.85, 'rgba(0, 0, 0, 0.35)'); - depthGrad.addColorStop(1, 'rgba(0, 0, 0, 0.85)'); + // 4. Spherical Depth Vignette (Cached) + let depthGrad = ctx._cachedGrads.depth; + if (!depthGrad) { + depthGrad = ctx.createRadialGradient(cx, cy, r * 0.55, cx, cy, r); + depthGrad.addColorStop(0, 'rgba(0, 0, 0, 0)'); + depthGrad.addColorStop(0.85, 'rgba(0, 0, 0, 0.35)'); + depthGrad.addColorStop(1, 'rgba(0, 0, 0, 0.85)'); + ctx._cachedGrads.depth = depthGrad; + } ctx.fillStyle = depthGrad; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); - // 5. Specular Sheen (Curved Upper Glass Reflection) + // 5. Specular Sheen (Curved Upper Glass Reflection, Cached) ctx.save(); ctx.beginPath(); ctx.ellipse(cx - r * 0.30, cy - r * 0.34, r * 0.42, r * 0.22, -Math.PI / 4.2, 0, Math.PI * 2); - const sheenGrad = ctx.createLinearGradient(cx - r * 0.5, cy - r * 0.5, cx, cy); - sheenGrad.addColorStop(0, 'rgba(255, 255, 255, 0.48)'); - sheenGrad.addColorStop(0.6, 'rgba(255, 255, 255, 0.12)'); - sheenGrad.addColorStop(1, 'rgba(255, 255, 255, 0)'); + let sheenGrad = ctx._cachedGrads.sheen; + if (!sheenGrad) { + sheenGrad = ctx.createLinearGradient(cx - r * 0.5, cy - r * 0.5, cx, cy); + sheenGrad.addColorStop(0, 'rgba(255, 255, 255, 0.48)'); + sheenGrad.addColorStop(0.6, 'rgba(255, 255, 255, 0.12)'); + sheenGrad.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx._cachedGrads.sheen = sheenGrad; + } ctx.fillStyle = sheenGrad; ctx.fill(); ctx.restore(); @@ -251,19 +276,24 @@ export class HudOrbs { ctx.restore(); // Restore clip boundary - // 7. Outer Metallic Bezel Ring (Grimdark Iron & Bronze) + // 7. Outer Metallic Bezel Ring (Grimdark Iron & Bronze, Cached) ctx.beginPath(); ctx.arc(cx, cy, r + 2, 0, Math.PI * 2); - const rimGrad = ctx.createLinearGradient(cx - r, cy - r, cx + r, cy + r); - if (isLowHp && type === 'life') { - rimGrad.addColorStop(0, '#ef4444'); - rimGrad.addColorStop(0.5, '#7f1d1d'); - rimGrad.addColorStop(1, '#dc2626'); - } else { - rimGrad.addColorStop(0, '#52525b'); - rimGrad.addColorStop(0.3, '#27272a'); - rimGrad.addColorStop(0.7, '#78350f'); - rimGrad.addColorStop(1, '#18181b'); + const rimKey = (isLowHp && type === 'life') ? 'rim_low_hp' : 'rim_normal'; + let rimGrad = ctx._cachedGrads[rimKey]; + if (!rimGrad) { + rimGrad = ctx.createLinearGradient(cx - r, cy - r, cx + r, cy + r); + if (isLowHp && type === 'life') { + rimGrad.addColorStop(0, '#ef4444'); + rimGrad.addColorStop(0.5, '#7f1d1d'); + rimGrad.addColorStop(1, '#dc2626'); + } else { + rimGrad.addColorStop(0, '#52525b'); + rimGrad.addColorStop(0.3, '#27272a'); + rimGrad.addColorStop(0.7, '#78350f'); + rimGrad.addColorStop(1, '#18181b'); + } + ctx._cachedGrads[rimKey] = rimGrad; } ctx.strokeStyle = rimGrad; ctx.lineWidth = 5.0; diff --git a/client/webapp/js/ui/skill_bar_controller.js b/client/webapp/js/ui/skill_bar_controller.js index 63e6473..816b7f4 100644 --- a/client/webapp/js/ui/skill_bar_controller.js +++ b/client/webapp/js/ui/skill_bar_controller.js @@ -31,7 +31,6 @@ export class SkillBarController { this.container = typeof containerEl === 'string' ? document.querySelector(containerEl) : (containerEl || document.getElementById('hud-skill-bar') || document.getElementById('combat-cluster')); - this._registerDefaultSlots(); this._bindKeyboardListener(); return this; @@ -47,7 +46,6 @@ export class SkillBarController { { id: '0', domId: 'skill-primary', key: 'LMB', numKey: '1', maxCd: 0.2, action: () => window.doPrimaryAttack?.() }, { id: 'dodge', domId: 'skill-dodge', key: 'SPACE', maxCd: 3.0, charges: 3, action: () => window.doDodge?.() } ]; - defaults.forEach(def => { const el = document.getElementById(def.domId); if (el) { @@ -93,7 +91,6 @@ export class SkillBarController { const el = slotState.element; if (!el) return; if (config.skillId && SKILL_ATLAS_MAP[config.skillId]) this.setSlotSkill(slotState.slotId, config.skillId); - el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.activateSlot(slotState.slotId); }); el.addEventListener('pointerdown', () => el.classList.add('is-pressed')); el.addEventListener('pointerup', () => el.classList.remove('is-pressed')); @@ -119,8 +116,21 @@ export class SkillBarController { triggerCooldown(slotId, durationSec) { const slot = this.slots.get(String(slotId)); if (!slot) return false; - const dur = (typeof durationSec === 'number' && durationSec > 0) ? durationSec : slot.cooldownTotal; - if (dur <= 0) return false; + + // Zero or negative duration clears cooldown immediately + if (typeof durationSec === 'number' && durationSec <= 0) { + if (slot.charges !== null) { + slot.charges = slot.maxCharges; + slot.rechargeRemaining = 0; + this._updateChargeBadge(slot); + } + this._finishCooldown(slot); + return true; + } + + const dur = (typeof durationSec === 'number' && Number.isFinite(durationSec) && durationSec > 0) + ? durationSec : slot.cooldownTotal; + if (!Number.isFinite(dur) || dur <= 0) return false; if (slot.charges !== null) { if (slot.charges > 0) { @@ -198,8 +208,17 @@ export class SkillBarController { const progress = 1.0 - (slot.rechargeRemaining / Math.max(0.001, slot.rechargeDuration)); this._applySweepUI(slot, progress, slot.rechargeRemaining); if (slot.rechargeRemaining <= 0) { + const wasDepleted = slot.charges === 0; slot.charges = Math.min(slot.maxCharges, slot.charges + 1); this._updateChargeBadge(slot); + if (slot.charges > 0) { + this._markSlotCooldownState(slot, false); + if (wasDepleted) { + this._triggerReadyFlash(slot); + const cbs = this.readyCallbacks.get(slot.slotId); + if (cbs && cbs.length) cbs.forEach(cb => { try { cb(slot.slotId, slot.skillId); } catch (_) {} }); + } + } if (slot.charges < slot.maxCharges) { slot.rechargeRemaining = slot.rechargeDuration; anyActive = true; @@ -223,15 +242,12 @@ export class SkillBarController { _applySweepUI(slot, progress, remaining) { if (!slot.element) return; - const clamped = Math.max(0, Math.min(1.0, progress)); - const sweepDeg = (clamped * 360).toFixed(1); + const sweepDeg = (Math.max(0, Math.min(1.0, progress)) * 360).toFixed(1); const overlay = slot.element.querySelector('.cooldown-overlay'); if (overlay) { overlay.style.setProperty('--cooldown-sweep', `${sweepDeg}deg`); const counter = overlay.querySelector('.cooldown-counter'); - if (counter) { - counter.textContent = remaining >= 10 ? `${Math.ceil(remaining)}s` : (remaining > 0 ? remaining.toFixed(1) : ''); - } + if (counter) counter.textContent = remaining >= 10 ? `${Math.ceil(remaining)}s` : (remaining > 0 ? remaining.toFixed(1) : ''); } } @@ -239,11 +255,8 @@ export class SkillBarController { slot.cooldownRemaining = 0; this._markSlotCooldownState(slot, false); this._triggerReadyFlash(slot); - const cbs = this.readyCallbacks.get(slot.slotId); - if (cbs && cbs.length) { - cbs.forEach(cb => { try { cb(slot.slotId, slot.skillId); } catch (_) {} }); - } + if (cbs && cbs.length) cbs.forEach(cb => { try { cb(slot.slotId, slot.skillId); } catch (_) {} }); if (typeof window.triggerHaptic === 'function') window.triggerHaptic([20]); } @@ -289,9 +302,7 @@ export class SkillBarController { _bindKeyboardListener() { this._keyHandler = (e) => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return; - const k = e.key.toLowerCase(); - const pressedKey = e.key.toUpperCase(); - + const k = e.key.toLowerCase(), pressedKey = e.key.toUpperCase(); for (const slot of this.slots.values()) { const slotKey = slot.key; if ( @@ -310,9 +321,11 @@ export class SkillBarController { } handleKeyPress(key) { - const norm = (key || '').toUpperCase(); + if (!key || typeof key !== 'string') return false; + const norm = key.trim().toUpperCase(); + if (!norm) return false; for (const slot of this.slots.values()) { - if (slot.key === norm || (slot.numKey && slot.numKey === norm)) { + if ((slot.key && slot.key === norm) || (slot.numKey && slot.numKey === norm)) { return this.activateSlot(slot.slotId); } } @@ -331,9 +344,6 @@ export const skillBarController = new SkillBarController(); if (typeof window !== 'undefined') { window.SkillBarController = skillBarController; window.skillBarController = skillBarController; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => skillBarController.init()); - } else { - skillBarController.init(); - } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => skillBarController.init()); + else skillBarController.init(); }