Lemontropia-Suite/ui/hud_overlay.py

1442 lines
53 KiB
Python

# Description: Transparent HUD Overlay for Lemontropia Suite
# Implements frameless, always-on-top, click-through overlay with draggable support
# Updated: Integrated armor decay tracking and hunting session metrics
# Follows Never-Break Rules: Decimal precision, 60+ FPS, Observer Pattern integration
import sys
import json
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
# Windows-specific imports for click-through support
if sys.platform == 'win32':
import ctypes
from ctypes import wintypes
# Windows API constants for window detection
GW_OWNER = 4
# GetForegroundWindow function
user32 = ctypes.windll.user32
GetForegroundWindow = user32.GetForegroundWindow
GetForegroundWindow.restype = wintypes.HWND
# GetWindowText functions
GetWindowTextLengthW = user32.GetWindowTextLengthW
GetWindowTextLengthW.argtypes = [wintypes.HWND]
GetWindowTextLengthW.restype = ctypes.c_int
GetWindowTextW = user32.GetWindowTextW
GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
GetWindowTextW.restype = ctypes.c_int
# FindWindow function
FindWindowW = user32.FindWindowW
FindWindowW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR]
FindWindowW.restype = wintypes.HWND
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QFrame, QSizePolicy
)
from PyQt6.QtCore import (
Qt, QTimer, pyqtSignal, QPoint, QSettings,
QObject
)
from PyQt6.QtGui import QFont, QColor, QPalette, QMouseEvent
@dataclass
class HUDStats:
"""Data structure for HUD statistics - Enhanced for hunting sessions."""
session_time: timedelta = field(default_factory=lambda: timedelta(0))
# Financial tracking
loot_total: Decimal = Decimal('0.0') # All loot including shrapnel
loot_other: Decimal = Decimal('0.0') # Excluding shrapnel/UA
shrapnel_total: Decimal = Decimal('0.0')
universal_ammo_total: Decimal = Decimal('0.0')
# Cost tracking
weapon_cost_total: Decimal = Decimal('0.0')
armor_cost_total: Decimal = Decimal('0.0') # NEW: Official armor decay
healing_cost_total: Decimal = Decimal('0.0')
plates_cost_total: Decimal = Decimal('0.0')
enhancer_cost_total: Decimal = Decimal('0.0')
mindforce_cost_total: Decimal = Decimal('0.0')
cost_total: Decimal = Decimal('0.0')
# Profit/Loss (calculated from other_loot - total_cost)
profit_loss: Decimal = Decimal('0.0')
# Return percentage
return_percentage: Decimal = Decimal('0.0')
# Combat stats
damage_dealt: Decimal = Decimal('0.0')
damage_taken: Decimal = Decimal('0.0')
healing_done: Decimal = Decimal('0.0')
shots_fired: int = 0
hits_taken: int = 0
heals_used: int = 0
kills: int = 0
globals_count: int = 0
hofs_count: int = 0
# Loadout-based cost metrics
cost_per_shot: Decimal = Decimal('0.0')
cost_per_hit: Decimal = Decimal('0.0')
cost_per_heal: Decimal = Decimal('0.0')
# Efficiency metrics
cost_per_kill: Decimal = Decimal('0.0')
dpp: Decimal = Decimal('0.0') # Damage Per PED
# Current gear
current_weapon: str = "None"
current_loadout: str = "None"
loadout_id: Optional[int] = None
current_armor: str = "None" # NEW
current_fap: str = "None"
# Gear stats
weapon_dpp: Decimal = Decimal('0.0')
weapon_cost_per_hour: Decimal = Decimal('0.0')
armor_durability: int = 2000 # Default Ghost
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
'session_time_seconds': self.session_time.total_seconds(),
'loot_total': str(self.loot_total),
'loot_other': str(self.loot_other),
'shrapnel_total': str(self.shrapnel_total),
'universal_ammo_total': str(self.universal_ammo_total),
'weapon_cost_total': str(self.weapon_cost_total),
'armor_cost_total': str(self.armor_cost_total),
'healing_cost_total': str(self.healing_cost_total),
'plates_cost_total': str(self.plates_cost_total),
'enhancer_cost_total': str(self.enhancer_cost_total),
'cost_total': str(self.cost_total),
'profit_loss': str(self.profit_loss),
'return_percentage': str(self.return_percentage),
'damage_dealt': float(self.damage_dealt),
'damage_taken': float(self.damage_taken),
'healing_done': float(self.healing_done),
'shots_fired': self.shots_fired,
'kills': self.kills,
'globals_count': self.globals_count,
'hofs_count': self.hofs_count,
'cost_per_kill': str(self.cost_per_kill),
'dpp': str(self.dpp),
'current_weapon': self.current_weapon,
'current_loadout': self.current_loadout,
'current_armor': self.current_armor,
'current_fap': self.current_fap,
'weapon_dpp': str(self.weapon_dpp),
'weapon_cost_per_hour': str(self.weapon_cost_per_hour),
'armor_durability': self.armor_durability,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'HUDStats':
"""Create from dictionary."""
return cls(
session_time=timedelta(seconds=data.get('session_time_seconds', 0)),
loot_total=Decimal(data.get('loot_total', '0.0')),
loot_other=Decimal(data.get('loot_other', '0.0')),
shrapnel_total=Decimal(data.get('shrapnel_total', '0.0')),
universal_ammo_total=Decimal(data.get('universal_ammo_total', '0.0')),
weapon_cost_total=Decimal(data.get('weapon_cost_total', '0.0')),
armor_cost_total=Decimal(data.get('armor_cost_total', '0.0')),
healing_cost_total=Decimal(data.get('healing_cost_total', '0.0')),
plates_cost_total=Decimal(data.get('plates_cost_total', '0.0')),
enhancer_cost_total=Decimal(data.get('enhancer_cost_total', '0.0')),
cost_total=Decimal(data.get('cost_total', '0.0')),
profit_loss=Decimal(data.get('profit_loss', '0.0')),
return_percentage=Decimal(data.get('return_percentage', '0.0')),
damage_dealt=Decimal(str(data.get('damage_dealt', 0))),
damage_taken=Decimal(str(data.get('damage_taken', 0))),
healing_done=Decimal(str(data.get('healing_done', 0))),
shots_fired=data.get('shots_fired', 0),
kills=data.get('kills', 0),
globals_count=data.get('globals_count', 0),
hofs_count=data.get('hofs_count', 0),
cost_per_kill=Decimal(data.get('cost_per_kill', '0.0')),
dpp=Decimal(data.get('dpp', '0.0')),
current_weapon=data.get('current_weapon', 'None'),
current_loadout=data.get('current_loadout', 'None'),
current_armor=data.get('current_armor', 'None'),
current_fap=data.get('current_fap', 'None'),
weapon_dpp=Decimal(data.get('weapon_dpp', '0.0')),
weapon_cost_per_hour=Decimal(data.get('weapon_cost_per_hour', '0.0')),
armor_durability=data.get('armor_durability', 2000),
)
def recalculate(self):
"""Recalculate derived statistics."""
# Total cost including mindforce
self.cost_total = (
self.weapon_cost_total +
self.armor_cost_total +
self.healing_cost_total +
self.plates_cost_total +
self.enhancer_cost_total +
self.mindforce_cost_total
)
# Profit/loss (excluding shrapnel from loot value for accurate profit calc)
self.profit_loss = self.loot_other - self.cost_total
# Return percentage
if self.cost_total > 0:
self.return_percentage = (self.loot_other / self.cost_total) * Decimal('100')
else:
self.return_percentage = Decimal('0.0')
# Cost per kill
if self.kills > 0:
self.cost_per_kill = self.cost_total / self.kills
else:
self.cost_per_kill = Decimal('0.0')
# DPP (Damage Per PED)
if self.cost_total > 0:
self.dpp = self.damage_dealt / self.cost_total
else:
self.dpp = Decimal('0.0')
def update_from_cost_tracker(self, summary: Dict[str, Any]):
"""Update stats from SessionCostTracker summary."""
self.weapon_cost_total = summary.get('weapon_cost', Decimal('0'))
self.armor_cost_total = summary.get('armor_cost', Decimal('0'))
self.healing_cost_total = summary.get('healing_cost', Decimal('0'))
self.enhancer_cost_total = summary.get('enhancer_cost', Decimal('0'))
self.mindforce_cost_total = summary.get('mindforce_cost', Decimal('0'))
self.shots_fired = summary.get('shots_fired', 0)
self.hits_taken = summary.get('hits_taken', 0)
self.heals_used = summary.get('heals_used', 0)
self.cost_per_shot = summary.get('cost_per_shot', Decimal('0'))
self.cost_per_hit = summary.get('cost_per_hit', Decimal('0'))
self.cost_per_heal = summary.get('cost_per_heal', Decimal('0'))
self.recalculate()
class HUDOverlay(QWidget):
"""
Transparent, always-on-top HUD overlay for Lemontropia Suite.
Features:
- Frameless window (no borders, title bar)
- Transparent background with semi-transparent content
- Always on top of other windows
- Click-through when not holding modifier key
- Draggable when holding Ctrl key
- Position persistence across sessions
- Real-time stat updates via signals/slots
- Enhanced hunting session tracking with armor decay
Window Flags:
- FramelessWindowHint: Removes window decorations
- WindowStaysOnTopHint: Keeps above other windows
- Tool: Makes it a tool window (no taskbar entry)
"""
# Signal emitted when stats are updated (for external integration)
stats_updated = pyqtSignal(dict)
# Signal emitted when HUD is moved
position_changed = pyqtSignal(QPoint)
def __init__(self, parent: Optional[QObject] = None,
config_path: Optional[str] = None):
"""
Initialize HUD Overlay.
Args:
parent: Parent widget (optional)
config_path: Path to config file for position persistence
"""
super().__init__(parent)
# Configuration path for saving position
if config_path is None:
ui_dir = Path(__file__).parent
self.config_path = ui_dir.parent / "data" / "hud_config.json"
else:
self.config_path = Path(config_path)
self.config_path.parent.mkdir(parents=True, exist_ok=True)
# Session tracking
self._session_start: Optional[datetime] = None
self._stats = HUDStats()
self.session_active = False
# Armor decay tracker (imported here to avoid circular imports)
self._armor_tracker = None
# Session cost tracker for loadout-based tracking
self._cost_tracker = None
# Drag state
self._dragging = False
self._drag_offset = QPoint()
self._modifier_pressed = False
# Game window detection
self._auto_hide_with_game = False
self._game_window_title = "Entropia Universe"
self._was_visible_before_unfocus = False
self._debug_window_detection = False
# Timer for session time updates
self._timer = QTimer(self)
self._timer.timeout.connect(self._update_session_time)
# Timer for game window detection (Windows only)
self._window_check_timer = QTimer(self)
self._window_check_timer.timeout.connect(self._check_game_window)
if sys.platform == 'win32':
self._window_check_timer.start(500)
self._setup_window()
self._setup_ui()
self._load_position()
def _setup_window(self) -> None:
"""Configure window properties for HUD behavior."""
# Window flags for frameless, always-on-top, tool window
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
# Enable transparency
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# Enable mouse tracking for hover detection
self.setMouseTracking(True)
# Size - increased to accommodate all rows
self.setFixedSize(340, 320)
# Accept focus for keyboard events
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def _setup_ui(self) -> None:
"""Build the HUD UI components."""
# Main container with semi-transparent background
self.container = QFrame(self)
self.container.setFixedSize(340, 320)
self.container.setObjectName("hudContainer")
# Style the container - semi-transparent dark background
self.container.setStyleSheet("""
#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;
}
.stat-label {
font-size: 10px;
color: #AAAAAA;
}
.stat-value {
font-size: 13px;
font-weight: bold;
color: #FFD700;
}
.header {
font-size: 14px;
font-weight: bold;
color: #FFD700;
}
.subheader {
font-size: 9px;
color: #888888;
}
.positive {
color: #7FFF7F;
}
.negative {
color: #FF7F7F;
}
.neutral {
color: #FFFFFF;
}
""")
# Main layout
layout = QVBoxLayout(self.container)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(4)
# === HEADER ===
header_layout = QHBoxLayout()
self.title_label = QLabel("🍋 LEMONTROPIA")
self.title_label.setProperty("class", "header")
self.title_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FFD700;")
header_layout.addWidget(self.title_label)
header_layout.addStretch()
self.time_label = QLabel("00:00:00")
self.time_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #00FFFF;")
header_layout.addWidget(self.time_label)
layout.addLayout(header_layout)
# Drag hint
self.drag_hint = QLabel("Hold Ctrl to drag")
self.drag_hint.setStyleSheet("font-size: 8px; color: #666666;")
self.drag_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.drag_hint)
# === SEPARATOR ===
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setStyleSheet("background-color: rgba(255, 215, 0, 50);")
separator.setFixedHeight(1)
layout.addWidget(separator)
# === FINANCIALS ROW ===
row0 = QHBoxLayout()
# Loot (marketable only, excluding shrapnel)
loot_layout = QVBoxLayout()
loot_label = QLabel("💰 LOOT")
loot_label.setStyleSheet("font-size: 9px; color: #888888;")
loot_layout.addWidget(loot_label)
self.loot_value_label = QLabel("0.00 PED")
self.loot_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #7FFF7F;")
loot_layout.addWidget(self.loot_value_label)
row0.addLayout(loot_layout)
row0.addStretch()
# Total Cost
cost_layout = QVBoxLayout()
cost_label = QLabel("💸 COST")
cost_label.setStyleSheet("font-size: 9px; color: #888888;")
cost_layout.addWidget(cost_label)
self.cost_value_label = QLabel("0.00 PED")
self.cost_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FF7F7F;")
cost_layout.addWidget(self.cost_value_label)
row0.addLayout(cost_layout)
row0.addStretch()
# Profit/Loss with return %
profit_layout = QVBoxLayout()
profit_label = QLabel("📊 P/L")
profit_label.setStyleSheet("font-size: 9px; color: #888888;")
profit_layout.addWidget(profit_label)
self.profit_value_label = QLabel("0.00 PED")
self.profit_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FFFFFF;")
profit_layout.addWidget(self.profit_value_label)
row0.addLayout(profit_layout)
layout.addLayout(row0)
# === RETURN % BAR ===
return_layout = QHBoxLayout()
return_label = QLabel("📈 Return:")
return_label.setStyleSheet("font-size: 9px; color: #888888;")
return_layout.addWidget(return_label)
self.return_label = QLabel("0.0%")
self.return_label.setStyleSheet("font-size: 11px; font-weight: bold; color: #FFFFFF;")
return_layout.addWidget(self.return_label)
return_layout.addStretch()
# Shrapnel indicator
self.shrapnel_label = QLabel("💎 Shrapnel: 0.00")
self.shrapnel_label.setStyleSheet("font-size: 9px; color: #AAAAAA;")
return_layout.addWidget(self.shrapnel_label)
layout.addLayout(return_layout)
# === COMBAT STATS ROW 1 ===
row1 = QHBoxLayout()
# Kills
kills_layout = QVBoxLayout()
kills_label = QLabel("💀 KILLS")
kills_label.setStyleSheet("font-size: 9px; color: #888888;")
kills_layout.addWidget(kills_label)
self.kills_value_label = QLabel("0")
self.kills_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FFFFFF;")
kills_layout.addWidget(self.kills_value_label)
row1.addLayout(kills_layout)
row1.addStretch()
# Globals/HoFs
globals_layout = QVBoxLayout()
globals_label = QLabel("🌍 G/H")
globals_label.setStyleSheet("font-size: 9px; color: #888888;")
globals_layout.addWidget(globals_label)
self.globals_value_label = QLabel("0 / 0")
self.globals_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FFD700;")
globals_layout.addWidget(self.globals_value_label)
row1.addLayout(globals_layout)
row1.addStretch()
# DPP
dpp_layout = QVBoxLayout()
dpp_label = QLabel("⚡ DPP")
dpp_label.setStyleSheet("font-size: 9px; color: #888888;")
dpp_layout.addWidget(dpp_label)
self.dpp_value_label = QLabel("0.0")
self.dpp_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #00FFFF;")
dpp_layout.addWidget(self.dpp_value_label)
row1.addLayout(dpp_layout)
layout.addLayout(row1)
# === COMBAT STATS ROW 2 ===
row2 = QHBoxLayout()
# Damage Dealt
dealt_layout = QVBoxLayout()
dealt_label = QLabel("⚔️ DEALT")
dealt_label.setStyleSheet("font-size: 9px; color: #888888;")
dealt_layout.addWidget(dealt_label)
self.dealt_value_label = QLabel("0")
self.dealt_value_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #7FFF7F;")
dealt_layout.addWidget(self.dealt_value_label)
row2.addLayout(dealt_layout)
row2.addStretch()
# Damage Taken
taken_layout = QVBoxLayout()
taken_label = QLabel("🛡️ TAKEN")
taken_label.setStyleSheet("font-size: 9px; color: #888888;")
taken_layout.addWidget(taken_label)
self.taken_value_label = QLabel("0")
self.taken_value_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #FF7F7F;")
taken_layout.addWidget(self.taken_value_label)
row2.addLayout(taken_layout)
row2.addStretch()
# Shots
shots_layout = QVBoxLayout()
shots_label = QLabel("🔫 SHOTS")
shots_label.setStyleSheet("font-size: 9px; color: #888888;")
shots_layout.addWidget(shots_label)
self.shots_value_label = QLabel("0")
self.shots_value_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #FFD700;")
shots_layout.addWidget(self.shots_value_label)
row2.addLayout(shots_layout)
layout.addLayout(row2)
# === COST BREAKDOWN ROW ===
row3 = QHBoxLayout()
# Weapon cost
wep_cost_layout = QVBoxLayout()
wep_cost_label = QLabel("🔫 WEP")
wep_cost_label.setStyleSheet("font-size: 8px; color: #888888;")
wep_cost_layout.addWidget(wep_cost_label)
self.wep_cost_value_label = QLabel("0.00")
self.wep_cost_value_label.setStyleSheet("font-size: 10px; color: #FFAAAA;")
wep_cost_layout.addWidget(self.wep_cost_value_label)
row3.addLayout(wep_cost_layout)
# Armor cost
arm_cost_layout = QVBoxLayout()
arm_cost_label = QLabel("🛡️ ARM")
arm_cost_label.setStyleSheet("font-size: 8px; color: #888888;")
arm_cost_layout.addWidget(arm_cost_label)
self.arm_cost_value_label = QLabel("0.00")
self.arm_cost_value_label.setStyleSheet("font-size: 10px; color: #FFAAAA;")
arm_cost_layout.addWidget(self.arm_cost_value_label)
row3.addLayout(arm_cost_layout)
# Healing cost
heal_cost_layout = QVBoxLayout()
heal_cost_label = QLabel("💊 FAP")
heal_cost_label.setStyleSheet("font-size: 8px; color: #888888;")
heal_cost_layout.addWidget(heal_cost_label)
self.heal_cost_value_label = QLabel("0.00")
self.heal_cost_value_label.setStyleSheet("font-size: 10px; color: #FFAAAA;")
heal_cost_layout.addWidget(self.heal_cost_value_label)
row3.addLayout(heal_cost_layout)
# Cost per kill
cpk_layout = QVBoxLayout()
cpk_label = QLabel("📊 CPK")
cpk_label.setStyleSheet("font-size: 8px; color: #888888;")
cpk_layout.addWidget(cpk_label)
self.cpk_value_label = QLabel("0.00")
self.cpk_value_label.setStyleSheet("font-size: 10px; color: #FFD700;")
cpk_layout.addWidget(self.cpk_value_label)
row3.addLayout(cpk_layout)
layout.addLayout(row3)
# === LOADOUT COST METRICS ROW ===
row4 = QHBoxLayout()
# Cost per shot
cps_layout = QVBoxLayout()
cps_label = QLabel("$/SHOT")
cps_label.setStyleSheet("font-size: 8px; color: #888888;")
cps_layout.addWidget(cps_label)
self.cps_value_label = QLabel("0.00")
self.cps_value_label.setStyleSheet("font-size: 10px; color: #FFAAAA;")
cps_layout.addWidget(self.cps_value_label)
row4.addLayout(cps_layout)
# Cost per hit
cph_layout = QVBoxLayout()
cph_label = QLabel("$/HIT")
cph_label.setStyleSheet("font-size: 8px; color: #888888;")
cph_layout.addWidget(cph_label)
self.cph_value_label = QLabel("0.00")
self.cph_value_label.setStyleSheet("font-size: 10px; color: #FFAAAA;")
cph_layout.addWidget(self.cph_value_label)
row4.addLayout(cph_layout)
# Cost per heal
cphl_layout = QVBoxLayout()
cphl_label = QLabel("$/HEAL")
cphl_label.setStyleSheet("font-size: 8px; color: #888888;")
cphl_layout.addWidget(cphl_label)
self.cphl_value_label = QLabel("0.00")
self.cphl_value_label.setStyleSheet("font-size: 10px; color: #FFAAAA;")
cphl_layout.addWidget(self.cphl_value_label)
row4.addLayout(cphl_layout)
# Hits taken
hits_layout = QVBoxLayout()
hits_label = QLabel("HITS")
hits_label.setStyleSheet("font-size: 8px; color: #888888;")
hits_layout.addWidget(hits_label)
self.hits_value_label = QLabel("0")
self.hits_value_label.setStyleSheet("font-size: 10px; color: #FFD700;")
hits_layout.addWidget(self.hits_value_label)
row4.addLayout(hits_layout)
# Heals used
heals_layout = QVBoxLayout()
heals_label = QLabel("HEALS")
heals_label.setStyleSheet("font-size: 8px; color: #888888;")
heals_layout.addWidget(heals_label)
self.heals_value_label = QLabel("0")
self.heals_value_label.setStyleSheet("font-size: 10px; color: #FFD700;")
heals_layout.addWidget(self.heals_value_label)
row4.addLayout(heals_layout)
layout.addLayout(row4)
# === EQUIPMENT INFO ===
equip_separator = QFrame()
equip_separator.setFrameShape(QFrame.Shape.HLine)
equip_separator.setStyleSheet("background-color: rgba(255, 215, 0, 30);")
equip_separator.setFixedHeight(1)
layout.addWidget(equip_separator)
equip_layout = QHBoxLayout()
# Weapon
self.weapon_label = QLabel("🔫 None")
self.weapon_label.setStyleSheet("font-size: 10px; color: #CCCCCC;")
equip_layout.addWidget(self.weapon_label)
equip_layout.addStretch()
# Armor
self.armor_label = QLabel("🛡️ None")
self.armor_label.setStyleSheet("font-size: 10px; color: #CCCCCC;")
equip_layout.addWidget(self.armor_label)
equip_layout.addStretch()
# Loadout
loadout_label = QLabel("Loadout:")
loadout_label.setStyleSheet("font-size: 9px; color: #888888;")
equip_layout.addWidget(loadout_label)
self.loadout_label = QLabel("Default")
self.loadout_label.setStyleSheet("font-size: 10px; color: #00FFFF;")
equip_layout.addWidget(self.loadout_label)
layout.addLayout(equip_layout)
# === STATUS BAR ===
self.status_label = QLabel("● Ready")
self.status_label.setStyleSheet("font-size: 9px; color: #7FFF7F;")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.status_label)
# ========================================================================
# POSITION PERSISTENCE
# ========================================================================
def _load_position(self) -> None:
"""Load saved position from config file."""
try:
if self.config_path.exists():
with open(self.config_path, 'r') as f:
config = json.load(f)
x = config.get('x', 100)
y = config.get('y', 100)
self.move(x, y)
# Load saved stats if available
if 'stats' in config:
self._stats = HUDStats.from_dict(config['stats'])
self._refresh_display()
else:
# Default position: top-right of screen
screen = QApplication.primaryScreen().geometry()
self.move(screen.width() - 360, 50)
except Exception as e:
# Default position on error
self.move(100, 100)
def _save_position(self) -> None:
"""Save current position to config file."""
try:
config = {
'x': self.x(),
'y': self.y(),
'stats': self._stats.to_dict(),
}
with open(self.config_path, 'w') as f:
json.dump(config, f, indent=2)
except Exception as e:
pass
# ========================================================================
# MOUSE HANDLING (Drag Support)
# ========================================================================
def mousePressEvent(self, event: QMouseEvent) -> None:
"""Handle mouse press - start drag if Ctrl is held."""
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)
self._enable_click_through(False)
event.accept()
else:
self._enable_click_through(True)
event.ignore()
def mouseMoveEvent(self, event: QMouseEvent) -> None:
"""Handle mouse move - drag window if in drag mode."""
if self._dragging:
new_pos = self.mapToGlobal(event.pos()) - self._drag_offset
self.move(new_pos)
self.position_changed.emit(new_pos)
event.accept()
else:
self.drag_hint.setStyleSheet("font-size: 8px; color: #FFD700;")
event.ignore()
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
"""Handle mouse release - end drag and save position."""
if event.button() == Qt.MouseButton.LeftButton and self._dragging:
self._dragging = False
self.setCursor(Qt.CursorShape.ArrowCursor)
self._save_position()
self._enable_click_through(True)
event.accept()
else:
event.ignore()
def _enable_click_through(self, enable: bool) -> None:
"""Enable or disable click-through behavior."""
if sys.platform == 'win32':
self._set_click_through_win32(enable)
else:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enable)
def _set_click_through_win32(self, enabled: bool) -> None:
"""Enable/disable click-through on Windows using WinAPI."""
GWL_EXSTYLE = -20
WS_EX_TRANSPARENT = 0x00000020
WS_EX_LAYERED = 0x00080000
SWP_FRAMECHANGED = 0x0020
SWP_NOMOVE = 0x0002
SWP_NOSIZE = 0x0001
SWP_NOZORDER = 0x0004
SWP_SHOWWINDOW = 0x0040
try:
hwnd = self.winId().__int__()
style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
if enabled:
style |= WS_EX_TRANSPARENT | WS_EX_LAYERED
else:
style &= ~(WS_EX_TRANSPARENT | WS_EX_LAYERED)
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style)
ctypes.windll.user32.SetWindowPos(
hwnd, 0, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW
)
except Exception:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enabled)
def keyPressEvent(self, event) -> None:
"""Handle key press - detect Ctrl for drag mode."""
if event.key() == Qt.Key.Key_Control:
self._modifier_pressed = True
self._enable_click_through(False)
self.drag_hint.setText("✋ Drag mode ON")
self.drag_hint.setStyleSheet("font-size: 8px; color: #00FF00;")
super().keyPressEvent(event)
def keyReleaseEvent(self, event) -> None:
"""Handle key release - detect Ctrl release."""
if event.key() == Qt.Key.Key_Control:
self._modifier_pressed = False
self._enable_click_through(True)
self.drag_hint.setText("Hold Ctrl to drag")
self.drag_hint.setStyleSheet("font-size: 8px; color: #666666;")
super().keyReleaseEvent(event)
# ========================================================================
# SESSION MANAGEMENT
# ========================================================================
def start_session(self, weapon: str = "Unknown", loadout: str = "Default",
weapon_dpp: Decimal = Decimal('0.0'),
weapon_cost_per_hour: Decimal = Decimal('0.0'),
armor: str = "None",
armor_durability: int = 2000,
fap: str = "None",
loadout_id: Optional[int] = None,
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 with loadout-based cost tracking.
Args:
weapon: Name of the current weapon
loadout: Name of the current loadout
weapon_dpp: Weapon DPP for cost calculations
weapon_cost_per_hour: Weapon cost per hour in PED
armor: Name of the equipped armor
armor_durability: Armor durability rating
fap: Name of the equipped FAP
loadout_id: Database ID of the loadout configuration
cost_per_shot: Cost per weapon shot from loadout
cost_per_hit: Cost per armor hit from loadout
cost_per_heal: Cost per heal from loadout
"""
self._session_start = datetime.now()
self._stats = HUDStats()
self._stats.current_weapon = weapon
self._stats.current_loadout = loadout
self._stats.loadout_id = loadout_id
self._stats.current_armor = armor
self._stats.current_fap = fap
self._stats.weapon_dpp = weapon_dpp
self._stats.weapon_cost_per_hour = weapon_cost_per_hour
self._stats.armor_durability = armor_durability
self._stats.cost_per_shot = cost_per_shot
self._stats.cost_per_hit = cost_per_hit
self._stats.cost_per_heal = cost_per_heal
self.session_active = True
# Initialize armor decay tracker
# With simplified loadouts, we use cost_per_hit directly instead of looking up armor
self._armor_tracker = None # Disabled - using loadout's cost_per_hit directly
self._timer.start(1000)
self._refresh_display()
self.status_label.setText("● Live - Recording")
self.status_label.setStyleSheet("font-size: 9px; color: #7FFF7F;")
def set_cost_tracker(self, cost_tracker: 'SessionCostTracker') -> None:
"""
Connect to a SessionCostTracker for real-time cost updates.
Args:
cost_tracker: SessionCostTracker instance
"""
self._cost_tracker = cost_tracker
cost_tracker.register_callback(self._on_cost_update)
def _on_cost_update(self, state: 'SessionCostState') -> None:
"""Handle cost update from SessionCostTracker."""
if not self.session_active:
return
# Update stats from cost tracker state
self._stats.weapon_cost_total = state.weapon_cost
self._stats.armor_cost_total = state.armor_cost
self._stats.healing_cost_total = state.healing_cost
self._stats.enhancer_cost_total = state.enhancer_cost
self._stats.mindforce_cost_total = state.mindforce_cost
self._stats.shots_fired = state.shots_fired
self._stats.hits_taken = state.hits_taken
self._stats.heals_used = state.heals_used
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def update_cost(self, cost_ped: Decimal, cost_type: str = 'weapon') -> None:
"""
Update total cost spent.
Args:
cost_ped: Cost in PED to add
cost_type: Type of cost ('weapon', 'armor', 'healing', 'plates', 'enhancer', 'mindforce')
"""
if not self.session_active:
return
if cost_type == 'weapon':
self._stats.weapon_cost_total += cost_ped
elif cost_type == 'armor':
self._stats.armor_cost_total += cost_ped
elif cost_type == 'healing':
self._stats.healing_cost_total += cost_ped
elif cost_type == 'plates':
self._stats.plates_cost_total += cost_ped
elif cost_type == 'enhancer':
self._stats.enhancer_cost_total += cost_ped
elif cost_type == 'mindforce':
self._stats.mindforce_cost_total += cost_ped
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def end_session(self) -> None:
"""End the current session."""
self._timer.stop()
# End armor tracking
if self._armor_tracker:
armor_summary = self._armor_tracker.end_session()
self._armor_tracker = None
self._session_start = None
self.session_active = False
self._save_position()
self.status_label.setText("○ Paused")
self.status_label.setStyleSheet("font-size: 9px; color: #888888;")
# ========================================================================
# EVENT HANDLERS (Called from LogWatcher)
# ========================================================================
def on_loot_event(self, item_name: str, value_ped: Decimal,
is_shrapnel: bool = False,
is_universal_ammo: bool = False) -> None:
"""
Called when loot is received from LogWatcher.
Args:
item_name: Name of the looted item
value_ped: Value in PED (Decimal for precision)
is_shrapnel: Whether this is shrapnel
is_universal_ammo: Whether this is universal ammo
"""
if not self.session_active:
return
# Always add to total loot
self._stats.loot_total += value_ped
# Track separately
if is_shrapnel:
self._stats.shrapnel_total += value_ped
elif is_universal_ammo:
self._stats.universal_ammo_total += value_ped
else:
# Marketable loot - counts toward profit/loss
self._stats.loot_other += value_ped
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def on_kill_event(self, creature_name: str = "") -> None:
"""
Called when a creature is killed.
Args:
creature_name: Name of the killed creature
"""
if not self.session_active:
return
self._stats.kills += 1
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def on_damage_dealt(self, damage: Decimal) -> None:
"""
Called when damage is dealt.
Args:
damage: Amount of damage dealt
"""
if not self.session_active:
return
self._stats.damage_dealt += damage
self._stats.shots_fired += 1
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def on_damage_taken(self, damage: Decimal) -> None:
"""
Called when damage is taken.
Calculates armor decay using official formula.
Args:
damage: Amount of damage taken (absorbed by armor)
"""
if not self.session_active:
return
self._stats.damage_taken += damage
# Calculate armor decay using tracker or loadout's cost_per_hit
if damage > 0:
if self._armor_tracker:
armor_decay_ped = self._armor_tracker.record_damage_taken(damage)
self._stats.armor_cost_total += armor_decay_ped
elif self._stats.cost_per_hit > 0:
# Use pre-calculated cost per hit from loadout
self._stats.armor_cost_total += self._stats.cost_per_hit
self._stats.hits_taken += 1
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def on_global(self, value_ped: Decimal = Decimal('0.0')) -> None:
"""Called on global event."""
if not self.session_active:
return
self._stats.globals_count += 1
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def on_hof(self, value_ped: Decimal = Decimal('0.0')) -> None:
"""Called on Hall of Fame event."""
if not self.session_active:
return
self._stats.hofs_count += 1
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def on_heal_event(self, heal_amount: Decimal, decay_cost: Decimal = Decimal('0')) -> None:
"""
Called when healing is done from LogWatcher.
Args:
heal_amount: Amount of HP healed
decay_cost: Cost of the heal in PED (based on FAP decay)
"""
if not self.session_active:
return
self._stats.healing_done += heal_amount
if decay_cost > 0:
self._stats.healing_cost_total += decay_cost
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def update_display(self) -> None:
"""Public method to refresh display."""
self._refresh_display()
def _update_session_time(self) -> None:
"""Update the session time display."""
if self._session_start:
self._stats.session_time = datetime.now() - self._session_start
self._update_time_display()
def _check_game_window(self) -> None:
"""Check if Entropia Universe window is in foreground."""
if not self._auto_hide_with_game or sys.platform != 'win32':
return
try:
hwnd = GetForegroundWindow()
if hwnd:
length = GetWindowTextLengthW(hwnd)
if length > 0:
buffer = ctypes.create_unicode_buffer(length + 1)
GetWindowTextW(hwnd, buffer, length + 1)
title = buffer.value
is_game_focused = self._game_window_title.lower() in title.lower()
if is_game_focused:
if not self.isVisible():
self.show()
else:
if self.isVisible():
self.hide()
except Exception:
pass
def _update_time_display(self) -> None:
"""Update the time label."""
total_seconds = int(self._stats.session_time.total_seconds())
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
# ========================================================================
# STATS UPDATE INTERFACE
# ========================================================================
def update_stats(self, stats: Dict[str, Any]) -> None:
"""
Update HUD with new statistics.
Args:
stats: Dictionary containing stat updates
"""
# Loot
if 'loot' in stats:
self._stats.loot_other = Decimal(str(stats['loot']))
elif 'loot_delta' in stats:
self._stats.loot_other += Decimal(str(stats['loot_delta']))
if 'shrapnel' in stats:
self._stats.shrapnel_total = Decimal(str(stats['shrapnel']))
elif 'shrapnel_delta' in stats:
self._stats.shrapnel_total += Decimal(str(stats['shrapnel_delta']))
# Damage
if 'damage_dealt' in stats:
self._stats.damage_dealt = Decimal(str(stats['damage_dealt']))
elif 'damage_dealt_add' in stats:
self._stats.damage_dealt += Decimal(str(stats['damage_dealt_add']))
if 'damage_taken' in stats:
self._stats.damage_taken = Decimal(str(stats['damage_taken']))
elif 'damage_taken_add' in stats:
self._stats.damage_taken += Decimal(str(stats['damage_taken_add']))
# Healing
if 'healing_done' in stats:
self._stats.healing_done = Decimal(str(stats['healing_done']))
elif 'healing_add' in stats:
self._stats.healing_done += Decimal(str(stats['healing_add']))
if 'healing_cost' in stats:
self._stats.healing_cost_total = Decimal(str(stats['healing_cost']))
elif 'healing_cost_add' in stats:
self._stats.healing_cost_total += Decimal(str(stats['healing_cost_add']))
# Shots & Kills
if 'shots_fired' in stats:
self._stats.shots_fired = int(stats['shots_fired'])
elif 'shots_add' in stats:
self._stats.shots_fired += int(stats['shots_add'])
if 'kills' in stats:
self._stats.kills = int(stats['kills'])
elif 'kills_add' in stats:
self._stats.kills += int(stats['kills_add'])
# Events
if 'globals' in stats:
self._stats.globals_count = int(stats['globals'])
elif 'globals_add' in stats:
self._stats.globals_count += int(stats['globals_add'])
if 'hofs' in stats:
self._stats.hofs_count = int(stats['hofs'])
elif 'hofs_add' in stats:
self._stats.hofs_count += int(stats['hofs_add'])
# Equipment
if 'weapon' in stats:
self._stats.current_weapon = str(stats['weapon'])
if 'armor' in stats:
self._stats.current_armor = str(stats['armor'])
if 'fap' in stats:
self._stats.current_fap = str(stats['fap'])
if 'loadout' in stats:
self._stats.current_loadout = str(stats['loadout'])
self._stats.recalculate()
self._refresh_display()
self.stats_updated.emit(self._stats.to_dict())
def _refresh_display(self) -> None:
"""Refresh all display labels with current stats."""
# Loot (marketable only)
self.loot_value_label.setText(f"{self._stats.loot_other:.2f} PED")
# Total Cost
self.cost_value_label.setText(f"{self._stats.cost_total:.2f} PED")
# Profit/Loss with color coding
profit = self._stats.profit_loss
self.profit_value_label.setText(f"{profit:+.2f}")
if profit > 0:
self.profit_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #7FFF7F;")
elif profit < 0:
self.profit_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FF7F7F;")
else:
self.profit_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FFFFFF;")
# Return percentage with color coding
ret_pct = self._stats.return_percentage
self.return_label.setText(f"{ret_pct:.1f}%")
if ret_pct >= 100:
self.return_label.setStyleSheet("font-size: 11px; font-weight: bold; color: #7FFF7F;")
elif ret_pct >= 90:
self.return_label.setStyleSheet("font-size: 11px; font-weight: bold; color: #FFFF7F;")
else:
self.return_label.setStyleSheet("font-size: 11px; font-weight: bold; color: #FF7F7F;")
# Shrapnel indicator
self.shrapnel_label.setText(f"💎 Shrapnel: {self._stats.shrapnel_total:.2f}")
# Combat stats
self.kills_value_label.setText(str(self._stats.kills))
self.globals_value_label.setText(f"{self._stats.globals_count} / {self._stats.hofs_count}")
self.dpp_value_label.setText(f"{self._stats.dpp:.1f}")
self.dealt_value_label.setText(f"{int(self._stats.damage_dealt)}")
self.taken_value_label.setText(f"{int(self._stats.damage_taken)}")
self.shots_value_label.setText(str(self._stats.shots_fired))
# Cost breakdown
self.wep_cost_value_label.setText(f"{self._stats.weapon_cost_total:.2f}")
self.arm_cost_value_label.setText(f"{self._stats.armor_cost_total:.3f}")
self.heal_cost_value_label.setText(f"{self._stats.healing_cost_total:.2f}")
self.cpk_value_label.setText(f"{self._stats.cost_per_kill:.2f}")
# Loadout cost metrics
self.cps_value_label.setText(f"{self._stats.cost_per_shot:.3f}")
self.cph_value_label.setText(f"{self._stats.cost_per_hit:.3f}")
self.cphl_value_label.setText(f"{self._stats.cost_per_heal:.3f}")
self.hits_value_label.setText(str(self._stats.hits_taken))
self.heals_value_label.setText(str(self._stats.heals_used))
# Equipment info
weapon_short = self._stats.current_weapon[:12] if len(self._stats.current_weapon) > 12 else self._stats.current_weapon
armor_short = self._stats.current_armor[:12] if len(self._stats.current_armor) > 12 else self._stats.current_armor
self.weapon_label.setText(f"🔫 {weapon_short}")
self.armor_label.setText(f"🛡️ {armor_short}")
self.loadout_label.setText(self._stats.current_loadout[:12])
# Update time
self._update_time_display()
def get_stats(self) -> HUDStats:
"""Get current stats."""
return self._stats
# ========================================================================
# WINDOW OVERRIDES
# ========================================================================
def showEvent(self, event) -> None:
"""Handle show event - ensure proper window attributes."""
# Don't enable click-through immediately - let user interact first
# Click-through will be enabled after mouse leaves the window
super().showEvent(event)
def enterEvent(self, event) -> None:
"""Handle mouse enter - disable click-through so user can interact."""
self._enable_click_through(False)
super().enterEvent(event)
def leaveEvent(self, event) -> None:
"""Handle mouse leave - reset drag hint and enable click-through."""
self.drag_hint.setStyleSheet("font-size: 8px; color: #666666;")
# Enable click-through when mouse leaves so it doesn't block game
if not self._dragging:
self._enable_click_through(True)
super().leaveEvent(event)
def moveEvent(self, event) -> None:
"""Handle move event - save position periodically."""
if not hasattr(self, '_last_save'):
self._last_save = 0
import time
current_time = time.time()
if current_time - self._last_save > 5:
self._save_position()
self._last_save = current_time
super().moveEvent(event)
def closeEvent(self, event) -> None:
"""Handle close event - save position before closing."""
self._save_position()
super().closeEvent(event)
# ============================================================================
# MOCK TESTING
# ============================================================================
def run_mock_test():
"""Run the HUD with mock data for testing."""
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
# Create HUD
hud = HUDOverlay()
hud.show()
# Start session
hud.start_session(
weapon="Omegaton M2100",
armor="Ghost",
armor_durability=2000,
fap="Vivo T10",
loadout="Hunting Set A"
)
# Simulate incoming stats
from PyQt6.QtCore import QTimer
mock_stats = {
'loot': Decimal('0.0'),
'shrapnel': Decimal('0.0'),
'damage_dealt': Decimal('0.0'),
'damage_taken': Decimal('0.0'),
'kills': 0,
'globals': 0,
'hofs': 0,
}
def simulate_event():
"""Simulate random game events."""
import random
event_type = random.choice(['loot', 'damage', 'kill', 'global', 'shrapnel', 'armor_hit'])
if event_type == 'loot':
value = Decimal(str(random.uniform(0.5, 15.0)))
mock_stats['loot'] += value
hud.update_stats({'loot': mock_stats['loot']})
print(f"[MOCK] Loot: {value:.2f} PED")
elif event_type == 'shrapnel':
value = Decimal(str(random.uniform(0.1, 2.0)))
mock_stats['shrapnel'] += value
hud._stats.shrapnel_total += value
hud._refresh_display()
print(f"[MOCK] Shrapnel: {value:.2f} PED")
elif event_type == 'damage':
damage = Decimal(str(random.randint(5, 50)))
mock_stats['damage_dealt'] += damage
hud.update_stats({'damage_dealt': mock_stats['damage_dealt'], 'shots_add': 1})
print(f"[MOCK] Damage dealt: {damage}")
elif event_type == 'armor_hit':
damage = Decimal(str(random.randint(5, 20)))
mock_stats['damage_taken'] += damage
hud.on_damage_taken(damage)
print(f"[MOCK] Damage taken (armor): {damage}")
elif event_type == 'kill':
mock_stats['kills'] += 1
hud.update_stats({'kills': mock_stats['kills']})
print(f"[MOCK] Kill! Total: {mock_stats['kills']}")
elif event_type == 'global':
mock_stats['globals'] += 1
hud.update_stats({'globals': mock_stats['globals']})
print(f"[MOCK] GLOBAL!!! Count: {mock_stats['globals']}")
# Simulate events every 3 seconds
timer = QTimer()
timer.timeout.connect(simulate_event)
timer.start(3000)
print("\n" + "="*60)
print("🍋 LEMONTROPIA HUD - MOCK TEST MODE")
print("="*60)
print("HUD should be visible on screen (top-right default)")
print("Features to test:")
print(" ✓ Frameless window with gold border")
print(" ✓ Transparent background")
print(" ✓ Always on top")
print(" ✓ Click-through (clicks pass to window below)")
print(" ✓ Hold Ctrl to drag the HUD")
print(" ✓ Stats update every 3 seconds (mock data)")
print(" ✓ Armor decay calculated using official formula")
print("\nPress Ctrl+C in terminal to exit")
print("="*60 + "\n")
sys.exit(app.exec())
if __name__ == "__main__":
run_mock_test()