diff --git a/borealis_overlay.py b/borealis_overlay.py index 4a496c5..3a3a3b1 100644 --- a/borealis_overlay.py +++ b/borealis_overlay.py @@ -1,23 +1,6 @@ #!/usr/bin/env python3 -""" -Project Borealis (Single Region: HP/MP/FP/EXP) -============================================== -• One region labeled "Player Stats" capturing 4 lines: - 1) HP: current / max - 2) MP: current / max - 3) FP: current / max - 4) EXP (percentage) - -• HP, MP, FP each have Rich progress bars that stay their assigned color at 100%. -• The 4th line (EXP) feeds into historical EXP logic (table + predicted time). -• Region is resizable & draggable (edit_mode = True). -• Tesseract uses --psm 4 for multi-line segmentation. - -Adjust region.x, region.y, region.w, region.h to match your UI. -""" import sys -import os import time import re import pytesseract @@ -26,28 +9,36 @@ from PyQt5.QtCore import Qt, QRect, QPoint, QTimer from PyQt5.QtGui import QPainter, QPen, QColor, QFont from PIL import Image, ImageGrab, ImageFilter -from rich.console import Console +from rich.console import Console, Group from rich.table import Table from rich.progress import Progress, BarColumn, TextColumn +from rich.text import Text +from rich.live import Live # ---- [ Global Config ] ---- pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" -OCR_ENGINE = "Tesseract" -POLLING_RATE_MS = 1000 -MAX_DATA_POINTS = 7 +OCR_ENGINE = "Tesseract" +POLLING_RATE_MS = 500 +MAX_DATA_POINTS = 8 -DEFAULT_WIDTH = 150 -DEFAULT_HEIGHT = 120 # taller to ensure line 4 is captured -HANDLE_SIZE = 10 +DEFAULT_WIDTH = 175 +DEFAULT_HEIGHT = 145 +HANDLE_SIZE = 7 LABEL_HEIGHT = 20 GREEN_HEADER_STYLE = "bold green" +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + def format_duration(seconds): """ + INFORMATION PROCESSING: + ----------------------- Convert total seconds into hours/min/seconds (e.g., "Xh Ym Zs"). - Returns '???' if None. + Returns '???' if the input is None or invalid. """ if seconds is None: return "???" @@ -63,6 +54,8 @@ def format_duration(seconds): def sanitize_experience_string(raw_text): """ + INFORMATION PROCESSING: + ----------------------- Extracts a float from raw OCR text for EXP (0-100%). Handles e.g. "25.5682%", "77.8649" etc. """ @@ -81,6 +74,8 @@ def sanitize_experience_string(raw_text): def format_experience_value(value): """ + INFORMATION DISPLAY (formatting): + --------------------------------- Format a float 0-100 to XX.XXXX for display in table output. """ if value < 0: @@ -98,8 +93,14 @@ def format_experience_value(value): int_part = "00" return f"{int_part}.{dec_part}" +# ----------------------------------------------------------------------------- +# Region Class +# ----------------------------------------------------------------------------- + class Region: """ + DATA STRUCTURE: + --------------- Defines a draggable/resizable screen region for OCR capture. """ def __init__(self, x, y, label="Region", color=QColor(0,0,255)): @@ -129,8 +130,14 @@ class Region: QRect(self.x + self.w - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # bottom-right ] +# ----------------------------------------------------------------------------- +# OverlayCanvas Class +# ----------------------------------------------------------------------------- + class OverlayCanvas(QWidget): """ + UI RENDERING LOGIC: + ------------------- Renders the overlay & handles region dragging/resizing. """ def __init__(self, regions, parent=None): @@ -166,7 +173,7 @@ class OverlayCanvas(QWidget): return if event.button() == Qt.LeftButton: - # Check topmost region first (reverse if multiple) + # Check topmost region first for region in reversed(self.regions): # Check each resize handle for i, handle in enumerate(region.resize_handles()): @@ -227,11 +234,17 @@ class OverlayCanvas(QWidget): self.selected_region = None self.selected_handle = None +# ----------------------------------------------------------------------------- +# BorealisOverlay Class +# ----------------------------------------------------------------------------- + class BorealisOverlay(QWidget): """ - Single Region Overlay for Player Stats (HP/MP/FP/EXP) + MAIN APPLICATION LOGIC: + ----------------------- + Single Region Overlay for Player Stats (HP/MP/FP/EXP) with OCR scanning. """ - def __init__(self): + def __init__(self, live=None): super().__init__() screen_geo = QApplication.primaryScreen().geometry() self.setGeometry(screen_geo) @@ -239,7 +252,7 @@ class BorealisOverlay(QWidget): self.setAttribute(Qt.WA_TranslucentBackground, True) # Single region, with an increased height (120) - region = Region(250, 50, label="Player Stats") + region = Region(250, 50, label="Character Status") region.h = 120 self.regions = [region] @@ -252,15 +265,30 @@ class BorealisOverlay(QWidget): # Keep history of EXP data self.points = [] + # We will store a reference to Rich.Live here + self.live = live + # Timer for periodic OCR scanning self.timer = QTimer(self) self.timer.timeout.connect(self.collect_ocr_data) self.timer.start(POLLING_RATE_MS) - # --------------------------------------------------------------------- + def set_live(self, live): + """ + Called by main() so we can update the Live object from inside this class. + """ + self.live = live + + # ------------------------------------------------------------------------- # OCR - # --------------------------------------------------------------------- + # ------------------------------------------------------------------------- def collect_ocr_data(self): + """ + INFORMATION GATHERING: + ---------------------- + Periodically invoked by QTimer. Captures region screenshot, OCR's it, + and triggers the terminal display update. + """ for region in self.regions: if region.visible: screenshot = ImageGrab.grab( @@ -272,10 +300,15 @@ class BorealisOverlay(QWidget): text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1') region.data = text.strip() - self.display_ocr_data_in_terminal() + # Instead of printing directly, we now build a Rich renderable and update Live. + if self.live is not None: + renderable = self.build_renderable() + self.live.update(renderable) def preprocess_image(self, image): """ + INFORMATION PROCESSING: + ----------------------- Convert to grayscale, scale up, threshold, median filter for improved Tesseract accuracy. """ @@ -284,11 +317,13 @@ class BorealisOverlay(QWidget): thresh = scaled.point(lambda p: p > 200 and 255) return thresh.filter(ImageFilter.MedianFilter(3)) - # --------------------------------------------------------------------- + # ------------------------------------------------------------------------- # Parsing - # --------------------------------------------------------------------- + # ------------------------------------------------------------------------- def parse_all_stats(self, raw_text): """ + INFORMATION ANALYSIS: + --------------------- Expect up to 4 lines: HP, MP, FP, EXP. Returns dict with keys "hp", "mp", "fp", "exp". """ @@ -328,6 +363,8 @@ class BorealisOverlay(QWidget): def update_points(self, new_val): """ + INFORMATION TRACKING: + --------------------- Track historical EXP changes for table & predicted time to level. """ now = time.time() @@ -343,11 +380,13 @@ class BorealisOverlay(QWidget): if len(self.points) > MAX_DATA_POINTS: self.points.pop(0) - # --------------------------------------------------------------------- - # Display - # --------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Display Logic + # ------------------------------------------------------------------------- def compute_time_to_100(self): """ + INFORMATION PREDICTION: + ----------------------- Estimate time to reach 100% from current EXP data. """ n = len(self.points) @@ -376,18 +415,17 @@ class BorealisOverlay(QWidget): return int(remain / rate_per_s) - def display_ocr_data_in_terminal(self): + def build_renderable(self): """ - Clears terminal, prints HP/MP/FP bars, EXP table, predicted time. + INFORMATION DISPLAY (Rich): + --------------------------- + Construct a single Rich renderable (Group) that includes: + - Title + - HP/MP/FP progress bars + - Historical EXP table + - Predicted time progress bar """ - console = Console() - os.system('cls' if os.name == 'nt' else 'clear') - - # Title - console.print("[bold white]Project Borealis[/bold white]") - console.print("[dim]Flyff Information Overlay[/dim]\n") - - # Parse stats + # Gather stats from first region raw_text = self.regions[0].data stats = self.parse_all_stats(raw_text) hp_cur, hp_max = stats["hp"] @@ -395,48 +433,57 @@ class BorealisOverlay(QWidget): fp_cur, fp_max = stats["fp"] exp_val = stats["exp"] - # Show HP / MP / FP bars, forcing each color to remain even at 100% - with Progress( + # Update historical EXP points if valid + if exp_val is not None: + self.update_points(exp_val) + current_exp = self.points[-1][1] if self.points else 0.0 + + # --------------------- + # 1) Title Section + # --------------------- + title_text = Text("Project Borealis\n", style="bold white") + subtitle_text = Text("Flyff Information Overlay\n\n", style="dim") + + # --------------------- + # 2) HP / MP / FP Bars + # --------------------- + bar_progress = Progress( "{task.description}", BarColumn(bar_width=30), TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"), - console=console, transient=False, - auto_refresh=True, - ) as progress: + auto_refresh=False # We'll refresh after all tasks are added + ) - progress.add_task( - "[bold red]HP[/bold red]", - total=hp_max, - completed=hp_cur, - style="red", - complete_style="red" # remain red at 100% - ) - progress.add_task( - "[bold blue]MP[/bold blue]", - total=mp_max, - completed=mp_cur, - style="blue", - complete_style="blue" # remain blue at 100% - ) - progress.add_task( - "[bold green]FP[/bold green]", - total=fp_max, - completed=fp_cur, - style="green", - complete_style="green" # remain green at 100% - ) + # HP + hp_task = bar_progress.add_task( + "[bold red]HP[/bold red]", + total=hp_max, + completed=hp_cur, + style="red", + complete_style="red" + ) + # MP + mp_task = bar_progress.add_task( + "[bold blue]MP[/bold blue]", + total=mp_max, + completed=mp_cur, + style="blue", + complete_style="blue" + ) + # FP + fp_task = bar_progress.add_task( + "[bold green]FP[/bold green]", + total=fp_max, + completed=fp_cur, + style="green", + complete_style="green" + ) + bar_progress.refresh() - progress.refresh() - progress.stop() - - console.print() # blank line after bars - - # If we have an EXP value, update historical data - if exp_val is not None: - self.update_points(exp_val) - - # Build the Historical EXP table + # --------------------- + # 3) Historical EXP Table + # --------------------- table = Table(show_header=True, header_style=GREEN_HEADER_STYLE, style=None) table.add_column("Historical EXP", justify="center", style="green") table.add_column("Time Since Last Kill", justify="center", style="green") @@ -454,9 +501,9 @@ class BorealisOverlay(QWidget): for i in range(1, n): t_cur, v_cur = self.points[i] t_prev, v_prev = self.points[i - 1] - delta_v = v_cur - v_prev delta_str = f"{delta_v:+.4f}%" + exp_main = format_experience_value(v_cur) exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]" @@ -473,34 +520,62 @@ class BorealisOverlay(QWidget): table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str) - console.print(table) - console.print() # blank line - - # Predicted time to level - current_exp = self.points[-1][1] if self.points else 0.0 + # --------------------- + # 4) Predicted Time to Level + # --------------------- secs_left = self.compute_time_to_100() time_str = format_duration(secs_left) - with Progress( + time_bar = Progress( TextColumn("[bold white]Predicted Time to Level:[/bold white] "), BarColumn(bar_width=30, complete_style="magenta"), TextColumn(" [green]{task.percentage:>5.2f}%[/green] "), TextColumn(f"[magenta]{time_str}[/magenta] until 100%", justify="right"), - console=console, transient=False, - ) as progress: - progress.add_task("", total=100, completed=current_exp) - progress.refresh() + auto_refresh=False + ) + t_task = time_bar.add_task("", total=100, completed=current_exp) + time_bar.refresh() + + # Combine everything into a Rich Group + # Title + Subtitle + HP/MP/FP Progress + Table + Time Bar + return Group( + title_text, + subtitle_text, + bar_progress, # HP/MP/FP + table, + time_bar # predicted-time progress + ) + +# ----------------------------------------------------------------------------- +# main() +# ----------------------------------------------------------------------------- def main(): """ - Launches the PyQt5 overlay, starts the event loop. + LAUNCH SEQUENCE: + --------------- + 1) Create QApplication. + 2) Create BorealisOverlay Window. + 3) Use Rich Live to continuously update terminal output with no flicker. + 4) Start PyQt event loop. """ app = QApplication(sys.argv) - window = BorealisOverlay() + window = BorealisOverlay() # We'll inject Live momentarily + window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)") window.show() - sys.exit(app.exec_()) + + console = Console() + + # Use a Live context manager so we can do partial updates + with Live(console=console, refresh_per_second=4) as live: + # Pass the live object to our BorealisOverlay so it can call live.update() + window.set_live(live) + # Run the PyQt event loop (blocking) + exit_code = app.exec_() + + sys.exit(exit_code) if __name__ == "__main__": main()