Added automatic character stat page locator logic at startup.
This commit is contained in:
parent
2812b96b7c
commit
b72d1e4c92
@ -3,7 +3,16 @@
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
import pytesseract
|
import pytesseract
|
||||||
|
|
||||||
|
try:
|
||||||
|
import winsound
|
||||||
|
HAS_WINSOUND = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_WINSOUND = False
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication, QWidget
|
from PyQt5.QtWidgets import QApplication, QWidget
|
||||||
from PyQt5.QtCore import Qt, QRect, QPoint, QTimer
|
from PyQt5.QtCore import Qt, QRect, QPoint, QTimer
|
||||||
from PyQt5.QtGui import QPainter, QPen, QColor, QFont
|
from PyQt5.QtGui import QPainter, QPen, QColor, QFont
|
||||||
@ -15,31 +24,92 @@ from rich.progress import Progress, BarColumn, TextColumn
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from rich.live import Live
|
from rich.live import Live
|
||||||
|
|
||||||
# ---- [ Global Config ] ----
|
# =============================================================================
|
||||||
|
# Global Config
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||||
|
|
||||||
OCR_ENGINE = "Tesseract"
|
|
||||||
POLLING_RATE_MS = 500
|
POLLING_RATE_MS = 500
|
||||||
MAX_DATA_POINTS = 8
|
MAX_DATA_POINTS = 8
|
||||||
|
|
||||||
DEFAULT_WIDTH = 175
|
# We still use these defaults for Region size.
|
||||||
DEFAULT_HEIGHT = 145
|
DEFAULT_WIDTH = 180
|
||||||
HANDLE_SIZE = 7
|
DEFAULT_HEIGHT = 130
|
||||||
|
HANDLE_SIZE = 8
|
||||||
LABEL_HEIGHT = 20
|
LABEL_HEIGHT = 20
|
||||||
|
|
||||||
GREEN_HEADER_STYLE = "bold green"
|
GREEN_HEADER_STYLE = "bold green"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
BEEP_INTERVAL_SECONDS = 1.0 # Only beep once every 1 second
|
||||||
|
|
||||||
|
# STATUS BAR AUTO-LOCATOR LOGIC (WILL BE BUILT-OUT TO BE MORE ROBUST LATER)
|
||||||
|
# Set your theme to "Masquerade" and Interface Scale to 140%, and browser zoom level to 110%
|
||||||
|
TEMPLATE_PATH = "G:\\Nextcloud\\Projects\\Scripting\\bars_template.png" # Path to your bars template file
|
||||||
|
MATCH_THRESHOLD = 0.5 # The correlation threshold to consider a "good" match
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# -----------------------------------------------------------------------------
|
# =============================================================================
|
||||||
|
|
||||||
|
def beep_hp_warning():
|
||||||
|
"""
|
||||||
|
Only beep if enough time has elapsed since the last beep (BEEP_INTERVAL_SECONDS).
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
if (beep_hp_warning.last_beep_time is None or
|
||||||
|
(current_time - beep_hp_warning.last_beep_time >= BEEP_INTERVAL_SECONDS)):
|
||||||
|
|
||||||
|
beep_hp_warning.last_beep_time = current_time
|
||||||
|
if HAS_WINSOUND:
|
||||||
|
# frequency=376 Hz, duration=100 ms
|
||||||
|
winsound.Beep(376, 100)
|
||||||
|
else:
|
||||||
|
# Attempt terminal bell
|
||||||
|
print('\a', end='')
|
||||||
|
|
||||||
|
beep_hp_warning.last_beep_time = None
|
||||||
|
|
||||||
|
|
||||||
|
def locate_bars_opencv(template_path, threshold=MATCH_THRESHOLD):
|
||||||
|
"""
|
||||||
|
Attempt to locate the bars via OpenCV template matching:
|
||||||
|
1) Grab the full screen using PIL.ImageGrab.
|
||||||
|
2) Convert to NumPy array in BGR format for cv2.
|
||||||
|
3) Load template from `template_path`.
|
||||||
|
4) Use cv2.matchTemplate to find the best match location.
|
||||||
|
5) If max correlation > threshold, return (x, y, w, h).
|
||||||
|
6) Else return None.
|
||||||
|
"""
|
||||||
|
# 1) Capture full screen
|
||||||
|
screenshot_pil = ImageGrab.grab()
|
||||||
|
screenshot_np = np.array(screenshot_pil) # shape (H, W, 4) possibly
|
||||||
|
# Convert RGBA or RGB to BGR
|
||||||
|
screenshot_bgr = cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
# 2) Load template from file
|
||||||
|
template_bgr = cv2.imread(template_path, cv2.IMREAD_COLOR)
|
||||||
|
if template_bgr is None:
|
||||||
|
print(f"[WARN] Could not load template file: {template_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3) Template matching
|
||||||
|
result = cv2.matchTemplate(screenshot_bgr, template_bgr, cv2.TM_CCOEFF_NORMED)
|
||||||
|
|
||||||
|
# 4) Find best match
|
||||||
|
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||||
|
# template width/height
|
||||||
|
th, tw, _ = template_bgr.shape
|
||||||
|
|
||||||
|
if max_val >= threshold:
|
||||||
|
# max_loc is top-left corner of the best match
|
||||||
|
found_x, found_y = max_loc
|
||||||
|
return (found_x, found_y, tw, th)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds):
|
def format_duration(seconds):
|
||||||
"""
|
|
||||||
INFORMATION PROCESSING:
|
|
||||||
-----------------------
|
|
||||||
Convert total seconds into hours/min/seconds (e.g., "Xh Ym Zs").
|
|
||||||
Returns '???' if the input is None or invalid.
|
|
||||||
"""
|
|
||||||
if seconds is None:
|
if seconds is None:
|
||||||
return "???"
|
return "???"
|
||||||
seconds = int(seconds)
|
seconds = int(seconds)
|
||||||
@ -52,13 +122,8 @@ def format_duration(seconds):
|
|||||||
else:
|
else:
|
||||||
return f"{mins}m {secs}s"
|
return f"{mins}m {secs}s"
|
||||||
|
|
||||||
|
|
||||||
def sanitize_experience_string(raw_text):
|
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.
|
|
||||||
"""
|
|
||||||
text_no_percent = raw_text.replace('%', '')
|
text_no_percent = raw_text.replace('%', '')
|
||||||
text_no_spaces = text_no_percent.replace(' ', '')
|
text_no_spaces = text_no_percent.replace(' ', '')
|
||||||
cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces)
|
cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces)
|
||||||
@ -72,12 +137,8 @@ def sanitize_experience_string(raw_text):
|
|||||||
val = 100
|
val = 100
|
||||||
return round(val, 4)
|
return round(val, 4)
|
||||||
|
|
||||||
|
|
||||||
def format_experience_value(value):
|
def format_experience_value(value):
|
||||||
"""
|
|
||||||
INFORMATION DISPLAY (formatting):
|
|
||||||
---------------------------------
|
|
||||||
Format a float 0-100 to XX.XXXX for display in table output.
|
|
||||||
"""
|
|
||||||
if value < 0:
|
if value < 0:
|
||||||
value = 0
|
value = 0
|
||||||
elif value > 100:
|
elif value > 100:
|
||||||
@ -96,11 +157,8 @@ def format_experience_value(value):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Region Class
|
# Region Class
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
class Region:
|
class Region:
|
||||||
"""
|
"""
|
||||||
DATA STRUCTURE:
|
|
||||||
---------------
|
|
||||||
Defines a draggable/resizable screen region for OCR capture.
|
Defines a draggable/resizable screen region for OCR capture.
|
||||||
"""
|
"""
|
||||||
def __init__(self, x, y, label="Region", color=QColor(0,0,255)):
|
def __init__(self, x, y, label="Region", color=QColor(0,0,255)):
|
||||||
@ -120,30 +178,24 @@ class Region:
|
|||||||
return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT)
|
return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT)
|
||||||
|
|
||||||
def resize_handles(self):
|
def resize_handles(self):
|
||||||
"""
|
|
||||||
Return four small rectangles (handles) for resizing each corner.
|
|
||||||
"""
|
|
||||||
return [
|
return [
|
||||||
QRect(self.x - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # top-left
|
QRect(self.x - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
|
||||||
QRect(self.x + self.w - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # top-right
|
QRect(self.x + self.w - HANDLE_SIZE // 2, self.y - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
|
||||||
QRect(self.x - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # bottom-left
|
QRect(self.x - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
|
||||||
QRect(self.x + self.w - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), # bottom-right
|
QRect(self.x + self.w - HANDLE_SIZE // 2, self.y + self.h - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE),
|
||||||
]
|
]
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# OverlayCanvas Class
|
# OverlayCanvas Class
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
class OverlayCanvas(QWidget):
|
class OverlayCanvas(QWidget):
|
||||||
"""
|
"""
|
||||||
UI RENDERING LOGIC:
|
|
||||||
-------------------
|
|
||||||
Renders the overlay & handles region dragging/resizing.
|
Renders the overlay & handles region dragging/resizing.
|
||||||
"""
|
"""
|
||||||
def __init__(self, regions, parent=None):
|
def __init__(self, regions, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.regions = regions
|
self.regions = regions
|
||||||
self.edit_mode = True # allow editing by default
|
self.edit_mode = True
|
||||||
self.selected_region = None
|
self.selected_region = None
|
||||||
self.selected_handle = None
|
self.selected_handle = None
|
||||||
self.drag_offset = QPoint()
|
self.drag_offset = QPoint()
|
||||||
@ -152,18 +204,15 @@ class OverlayCanvas(QWidget):
|
|||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
for region in self.regions:
|
for region in self.regions:
|
||||||
if region.visible:
|
if region.visible:
|
||||||
# Draw the bounding rectangle
|
|
||||||
pen = QPen(region.color)
|
pen = QPen(region.color)
|
||||||
pen.setWidth(3)
|
pen.setWidth(3)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
painter.drawRect(region.x, region.y, region.w, region.h)
|
painter.drawRect(region.x, region.y, region.w, region.h)
|
||||||
|
|
||||||
# Draw the region label
|
|
||||||
painter.setFont(QFont("Arial", 12, QFont.Bold))
|
painter.setFont(QFont("Arial", 12, QFont.Bold))
|
||||||
painter.setPen(region.color)
|
painter.setPen(region.color)
|
||||||
painter.drawText(region.x, region.y - 5, region.label)
|
painter.drawText(region.x, region.y - 5, region.label)
|
||||||
|
|
||||||
# If in edit mode, show corner handles
|
|
||||||
if self.edit_mode:
|
if self.edit_mode:
|
||||||
for handle in region.resize_handles():
|
for handle in region.resize_handles():
|
||||||
painter.fillRect(handle, region.color)
|
painter.fillRect(handle, region.color)
|
||||||
@ -171,17 +220,13 @@ class OverlayCanvas(QWidget):
|
|||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
if event.button() == Qt.LeftButton:
|
if event.button() == Qt.LeftButton:
|
||||||
# Check topmost region first
|
|
||||||
for region in reversed(self.regions):
|
for region in reversed(self.regions):
|
||||||
# Check each resize handle
|
|
||||||
for i, handle in enumerate(region.resize_handles()):
|
for i, handle in enumerate(region.resize_handles()):
|
||||||
if handle.contains(event.pos()):
|
if handle.contains(event.pos()):
|
||||||
self.selected_region = region
|
self.selected_region = region
|
||||||
self.selected_handle = i
|
self.selected_handle = i
|
||||||
return
|
return
|
||||||
# Check label or main rect for dragging
|
|
||||||
if region.label_rect().contains(event.pos()):
|
if region.label_rect().contains(event.pos()):
|
||||||
self.selected_region = region
|
self.selected_region = region
|
||||||
self.selected_handle = None
|
self.selected_handle = None
|
||||||
@ -198,11 +243,9 @@ class OverlayCanvas(QWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.selected_handle is None:
|
if self.selected_handle is None:
|
||||||
# Drag entire rectangle
|
|
||||||
self.selected_region.x = event.x() - self.drag_offset.x()
|
self.selected_region.x = event.x() - self.drag_offset.x()
|
||||||
self.selected_region.y = event.y() - self.drag_offset.y()
|
self.selected_region.y = event.y() - self.drag_offset.y()
|
||||||
else:
|
else:
|
||||||
# Resize
|
|
||||||
sr = self.selected_region
|
sr = self.selected_region
|
||||||
if self.selected_handle == 0: # top-left
|
if self.selected_handle == 0: # top-left
|
||||||
sr.w += sr.x - event.x()
|
sr.w += sr.x - event.x()
|
||||||
@ -221,11 +264,10 @@ class OverlayCanvas(QWidget):
|
|||||||
sr.w = event.x() - sr.x
|
sr.w = event.x() - sr.x
|
||||||
sr.h = event.y() - sr.y
|
sr.h = event.y() - sr.y
|
||||||
|
|
||||||
# Enforce min size
|
|
||||||
sr.w = max(sr.w, 10)
|
sr.w = max(sr.w, 10)
|
||||||
sr.h = max(sr.h, 10)
|
sr.h = max(sr.h, 10)
|
||||||
|
|
||||||
self.update() # repaint
|
self.update()
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
def mouseReleaseEvent(self, event):
|
||||||
if not self.edit_mode:
|
if not self.edit_mode:
|
||||||
@ -237,12 +279,13 @@ class OverlayCanvas(QWidget):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# BorealisOverlay Class
|
# BorealisOverlay Class
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
class BorealisOverlay(QWidget):
|
class BorealisOverlay(QWidget):
|
||||||
"""
|
"""
|
||||||
MAIN APPLICATION LOGIC:
|
Single Region Overlay for Player Stats (HP/MP/FP/EXP) with:
|
||||||
-----------------------
|
- Automatic location via OpenCV template matching at startup
|
||||||
Single Region Overlay for Player Stats (HP/MP/FP/EXP) with OCR scanning.
|
- OCR scanning
|
||||||
|
- Low-HP beep
|
||||||
|
- Rich Live updates in terminal
|
||||||
"""
|
"""
|
||||||
def __init__(self, live=None):
|
def __init__(self, live=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -251,9 +294,24 @@ class BorealisOverlay(QWidget):
|
|||||||
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||||
|
|
||||||
# Single region, with an increased height (120)
|
# Try to find the bars automatically
|
||||||
region = Region(250, 50, label="Character Status")
|
# If found => use that location, else default
|
||||||
region.h = 120
|
initial_x, initial_y = 250, 50
|
||||||
|
region_w, region_h = DEFAULT_WIDTH, DEFAULT_HEIGHT
|
||||||
|
|
||||||
|
match_result = locate_bars_opencv(TEMPLATE_PATH, MATCH_THRESHOLD)
|
||||||
|
if match_result is not None:
|
||||||
|
found_x, found_y, w, h = match_result
|
||||||
|
print(f"Template matched at {found_x}, {found_y} with confidence >= {MATCH_THRESHOLD}.")
|
||||||
|
initial_x, initial_y = found_x, found_y
|
||||||
|
# Optionally override region size with template size
|
||||||
|
region_w, region_h = w, h
|
||||||
|
else:
|
||||||
|
print("No high-confidence match found. Using default region.")
|
||||||
|
|
||||||
|
region = Region(initial_x, initial_y, label="Character Status")
|
||||||
|
region.w = region_w
|
||||||
|
region.h = region_h
|
||||||
self.regions = [region]
|
self.regions = [region]
|
||||||
|
|
||||||
self.canvas = OverlayCanvas(self.regions, self)
|
self.canvas = OverlayCanvas(self.regions, self)
|
||||||
@ -265,7 +323,6 @@ class BorealisOverlay(QWidget):
|
|||||||
# Keep history of EXP data
|
# Keep history of EXP data
|
||||||
self.points = []
|
self.points = []
|
||||||
|
|
||||||
# We will store a reference to Rich.Live here
|
|
||||||
self.live = live
|
self.live = live
|
||||||
|
|
||||||
# Timer for periodic OCR scanning
|
# Timer for periodic OCR scanning
|
||||||
@ -274,121 +331,69 @@ class BorealisOverlay(QWidget):
|
|||||||
self.timer.start(POLLING_RATE_MS)
|
self.timer.start(POLLING_RATE_MS)
|
||||||
|
|
||||||
def set_live(self, live):
|
def set_live(self, live):
|
||||||
"""
|
|
||||||
Called by main() so we can update the Live object from inside this class.
|
|
||||||
"""
|
|
||||||
self.live = live
|
self.live = live
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# OCR
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
def collect_ocr_data(self):
|
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:
|
for region in self.regions:
|
||||||
if region.visible:
|
if region.visible:
|
||||||
screenshot = ImageGrab.grab(
|
screenshot = ImageGrab.grab(
|
||||||
bbox=(region.x, region.y, region.x + region.w, region.y + region.h)
|
bbox=(region.x, region.y, region.x + region.w, region.y + region.h)
|
||||||
)
|
)
|
||||||
processed = self.preprocess_image(screenshot)
|
processed = self.preprocess_image(screenshot)
|
||||||
|
|
||||||
# Use psm=4 for multi-line
|
|
||||||
text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
|
text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
|
||||||
region.data = text.strip()
|
region.data = text.strip()
|
||||||
|
|
||||||
# Instead of printing directly, we now build a Rich renderable and update Live.
|
|
||||||
if self.live is not None:
|
if self.live is not None:
|
||||||
renderable = self.build_renderable()
|
renderable = self.build_renderable()
|
||||||
self.live.update(renderable)
|
self.live.update(renderable)
|
||||||
|
|
||||||
def preprocess_image(self, image):
|
def preprocess_image(self, image):
|
||||||
"""
|
|
||||||
INFORMATION PROCESSING:
|
|
||||||
-----------------------
|
|
||||||
Convert to grayscale, scale up, threshold, median filter
|
|
||||||
for improved Tesseract accuracy.
|
|
||||||
"""
|
|
||||||
gray = image.convert("L")
|
gray = image.convert("L")
|
||||||
scaled = gray.resize((gray.width * 3, gray.height * 3))
|
scaled = gray.resize((gray.width * 3, gray.height * 3))
|
||||||
thresh = scaled.point(lambda p: p > 200 and 255)
|
thresh = scaled.point(lambda p: p > 200 and 255)
|
||||||
return thresh.filter(ImageFilter.MedianFilter(3))
|
return thresh.filter(ImageFilter.MedianFilter(3))
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Parsing
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
def parse_all_stats(self, raw_text):
|
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".
|
|
||||||
"""
|
|
||||||
raw_lines = raw_text.splitlines()
|
raw_lines = raw_text.splitlines()
|
||||||
lines = [l.strip() for l in raw_lines if l.strip()] # remove empty lines
|
lines = [l.strip() for l in raw_lines if l.strip()]
|
||||||
|
|
||||||
stats_dict = {
|
stats_dict = {
|
||||||
"hp": (0, 1),
|
"hp": (0,1),
|
||||||
"mp": (0, 1),
|
"mp": (0,1),
|
||||||
"fp": (0, 1),
|
"fp": (0,1),
|
||||||
"exp": None
|
"exp": None
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(lines) < 4:
|
if len(lines) < 4:
|
||||||
return stats_dict
|
return stats_dict
|
||||||
|
|
||||||
# HP
|
|
||||||
hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0])
|
hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0])
|
||||||
if hp_match:
|
if hp_match:
|
||||||
stats_dict["hp"] = (int(hp_match.group(1)), int(hp_match.group(2)))
|
stats_dict["hp"] = (int(hp_match.group(1)), int(hp_match.group(2)))
|
||||||
|
|
||||||
# MP
|
|
||||||
mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1])
|
mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1])
|
||||||
if mp_match:
|
if mp_match:
|
||||||
stats_dict["mp"] = (int(mp_match.group(1)), int(mp_match.group(2)))
|
stats_dict["mp"] = (int(mp_match.group(1)), int(mp_match.group(2)))
|
||||||
|
|
||||||
# FP
|
|
||||||
fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2])
|
fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2])
|
||||||
if fp_match:
|
if fp_match:
|
||||||
stats_dict["fp"] = (int(fp_match.group(1)), int(fp_match.group(2)))
|
stats_dict["fp"] = (int(fp_match.group(1)), int(fp_match.group(2)))
|
||||||
|
|
||||||
# EXP
|
|
||||||
exp_val = sanitize_experience_string(lines[3])
|
exp_val = sanitize_experience_string(lines[3])
|
||||||
stats_dict["exp"] = exp_val
|
stats_dict["exp"] = exp_val
|
||||||
|
|
||||||
return stats_dict
|
return stats_dict
|
||||||
|
|
||||||
def update_points(self, new_val):
|
def update_points(self, new_val):
|
||||||
"""
|
|
||||||
INFORMATION TRACKING:
|
|
||||||
---------------------
|
|
||||||
Track historical EXP changes for table & predicted time to level.
|
|
||||||
"""
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if self.points:
|
if self.points:
|
||||||
_, last_v = self.points[-1]
|
_, last_v = self.points[-1]
|
||||||
# skip duplicates
|
|
||||||
if abs(new_val - last_v) < 1e-6:
|
if abs(new_val - last_v) < 1e-6:
|
||||||
return
|
return
|
||||||
# if new_val < last_v, assume rollover
|
|
||||||
if new_val < last_v:
|
if new_val < last_v:
|
||||||
self.points.clear()
|
self.points.clear()
|
||||||
self.points.append((now, new_val))
|
self.points.append((now, new_val))
|
||||||
if len(self.points) > MAX_DATA_POINTS:
|
if len(self.points) > MAX_DATA_POINTS:
|
||||||
self.points.pop(0)
|
self.points.pop(0)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Display Logic
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
def compute_time_to_100(self):
|
def compute_time_to_100(self):
|
||||||
"""
|
|
||||||
INFORMATION PREDICTION:
|
|
||||||
-----------------------
|
|
||||||
Estimate time to reach 100% from current EXP data.
|
|
||||||
"""
|
|
||||||
n = len(self.points)
|
n = len(self.points)
|
||||||
if n < 2:
|
if n < 2:
|
||||||
return None
|
return None
|
||||||
@ -416,16 +421,6 @@ class BorealisOverlay(QWidget):
|
|||||||
return int(remain / rate_per_s)
|
return int(remain / rate_per_s)
|
||||||
|
|
||||||
def build_renderable(self):
|
def build_renderable(self):
|
||||||
"""
|
|
||||||
INFORMATION DISPLAY (Rich):
|
|
||||||
---------------------------
|
|
||||||
Construct a single Rich renderable (Group) that includes:
|
|
||||||
- Title
|
|
||||||
- HP/MP/FP progress bars
|
|
||||||
- Historical EXP table
|
|
||||||
- Predicted time progress bar
|
|
||||||
"""
|
|
||||||
# Gather stats from first region
|
|
||||||
raw_text = self.regions[0].data
|
raw_text = self.regions[0].data
|
||||||
stats = self.parse_all_stats(raw_text)
|
stats = self.parse_all_stats(raw_text)
|
||||||
hp_cur, hp_max = stats["hp"]
|
hp_cur, hp_max = stats["hp"]
|
||||||
@ -433,57 +428,37 @@ class BorealisOverlay(QWidget):
|
|||||||
fp_cur, fp_max = stats["fp"]
|
fp_cur, fp_max = stats["fp"]
|
||||||
exp_val = stats["exp"]
|
exp_val = stats["exp"]
|
||||||
|
|
||||||
# Update historical EXP points if valid
|
# HP beep logic
|
||||||
|
if hp_max > 0:
|
||||||
|
hp_ratio = hp_cur / hp_max
|
||||||
|
if 0 < hp_ratio <= 0.40:
|
||||||
|
beep_hp_warning()
|
||||||
|
|
||||||
if exp_val is not None:
|
if exp_val is not None:
|
||||||
self.update_points(exp_val)
|
self.update_points(exp_val)
|
||||||
current_exp = self.points[-1][1] if self.points else 0.0
|
current_exp = self.points[-1][1] if self.points else 0.0
|
||||||
|
|
||||||
# ---------------------
|
# Title
|
||||||
# 1) Title Section
|
|
||||||
# ---------------------
|
|
||||||
title_text = Text("Project Borealis\n", style="bold white")
|
title_text = Text("Project Borealis\n", style="bold white")
|
||||||
subtitle_text = Text("Flyff Information Overlay\n\n", style="dim")
|
subtitle_text = Text("Flyff Information Overlay\n\n", style="dim")
|
||||||
|
|
||||||
# ---------------------
|
# HP / MP / FP bars
|
||||||
# 2) HP / MP / FP Bars
|
|
||||||
# ---------------------
|
|
||||||
bar_progress = Progress(
|
bar_progress = Progress(
|
||||||
"{task.description}",
|
"{task.description}",
|
||||||
BarColumn(bar_width=30),
|
BarColumn(bar_width=30),
|
||||||
TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"),
|
TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"),
|
||||||
transient=False,
|
transient=False,
|
||||||
auto_refresh=False # We'll refresh after all tasks are added
|
auto_refresh=False
|
||||||
)
|
|
||||||
|
|
||||||
# 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.add_task("[bold red]HP[/bold red]", total=hp_max, completed=hp_cur,
|
||||||
|
style="red", complete_style="red")
|
||||||
|
bar_progress.add_task("[bold blue]MP[/bold blue]", total=mp_max, completed=mp_cur,
|
||||||
|
style="blue", complete_style="blue")
|
||||||
|
bar_progress.add_task("[bold green]FP[/bold green]", total=fp_max, completed=fp_cur,
|
||||||
|
style="green", complete_style="green")
|
||||||
bar_progress.refresh()
|
bar_progress.refresh()
|
||||||
|
|
||||||
# ---------------------
|
# Historical EXP table
|
||||||
# 3) Historical EXP Table
|
|
||||||
# ---------------------
|
|
||||||
table = Table(show_header=True, header_style=GREEN_HEADER_STYLE, style=None)
|
table = Table(show_header=True, header_style=GREEN_HEADER_STYLE, style=None)
|
||||||
table.add_column("Historical EXP", justify="center", style="green")
|
table.add_column("Historical EXP", justify="center", style="green")
|
||||||
table.add_column("Time Since Last Kill", justify="center", style="green")
|
table.add_column("Time Since Last Kill", justify="center", style="green")
|
||||||
@ -503,7 +478,6 @@ class BorealisOverlay(QWidget):
|
|||||||
t_prev, v_prev = self.points[i - 1]
|
t_prev, v_prev = self.points[i - 1]
|
||||||
delta_v = v_cur - v_prev
|
delta_v = v_cur - v_prev
|
||||||
delta_str = f"{delta_v:+.4f}%"
|
delta_str = f"{delta_v:+.4f}%"
|
||||||
|
|
||||||
exp_main = format_experience_value(v_cur)
|
exp_main = format_experience_value(v_cur)
|
||||||
exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]"
|
exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]"
|
||||||
|
|
||||||
@ -520,9 +494,7 @@ class BorealisOverlay(QWidget):
|
|||||||
|
|
||||||
table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str)
|
table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str)
|
||||||
|
|
||||||
# ---------------------
|
# Predicted Time to Level
|
||||||
# 4) Predicted Time to Level
|
|
||||||
# ---------------------
|
|
||||||
secs_left = self.compute_time_to_100()
|
secs_left = self.compute_time_to_100()
|
||||||
time_str = format_duration(secs_left)
|
time_str = format_duration(secs_left)
|
||||||
|
|
||||||
@ -534,45 +506,35 @@ class BorealisOverlay(QWidget):
|
|||||||
transient=False,
|
transient=False,
|
||||||
auto_refresh=False
|
auto_refresh=False
|
||||||
)
|
)
|
||||||
t_task = time_bar.add_task("", total=100, completed=current_exp)
|
time_bar.add_task("", total=100, completed=current_exp)
|
||||||
time_bar.refresh()
|
time_bar.refresh()
|
||||||
|
|
||||||
# Combine everything into a Rich Group
|
|
||||||
# Title + Subtitle + HP/MP/FP Progress + Table + Time Bar
|
|
||||||
return Group(
|
return Group(
|
||||||
title_text,
|
title_text,
|
||||||
subtitle_text,
|
subtitle_text,
|
||||||
bar_progress, # HP/MP/FP
|
bar_progress,
|
||||||
table,
|
table,
|
||||||
time_bar # predicted-time progress
|
time_bar
|
||||||
)
|
)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# main()
|
# main
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""
|
"""
|
||||||
LAUNCH SEQUENCE:
|
1) Attempt to locate HP/MP/FP/Exp bars using OpenCV template matching.
|
||||||
---------------
|
2) Position overlay region accordingly if found, else default.
|
||||||
1) Create QApplication.
|
3) Start PyQt, periodically OCR the region, update Rich Live in terminal.
|
||||||
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)
|
app = QApplication(sys.argv)
|
||||||
window = BorealisOverlay() # We'll inject Live momentarily
|
window = BorealisOverlay()
|
||||||
|
|
||||||
window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)")
|
window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)")
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
# Use a Live context manager so we can do partial updates
|
|
||||||
with Live(console=console, refresh_per_second=4) as live:
|
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)
|
window.set_live(live)
|
||||||
# Run the PyQt event loop (blocking)
|
|
||||||
exit_code = app.exec_()
|
exit_code = app.exec_()
|
||||||
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user