Restructured the project and optimized several nodes.
This commit is contained in:
parent
670ae774ef
commit
e30ba4ec4f
BIN
Modules/__pycache__/data_collector.cpython-312.pyc
Normal file
BIN
Modules/__pycache__/data_collector.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Modules/__pycache__/data_manager.cpython-312.pyc
Normal file
BIN
Modules/__pycache__/data_manager.cpython-312.pyc
Normal file
Binary file not shown.
@ -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
66
Modules/data_manager.py
Normal 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()
|
BIN
Nodes/Flyff/__pycache__/flyff_EXP_current.cpython-312.pyc
Normal file
BIN
Nodes/Flyff/__pycache__/flyff_EXP_current.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Nodes/Flyff/__pycache__/flyff_FP_current.cpython-312.pyc
Normal file
BIN
Nodes/Flyff/__pycache__/flyff_FP_current.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Nodes/Flyff/__pycache__/flyff_FP_total.cpython-312.pyc
Normal file
BIN
Nodes/Flyff/__pycache__/flyff_FP_total.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Nodes/Flyff/__pycache__/flyff_HP_current.cpython-312.pyc
Normal file
BIN
Nodes/Flyff/__pycache__/flyff_HP_current.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Nodes/Flyff/__pycache__/flyff_HP_total.cpython-312.pyc
Normal file
BIN
Nodes/Flyff/__pycache__/flyff_HP_total.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Nodes/Flyff/__pycache__/flyff_MP_current.cpython-312.pyc
Normal file
BIN
Nodes/Flyff/__pycache__/flyff_MP_current.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Nodes/Flyff/__pycache__/flyff_MP_total.cpython-312.pyc
Normal file
BIN
Nodes/Flyff/__pycache__/flyff_MP_total.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
108
Nodes/Flyff/flyff_character_status_node.py
Normal file
108
Nodes/Flyff/flyff_character_status_node.py
Normal 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}%")
|
BIN
Nodes/Organization/__pycache__/backdrop_node.cpython-312.pyc
Normal file
BIN
Nodes/Organization/__pycache__/backdrop_node.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
@ -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')
|
@ -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))
|
74
borealis.py
74
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__ + "."):
|
||||
|
||||
# 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 |
Loading…
x
Reference in New Issue
Block a user