""" Lemontropia Suite - HUD Overlay v2.0 Cleaner, customizable HUD with collapsible sections. """ import sys import json import logging from pathlib import Path from decimal import Decimal from datetime import datetime, timedelta from dataclasses import dataclass, asdict, field from typing import Optional, Dict, Any, List, Set logger = logging.getLogger(__name__) # Windows-specific imports for click-through support if sys.platform == 'win32': import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 GetForegroundWindow = user32.GetForegroundWindow GetForegroundWindow.restype = wintypes.HWND from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QSizePolicy, QPushButton, QMenu, QDialog, QCheckBox, QFormLayout, QScrollArea ) from PyQt6.QtCore import ( Qt, QTimer, pyqtSignal, QPoint, QSettings, QObject ) from PyQt6.QtGui import QFont, QColor, QPalette, QMouseEvent @dataclass class HUDConfig: """Configuration for which HUD elements to show.""" show_session_time: bool = True show_profit_loss: bool = True show_return_pct: bool = True show_total_cost: bool = True # NEW: Total cost display show_cost_breakdown: bool = False # Weapon/Armor/Heal costs show_combat_stats: bool = False # Kills, Globals, DPP show_damage_stats: bool = False # Damage dealt/taken show_cost_metrics: bool = True # Cost per shot/hit/heal show_shrapnel: bool = False # Deprecated - now integrated in loot summary show_gear_info: bool = True show_skills: bool = False # NEW: Total skill gained compact_mode: bool = False def to_dict(self) -> dict: return { 'show_session_time': self.show_session_time, 'show_profit_loss': self.show_profit_loss, 'show_return_pct': self.show_return_pct, 'show_total_cost': self.show_total_cost, 'show_cost_breakdown': self.show_cost_breakdown, 'show_combat_stats': self.show_combat_stats, 'show_damage_stats': self.show_damage_stats, 'show_cost_metrics': self.show_cost_metrics, 'show_gear_info': self.show_gear_info, 'show_skills': self.show_skills, 'compact_mode': self.compact_mode, } @classmethod def from_dict(cls, data: dict) -> "HUDConfig": return cls( show_session_time=data.get('show_session_time', True), show_profit_loss=data.get('show_profit_loss', True), show_return_pct=data.get('show_return_pct', True), show_total_cost=data.get('show_total_cost', True), show_cost_breakdown=data.get('show_cost_breakdown', False), show_combat_stats=data.get('show_combat_stats', False), show_damage_stats=data.get('show_damage_stats', False), show_cost_metrics=data.get('show_cost_metrics', True), show_gear_info=data.get('show_gear_info', True), show_skills=data.get('show_skills', False), compact_mode=data.get('compact_mode', False), ) @dataclass class HUDStats: """Simplified stats for HUD display.""" session_time: timedelta = field(default_factory=lambda: timedelta(0)) # Financial (core) loot_total: Decimal = Decimal('0.0') cost_total: Decimal = Decimal('0.0') profit_loss: Decimal = Decimal('0.0') return_percentage: Decimal = Decimal('0.0') # Cost breakdown (optional) weapon_cost_total: Decimal = Decimal('0.0') armor_cost_total: Decimal = Decimal('0.0') healing_cost_total: Decimal = Decimal('0.0') # Combat (optional) kills: int = 0 globals_count: int = 0 hofs_count: int = 0 # Damage stats (optional) damage_dealt: Decimal = Decimal('0.0') damage_taken: Decimal = Decimal('0.0') healing_done: Decimal = Decimal('0.0') # HP healed # Shrapnel (optional) shrapnel_total: Decimal = Decimal('0.0') loot_other: Decimal = Decimal('0.0') # Non-shrapnel loot # Session records highest_loot: Decimal = Decimal('0.0') # Highest total loot from single kill # Kill tracking buffer _current_kill_loot: Decimal = Decimal('0.0') _last_loot_time: Optional[datetime] = None # Cost metrics (core) cost_per_shot: Decimal = Decimal('0.0') cost_per_hit: Decimal = Decimal('0.0') cost_per_heal: Decimal = Decimal('0.0') # Gear current_weapon: str = "None" current_armor: str = "None" current_fap: str = "None" current_loadout: str = "None" # Skills (NEW) total_skill_gained: Decimal = Decimal('0.0') # Total of all skill gains skill_gains: Dict[str, Decimal] = field(default_factory=dict) # Per-skill tracking def recalculate(self): """Recalculate derived values.""" # Total loot is shrapnel + other loot self.loot_total = self.shrapnel_total + self.loot_other self.profit_loss = self.loot_total - self.cost_total if self.cost_total > 0: self.return_percentage = (self.loot_total / self.cost_total) * Decimal('100') else: self.return_percentage = Decimal('0') def to_dict(self) -> dict: return { 'session_time': str(self.session_time), 'loot_total': str(self.loot_total), 'cost_total': str(self.cost_total), 'profit_loss': str(self.profit_loss), 'return_percentage': str(self.return_percentage), 'weapon_cost_total': str(self.weapon_cost_total), 'armor_cost_total': str(self.armor_cost_total), 'healing_cost_total': str(self.healing_cost_total), 'kills': self.kills, 'globals_count': self.globals_count, 'hofs_count': self.hofs_count, 'damage_dealt': str(self.damage_dealt), 'damage_taken': str(self.damage_taken), 'healing_done': str(self.healing_done), 'shrapnel_total': str(self.shrapnel_total), 'loot_other': str(self.loot_other), 'highest_loot': str(self.highest_loot), 'cost_per_shot': str(self.cost_per_shot), 'cost_per_hit': str(self.cost_per_hit), 'cost_per_heal': str(self.cost_per_heal), 'current_weapon': self.current_weapon, 'current_armor': self.current_armor, 'current_fap': self.current_fap, 'current_loadout': self.current_loadout, } class HUDSettingsDialog(QDialog): """Dialog to configure which HUD elements to show.""" def __init__(self, config: HUDConfig, parent=None): super().__init__(parent) self.setWindowTitle("HUD Settings") self.setMinimumWidth(300) self.config = config self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) scroll = QScrollArea() scroll.setWidgetResizable(True) content = QWidget() form = QFormLayout(content) # Core stats (always recommended) form.addRow(QLabel("Core Stats (Recommended)")) self.cb_time = QCheckBox("Session Time") self.cb_time.setChecked(self.config.show_session_time) form.addRow(self.cb_time) self.cb_profit = QCheckBox("Profit/Loss") self.cb_profit.setChecked(self.config.show_profit_loss) form.addRow(self.cb_profit) self.cb_return = QCheckBox("Return %") self.cb_return.setChecked(self.config.show_return_pct) form.addRow(self.cb_return) self.cb_total_cost = QCheckBox("Cost & Loot Summary") self.cb_total_cost.setChecked(self.config.show_total_cost) form.addRow(self.cb_total_cost) self.cb_cost_metrics = QCheckBox("Cost per Shot/Hit/Heal") self.cb_cost_metrics.setChecked(self.config.show_cost_metrics) form.addRow(self.cb_cost_metrics) self.cb_gear = QCheckBox("Current Gear") self.cb_gear.setChecked(self.config.show_gear_info) form.addRow(self.cb_gear) # Optional stats form.addRow(QLabel("Optional Stats")) self.cb_cost_breakdown = QCheckBox("Cost Breakdown (Weapon/Armor/Heal)") self.cb_cost_breakdown.setChecked(self.config.show_cost_breakdown) form.addRow(self.cb_cost_breakdown) self.cb_combat = QCheckBox("Combat Stats (Kills, Globals)") self.cb_combat.setChecked(self.config.show_combat_stats) form.addRow(self.cb_combat) self.cb_damage = QCheckBox("Damage Stats (Dealt/Taken)") self.cb_damage.setChecked(self.config.show_damage_stats) form.addRow(self.cb_damage) self.cb_skills = QCheckBox("Total Skills Gained") self.cb_skills.setChecked(self.config.show_skills) form.addRow(self.cb_skills) # Display mode form.addRow(QLabel("Display Mode")) self.cb_compact = QCheckBox("Compact Mode (smaller font)") self.cb_compact.setChecked(self.config.compact_mode) form.addRow(self.cb_compact) scroll.setWidget(content) layout.addWidget(scroll) # Buttons btn_layout = QHBoxLayout() btn_layout.addStretch() save_btn = QPushButton("Save") save_btn.clicked.connect(self._on_save) btn_layout.addWidget(save_btn) cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(cancel_btn) layout.addLayout(btn_layout) def _on_save(self): self.config.show_session_time = self.cb_time.isChecked() self.config.show_profit_loss = self.cb_profit.isChecked() self.config.show_return_pct = self.cb_return.isChecked() self.config.show_total_cost = self.cb_total_cost.isChecked() self.config.show_cost_metrics = self.cb_cost_metrics.isChecked() self.config.show_gear_info = self.cb_gear.isChecked() self.config.show_cost_breakdown = self.cb_cost_breakdown.isChecked() self.config.show_combat_stats = self.cb_combat.isChecked() self.config.show_damage_stats = self.cb_damage.isChecked() self.config.show_skills = self.cb_skills.isChecked() self.config.compact_mode = self.cb_compact.isChecked() self.accept() def get_config(self) -> HUDConfig: return self.config class HUDOverlay(QWidget): """ Clean, customizable HUD Overlay for Lemontropia Suite. Features: - Collapsible sections - Customizable display elements - Compact mode - Draggable with Ctrl+click """ position_changed = pyqtSignal(QPoint) stats_updated = pyqtSignal(dict) def __init__(self, parent=None, config_path: Optional[str] = None): super().__init__(parent) self.config_path = Path(config_path) if config_path else Path.home() / ".lemontropia" / "hud_config.json" self.config_path.parent.mkdir(parents=True, exist_ok=True) # Load HUD config self.hud_config = self._load_hud_config() # Session state self._session_start: Optional[datetime] = None self._stats = HUDStats() self.session_active = False # Drag state self._dragging = False self._drag_offset = QPoint() # Setup self._setup_window() self._setup_ui() self._load_position() # Timer for session time self._timer = QTimer(self) self._timer.timeout.connect(self._update_session_time) # Batch refresh timer - prevents too frequent GUI updates self._refresh_pending = False self._refresh_timer = QTimer(self) self._refresh_timer.timeout.connect(self._do_refresh) self._refresh_timer.start(100) # Refresh at most 10 times per second def _load_hud_config(self) -> HUDConfig: """Load HUD configuration.""" try: if self.config_path.exists(): with open(self.config_path, 'r') as f: data = json.load(f) return HUDConfig.from_dict(data) except Exception as e: pass return HUDConfig() # Default config def _save_hud_config(self): """Save HUD configuration.""" try: with open(self.config_path, 'w') as f: json.dump(self.hud_config.to_dict(), f, indent=2) except Exception as e: pass def _setup_window(self): """Configure window properties.""" self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setMouseTracking(True) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) def _setup_ui(self): """Build the HUD UI.""" # Create container first self.container = QFrame(self) self.container.setObjectName("hudContainer") self.container.setStyleSheet(self._get_stylesheet()) # Now calculate and set size self._update_window_size() layout = QVBoxLayout(self.container) layout.setContentsMargins(12, 12, 12, 8) layout.setSpacing(4) # === HEADER: Status + Gear === header = QHBoxLayout() self.status_label = QLabel("● Ready") self.status_label.setStyleSheet("color: #7FFF7F; font-weight: bold;") header.addWidget(self.status_label) header.addStretch() # Settings button self.settings_btn = QPushButton("...") self.settings_btn.setFixedSize(24, 24) self.settings_btn.setStyleSheet(""" QPushButton { background: transparent; border: 1px solid rgba(255, 255, 255, 100); border-radius: 4px; color: #888; font-size: 10px; font-weight: bold; } QPushButton:hover { background: rgba(255, 255, 255, 30); border-color: #FFF; color: #FFF; } """) self.settings_btn.clicked.connect(self._show_settings) header.addWidget(self.settings_btn) layout.addLayout(header) # Separator after header sep1 = QFrame() sep1.setFrameShape(QFrame.Shape.HLine) sep1.setStyleSheet("background-color: rgba(255, 215, 0, 50);") sep1.setFixedHeight(1) layout.addWidget(sep1) # === GEAR INFO (if enabled) === if self.hud_config.show_gear_info: gear_box = QFrame() gear_box.setStyleSheet("background-color: rgba(255, 255, 255, 5); border-radius: 4px;") gear_layout = QVBoxLayout(gear_box) gear_layout.setContentsMargins(6, 4, 6, 4) gear_layout.setSpacing(2) self.weapon_label = QLabel("W: None") self.armor_label = QLabel("A: None") self.loadout_label = QLabel("L: No Loadout") gear_layout.addWidget(self.weapon_label) gear_layout.addWidget(self.armor_label) gear_layout.addWidget(self.loadout_label) layout.addWidget(gear_box) # === CORE: P/L + Return % === if self.hud_config.show_profit_loss or self.hud_config.show_return_pct: core_box = QFrame() core_box.setStyleSheet("background-color: rgba(0, 0, 0, 50); border: 1px solid rgba(255, 215, 0, 30); border-radius: 4px;") core_layout = QHBoxLayout(core_box) core_layout.setContentsMargins(8, 6, 8, 6) if self.hud_config.show_profit_loss: self.profit_label = QLabel("P/L: 0.00 PED") self.profit_label.setStyleSheet("font-size: 18px; font-weight: bold;") core_layout.addWidget(self.profit_label) core_layout.addStretch() if self.hud_config.show_return_pct: self.return_label = QLabel("0.0%") self.return_label.setStyleSheet("font-size: 16px; font-weight: bold;") core_layout.addWidget(self.return_label) layout.addWidget(core_box) # === TOTAL COST + LOOT SUMMARY === if self.hud_config.show_total_cost: summary_frame = QFrame() summary_frame.setStyleSheet("background-color: rgba(0, 0, 0, 100); border-radius: 4px;") summary_layout = QVBoxLayout(summary_frame) summary_layout.setContentsMargins(8, 4, 8, 4) summary_layout.setSpacing(2) # Cost row cost_layout = QHBoxLayout() self.total_cost_label = QLabel("Cost: 0.00") self.total_cost_label.setStyleSheet("font-size: 12px; color: #FFAAAA;") cost_layout.addWidget(self.total_cost_label) cost_layout.addStretch() summary_layout.addLayout(cost_layout) # Loot breakdown row loot_layout = QHBoxLayout() self.total_loot_label = QLabel("Total: 0.00") self.total_loot_label.setStyleSheet("font-size: 11px; color: #AAFFAA;") self.shrapnel_value_label = QLabel("S: 0.00") self.shrapnel_value_label.setStyleSheet("font-size: 11px; color: #87CEEB;") self.regular_loot_label = QLabel("R: 0.00") self.regular_loot_label.setStyleSheet("font-size: 11px; color: #FFD700;") loot_layout.addWidget(self.total_loot_label) loot_layout.addStretch() loot_layout.addWidget(self.shrapnel_value_label) loot_layout.addWidget(self.regular_loot_label) summary_layout.addLayout(loot_layout) # Highest loot row highest_layout = QHBoxLayout() self.highest_loot_label = QLabel("Highest: 0.00") self.highest_loot_label.setStyleSheet("font-size: 11px; color: #FFD700; font-weight: bold;") highest_layout.addWidget(self.highest_loot_label) highest_layout.addStretch() summary_layout.addLayout(highest_layout) layout.addWidget(summary_frame) # Separator before cost metrics if self.hud_config.show_total_cost and self.hud_config.show_cost_metrics: sep2 = QFrame() sep2.setFrameShape(QFrame.Shape.HLine) sep2.setStyleSheet("background-color: rgba(255, 255, 255, 30);") sep2.setFixedHeight(1) layout.addWidget(sep2) # === COST METRICS (if enabled) === if self.hud_config.show_cost_metrics: metrics_box = QFrame() metrics_box.setStyleSheet("background-color: rgba(50, 50, 50, 100); border: 1px solid rgba(255, 255, 255, 30); border-radius: 4px;") metrics_layout = QHBoxLayout(metrics_box) metrics_layout.setContentsMargins(8, 4, 8, 4) self.cps_label = QLabel("Shot: 0.0000") self.cph_label = QLabel("Hit: 0.0000") self.cphl_label = QLabel("Heal: 0.0000") for lbl in [self.cps_label, self.cph_label, self.cphl_label]: lbl.setStyleSheet("font-size: 10px; color: #CCCCCC;") metrics_layout.addWidget(lbl) metrics_layout.addStretch() layout.addWidget(metrics_box) # Separator before cost breakdown if self.hud_config.show_cost_breakdown and (self.hud_config.show_total_cost or self.hud_config.show_cost_metrics): sep3 = QFrame() sep3.setFrameShape(QFrame.Shape.HLine) sep3.setStyleSheet("background-color: rgba(255, 255, 255, 30);") sep3.setFixedHeight(1) layout.addWidget(sep3) # === OPTIONAL: Cost Breakdown === if self.hud_config.show_cost_breakdown: breakdown_box = QFrame() breakdown_box.setStyleSheet("background-color: rgba(255, 0, 0, 10); border: 1px solid rgba(255, 100, 100, 30); border-radius: 4px;") breakdown_layout = QFormLayout(breakdown_box) breakdown_layout.setContentsMargins(6, 4, 6, 4) breakdown_layout.setSpacing(2) self.wep_cost_label = QLabel("0.00") self.arm_cost_label = QLabel("0.00") self.heal_cost_label = QLabel("0.00") breakdown_layout.addRow("Weapon:", self.wep_cost_label) breakdown_layout.addRow("Armor:", self.arm_cost_label) breakdown_layout.addRow("Healing:", self.heal_cost_label) layout.addWidget(breakdown_box) # Separator before combat stats if self.hud_config.show_combat_stats and (self.hud_config.show_cost_breakdown or self.hud_config.show_total_cost): sep4 = QFrame() sep4.setFrameShape(QFrame.Shape.HLine) sep4.setStyleSheet("background-color: rgba(255, 255, 255, 30);") sep4.setFixedHeight(1) layout.addWidget(sep4) # === OPTIONAL: Combat Stats === if self.hud_config.show_combat_stats: combat_box = QFrame() combat_box.setStyleSheet("background-color: rgba(0, 100, 0, 30); border: 1px solid rgba(100, 255, 100, 30); border-radius: 4px;") combat_layout = QHBoxLayout(combat_box) combat_layout.setContentsMargins(6, 4, 6, 4) self.kills_label = QLabel("Kills: 0") self.globals_label = QLabel("Globals: 0") combat_layout.addWidget(self.kills_label) combat_layout.addStretch() combat_layout.addWidget(self.globals_label) layout.addWidget(combat_box) # Separator before damage stats if self.hud_config.show_damage_stats and self.hud_config.show_combat_stats: sep5 = QFrame() sep5.setFrameShape(QFrame.Shape.HLine) sep5.setStyleSheet("background-color: rgba(255, 255, 255, 30);") sep5.setFixedHeight(1) layout.addWidget(sep5) # === OPTIONAL: Damage Stats === if self.hud_config.show_damage_stats: damage_box = QFrame() damage_box.setStyleSheet("background-color: rgba(100, 0, 0, 30); border: 1px solid rgba(255, 100, 100, 30); border-radius: 4px;") damage_layout = QHBoxLayout(damage_box) damage_layout.setContentsMargins(6, 4, 6, 4) self.damage_dealt_label = QLabel("Dealt: 0") self.damage_taken_label = QLabel("Taken: 0") damage_layout.addWidget(self.damage_dealt_label) damage_layout.addStretch() damage_layout.addWidget(self.damage_taken_label) layout.addWidget(damage_box) # === SKILLS (if enabled) === if self.hud_config.show_skills: skills_box = QFrame() skills_box.setStyleSheet("background-color: rgba(100, 50, 150, 80); border: 1px solid rgba(200, 150, 255, 50); border-radius: 4px;") skills_layout = QHBoxLayout(skills_box) skills_layout.setContentsMargins(8, 4, 8, 4) self.skills_label = QLabel("Skills: 0.00") self.skills_label.setStyleSheet("font-size: 11px; color: #DDA0DD;") skills_layout.addWidget(self.skills_label) skills_layout.addStretch() layout.addWidget(skills_box) # Separator before footer sep_footer = QFrame() sep_footer.setFrameShape(QFrame.Shape.HLine) sep_footer.setStyleSheet("background-color: rgba(255, 215, 0, 50);") sep_footer.setFixedHeight(1) layout.addWidget(sep_footer) # === FOOTER: Session Time + Drag Hint === footer = QHBoxLayout() if self.hud_config.show_session_time: self.time_label = QLabel("00:00:00") self.time_label.setStyleSheet("color: #888; font-size: 11px;") footer.addWidget(self.time_label) else: footer.addStretch() footer.addStretch() self.drag_hint = QLabel("Ctrl+drag to move") self.drag_hint.setStyleSheet("font-size: 9px; color: #666;") footer.addWidget(self.drag_hint) layout.addLayout(footer) # Ensure container is visible self.container.show() def _get_stylesheet(self) -> str: """Get stylesheet based on compact mode.""" font_size = "11px" if self.hud_config.compact_mode else "12px" return f""" #hudContainer {{ background-color: rgba(0, 0, 0, 180); border: 1px solid rgba(255, 215, 0, 80); border-radius: 8px; }} QLabel {{ color: #FFFFFF; font-family: 'Segoe UI', 'Arial', sans-serif; font-size: {font_size}; }} """ def _update_window_size(self): """Calculate window size based on enabled features.""" height = 80 # Base height if self.hud_config.show_gear_info: height += 70 if self.hud_config.show_profit_loss or self.hud_config.show_return_pct: height += 40 if self.hud_config.show_total_cost: height += 70 # Three rows now: cost + loot breakdown + highest if self.hud_config.show_cost_metrics: height += 30 if self.hud_config.show_cost_breakdown: height += 70 if self.hud_config.show_combat_stats: height += 25 if self.hud_config.show_damage_stats: height += 25 if self.hud_config.show_skills: height += 25 if self.hud_config.show_session_time: height += 20 width = 280 if self.hud_config.compact_mode else 320 self.setFixedSize(width, height) self.container.setFixedSize(width, height) def _show_settings(self): """Show settings dialog.""" dialog = HUDSettingsDialog(self.hud_config, self) if dialog.exec() == QDialog.DialogCode.Accepted: self.hud_config = dialog.get_config() self._save_hud_config() # Rebuild UI with new settings self._rebuild_ui() def _rebuild_ui(self): """Rebuild UI with current config.""" # Hide first to avoid visual glitch self.hide() # Delete old container if hasattr(self, 'container') and self.container: self.container.deleteLater() self.container = None # Small delay to ensure cleanup from PyQt6.QtCore import QTimer QTimer.singleShot(10, self._do_rebuild) def _do_rebuild(self): """Actually rebuild the UI.""" try: self._setup_ui() self._refresh_display() self.show() except Exception as e: logger.error(f"Error rebuilding HUD: {e}") # Try to recover self.show() # === Session Management === def start_session(self, weapon: str = "Unknown", armor: str = "None", fap: str = "None", loadout: str = "Default", weapon_dpp: Decimal = Decimal('0.0'), weapon_cost_per_hour: Decimal = Decimal('0.0'), cost_per_shot: Decimal = Decimal('0.0'), cost_per_hit: Decimal = Decimal('0.0'), cost_per_heal: Decimal = Decimal('0.0')) -> None: """Start a new hunting session.""" self._session_start = datetime.now() self._stats = HUDStats() self._stats.current_weapon = weapon self._stats.current_armor = armor self._stats.current_fap = fap self._stats.current_loadout = loadout self._stats.cost_per_shot = cost_per_shot self._stats.cost_per_hit = cost_per_hit self._stats.cost_per_heal = cost_per_heal # Initialize kill tracking self._stats._current_kill_loot = Decimal('0.0') self._stats._last_loot_time = None self.session_active = True self._timer.start(1000) self._refresh_display() self.status_label.setText("● Live") self.status_label.setStyleSheet("color: #7FFF7F; font-weight: bold;") def end_session(self) -> None: """Alias for stop_session for backward compatibility.""" self.stop_session() def stop_session(self) -> None: """Stop the current session.""" # Check if current kill is highest before stopping if self._stats._current_kill_loot > self._stats.highest_loot: self._stats.highest_loot = self._stats._current_kill_loot self._refresh_display() self.session_active = False self._timer.stop() self.status_label.setText("● Stopped") self.status_label.setStyleSheet("color: #FF7F7F; font-weight: bold;") # === Event Handlers === def _update_session_time(self): """Update session time display.""" if self._session_start and self.session_active: elapsed = datetime.now() - self._session_start hours, remainder = divmod(elapsed.seconds, 3600) minutes, seconds = divmod(remainder, 60) if hasattr(self, 'time_label'): self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}") def _safe_set_text(self, widget_name: str, text: str): """Safely set text on a widget if it exists and is valid.""" try: widget = getattr(self, widget_name, None) if widget is not None: widget.setText(text) except RuntimeError: # Widget was deleted pass def _request_refresh(self): """Request a refresh - will be batched and executed on main thread.""" self._refresh_pending = True def _do_refresh(self): """Actually perform the refresh if pending.""" if self._refresh_pending: self._refresh_pending = False self._refresh_display() def _refresh_display(self): """Refresh all display labels.""" try: # Profit/Loss - just update text, no dynamic stylesheet self._safe_set_text('profit_label', f"{self._stats.profit_loss:+.2f} PED") # Return % self._safe_set_text('return_label', f"{self._stats.return_percentage:.1f}%") # Total Cost + Loot breakdown + Highest self._safe_set_text('total_cost_label', f"Cost: {self._stats.cost_total:.2f}") self._safe_set_text('total_loot_label', f"Total: {self._stats.loot_total:.2f}") self._safe_set_text('shrapnel_value_label', f"S: {self._stats.shrapnel_total:.2f}") self._safe_set_text('regular_loot_label', f"R: {self._stats.loot_other:.2f}") self._safe_set_text('highest_loot_label', f"Highest: {self._stats.highest_loot:.2f}") # Cost metrics self._safe_set_text('cps_label', f"Shot: {self._stats.cost_per_shot:.4f}") self._safe_set_text('cph_label', f"Hit: {self._stats.cost_per_hit:.4f}") self._safe_set_text('cphl_label', f"Heal: {self._stats.cost_per_heal:.4f}") # Gear self._safe_set_text('weapon_label', f"W: {self._stats.current_weapon[:20]}") self._safe_set_text('armor_label', f"A: {self._stats.current_armor[:20]}") self._safe_set_text('loadout_label', f"L: {self._stats.current_loadout[:20]}") # Cost breakdown self._safe_set_text('wep_cost_label', f"{self._stats.weapon_cost_total:.2f}") self._safe_set_text('arm_cost_label', f"{self._stats.armor_cost_total:.2f}") self._safe_set_text('heal_cost_label', f"{self._stats.healing_cost_total:.2f}") # Combat self._safe_set_text('kills_label', f"Kills: {self._stats.kills}") self._safe_set_text('globals_label', f"Globals: {self._stats.globals_count}") # Damage stats self._safe_set_text('damage_dealt_label', f"Dealt: {int(self._stats.damage_dealt)}") self._safe_set_text('damage_taken_label', f"Taken: {int(self._stats.damage_taken)}") # Skills if hasattr(self, 'skills_label'): self._safe_set_text('skills_label', f"Skills: {self._stats.total_skill_gained:.2f}") except Exception as e: logger.error(f"Error in _refresh_display: {e}") # Skills if hasattr(self, 'skills_label'): self._safe_set_text('skills_label', f"Skills: {self._stats.total_skill_gained:.2f}") except Exception as e: logger.error(f"Error in _refresh_display: {e}") # === Public Update Methods === def update_loot(self, value_ped: Decimal, is_shrapnel: bool = False): """Update loot value - tracks per-kill totals for highest loot.""" logger.debug(f"[HUD] update_loot called: value={value_ped}, is_shrapnel={is_shrapnel}, session_active={self.session_active}") if self.session_active: now = datetime.now() is_new_kill = False # Check if this is a new kill (gap of >2 seconds since last loot) if self._stats._last_loot_time is not None: time_since_last = (now - self._stats._last_loot_time).total_seconds() if time_since_last > 2.0: # Previous kill is complete, check if it was highest if self._stats._current_kill_loot > self._stats.highest_loot: self._stats.highest_loot = self._stats._current_kill_loot # Start new kill self._stats._current_kill_loot = value_ped is_new_kill = True else: # Same kill, add to current self._stats._current_kill_loot += value_ped else: # First loot of session - this is a new kill self._stats._current_kill_loot = value_ped is_new_kill = True self._stats._last_loot_time = now # Only count kill if it's a new mob if is_new_kill: self._stats.kills += 1 logger.debug(f"[HUD] New kill counted! Total kills: {self._stats.kills}") # Add to totals if is_shrapnel: self._stats.shrapnel_total += value_ped else: self._stats.loot_other += value_ped self._stats.recalculate() self._request_refresh() logger.debug(f"[HUD] update_loot complete: kills={self._stats.kills}, shrapnel={self._stats.shrapnel_total}, other={self._stats.loot_other}") logger.debug(f"[HUD] update_loot complete: shrapnel={self._stats.shrapnel_total}, other={self._stats.loot_other}") def update_shrapnel(self, amount: Decimal): """Update shrapnel amount (convenience method).""" self.update_loot(amount, is_shrapnel=True) def update_weapon_cost(self, cost_ped: Decimal): """Update weapon cost.""" logger.debug(f"[HUD] update_weapon_cost called: cost={cost_ped}, session_active={self.session_active}") if self.session_active: self._stats.weapon_cost_total += cost_ped self._stats.cost_total += cost_ped self._stats.recalculate() self._request_refresh() def update_armor_cost(self, cost_ped: Decimal): """Update armor cost.""" logger.debug(f"[HUD] update_armor_cost called: cost={cost_ped}, session_active={self.session_active}") if self.session_active: self._stats.armor_cost_total += cost_ped self._stats.cost_total += cost_ped self._stats.recalculate() self._request_refresh() def update_healing_cost(self, cost_ped: Decimal): """Update healing cost.""" logger.debug(f"[HUD] update_healing_cost called: cost={cost_ped}, session_active={self.session_active}") if self.session_active: self._stats.healing_cost_total += cost_ped self._stats.cost_total += cost_ped self._stats.recalculate() self._request_refresh() def update_kills(self, count: int = 1): """Update kill count.""" logger.debug(f"[HUD] update_kills called: count={count}, session_active={self.session_active}") if self.session_active: self._stats.kills += count self._request_refresh() def update_skill(self, skill_name: str, amount: Decimal): """Update skill gain tracking.""" logger.debug(f"[HUD] update_skill called: skill={skill_name}, amount={amount}") if self.session_active: # Track per-skill gains if skill_name not in self._stats.skill_gains: self._stats.skill_gains[skill_name] = Decimal('0.0') self._stats.skill_gains[skill_name] += amount # Update total self._stats.total_skill_gained += amount self._request_refresh() def update_globals(self): """Update global count.""" if self.session_active: self._stats.globals_count += 1 self._request_refresh() def update_damage(self, dealt: Decimal = Decimal('0'), taken: Decimal = Decimal('0')): """Update damage stats.""" if self.session_active: if dealt > 0: self._stats.damage_dealt += dealt if taken > 0: self._stats.damage_taken += taken self._request_refresh() # === Event Handlers for LogWatcher === def on_global(self, value_ped=Decimal('0.0')): """Handle global event from LogWatcher.""" if self.session_active: self._stats.globals_count += 1 self._request_refresh() def on_hof(self, value_ped=Decimal('0.0')): """Handle Hall of Fame event from LogWatcher.""" if self.session_active: self._stats.hofs_count += 1 self._request_refresh() def on_personal_global(self, value_ped=Decimal('0.0')): """Handle personal global event from LogWatcher.""" # Only count personal globals, not all globals if self.session_active: self._stats.globals_count += 1 self._request_refresh() def on_damage_dealt(self, damage): """Handle damage dealt event from LogWatcher.""" # Convert float to Decimal if needed if isinstance(damage, float): damage = Decimal(str(damage)) elif not isinstance(damage, Decimal): damage = Decimal(damage) self.update_damage(dealt=damage) def on_damage_taken(self, damage): """Handle damage taken event from LogWatcher.""" # Convert float to Decimal if needed if isinstance(damage, float): damage = Decimal(str(damage)) elif not isinstance(damage, Decimal): damage = Decimal(damage) self.update_damage(taken=damage) def on_loot_event(self, event): """Handle loot event from LogWatcher.""" if self.session_active: # Extract loot value from event value = event.get('value_ped', Decimal('0')) if isinstance(value, float): value = Decimal(str(value)) elif not isinstance(value, Decimal): value = Decimal(value) # Check if shrapnel item_name = event.get('item_name', '') is_shrap = 'shrapnel' in item_name.lower() self.update_loot(value, is_shrapnel=is_shrap) def on_heal_event(self, event): """Handle heal event from LogWatcher.""" if self.session_active: heal_amount = event.get('heal_amount', Decimal('0')) if isinstance(heal_amount, float): heal_amount = Decimal(str(heal_amount)) elif not isinstance(heal_amount, Decimal): heal_amount = Decimal(heal_amount) # Update healing done self._stats.healing_done += heal_amount self._refresh_display() def update_stats(self, stats): """Handle stats update from LogWatcher (legacy compatibility).""" if self.session_active: # Update loot from stats if 'loot_value' in stats: value = stats['loot_value'] if isinstance(value, float): value = Decimal(str(value)) self.update_loot(value) # Update other stats as needed self._refresh_display() # === Mouse Handling === def mousePressEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton: if event.modifiers() == Qt.KeyboardModifier.ControlModifier: self._dragging = True self._drag_offset = event.pos() self.setCursor(Qt.CursorShape.ClosedHandCursor) event.accept() def mouseMoveEvent(self, event: QMouseEvent): if self._dragging: new_pos = self.mapToGlobal(event.pos()) - self._drag_offset self.move(new_pos) self.position_changed.emit(new_pos) event.accept() def mouseReleaseEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton and self._dragging: self._dragging = False self.setCursor(Qt.CursorShape.ArrowCursor) self._save_position() event.accept() def enterEvent(self, event): """Mouse entered - enable interaction.""" super().enterEvent(event) def leaveEvent(self, event): """Mouse left - disable interaction.""" super().leaveEvent(event) def _save_position(self): """Save window position.""" try: pos_file = self.config_path.parent / "hud_position.json" with open(pos_file, 'w') as f: json.dump({'x': self.x(), 'y': self.y()}, f) except: pass def _load_position(self): """Load window position.""" try: pos_file = self.config_path.parent / "hud_position.json" if pos_file.exists(): with open(pos_file, 'r') as f: pos = json.load(f) self.move(pos.get('x', 100), pos.get('y', 100)) else: screen = QApplication.primaryScreen().geometry() self.move(screen.width() - 360, 50) except: self.move(100, 100)