Restructured the project and optimized several nodes.

This commit is contained in:
Nicole Rappe 2025-02-16 18:53:21 -07:00
parent 670ae774ef
commit e30ba4ec4f
32 changed files with 390 additions and 576 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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
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': ""
}
collector_mutex.unlock()
_ensure_overlay()
# 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
}
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
# =============================================================================
# OCR Data Collection
# =============================================================================
def start_collector():
t = threading.Thread(target=_update_ocr_loop, daemon=True)
t.start()
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
}
if len(lines) < 4:
return stats_dict
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)))
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)))
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**
for rid in region_ids:
collector_mutex.lock()
bbox = regions[rid]['bbox'][:]
collector_mutex.unlock()
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')
# **Debug: Save screenshots to verify capture**
screenshot.save("debug_screenshot.png")
collector_mutex.lock()
if rid in regions:
regions[rid]['raw_text'] = raw_text
collector_mutex.unlock()
# Preprocess image
processed = preprocess_image(screenshot)
processed.save("debug_processed.png") # Debug: Save processed image
time.sleep(0.7)
# Run OCR
text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
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))
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
}
# 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}%")
time.sleep(0.5)
# =============================================================================
# OverlayCanvas (UI)
# =============================================================================
class OverlayCanvas(QWidget):
def _ensure_overlay():
"""
UI overlay that allows dragging/resizing of the OCR region.
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).
"""
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.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
def paintEvent(self, event):
"""Draw the blue OCR region."""
painter = QPainter(self)
pen = QPen(QColor(0, 0, 255))
pen.setWidth(5) # Thicker lines
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")
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()
for i, handle in enumerate(self.resize_handles()):
if handle.contains(event.pos()):
self.selected_handle = i
global overlay_window
if overlay_window is not None:
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 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()
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()
self.update()
def mouseReleaseEvent(self, event):
"""End drag or resize event."""
self.selected_handle = None
self.drag_offset = None
def resize_handles(self):
"""Get the resizing handles of the region."""
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)
]
# =============================================================================
# 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([])
# 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()
# Run event loop
app_gui.exec_()
QTimer.singleShot(0, delayed_create)
class OverlayCanvas(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
screen_geo = QApplication.primaryScreen().geometry()
self.setGeometry(screen_geo)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self.drag_offset = None
self.selected_handle = None
self.selected_region_id = None
def paintEvent(self, event):
painter = QPainter(self)
pen = QPen(QColor(0, 0, 255))
pen.setWidth(5)
painter.setPen(pen)
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):
if event.button() == Qt.LeftButton:
collector_mutex.lock()
all_items = list(regions.items())
collector_mutex.unlock()
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
def mouseMoveEvent(self, event):
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:
# 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:
# 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):
self.selected_handle = None
self.drag_offset = None
self.selected_region_id = None
def _resize_handles(self, x, y, w, h):
return [
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
]

66
Modules/data_manager.py Normal file
View File

@ -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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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}%")

View File

@ -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

View File

@ -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')

View File

@ -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))

View File

@ -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__ + "."):
# 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")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB