diff --git a/Modules/__pycache__/data_collector.cpython-312.pyc b/Modules/__pycache__/data_collector.cpython-312.pyc new file mode 100644 index 0000000..bb082f3 Binary files /dev/null and b/Modules/__pycache__/data_collector.cpython-312.pyc differ diff --git a/Modules/__pycache__/data_manager.cpython-312.pyc b/Modules/__pycache__/data_manager.cpython-312.pyc new file mode 100644 index 0000000..16fcb0f Binary files /dev/null and b/Modules/__pycache__/data_manager.cpython-312.pyc differ diff --git a/Modules/data_collector.py b/Modules/data_collector.py index da9a44e..2144aa8 100644 --- a/Modules/data_collector.py +++ b/Modules/data_collector.py @@ -1,326 +1,206 @@ -#!/usr/bin/env python3 +# Modules/data_collector.py + +import threading import time import re -import threading import numpy as np import cv2 import pytesseract -from flask import Flask, jsonify from PIL import Image, ImageGrab, ImageFilter -from PyQt5.QtWidgets import QApplication, QWidget -from PyQt5.QtCore import QTimer, QRect, QPoint, Qt, QMutex + +from PyQt5.QtWidgets import QWidget, QApplication +from PyQt5.QtCore import QRect, QPoint, Qt, QMutex, QTimer from PyQt5.QtGui import QPainter, QPen, QColor, QFont pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" -# ============================================================================= -# Global Config -# ============================================================================= - -POLLING_RATE_MS = 500 -MAX_DATA_POINTS = 8 DEFAULT_WIDTH = 180 DEFAULT_HEIGHT = 130 HANDLE_SIZE = 8 LABEL_HEIGHT = 20 -# Template Matching Threshold (Define it here) -MATCH_THRESHOLD = 0.4 # Set to 0.4 as a typical value for correlation threshold +collector_mutex = QMutex() +regions = {} +overlay_window = None -# Flask API setup -app = Flask(__name__) - -# **Shared Region Data (Thread-Safe)** -region_lock = QMutex() # Mutex to synchronize access between UI and OCR thread -shared_region = { - "x": 250, - "y": 50, - "w": DEFAULT_WIDTH, - "h": DEFAULT_HEIGHT -} - -# Global variable for OCR data -latest_data = { - "hp_current": 0, - "hp_total": 0, - "mp_current": 0, - "mp_total": 0, - "fp_current": 0, - "fp_total": 0, - "exp": 0.0000 -} - -# ============================================================================= -# OCR Data Collection -# ============================================================================= - -def preprocess_image(image): - """ - Preprocess the image for OCR: convert to grayscale, resize, and apply thresholding. - """ - gray = image.convert("L") # Convert to grayscale - scaled = gray.resize((gray.width * 3, gray.height * 3)) # Upscale the image for better accuracy - thresh = scaled.point(lambda p: p > 200 and 255) # Apply a threshold to clean up the image - return thresh.filter(ImageFilter.MedianFilter(3)) # Apply a median filter to remove noise - -def sanitize_experience_string(raw_text): - text_no_percent = raw_text.replace('%', '') - text_no_spaces = text_no_percent.replace(' ', '') - cleaned = re.sub(r'[^0-9\.]', '', text_no_spaces) - match = re.search(r'\d+(?:\.\d+)?', cleaned) - if not match: - return None - val = float(match.group(0)) - if val < 0: - val = 0 - elif val > 100: - val = 100 - return round(val, 4) - -def locate_bars_opencv(template_path, threshold=MATCH_THRESHOLD): - """ - Attempt to locate the bars via OpenCV template matching. - """ - screenshot_pil = ImageGrab.grab() - screenshot_np = np.array(screenshot_pil) - screenshot_bgr = cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR) - template_bgr = cv2.imread(template_path, cv2.IMREAD_COLOR) - if template_bgr is None: - return None - result = cv2.matchTemplate(screenshot_bgr, template_bgr, cv2.TM_CCOEFF_NORMED) - min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) - th, tw, _ = template_bgr.shape - if max_val >= threshold: - found_x, found_y = max_loc - return (found_x, found_y, tw, th) - else: - return None - -def parse_all_stats(raw_text): - raw_lines = raw_text.splitlines() - lines = [l.strip() for l in raw_lines if l.strip()] - stats_dict = { - "hp": (0,1), - "mp": (0,1), - "fp": (0,1), - "exp": None +def create_ocr_region(region_id, x=250, y=50, w=DEFAULT_WIDTH, h=DEFAULT_HEIGHT): + collector_mutex.lock() + if region_id in regions: + collector_mutex.unlock() + return + regions[region_id] = { + 'bbox': [x, y, w, h], + 'raw_text': "" } - if len(lines) < 4: - return stats_dict + collector_mutex.unlock() + _ensure_overlay() - hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0]) - if hp_match: - stats_dict["hp"] = (int(hp_match.group(1)), int(hp_match.group(2))) +def get_raw_text(region_id): + collector_mutex.lock() + if region_id not in regions: + collector_mutex.unlock() + return "" + text = regions[region_id]['raw_text'] + collector_mutex.unlock() + return text - mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1]) - if mp_match: - stats_dict["mp"] = (int(mp_match.group(1)), int(mp_match.group(2))) +def start_collector(): + t = threading.Thread(target=_update_ocr_loop, daemon=True) + t.start() - fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2]) - if fp_match: - stats_dict["fp"] = (int(fp_match.group(1)), int(fp_match.group(2))) - - exp_val = sanitize_experience_string(lines[3]) - stats_dict["exp"] = exp_val - return stats_dict - -# ============================================================================= -# Region & UI -# ============================================================================= - -class Region: - """ - Defines a draggable/resizable screen region for OCR capture. - """ - def __init__(self, x, y, label="Region", color=QColor(0, 0, 255)): - self.x = x - self.y = y - self.w = DEFAULT_WIDTH - self.h = DEFAULT_HEIGHT - self.label = label - self.color = color - self.visible = True - self.data = "" - - def rect(self): - return QRect(self.x, self.y, self.w, self.h) - - def label_rect(self): - return QRect(self.x, self.y - LABEL_HEIGHT, self.w, LABEL_HEIGHT) - - def resize_handles(self): - return [ - 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), - 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), - ] - -# ============================================================================= -# Flask API Server -# ============================================================================= - -@app.route('/data') -def get_data(): - """Returns the latest OCR data as JSON.""" - return jsonify(latest_data) - -def collect_ocr_data(): - """ - Collects OCR data every 0.5 seconds and updates global latest_data. - """ - global latest_data +def _update_ocr_loop(): while True: - # **Fetch updated region values from UI (thread-safe)** - region_lock.lock() # Lock for thread safety - x, y, w, h = shared_region["x"], shared_region["y"], shared_region["w"], shared_region["h"] - region_lock.unlock() + collector_mutex.lock() + region_ids = list(regions.keys()) + collector_mutex.unlock() - # **Grab the image of the updated region** - screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h)) + for rid in region_ids: + collector_mutex.lock() + bbox = regions[rid]['bbox'][:] + collector_mutex.unlock() - # **Debug: Save screenshots to verify capture** - screenshot.save("debug_screenshot.png") + x, y, w, h = bbox + screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h)) + processed = _preprocess_image(screenshot) + raw_text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1') - # Preprocess image - processed = preprocess_image(screenshot) - processed.save("debug_processed.png") # Debug: Save processed image + collector_mutex.lock() + if rid in regions: + regions[rid]['raw_text'] = raw_text + collector_mutex.unlock() - # Run OCR - text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1') + time.sleep(0.7) - stats = parse_all_stats(text.strip()) - hp_cur, hp_max = stats["hp"] - mp_cur, mp_max = stats["mp"] - fp_cur, fp_max = stats["fp"] - exp_val = stats["exp"] - - # Update latest data - latest_data = { - "hp_current": hp_cur, - "hp_total": hp_max, - "mp_current": mp_cur, - "mp_total": mp_max, - "fp_current": fp_cur, - "fp_total": fp_max, - "exp": exp_val - } +def _preprocess_image(image): + gray = image.convert("L") + scaled = gray.resize((gray.width * 3, gray.height * 3)) + thresh = scaled.point(lambda p: 255 if p > 200 else 0) + return thresh.filter(ImageFilter.MedianFilter(3)) - # DEBUG OUTPUT - print(f"Flyff - Character Status: HP: {hp_cur}/{hp_max}, MP: {mp_cur}/{mp_max}, FP: {fp_cur}/{fp_max}, EXP: {exp_val}%") +def _ensure_overlay(): + """ + Creates the overlay window if none exists. + If no QApplication instance is running yet, schedule the creation after + the main application event loop starts (to avoid "Must construct a QApplication first" errors). + """ + global overlay_window + if overlay_window is not None: + return - time.sleep(0.5) + # If there's already a running QApplication, create overlay immediately. + if QApplication.instance() is not None: + overlay_window = OverlayCanvas() + overlay_window.show() + else: + # Schedule creation for when the app event loop is up. + def delayed_create(): + global overlay_window + if overlay_window is None: + overlay_window = OverlayCanvas() + overlay_window.show() -# ============================================================================= -# OverlayCanvas (UI) -# ============================================================================= + QTimer.singleShot(0, delayed_create) class OverlayCanvas(QWidget): - """ - UI overlay that allows dragging/resizing of the OCR region. - """ def __init__(self, parent=None): super().__init__(parent) - - # **Full-screen overlay** screen_geo = QApplication.primaryScreen().geometry() - self.setGeometry(screen_geo) # Set to full screen - + self.setGeometry(screen_geo) self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.setAttribute(Qt.WA_TranslucentBackground, True) - # **Load shared region** - self.region = shared_region self.drag_offset = None self.selected_handle = None + self.selected_region_id = None def paintEvent(self, event): - """Draw the blue OCR region.""" painter = QPainter(self) pen = QPen(QColor(0, 0, 255)) - pen.setWidth(5) # Thicker lines + pen.setWidth(5) painter.setPen(pen) - painter.drawRect(self.region["x"], self.region["y"], self.region["w"], self.region["h"]) - painter.setFont(QFont("Arial", 12, QFont.Bold)) - painter.setPen(QColor(0, 0, 255)) - painter.drawText(self.region["x"], self.region["y"] - 5, "Character Status") + + collector_mutex.lock() + region_copy = {rid: data['bbox'][:] for rid, data in regions.items()} + collector_mutex.unlock() + + for rid, bbox in region_copy.items(): + x, y, w, h = bbox + painter.drawRect(x, y, w, h) + painter.setFont(QFont("Arial", 12, QFont.Bold)) + painter.setPen(QColor(0, 0, 255)) + painter.drawText(x, y - 5, f"OCR Region: {rid}") def mousePressEvent(self, event): - """Handle drag and resize interactions.""" if event.button() == Qt.LeftButton: - region_lock.lock() # Lock for thread safety - x, y, w, h = self.region["x"], self.region["y"], self.region["w"], self.region["h"] - region_lock.unlock() + collector_mutex.lock() + all_items = list(regions.items()) + collector_mutex.unlock() - for i, handle in enumerate(self.resize_handles()): - if handle.contains(event.pos()): - self.selected_handle = i + for rid, data in all_items: + x, y, w, h = data['bbox'] + handles = self._resize_handles(x, y, w, h) + for i, handle_rect in enumerate(handles): + if handle_rect.contains(event.pos()): + self.selected_handle = i + self.selected_region_id = rid + return + if QRect(x, y, w, h).contains(event.pos()): + self.drag_offset = event.pos() - QPoint(x, y) + self.selected_region_id = rid return - if QRect(x, y, w, h).contains(event.pos()): - self.drag_offset = event.pos() - QPoint(x, y) - def mouseMoveEvent(self, event): - """Allow dragging and resizing.""" + if not self.selected_region_id: + return + collector_mutex.lock() + if self.selected_region_id not in regions: + collector_mutex.unlock() + return + bbox = regions[self.selected_region_id]['bbox'] + collector_mutex.unlock() + + x, y, w, h = bbox if self.selected_handle is not None: - region_lock.lock() - sr = self.region - if self.selected_handle == 0: # Top-left - sr["w"] += sr["x"] - event.x() - sr["h"] += sr["y"] - event.y() - sr["x"] = event.x() - sr["y"] = event.y() - elif self.selected_handle == 1: # Bottom-right - sr["w"] = event.x() - sr["x"] - sr["h"] = event.y() - sr["y"] - - sr["w"] = max(sr["w"], 10) - sr["h"] = max(sr["h"], 10) - region_lock.unlock() - + # resizing + if self.selected_handle == 0: # top-left + new_w = w + (x - event.x()) + new_h = h + (y - event.y()) + new_x = event.x() + new_y = event.y() + if new_w < 10: new_w = 10 + if new_h < 10: new_h = 10 + collector_mutex.lock() + if self.selected_region_id in regions: + regions[self.selected_region_id]['bbox'] = [new_x, new_y, new_w, new_h] + collector_mutex.unlock() + elif self.selected_handle == 1: # bottom-right + new_w = event.x() - x + new_h = event.y() - y + if new_w < 10: new_w = 10 + if new_h < 10: new_h = 10 + collector_mutex.lock() + if self.selected_region_id in regions: + regions[self.selected_region_id]['bbox'] = [x, y, new_w, new_h] + collector_mutex.unlock() self.update() - elif self.drag_offset: - region_lock.lock() - self.region["x"] = event.x() - self.drag_offset.x() - self.region["y"] = event.y() - self.drag_offset.y() - region_lock.unlock() - + # dragging + new_x = event.x() - self.drag_offset.x() + new_y = event.y() - self.drag_offset.y() + collector_mutex.lock() + if self.selected_region_id in regions: + regions[self.selected_region_id]['bbox'][0] = new_x + regions[self.selected_region_id]['bbox'][1] = new_y + collector_mutex.unlock() self.update() def mouseReleaseEvent(self, event): - """End drag or resize event.""" self.selected_handle = None self.drag_offset = None + self.selected_region_id = None - def resize_handles(self): - """Get the resizing handles of the region.""" + def _resize_handles(self, x, y, w, h): return [ - QRect(self.region["x"] - HANDLE_SIZE // 2, self.region["y"] - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE), - QRect(self.region["x"] + self.region["w"] - HANDLE_SIZE // 2, self.region["y"] + self.region["h"] - HANDLE_SIZE // 2, HANDLE_SIZE, HANDLE_SIZE) + QRect(x - HANDLE_SIZE//2, y - HANDLE_SIZE//2, HANDLE_SIZE, HANDLE_SIZE), # top-left + QRect(x + w - HANDLE_SIZE//2, y + h - HANDLE_SIZE//2, HANDLE_SIZE, HANDLE_SIZE) # bottom-right ] - -# ============================================================================= -# Start Application -# ============================================================================= - -def run_flask_app(): - """Runs the Flask API server in a separate thread.""" - app.run(host="127.0.0.1", port=5000) - -if __name__ == '__main__': - # Start the OCR thread - collector_thread = threading.Thread(target=collect_ocr_data, daemon=True) - collector_thread.start() - - # Start the Flask API thread - flask_thread = threading.Thread(target=run_flask_app, daemon=True) - flask_thread.start() - - # Start PyQt5 GUI - app_gui = QApplication([]) - overlay_window = OverlayCanvas() - overlay_window.show() - - # Run event loop - app_gui.exec_() diff --git a/Modules/data_manager.py b/Modules/data_manager.py new file mode 100644 index 0000000..72e9521 --- /dev/null +++ b/Modules/data_manager.py @@ -0,0 +1,66 @@ +# Modules/data_manager.py +import threading +import time +from flask import Flask, jsonify +from PyQt5.QtCore import QMutex + +# Global datastore for character metrics +data_store = { + "hp_current": 0, + "hp_total": 0, + "mp_current": 0, + "mp_total": 0, + "fp_current": 0, + "fp_total": 0, + "exp": 0.0 +} + +# Mutex for thread safety +data_mutex = QMutex() + +# Flag to ensure only one character status collector node exists +character_status_collector_exists = False + +# Flask Application +app = Flask(__name__) + +@app.route('/data') +def data_api(): + """ + Returns the current character metrics as JSON. + """ + return jsonify(get_data()) + +def start_api_server(): + """ + Starts the Flask API server in a separate daemon thread. + """ + def run(): + app.run(host="127.0.0.1", port=5000) + t = threading.Thread(target=run, daemon=True) + t.start() + +def get_data(): + """ + Return a copy of the global data_store. + """ + data_mutex.lock() + data_copy = data_store.copy() + data_mutex.unlock() + return data_copy + +def set_data(key, value): + """ + Set a single metric in the global data_store. + """ + data_mutex.lock() + data_store[key] = value + data_mutex.unlock() + +def set_data_bulk(metrics_dict): + """ + Update multiple metrics in the global data_store at once. + """ + data_mutex.lock() + data_store.update(metrics_dict) + data_mutex.unlock() diff --git a/Nodes/Flyff/__pycache__/flyff_EXP_current.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_EXP_current.cpython-312.pyc new file mode 100644 index 0000000..84bb711 Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_EXP_current.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_FP_current.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_FP_current.cpython-312.pyc new file mode 100644 index 0000000..01a998e Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_FP_current.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_FP_total.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_FP_total.cpython-312.pyc new file mode 100644 index 0000000..8f157eb Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_FP_total.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_HP_current.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_HP_current.cpython-312.pyc new file mode 100644 index 0000000..c81788a Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_HP_current.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_HP_total.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_HP_total.cpython-312.pyc new file mode 100644 index 0000000..4c3b6e7 Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_HP_total.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_MP_current.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_MP_current.cpython-312.pyc new file mode 100644 index 0000000..347d4ce Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_MP_current.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_MP_total.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_MP_total.cpython-312.pyc new file mode 100644 index 0000000..815f1a6 Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_MP_total.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_character_status_node.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_character_status_node.cpython-312.pyc new file mode 100644 index 0000000..fa3bce4 Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_character_status_node.cpython-312.pyc differ diff --git a/Nodes/Flyff/__pycache__/flyff_low_health_alert_node.cpython-312.pyc b/Nodes/Flyff/__pycache__/flyff_low_health_alert_node.cpython-312.pyc new file mode 100644 index 0000000..4439490 Binary files /dev/null and b/Nodes/Flyff/__pycache__/flyff_low_health_alert_node.cpython-312.pyc differ diff --git a/Nodes/flyff_EXP_current.py b/Nodes/Flyff/flyff_EXP_current.py similarity index 100% rename from Nodes/flyff_EXP_current.py rename to Nodes/Flyff/flyff_EXP_current.py diff --git a/Nodes/flyff_FP_current.py b/Nodes/Flyff/flyff_FP_current.py similarity index 100% rename from Nodes/flyff_FP_current.py rename to Nodes/Flyff/flyff_FP_current.py diff --git a/Nodes/flyff_FP_total.py b/Nodes/Flyff/flyff_FP_total.py similarity index 100% rename from Nodes/flyff_FP_total.py rename to Nodes/Flyff/flyff_FP_total.py diff --git a/Nodes/flyff_HP_current.py b/Nodes/Flyff/flyff_HP_current.py similarity index 100% rename from Nodes/flyff_HP_current.py rename to Nodes/Flyff/flyff_HP_current.py diff --git a/Nodes/flyff_HP_total.py b/Nodes/Flyff/flyff_HP_total.py similarity index 100% rename from Nodes/flyff_HP_total.py rename to Nodes/Flyff/flyff_HP_total.py diff --git a/Nodes/flyff_MP_current.py b/Nodes/Flyff/flyff_MP_current.py similarity index 100% rename from Nodes/flyff_MP_current.py rename to Nodes/Flyff/flyff_MP_current.py diff --git a/Nodes/flyff_MP_total.py b/Nodes/Flyff/flyff_MP_total.py similarity index 100% rename from Nodes/flyff_MP_total.py rename to Nodes/Flyff/flyff_MP_total.py diff --git a/Nodes/Flyff/flyff_character_status_node.py b/Nodes/Flyff/flyff_character_status_node.py new file mode 100644 index 0000000..afd9701 --- /dev/null +++ b/Nodes/Flyff/flyff_character_status_node.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Flyff Character Status Node (New Version): + - Has no inputs/outputs. + - Creates an OCR region in data_collector. + - Periodically grabs raw text from that region, parses it here in the node, + and sets data_manager's HP, MP, FP, EXP accordingly. + - Also updates its own text fields with the parsed values. +""" + +import re +from OdenGraphQt import BaseNode +from PyQt5.QtWidgets import QMessageBox +from Modules import data_manager, data_collector + +class FlyffCharacterStatusNode(BaseNode): + __identifier__ = 'bunny-lab.io.flyff_character_status_node' + NODE_NAME = 'Flyff - Character Status' + + def __init__(self): + super(FlyffCharacterStatusNode, self).__init__() + # Prevent duplicates + if data_manager.character_status_collector_exists: + QMessageBox.critical(None, "Error", "Only one Flyff Character Status Collector node is allowed.") + raise Exception("Duplicate Character Status Node.") + data_manager.character_status_collector_exists = True + + # Add text fields for display + self.add_text_input('hp', 'HP', text="HP: 0/0") + self.add_text_input('mp', 'MP', text="MP: 0/0") + self.add_text_input('fp', 'FP', text="FP: 0/0") + self.add_text_input('exp', 'EXP', text="EXP: 0%") + + # Create a unique region id for this node (or just "character_status") + self.region_id = "character_status" + data_collector.create_ocr_region(self.region_id, x=250, y=50, w=180, h=130) + + # Start the data_collector background thread (if not already started) + data_collector.start_collector() + + # Set the node name + self.set_name("Flyff - Character Status") + + def parse_character_stats(self, raw_text): + """ + Extract HP, MP, FP, EXP from the raw OCR text lines. + """ + lines = [l.strip() for l in raw_text.splitlines() if l.strip()] + hp_current, hp_total = 0, 0 + mp_current, mp_total = 0, 0 + fp_current, fp_total = 0, 0 + exp_value = 0.0 + + if len(lines) >= 4: + # line 1: HP + hp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[0]) + if hp_match: + hp_current = int(hp_match.group(1)) + hp_total = int(hp_match.group(2)) + + # line 2: MP + mp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[1]) + if mp_match: + mp_current = int(mp_match.group(1)) + mp_total = int(mp_match.group(2)) + + # line 3: FP + fp_match = re.search(r"(\d+)\s*/\s*(\d+)", lines[2]) + if fp_match: + fp_current = int(fp_match.group(1)) + fp_total = int(fp_match.group(2)) + + # line 4: EXP + exp_match = re.search(r"(\d+(?:\.\d+)?)", lines[3]) + if exp_match: + val = float(exp_match.group(1)) + if val < 0: val = 0 + if val > 100: val = 100 + exp_value = val + + return hp_current, hp_total, mp_current, mp_total, fp_current, fp_total, exp_value + + def process_input(self): + """ + Called periodically by the global timer in your main application (borealis.py). + """ + # Grab raw text from data_collector + raw_text = data_collector.get_raw_text(self.region_id) + + # Parse it + hp_c, hp_t, mp_c, mp_t, fp_c, fp_t, exp_v = self.parse_character_stats(raw_text) + + # Update data_manager + data_manager.set_data_bulk({ + "hp_current": hp_c, + "hp_total": hp_t, + "mp_current": mp_c, + "mp_total": mp_t, + "fp_current": fp_c, + "fp_total": fp_t, + "exp": exp_v + }) + + # Update the node's text fields + self.set_property('hp', f"HP: {hp_c}/{hp_t}") + self.set_property('mp', f"MP: {mp_c}/{mp_t}") + self.set_property('fp', f"FP: {fp_c}/{fp_t}") + self.set_property('exp', f"EXP: {exp_v}%") diff --git a/Nodes/flyff_low_health_alert_node.py b/Nodes/Flyff/flyff_low_health_alert_node.py similarity index 100% rename from Nodes/flyff_low_health_alert_node.py rename to Nodes/Flyff/flyff_low_health_alert_node.py diff --git a/Nodes/Organization/__pycache__/backdrop_node.cpython-312.pyc b/Nodes/Organization/__pycache__/backdrop_node.cpython-312.pyc new file mode 100644 index 0000000..6280241 Binary files /dev/null and b/Nodes/Organization/__pycache__/backdrop_node.cpython-312.pyc differ diff --git a/Nodes/backdrop_node.py b/Nodes/Organization/backdrop_node.py similarity index 100% rename from Nodes/backdrop_node.py rename to Nodes/Organization/backdrop_node.py diff --git a/Nodes/__pycache__/flyff_character_status_node.cpython-312.pyc b/Nodes/__pycache__/flyff_character_status_node.cpython-312.pyc index 4921b72..379ae78 100644 Binary files a/Nodes/__pycache__/flyff_character_status_node.cpython-312.pyc and b/Nodes/__pycache__/flyff_character_status_node.cpython-312.pyc differ diff --git a/Nodes/__pycache__/widget_node.cpython-312.pyc b/Nodes/__pycache__/widget_node.cpython-312.pyc index 0476609..4f511ec 100644 Binary files a/Nodes/__pycache__/widget_node.cpython-312.pyc and b/Nodes/__pycache__/widget_node.cpython-312.pyc differ diff --git a/Nodes/flyff_character_status_node.py b/Nodes/flyff_character_status_node.py deleted file mode 100644 index d48e9dd..0000000 --- a/Nodes/flyff_character_status_node.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 - -""" -Standardized Flyff Character Status Node: - - Polls an API for character stats and updates values dynamically. - - Uses a global update timer for processing. - - Immediately transmits updated values to connected nodes. - - If the API is unreachable, it will wait for 5 seconds before retrying - and log the error only once per retry period. - - Calls self.view.draw_node() after updating the node name, ensuring - the node's bounding box recalculates even when the API is disconnected. - - Port colors adjusted to match earlier styling. -""" - -from OdenGraphQt import BaseNode -from Qt import QtCore -import requests -import traceback -import time - -class FlyffCharacterStatusNode(BaseNode): - __identifier__ = 'bunny-lab.io.flyff_character_status_node' - NODE_NAME = 'Flyff - Character Status' - - def __init__(self): - super(FlyffCharacterStatusNode, self).__init__() - self.values = { - "HP: Current": "N/A", - "HP: Total": "N/A", - "MP: Current": "N/A", - "MP: Total": "N/A", - "FP: Current": "N/A", - "FP: Total": "N/A", - "EXP": "N/A" - } - - # Set each output with a custom color: - # (Choose values close to the screenshot for each port.) - self.add_output('HP: Current', color=(126, 36, 57)) - self.add_output('HP: Total', color=(126, 36, 57)) - self.add_output('MP: Current', color=(35, 89, 144)) - self.add_output('MP: Total', color=(35, 89, 144)) - self.add_output('FP: Current', color=(36, 116, 32)) - self.add_output('FP: Total', color=(36, 116, 32)) - self.add_output('EXP', color=(48, 116, 143)) - - self.set_name("Flyff - Character Status (API Disconnected)") - self.view.draw_node() # ensure bounding box updates initially - - # Variables to handle API downtime gracefully: - self._api_down = False - self._last_api_attempt = 0 - self._retry_interval = 5 # seconds to wait before retrying after a failure - self._last_error_printed = 0 - - def process_input(self): - current_time = time.time() - # If the API is down, only retry after _retry_interval seconds - if self._api_down and (current_time - self._last_api_attempt < self._retry_interval): - return - - self._last_api_attempt = current_time - try: - response = requests.get("http://127.0.0.1:5000/data", timeout=1) - if response.status_code == 200: - data = response.json() - if isinstance(data, dict): - mapping = { - "hp_current": "HP: Current", - "hp_total": "HP: Total", - "mp_current": "MP: Current", - "mp_total": "MP: Total", - "fp_current": "FP: Current", - "fp_total": "FP: Total", - "exp": "EXP" - } - updated = False - for key, value in data.items(): - if key in mapping: - formatted_key = mapping[key] - if str(value) != self.values.get(formatted_key, None): - self.values[formatted_key] = str(value) - updated = True - self._api_down = False - if updated: - self.set_name("Flyff - Character Status (API Connected)") - self.view.draw_node() # recalc bounding box on connect - self.transmit_data() - else: - if current_time - self._last_error_printed >= self._retry_interval: - print("[ERROR] Unexpected API response format (not a dict):", data) - self._last_error_printed = current_time - self.set_name("Flyff - Character Status (API Disconnected)") - self.view.draw_node() # recalc bounding box on disconnect - self._api_down = True - else: - if current_time - self._last_error_printed >= self._retry_interval: - print(f"[ERROR] API request failed with status code {response.status_code}") - self._last_error_printed = current_time - self.set_name("Flyff - Character Status (API Disconnected)") - self.view.draw_node() - self._api_down = True - except Exception as e: - if current_time - self._last_error_printed >= self._retry_interval: - print("[ERROR] Error polling API in CharacterStatusNode:", str(e)) - self._last_error_printed = current_time - self.set_name("Flyff - Character Status (API Disconnected)") - self.view.draw_node() - self._api_down = True - - def transmit_data(self): - for stat, value in self.values.items(): - port = self.get_output(stat) - if port and port.connected_ports(): - for connected_port in port.connected_ports(): - connected_node = connected_port.node() - if hasattr(connected_node, 'receive_data'): - try: - connected_node.receive_data(value, stat) - except Exception as e: - print(f"[ERROR] Error transmitting data to {connected_node}: {e}") - print("[ERROR] Stack Trace:\n", traceback.format_exc()) - - def receive_data(self, data, source_port_name=None): - # This node only transmits data; it does not receive external data. - pass diff --git a/Nodes/group_node.py b/Nodes/group_node.py deleted file mode 100644 index 9b937b5..0000000 --- a/Nodes/group_node.py +++ /dev/null @@ -1,21 +0,0 @@ -from OdenGraphQt import GroupNode - - -class MyGroupNode(GroupNode): - """ - example test group node with a in port and out port. - """ - - # set a unique node identifier. - __identifier__ = 'nodes.group' - - # set the initial default node name. - NODE_NAME = 'group node' - - def __init__(self): - super(MyGroupNode, self).__init__() - self.set_color(50, 8, 25) - - # create input and output port. - self.add_input('in') - self.add_output('out') \ No newline at end of file diff --git a/Nodes/widget_node.py b/Nodes/widget_node.py deleted file mode 100644 index eff1ac2..0000000 --- a/Nodes/widget_node.py +++ /dev/null @@ -1,155 +0,0 @@ -from OdenGraphQt import BaseNode -from OdenGraphQt.constants import NodePropWidgetEnum -from OdenGraphQt.widgets.node_widgets import NodeLineEditValidatorCheckBox - - -class DropdownMenuNode(BaseNode): - """ - An example node with a embedded added QCombobox menu. - """ - - # unique node identifier. - __identifier__ = 'nodes.widget' - - # initial default node name. - NODE_NAME = 'menu' - - def __init__(self): - super(DropdownMenuNode, self).__init__() - - # create input & output ports - self.add_input('in 1') - self.add_output('out 1') - self.add_output('out 2') - - # create the QComboBox menu. - items = ["item 1", "item 2", "item 3"] - self.add_combo_menu( - "my_menu", - "Menu Test", - items=items, - tooltip="example custom tooltip", - ) - - -class TextInputNode(BaseNode): - """ - An example of a node with a embedded QLineEdit. - """ - - # unique node identifier. - __identifier__ = 'nodes.widget' - - # initial default node name. - NODE_NAME = 'text' - - def __init__(self): - super().__init__() - pattern = r"^[A-Za-z0-9]*$" - placeholder = "" - tooltip = "Valid characters: A-Z a-z 0-9" - is_case_sensitive = True - checkbox_label = "Use Parser?" - - # create input & output ports - self.add_input('in') - self.add_output('out') - - # create QLineEdit text input widget. - self.add_text_input('my_input', 'Text Input', tab='widgets') - - tool_btn_kwargs = { - "func": self._callback, - "tooltip": "Awesome" - } - kwargs = { - "validator": { - "pattern": pattern, - "placeholder": placeholder, - "tooltip": tooltip, - "is_case_insensitive": is_case_sensitive, - "checkbox_visible": True, - "tool_btn_visible": True, - }, - "checkbox_label": checkbox_label, - "tool_btn": tool_btn_kwargs, - } - node_widget = NodeLineEditValidatorCheckBox( - "src_path", - pattern, - placeholder, - tooltip, - is_case_sensitive, - checkbox_label, - checkbox_visible=True, - tool_btn_visible=True, - widget_label="src_path", - parent=self.view, - ) - node_widget.get_custom_widget().set_tool_btn(**tool_btn_kwargs) - self.add_custom_widget( - node_widget, - NodePropWidgetEnum.LINEEDIT_VALIDATOR_CHECKBOX.value, - "widgets", - **kwargs, - ) - - kwargs2 = { - "validator": { - "pattern": pattern, - "placeholder": placeholder, - "tooltip": tooltip, - "is_case_insensitive": is_case_sensitive, - "checkbox_visible": False, - "tool_btn_visible": False, - }, - "checkbox_label": "Check In Luggage?", - "tool_btn": tool_btn_kwargs, - } - node_widget2 = NodeLineEditValidatorCheckBox( - "dst_path", - pattern, - placeholder, - tooltip, - is_case_sensitive, - "Check In Luggage?", - checkbox_visible=False, - tool_btn_visible=False, - widget_label="dst_path", - parent=self.view, - ) - node_widget2.get_custom_widget().set_tool_btn(**tool_btn_kwargs) - node_widget2.set_checkbox_visible(False) - node_widget2.set_tool_btn_visible(False) - self.add_custom_widget( - node_widget2, - NodePropWidgetEnum.LINEEDIT_VALIDATOR_CHECKBOX.value, - "widgets", - **kwargs2, - ) - - def _callback(self): - print(f"YOU HAVE CLICKED ON '{self.NODE_NAME}'") - - -class CheckboxNode(BaseNode): - """ - An example of a node with 2 embedded QCheckBox widgets. - """ - - # set a unique node identifier. - __identifier__ = 'nodes.widget' - - # set the initial default node name. - NODE_NAME = 'checkbox' - - def __init__(self): - super(CheckboxNode, self).__init__() - - # create the checkboxes. - self.add_checkbox('cb_1', '', 'Checkbox 1', True) - self.add_checkbox('cb_2', '', 'Checkbox 2', False) - - # create input and output port. - self.add_input('in', color=(200, 100, 0)) - self.add_output('out', color=(0, 100, 200)) \ No newline at end of file diff --git a/borealis.py b/borealis.py index 18cc76a..dba6bee 100644 --- a/borealis.py +++ b/borealis.py @@ -1,11 +1,29 @@ # -*- coding: utf-8 -*- #!/usr/bin/env python3 -from Qt import QtWidgets, QtCore, QtGui import sys import pkgutil import importlib import inspect +import os + +from Qt import QtWidgets, QtCore, QtGui + +# ------------------------------------------------------------------ +# MONKEY-PATCH to fix "module 'qtpy.QtGui' has no attribute 'QUndoStack'" +# OdenGraphQt tries to do QtGui.QUndoStack(self). +# We'll import QUndoStack from QtWidgets and attach it to QtGui. +try: + from qtpy.QtWidgets import QUndoStack + import qtpy + # Force QtGui.QUndoStack to reference QtWidgets.QUndoStack + qtpy.QtGui.QUndoStack = QUndoStack +except ImportError: + print("WARNING: Could not monkey-patch QUndoStack. You may see an error if OdenGraphQt needs it.") +# ------------------------------------------------------------------ + +# Import your data_manager so we can start the Flask server +from Modules import data_manager # --- BEGIN MONKEY PATCH FOR PIPE COLOR (Optional) --- # If you want custom pipe colors, uncomment this patch: @@ -59,34 +77,78 @@ from OdenGraphQt import NodeGraph, BaseNode def import_nodes_from_folder(package_name): """ - Dynamically import all modules from the given package + Recursively import all modules from the given package and return a list of classes that subclass BaseNode. """ imported_nodes = [] package = importlib.import_module(package_name) - for loader, module_name, is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + "."): - module = importlib.import_module(module_name) - for name, obj in inspect.getmembers(module, inspect.isclass): - if issubclass(obj, BaseNode) and obj.__module__ == module.__name__: - imported_nodes.append(obj) + + # Get the root directory of the package + package_path = package.__path__[0] + + for root, _, files in os.walk(package_path): + rel_path = os.path.relpath(root, package_path).replace(os.sep, '.') + module_prefix = f"{package_name}.{rel_path}" if rel_path != '.' else package_name + + for file in files: + if file.endswith(".py") and file != "__init__.py": + module_name = f"{module_prefix}.{file[:-3]}" + try: + module = importlib.import_module(module_name) + for name, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, BaseNode) and obj.__module__ == module.__name__: + imported_nodes.append(obj) + except Exception as e: + print(f"Failed to import {module_name}: {e}") + return imported_nodes def make_node_command(graph, node_type_str): """ Return a function that creates a node of the given type at the current cursor position. + For the Flyff Character Status Collector node, check if one already exists. + If so, schedule an error message to be shown. + + Also ensure that node creation is delayed until after QApplication is up, to avoid + 'QWidget: Must construct a QApplication before a QWidget' errors. """ - def command(): + def real_create(): + # Check if we are about to create a duplicate Character Status Collector node. + if node_type_str.startswith("bunny-lab.io.flyff_character_status_node"): + for node in graph.all_nodes(): + if node.__class__.__name__ == "FlyffCharacterStatusNode": + # Show error message about duplicates + QtWidgets.QMessageBox.critical( + None, + "Error", + "Only one Flyff Character Status Collector node is allowed. If you added more, things would break (really) badly." + ) + return try: pos = graph.cursor_pos() graph.create_node(node_type_str, pos=pos) except Exception as e: - print(f"Error creating node of type {node_type_str}: {e}") + QtWidgets.QMessageBox.critical(None, "Error", str(e)) + + def command(): + # If there's already a QApplication running, just create the node now. + if QtWidgets.QApplication.instance(): + real_create() + else: + # Otherwise, schedule the node creation for the next event cycle. + QtCore.QTimer.singleShot(0, real_create) + return command if __name__ == "__main__": + # Create the QApplication first app = QtWidgets.QApplication([]) - # Create the NodeGraph controller. + # Start the Flask server from data_manager so /data is always available + data_manager.start_api_server() + + # Create the NodeGraph controller + # (the monkey-patch ensures NodeGraph won't crash if it tries QtGui.QUndoStack(self)) graph = NodeGraph() graph.widget.setWindowTitle("Project Borealis - Flyff Information Overlay") diff --git a/debug_processed.png b/debug_processed.png deleted file mode 100644 index 5e945f8..0000000 Binary files a/debug_processed.png and /dev/null differ diff --git a/debug_screenshot.png b/debug_screenshot.png deleted file mode 100644 index 9fe9c30..0000000 Binary files a/debug_screenshot.png and /dev/null differ