Transparent Blueprint Grid Implemented (But Broken)

This commit is contained in:
Nicole Rappe 2025-02-14 00:38:26 -07:00
parent 39ede691d4
commit faee07b720
15 changed files with 238 additions and 1219 deletions

View File

@ -1,114 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ProjectBorealis</class>
<widget class="QMainWindow" name="ProjectBorealis">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>925</width>
<height>698</height>
</rect>
</property>
<property name="windowTitle">
<string>Project Borealis - Flyff Information Overlay</string>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<widget class="QWidget" name="centralwidget">
<widget class="QLabel" name="label">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<width>260</width>
<height>41</height>
</rect>
</property>
<property name="font">
<font>
<family>Microsoft YaHei UI Light</family>
<pointsize>24</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Project Borealis</string>
</property>
</widget>
<widget class="QTableView" name="tableView">
<property name="geometry">
<rect>
<x>0</x>
<y>391</y>
<width>921</width>
<height>271</height>
</rect>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
</widget>
<widget class="QLabel" name="label_2">
<property name="geometry">
<rect>
<x>10</x>
<y>50</y>
<width>211</width>
<height>31</height>
</rect>
</property>
<property name="font">
<font>
<family>Microsoft YaHei UI Light</family>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>Flyff Information Overlay</string>
</property>
</widget>
<widget class="QTabWidget" name="tabWidget">
<property name="geometry">
<rect>
<x>250</x>
<y>150</y>
<width>401</width>
<height>211</height>
</rect>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab">
<attribute name="title">
<string>Tab 1</string>
</attribute>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>Tab 2</string>
</attribute>
</widget>
</widget>
<widget class="QLineEdit" name="lineEdit">
<property name="geometry">
<rect>
<x>30</x>
<y>280</y>
<width>113</width>
<height>20</height>
</rect>
</property>
</widget>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -1,192 +0,0 @@
import signal
from qtpy import QtWidgets
from OdenGraphQt import BaseNode, NodeGraph
from OdenGraphQt.constants import PortTypeEnum
from OdenGraphQt.qgraphics.node_base import NodeItem
class PublishWriteNodeItem(NodeItem):
def _align_widgets_horizontal(self, v_offset: int):
if not self._widgets:
return
rect = self.boundingRect()
y = rect.y() + v_offset
for widget in self._widgets.values():
if not widget.isVisible():
continue
widget_rect = widget.boundingRect()
x = rect.center().x() - (widget_rect.width() / 2)
widget.widget().setTitleAlign('center')
widget.setPos(x, y)
y += widget_rect.height()
class PrevNextNode(BaseNode):
__identifier__ = "action"
NODE_NAME = "Action Node"
def __init__(self):
super().__init__()
# create an input port.
input_port = self.add_input("_prev", color=(180, 80, 0), multi_input=False)
# create an output port.
output_port = self.add_output("_next", multi_output=False)
input_port.port_item.set_allow_partial_match_constraint(True)
input_port.port_item.set_accept_constraint(
port_name=output_port.name(),
port_type=PortTypeEnum.OUT.value,
node_identifier=self.__identifier__,
)
output_port.port_item.set_allow_partial_match_constraint(True)
output_port.port_item.set_accept_constraint(
port_name=input_port.name(),
port_type=PortTypeEnum.IN.value,
node_identifier=self.__identifier__,
)
class IngredientNode(BaseNode):
__identifier__ = "ingredient"
class SpamNode(IngredientNode):
__identifier__ = "spam"
NODE_NAME = "Spam"
def __init__(self):
super().__init__()
spam_port = self.add_output(
"spam",
color=(50, 150, 222),
)
class EggNode(IngredientNode):
__identifier__ = "egg"
NODE_NAME = "Egg"
def __init__(self):
super().__init__()
egg_port = self.add_output(
"egg",
color=(50, 150, 222),
)
class MealNode(BaseNode):
NODE_NAME = "Meal"
def __init__(self):
super().__init__()
spam_port = self.add_input("spam", color=(222, 15, 0), multi_input=False)
spam_port.port_item.set_reject_constraint(
port_name="egg",
port_type=PortTypeEnum.OUT.value,
node_identifier="egg",
)
egg_port = self.add_input("egg", color=(222, 15, 0), multi_input=False)
egg_port.port_item.set_reject_constraint(
port_name="spam",
port_type=PortTypeEnum.OUT.value,
node_identifier="spam",
)
class BasePublishNode(PrevNextNode):
__identifier__ = "publish"
allow_multiple_write = False
def __init__(self):
super().__init__()
port = self.add_output(
"write",
color=(184, 150, 0),
multi_output=self.allow_multiple_write,
)
port.port_item.set_accept_constraint(
port_name="src",
port_type=PortTypeEnum.IN.value,
node_identifier="publish",
)
class PubNode(PrevNextNode):
__identifier__ = "pub"
NODE_NAME = "Not Tavern"
class PublishFileActionNode(BasePublishNode):
NODE_NAME = "Publish File"
allow_multiple_write = False
class PublishFileToManyActionNode(BasePublishNode):
NODE_NAME = "Publish File to Many"
allow_multiple_write = True
class PublishWriteNode(BaseNode):
__identifier__ = "publish"
NODE_NAME = "Publish Write"
def __init__(self):
super().__init__(qgraphics_item=PublishWriteNodeItem)
self.set_color(164, 130, 0)
self.add_text_input("write", "Path:")
port = self.add_input("src", multi_input=False)
port.port_item.set_accept_constraint(
port_name="write",
port_type=PortTypeEnum.OUT.value,
node_identifier="publish",
)
if __name__ == '__main__':
# handle SIGINT to make the app terminate on CTRL+C
signal.signal(signal.SIGINT, signal.SIG_DFL)
app = QtWidgets.QApplication([])
# create graph controller.
graph = NodeGraph()
# set up context menu for the node graph.
graph.set_context_menu_from_file('../examples/hotkeys/hotkeys.json')
# registered example nodes.
graph.register_nodes([
SpamNode,
EggNode,
MealNode,
PubNode,
PublishFileActionNode,
PublishFileToManyActionNode,
PublishWriteNode,
])
# add nodes
graph.add_node(SpamNode())
graph.add_node(EggNode())
graph.add_node(MealNode())
graph.add_node(PubNode())
graph.add_node(PublishFileToManyActionNode())
graph.add_node(PublishFileActionNode())
graph.add_node(PublishWriteNode())
graph.auto_layout_nodes()
graph.clear_selection()
# show the node graph widget.
graph_widget = graph.widget
graph_widget.resize(1100, 800)
graph_widget.show()
app.exec_()

View File

@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""
Standalone NodeGraphQT Math Node Example
This example defines a custom "Math Node" that:
- Uses two text inputs for numeric operands (via add_text_input)
- Provides a combo box for operator selection (via add_combo_menu)
- Offers a checkbox to enable/disable the operation (via add_checkbox)
- Computes a result and updates its title accordingly.
"""
from NodeGraphQt import NodeGraph, BaseNode
class MathNode(BaseNode):
"""
Math Node:
- Operands: Two text inputs (Operand 1 and Operand 2)
- Operator: Combo box to select 'Add', 'Subtract', 'Multiply', or 'Divide'
- Enable: Checkbox to enable/disable the math operation
- Output: Result of the math operation (if enabled)
"""
__identifier__ = 'example.math'
NODE_NAME = 'Math Node'
def __init__(self):
super(MathNode, self).__init__()
# Add two text inputs for operands.
self.add_text_input('operand1', 'Operand 1', text='10')
self.add_text_input('operand2', 'Operand 2', text='5')
# Add a combo box for operator selection.
self.add_combo_menu('operator', 'Operator', items=['Add', 'Subtract', 'Multiply', 'Divide'])
# Add a checkbox to enable/disable the operation.
self.add_checkbox('enable', 'Enable Operation', state=True)
# Add an output port to transmit the result.
self.add_output('Result')
self.value = 0
self.set_name("Math Node")
self.process_input()
def process_input(self):
"""
Gather values from the widgets, perform the math operation if enabled,
update the node title, and send the result to connected nodes.
"""
try:
op1 = float(self.get_property('operand1'))
except (ValueError, TypeError):
op1 = 0.0
try:
op2 = float(self.get_property('operand2'))
except (ValueError, TypeError):
op2 = 0.0
operator = self.get_property('operator')
enable = self.get_property('enable')
if enable:
if operator == 'Add':
result = op1 + op2
elif operator == 'Subtract':
result = op1 - op2
elif operator == 'Multiply':
result = op1 * op2
elif operator == 'Divide':
result = op1 / op2 if op2 != 0 else 0.0
else:
result = 0.0
else:
result = 0.0
self.value = result
self.set_name(f"Result: {result}")
output_port = self.output(0)
if output_port and output_port.connected_ports():
for connected_port in output_port.connected_ports():
connected_node = connected_port.node()
if hasattr(connected_node, 'receive_data'):
connected_node.receive_data(result, source_port_name='Result')
if __name__ == '__main__':
import sys
try:
from PySide2.QtWidgets import QApplication
except ImportError:
from PySide6.QtWidgets import QApplication
app = QApplication(sys.argv)
graph = NodeGraph()
graph.register_node(MathNode)
node = graph.create_node('example.math.MathNode', name='Math Node')
node.set_pos(100, 100)
graph.widget.resize(1200, 800)
graph.widget.setWindowTitle("NodeGraphQT Math Node Demo")
graph.widget.show()
sys.exit(app.exec_())

View File

@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""
Collector Process:
- Runs the OCR engine.
- Updates OCR data every 0.5 seconds.
- Exposes the latest data via an HTTP API using Flask.
This version splits the HP, MP, and FP values into 'current' and 'total' before
sending them via the API, so the Character Status Node can ingest them directly.
"""
import time
import threading
from flask import Flask, jsonify
app = Flask(__name__)
# Global variable to hold the latest stats (HP, MP, FP, EXP)
latest_data = {
"hp_current": 0,
"hp_total": 0,
"mp_current": 0,
"mp_total": 0,
"fp_current": 0,
"fp_total": 0,
"exp": 0.0000
}
def ocr_collector():
"""
This function simulates the OCR process.
Replace the code below with your actual OCR logic.
"""
global latest_data
counter = 0
while True:
# Simulate updating stats:
hp_current = 50 + counter % 10
hp_total = 100
mp_current = 30 + counter % 5
mp_total = 50
fp_current = 20 # fixed, for example
fp_total = 20
exp_val = round(10.0 + (counter * 0.1), 4)
latest_data = {
"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_val
}
counter += 1
time.sleep(0.5)
@app.route('/data')
def get_data():
"""Return the latest OCR data as JSON."""
return jsonify(latest_data)
if __name__ == '__main__':
# Start the OCR collector in a background thread.
collector_thread = threading.Thread(target=ocr_collector)
collector_thread.daemon = True
collector_thread.start()
# Run the Flask app on localhost:5000.
app.run(host="127.0.0.1", port=5000)

View File

@ -1,542 +0,0 @@
#!/usr/bin/env python3
import sys
import time
import re
import numpy as np
import cv2
import pytesseract
try:
import winsound
HAS_WINSOUND = True
except ImportError:
HAS_WINSOUND = False
from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtCore import Qt, QRect, QPoint, QTimer
from PyQt5.QtGui import QPainter, QPen, QColor, QFont
from PIL import Image, ImageGrab, ImageFilter
from rich.console import Console, Group
from rich.table import Table
from rich.progress import Progress, BarColumn, TextColumn
from rich.text import Text
from rich.live import Live
# =============================================================================
# Global Config
# =============================================================================
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
POLLING_RATE_MS = 500
MAX_DATA_POINTS = 8
# We still use these defaults for Region size.
DEFAULT_WIDTH = 180
DEFAULT_HEIGHT = 130
HANDLE_SIZE = 8
LABEL_HEIGHT = 20
GREEN_HEADER_STYLE = "bold green"
BEEP_INTERVAL_SECONDS = 1.0 # Only beep once every 1 second
# STATUS BAR AUTO-LOCATOR LOGIC (WILL BE BUILT-OUT TO BE MORE ROBUST LATER)
TEMPLATE_PATH = "G:\\Nextcloud\\Projects\\Scripting\\bars_template.png" # Path to your bars template file
MATCH_THRESHOLD = 0.4 # The correlation threshold to consider a "good" match
# =============================================================================
# Helper Functions
# =============================================================================
def beep_hp_warning():
"""
Only beep if enough time has elapsed since the last beep (BEEP_INTERVAL_SECONDS).
"""
current_time = time.time()
if (beep_hp_warning.last_beep_time is None or
(current_time - beep_hp_warning.last_beep_time >= BEEP_INTERVAL_SECONDS)):
beep_hp_warning.last_beep_time = current_time
if HAS_WINSOUND:
# frequency=376 Hz, duration=100 ms
winsound.Beep(376, 100)
else:
# Attempt terminal bell
print('\a', end='')
beep_hp_warning.last_beep_time = None
def locate_bars_opencv(template_path, threshold=MATCH_THRESHOLD):
"""
Attempt to locate the bars via OpenCV template matching:
1) Grab the full screen using PIL.ImageGrab.
2) Convert to NumPy array in BGR format for cv2.
3) Load template from `template_path`.
4) Use cv2.matchTemplate to find the best match location.
5) If max correlation > threshold, return (x, y, w, h).
6) Else return None.
"""
# 1) Capture full screen
screenshot_pil = ImageGrab.grab()
screenshot_np = np.array(screenshot_pil) # shape (H, W, 4) possibly
# Convert RGBA or RGB to BGR
screenshot_bgr = cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR)
# 2) Load template from file
template_bgr = cv2.imread(template_path, cv2.IMREAD_COLOR)
if template_bgr is None:
print(f"[WARN] Could not load template file: {template_path}")
return None
# 3) Template matching
result = cv2.matchTemplate(screenshot_bgr, template_bgr, cv2.TM_CCOEFF_NORMED)
# 4) Find best match
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
# template width/height
th, tw, _ = template_bgr.shape
if max_val >= threshold:
# max_loc is top-left corner of the best match
found_x, found_y = max_loc
return (found_x, found_y, tw, th)
else:
return None
def format_duration(seconds):
if seconds is None:
return "???"
seconds = int(seconds)
hours = seconds // 3600
leftover = seconds % 3600
mins = leftover // 60
secs = leftover % 60
if hours > 0:
return f"{hours}h {mins}m {secs}s"
else:
return f"{mins}m {secs}s"
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 format_experience_value(value):
if value < 0:
value = 0
elif value > 100:
value = 100
float_4 = round(value, 4)
raw_str = f"{float_4:.4f}"
int_part, dec_part = raw_str.split('.')
if int_part == "100":
pass
elif len(int_part) == 1 and int_part != "0":
int_part = "0" + int_part
elif int_part == "0":
int_part = "00"
return f"{int_part}.{dec_part}"
# -----------------------------------------------------------------------------
# Region Class
# -----------------------------------------------------------------------------
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),
]
# -----------------------------------------------------------------------------
# OverlayCanvas Class
# -----------------------------------------------------------------------------
class OverlayCanvas(QWidget):
"""
Renders the overlay & handles region dragging/resizing.
"""
def __init__(self, regions, parent=None):
super().__init__(parent)
self.regions = regions
self.edit_mode = True
self.selected_region = None
self.selected_handle = None
self.drag_offset = QPoint()
def paintEvent(self, event):
painter = QPainter(self)
for region in self.regions:
if region.visible:
pen = QPen(region.color)
pen.setWidth(3)
painter.setPen(pen)
painter.drawRect(region.x, region.y, region.w, region.h)
painter.setFont(QFont("Arial", 12, QFont.Bold))
painter.setPen(region.color)
painter.drawText(region.x, region.y - 5, region.label)
if self.edit_mode:
for handle in region.resize_handles():
painter.fillRect(handle, region.color)
def mousePressEvent(self, event):
if not self.edit_mode:
return
if event.button() == Qt.LeftButton:
for region in reversed(self.regions):
for i, handle in enumerate(region.resize_handles()):
if handle.contains(event.pos()):
self.selected_region = region
self.selected_handle = i
return
if region.label_rect().contains(event.pos()):
self.selected_region = region
self.selected_handle = None
self.drag_offset = event.pos() - QPoint(region.x, region.y)
return
if region.rect().contains(event.pos()):
self.selected_region = region
self.selected_handle = None
self.drag_offset = event.pos() - QPoint(region.x, region.y)
return
def mouseMoveEvent(self, event):
if not self.edit_mode or self.selected_region is None:
return
if self.selected_handle is None:
self.selected_region.x = event.x() - self.drag_offset.x()
self.selected_region.y = event.y() - self.drag_offset.y()
else:
sr = self.selected_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: # top-right
sr.w = event.x() - sr.x
sr.h += sr.y - event.y()
sr.y = event.y()
elif self.selected_handle == 2: # bottom-left
sr.w += sr.x - event.x()
sr.h = event.y() - sr.y
sr.x = event.x()
elif self.selected_handle == 3: # 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)
self.update()
def mouseReleaseEvent(self, event):
if not self.edit_mode:
return
if event.button() == Qt.LeftButton:
self.selected_region = None
self.selected_handle = None
# -----------------------------------------------------------------------------
# BorealisOverlay Class
# -----------------------------------------------------------------------------
class BorealisOverlay(QWidget):
"""
Single Region Overlay for Player Stats (HP/MP/FP/EXP) with:
- Automatic location via OpenCV template matching at startup
- OCR scanning
- Low-HP beep
- Rich Live updates in terminal
"""
def __init__(self, live=None):
super().__init__()
screen_geo = QApplication.primaryScreen().geometry()
self.setGeometry(screen_geo)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground, True)
# Try to find the bars automatically
# If found => use that location, else default
initial_x, initial_y = 250, 50
region_w, region_h = DEFAULT_WIDTH, DEFAULT_HEIGHT
match_result = locate_bars_opencv(TEMPLATE_PATH, MATCH_THRESHOLD)
if match_result is not None:
found_x, found_y, w, h = match_result
print(f"Character Status Located at {found_x}, {found_y} with confidence >= {MATCH_THRESHOLD}.")
initial_x, initial_y = found_x, found_y
# Optionally override region size with template size
region_w, region_h = w, h
else:
print("Could not auto-locate the character status page. Set your theme to Masquerade and Interface Scale to 140%, and browser zoom level to 110%. Using default region.")
region = Region(initial_x, initial_y, label="Character Status")
region.w = region_w
region.h = region_h
self.regions = [region]
self.canvas = OverlayCanvas(self.regions, self)
self.canvas.setGeometry(self.rect())
# Tesseract
self.engine = pytesseract
# Keep history of EXP data
self.points = []
self.live = live
# Timer for periodic OCR scanning
self.timer = QTimer(self)
self.timer.timeout.connect(self.collect_ocr_data)
self.timer.start(POLLING_RATE_MS)
def set_live(self, live):
self.live = live
def collect_ocr_data(self):
for region in self.regions:
if region.visible:
screenshot = ImageGrab.grab(
bbox=(region.x, region.y, region.x + region.w, region.y + region.h)
)
processed = self.preprocess_image(screenshot)
text = pytesseract.image_to_string(processed, config='--psm 4 --oem 1')
region.data = text.strip()
if self.live is not None:
renderable = self.build_renderable()
self.live.update(renderable)
def preprocess_image(self, image):
gray = image.convert("L")
scaled = gray.resize((gray.width * 3, gray.height * 3))
thresh = scaled.point(lambda p: p > 200 and 255)
return thresh.filter(ImageFilter.MedianFilter(3))
def parse_all_stats(self, 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
def update_points(self, new_val):
now = time.time()
if self.points:
_, last_v = self.points[-1]
if abs(new_val - last_v) < 1e-6:
return
if new_val < last_v:
self.points.clear()
self.points.append((now, new_val))
if len(self.points) > MAX_DATA_POINTS:
self.points.pop(0)
def compute_time_to_100(self):
n = len(self.points)
if n < 2:
return None
first_t, first_v = self.points[0]
last_t, last_v = self.points[-1]
diff_v = last_v - first_v
if diff_v <= 0:
return None
steps = n - 1
total_time = last_t - first_t
if total_time <= 0:
return None
avg_change = diff_v / steps
remain = 100.0 - last_v
if remain <= 0:
return None
avg_time = total_time / steps
rate_per_s = avg_change / avg_time if avg_time > 0 else 0
if rate_per_s <= 0:
return None
return int(remain / rate_per_s)
def build_renderable(self):
raw_text = self.regions[0].data
stats = self.parse_all_stats(raw_text)
hp_cur, hp_max = stats["hp"]
mp_cur, mp_max = stats["mp"]
fp_cur, fp_max = stats["fp"]
exp_val = stats["exp"]
# HP beep logic
if hp_max > 0:
hp_ratio = hp_cur / hp_max
if 0 < hp_ratio <= 0.40:
beep_hp_warning()
if exp_val is not None:
self.update_points(exp_val)
current_exp = self.points[-1][1] if self.points else 0.0
# Title
title_text = Text("Project Borealis\n", style="bold white")
subtitle_text = Text("Flyff Information Overlay\n\n", style="dim")
# HP / MP / FP bars
bar_progress = Progress(
"{task.description}",
BarColumn(bar_width=30),
TextColumn(" {task.completed}/{task.total} ({task.percentage:>5.2f}%)"),
transient=False,
auto_refresh=False
)
bar_progress.add_task("[bold red]HP[/bold red]", total=hp_max, completed=hp_cur,
style="red", complete_style="red")
bar_progress.add_task("[bold blue]MP[/bold blue]", total=mp_max, completed=mp_cur,
style="blue", complete_style="blue")
bar_progress.add_task("[bold green]FP[/bold green]", total=fp_max, completed=fp_cur,
style="green", complete_style="green")
bar_progress.refresh()
# Historical EXP table
table = Table(show_header=True, header_style=GREEN_HEADER_STYLE, style=None)
table.add_column("Historical EXP", justify="center", style="green")
table.add_column("Time Since Last Kill", justify="center", style="green")
table.add_column("Average EXP Per Kill", justify="center", style="green")
table.add_column("Average Time Between Kills", justify="center", style="green")
n = len(self.points)
if n == 0:
table.add_row("N/A", "N/A", "N/A", "N/A")
elif n == 1:
_, v0 = self.points[0]
exp_str = f"[green]{format_experience_value(v0)}%[/green]"
table.add_row(exp_str, "N/A", "N/A", "N/A")
else:
for i in range(1, n):
t_cur, v_cur = self.points[i]
t_prev, v_prev = self.points[i - 1]
delta_v = v_cur - v_prev
delta_str = f"{delta_v:+.4f}%"
exp_main = format_experience_value(v_cur)
exp_str = f"[green]{exp_main}%[/green] [dim]({delta_str})[/dim]"
delta_t = t_cur - t_prev
t_since_str = f"{delta_t:.1f}s"
diff_v = v_cur - self.points[0][1]
steps = i
avg_exp_str = f"{diff_v/steps:.4f}%"
total_time = t_cur - self.points[0][0]
avg_kill_time = total_time / steps
avg_time_str = f"{avg_kill_time:.1f}s"
table.add_row(exp_str, t_since_str, avg_exp_str, avg_time_str)
# Predicted Time to Level
secs_left = self.compute_time_to_100()
time_str = format_duration(secs_left)
time_bar = Progress(
TextColumn("[bold white]Predicted Time to Level:[/bold white] "),
BarColumn(bar_width=30, complete_style="magenta"),
TextColumn(" [green]{task.percentage:>5.2f}%[/green] "),
TextColumn(f"[magenta]{time_str}[/magenta] until 100%", justify="right"),
transient=False,
auto_refresh=False
)
time_bar.add_task("", total=100, completed=current_exp)
time_bar.refresh()
return Group(
title_text,
subtitle_text,
bar_progress,
table,
time_bar
)
# -----------------------------------------------------------------------------
# main
# -----------------------------------------------------------------------------
def main():
"""
1) Attempt to locate HP/MP/FP/Exp bars using OpenCV template matching.
2) Position overlay region accordingly if found, else default.
3) Start PyQt, periodically OCR the region, update Rich Live in terminal.
"""
app = QApplication(sys.argv)
window = BorealisOverlay()
window.setWindowTitle("Project Borealis Overlay (HP/MP/FP/EXP)")
window.show()
console = Console()
with Live(console=console, refresh_per_second=4) as live:
window.set_live(live)
exit_code = app.exec_()
sys.exit(exit_code)
if __name__ == "__main__":
main()

View File

@ -1,121 +0,0 @@
#!/usr/bin/python
from qtpy import QtCore, QtGui
from OdenGraphQt import BaseNode
def draw_triangle_port(painter, rect, info):
"""
Custom paint function for drawing a Triangle shaped port.
Args:
painter (QtGui.QPainter): painter object.
rect (QtCore.QRectF): port rect used to describe parameters
needed to draw.
info (dict): information describing the ports current state.
{
'port_type': 'in',
'color': (0, 0, 0),
'border_color': (255, 255, 255),
'multi_connection': False,
'connected': False,
'hovered': False,
}
"""
painter.save()
size = int(rect.height() / 2)
triangle = QtGui.QPolygonF()
triangle.append(QtCore.QPointF(-size, size))
triangle.append(QtCore.QPointF(0.0, -size))
triangle.append(QtCore.QPointF(size, size))
transform = QtGui.QTransform()
transform.translate(rect.center().x(), rect.center().y())
port_poly = transform.map(triangle)
# mouse over port color.
if info['hovered']:
color = QtGui.QColor(14, 45, 59)
border_color = QtGui.QColor(136, 255, 35)
# port connected color.
elif info['connected']:
color = QtGui.QColor(195, 60, 60)
border_color = QtGui.QColor(200, 130, 70)
# default port color
else:
color = QtGui.QColor(*info['color'])
border_color = QtGui.QColor(*info['border_color'])
pen = QtGui.QPen(border_color, 1.8)
pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin)
painter.setPen(pen)
painter.setBrush(color)
painter.drawPolygon(port_poly)
painter.restore()
def draw_square_port(painter, rect, info):
"""
Custom paint function for drawing a Square shaped port.
Args:
painter (QtGui.QPainter): painter object.
rect (QtCore.QRectF): port rect used to describe parameters
needed to draw.
info (dict): information describing the ports current state.
{
'port_type': 'in',
'color': (0, 0, 0),
'border_color': (255, 255, 255),
'multi_connection': False,
'connected': False,
'hovered': False,
}
"""
painter.save()
# mouse over port color.
if info['hovered']:
color = QtGui.QColor(14, 45, 59)
border_color = QtGui.QColor(136, 255, 35, 255)
# port connected color.
elif info['connected']:
color = QtGui.QColor(195, 60, 60)
border_color = QtGui.QColor(200, 130, 70)
# default port color
else:
color = QtGui.QColor(*info['color'])
border_color = QtGui.QColor(*info['border_color'])
pen = QtGui.QPen(border_color, 1.8)
pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin)
painter.setPen(pen)
painter.setBrush(color)
painter.drawRect(rect)
painter.restore()
class CustomPortsNode(BaseNode):
"""
example test node with custom shaped ports.
"""
# set a unique node identifier.
__identifier__ = 'nodes.custom.ports'
# set the initial default node name.
NODE_NAME = 'node'
def __init__(self):
super(CustomPortsNode, self).__init__()
# create input and output port.
self.add_input('in', color=(200, 10, 0))
self.add_output('default')
self.add_output('square', painter_func=draw_square_port)
self.add_output('triangle', painter_func=draw_triangle_port)

BIN
Project_Borealis.zip Normal file

Binary file not shown.

78
QML/background_grid.qml Normal file
View File

@ -0,0 +1,78 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Shapes 1.15
import QtQuick.Window 2.15
Item {
id: root
width: Screen.width
height: Screen.height
// Grid overlay is enabled at startup.
property bool editMode: true
// Blue gradient background (edges fading inward) with stops shifted inward.
Rectangle {
id: gradientBackground
width: parent.width
height: parent.height
opacity: 0.5
gradient: Gradient {
// Shifted stops: outer stops moved to 0.1 and 0.9, inner stops to 0.4 and 0.6.
GradientStop { position: 0.1; color: Qt.rgba(0, 100/255, 255/255, 0.5) }
GradientStop { position: 0.4; color: Qt.rgba(0, 50/255, 180/255, 0.2) }
GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.0) }
GradientStop { position: 0.6; color: Qt.rgba(0, 50/255, 180/255, 0.2) }
GradientStop { position: 0.9; color: Qt.rgba(0, 100/255, 255/255, 0.5) }
}
visible: editMode // Only show the gradient in edit mode
}
// Top & Bottom fade remains unchanged.
Rectangle {
id: topBottomGradient
width: parent.width
height: parent.height
opacity: 0.3
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: Qt.rgba(0, 100/255, 255/255, 0.4) }
GradientStop { position: 0.3; color: Qt.rgba(0, 50/255, 180/255, 0.1) }
GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.0) }
GradientStop { position: 0.7; color: Qt.rgba(0, 50/255, 180/255, 0.1) }
GradientStop { position: 1.0; color: Qt.rgba(0, 100/255, 255/255, 0.4) }
}
visible: editMode
}
// Full-Screen Dynamic Grid with 10% increased transparency (grid lines at 0.3 opacity).
Canvas {
id: gridCanvas
width: parent.width
height: parent.height
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = "rgba(255, 255, 255, 0.3)"; // Reduced opacity from 0.4 to 0.3.
ctx.lineWidth = 1;
var step = 120; // Grid spacing remains unchanged.
for (var x = 0; x < width; x += step) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (var y = 0; y < height; y += step) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
Component.onCompleted: requestPaint()
onVisibleChanged: requestPaint()
visible: editMode // Hide when edit mode is off.
}
}

View File

@ -1,104 +1,187 @@
import os
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import pkgutil
import importlib
import inspect
from PyQt5.QtWidgets import QApplication, QMainWindow, QAction, QMenu, QUndoStack
from PyQt5.QtCore import Qt, QTimer, QRect
from PyQt5.QtGui import QColor, QPainter, QPen, QBrush
import types
from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QWidget
from PyQt5.QtCore import Qt, QUrl, QTimer
from PyQt5.QtGui import QGuiApplication
from PyQt5.QtQuick import QQuickView
# OdenGraphQt Fix: Monkey-patch QUndoStack
import OdenGraphQt.base.graph as base_graph
from PyQt5 import QtWidgets
base_graph.QtGui.QUndoStack = QtWidgets.QUndoStack
import OdenGraphQt.base.commands as base_commands
_original_redo = base_commands.NodesRemovedCmd.redo
_original_undo = base_commands.NodesRemovedCmd.undo
def _patched_redo(self):
try:
_original_redo(self)
except TypeError as e:
if "unexpected type" in str(e) and hasattr(self, 'node'):
node_ids = []
if isinstance(self.node, list):
node_ids = [getattr(n, 'id', str(n)) for n in self.node]
else:
node_ids = [getattr(self.node, 'id', str(self.node))]
self.graph.nodes_deleted.emit(node_ids)
else:
raise
def _patched_undo(self):
try:
_original_undo(self)
except TypeError as e:
if "unexpected type" in str(e) and hasattr(self, 'node'):
node_ids = []
if isinstance(self.node, list):
node_ids = [getattr(n, 'id', str(n)) for n in self.node]
else:
node_ids = [getattr(self.node, 'id', str(self.node))]
self.graph.nodes_deleted.emit(node_ids)
else:
raise
base_commands.NodesRemovedCmd.redo = _patched_redo
base_commands.NodesRemovedCmd.undo = _patched_undo
# OdenGraphQt Transparent Viewer
from OdenGraphQt.widgets.viewer import NodeViewer
class TransparentViewer(NodeViewer):
"""A NodeViewer that does not paint anything in drawBackground() -> Fully transparent."""
def drawBackground(self, painter, rect):
pass # Do nothing, ensuring transparency.
# NodeGraph & Node Import Helpers
from OdenGraphQt import NodeGraph, BaseNode
# Force qtpy to use PyQt5 explicitly
os.environ["QT_API"] = "pyqt5"
# --- PATCH OdenGraphQt to use QtWidgets.QUndoStack instead of QtGui.QUndoStack ---
import qtpy.QtGui
import qtpy.QtWidgets
if not hasattr(qtpy.QtGui, "QUndoStack"): # Ensure patching only if necessary
qtpy.QtGui.QUndoStack = qtpy.QtWidgets.QUndoStack
# --- END PATCH ---
class TransparentGraphWindow(QMainWindow):
def __init__(self):
super().__init__()
# Enable transparency & always-on-top behavior
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
self.setAttribute(Qt.WA_TranslucentBackground, True)
# Get full-screen size for overlay
screen_geometry = QApplication.primaryScreen().geometry()
self.setGeometry(screen_geometry)
# Create Node Graph
self.graph = NodeGraph()
self.graph.widget.setParent(self)
self.graph.widget.setGeometry(self.rect())
# Make bottom-left corner interactive for context menu
self.context_menu_area = QRect(10, self.height() - 40, 50, 30)
# Load nodes dynamically
self.import_nodes()
# Global update timer for processing nodes
self.timer = QTimer()
self.timer.timeout.connect(self.update_nodes)
self.timer.start(500)
def import_nodes(self):
"""Dynamically import all custom node classes from the 'Nodes' package."""
try:
package_name = 'Nodes'
def import_nodes_from_folder(package_name):
imported_nodes = []
package = importlib.import_module(package_name)
for loader, module_name, is_pkg in pkgutil.walk_packages(package.__path__, 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__:
self.graph.register_node(obj)
except Exception as e:
print(f"Error loading nodes: {e}")
imported_nodes.append(obj)
return imported_nodes
def update_nodes(self):
"""Calls process_input() on all nodes, if applicable."""
def make_node_command(graph, node_type):
def command():
try:
graph.create_node(node_type)
except Exception as e:
print(f"Error creating node of type {node_type}: {e}")
return command
# Edit Mode Button
class EditButton(QPushButton):
"""A small, frameless button to toggle edit mode."""
def __init__(self, parent=None):
super().__init__("Enter Edit Mode", parent)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setStyleSheet("background-color: rgba(255,255,255,200); border: 1px solid black;")
self.resize(140, 40)
# Main Overlay Window
class MainWindow(QMainWindow):
"""A frameless, transparent overlay with OdenGraphQt nodes & edit mode toggle."""
def __init__(self):
super().__init__()
# Full-screen overlay
app = QApplication.instance()
screen_geo = app.primaryScreen().geometry()
self.setGeometry(screen_geo)
# Frameless, top-most, fully transparent
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground, True)
# QML Background
self.qml_view = QQuickView()
self.qml_view.setSource(QUrl("qml/background_grid.qml"))
self.qml_view.setFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.qml_view.setClearBeforeRendering(True)
self.qml_view.setColor(Qt.transparent)
self.qml_view.show()
# Save the QML root object for later property sync
self.qml_root = self.qml_view.rootObject()
# NodeGraph with TransparentViewer
self.graph = NodeGraph(viewer=TransparentViewer())
self.nodeGraphWidget = self.graph.widget
self.nodeGraphWidget.setStyleSheet("background: transparent; border: none;")
# Transparent central widget
central = QWidget(self)
central.setAttribute(Qt.WA_TranslucentBackground, True)
self.setCentralWidget(central)
self.nodeGraphWidget.setParent(central)
self.nodeGraphWidget.setGeometry(central.rect())
# Edit Mode Button (Python controlled)
self.editButton = EditButton(self)
self.editButton.move(10, 10)
self.editButton.clicked.connect(self.toggleEditMode)
self.isEditMode = True # Set edit mode enabled by default
self.editButton.setText("Exit Edit Mode") # Reflect that grid is active
# Ensure QML grid overlay is enabled at startup
if self.qml_root:
self.qml_root.setProperty("editMode", self.isEditMode)
# Import custom nodes
try:
custom_nodes = import_nodes_from_folder('Nodes')
for node_class in custom_nodes:
self.graph.register_node(node_class)
graph_menu = self.graph.get_context_menu('graph')
for node_class in custom_nodes:
node_type = f"{node_class.__identifier__}.{node_class.__name__}"
node_name = node_class.NODE_NAME
graph_menu.add_command(
f"Add {node_name}",
make_node_command(self.graph, node_type)
)
except Exception as e:
print(f"Error setting up custom nodes: {e}")
# Global update timer
self.timer = QTimer(self)
self.timer.timeout.connect(self.global_update)
self.timer.start(500)
self.show()
self.nodeGraphWidget.setAttribute(Qt.WA_TransparentForMouseEvents, not self.isEditMode)
def toggleEditMode(self):
"""Toggle edit mode (pass-through clicks vs interactive)."""
self.isEditMode = not self.isEditMode
self.nodeGraphWidget.setAttribute(Qt.WA_TransparentForMouseEvents, not self.isEditMode)
self.editButton.setText("Exit Edit Mode" if self.isEditMode else "Enter Edit Mode")
if self.qml_root:
self.qml_root.setProperty("editMode", self.isEditMode)
def global_update(self):
"""Update all nodes periodically."""
for node in self.graph.all_nodes():
if hasattr(node, "process_input"):
try:
node.process_input()
except Exception as e:
print(f"Error processing node {node}: {e}")
def mousePressEvent(self, event):
"""Override mouse press to handle context menu in bottom-left area."""
if event.button() == Qt.RightButton and self.context_menu_area.contains(event.pos()):
self.show_context_menu(event.globalPos())
def show_context_menu(self, position):
"""Displays a right-click context menu."""
menu = QMenu(self)
quit_action = QAction("Quit", self)
quit_action.triggered.connect(self.close)
menu.addAction(quit_action)
menu.exec_(position)
def paintEvent(self, event):
"""Render transparent overlay and the small clickable menu area."""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Draw semi-transparent context menu area
painter.setBrush(QBrush(QColor(50, 50, 50, 150))) # Dark semi-transparent box
painter.setPen(QPen(QColor(200, 200, 200, 200)))
painter.drawRect(self.context_menu_area)
painter.setFont(self.font())
painter.setPen(QColor(255, 255, 255))
painter.drawText(self.context_menu_area, Qt.AlignCenter, "Menu")
if __name__ == "__main__":
# Entry Point
if __name__ == '__main__':
app = QApplication(sys.argv)
window = TransparentGraphWindow()
window = MainWindow()
window.show()
sys.exit(app.exec_())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB