From 27d4451971d683bfe9a6c4c620d6f434115171cd Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 04:46:47 +0100 Subject: [PATCH 1/8] Enhance Custom Follower Formations with angle presets and per-follower radius --- HeroAI/following.py | 848 +++++++++++++++++++++++ Widgets/Automation/Multiboxing/HeroAI.py | 112 +-- Widgets/Data/rarity_filter_data.json | 6 +- 3 files changed, 878 insertions(+), 88 deletions(-) create mode 100644 HeroAI/following.py diff --git a/HeroAI/following.py b/HeroAI/following.py new file mode 100644 index 000000000..632160fa9 --- /dev/null +++ b/HeroAI/following.py @@ -0,0 +1,848 @@ +""" +HeroAI Following Module — Leader-Driven Follow Logic + +The leader calculates formation positions for all followers and writes them +to shared memory (HeroAIOptionStruct.FollowPos). Each follower simply reads +its own assigned position and moves there. + +Also contains the leader-only config window with per-follower angle control, +formation canvas preview, and 3D overlay visualization. +""" + +import math +import Py4GW +import PyImGui + +from Py4GWCoreLib import ( + GLOBAL_CACHE, Agent, Player, Map, Range, Utils, + ActionQueueManager, Routines, +) +from Py4GWCoreLib.Overlay import Overlay +from Py4GWCoreLib.ImGui import ImGui +from Py4GWCoreLib.IniManager import IniManager +from Py4GWCoreLib.py4gwcorelib_src.Color import Color, ColorPalette +from Py4GWCoreLib.py4gwcorelib_src.Console import ConsoleLog +from Py4GWCoreLib.native_src.internals.types import Vec2f + +from HeroAI.cache_data import CacheData +from HeroAI.constants import ( + MODULE_NAME, + MELEE_RANGE_VALUE, + RANGED_RANGE_VALUE, + FOLLOW_DISTANCE_OUT_OF_COMBAT, +) + +# ───────────────────────────────────────────── +# Constants +# ───────────────────────────────────────────── +FOLLOW_COMBAT_DISTANCE = 25.0 # body-block close range when flagged +MAX_FOLLOWERS = 12 + +# ───────────────────────────────────────────── +# INI persistence +# ───────────────────────────────────────────── +INI_DIR = "HeroAI" +INI_FILENAME = "FollowModule.ini" +_ini_key = "" + +# ───────────────────────────────────────────── +# Ring config (merged from FollowingModule.py) +# ───────────────────────────────────────────── +class RingConfig: + def __init__(self, radius, color: Color, thickness, show=True): + if isinstance(radius, Range): + self.caption = radius.name + self.radius = radius.value + else: + self.caption = f"{radius:.0f}" + self.radius = float(radius) + self.show = show + self.color: Color = color + self.thickness = thickness + + +# ───────────────────────────────────────────── +# Per-follower config +# ───────────────────────────────────────────── +class FollowerConfig: + """Per-follower formation settings controlled by the leader.""" + def __init__(self, angle_deg: float = 0.0, radius: float = -1.0, color: Color = None): + self.angle_deg = angle_deg + self.radius = radius # -1 means use global formation_radius + self.color: Color = color or ColorPalette.GetColor("white") + + def get_radius(self, global_radius: float) -> float: + """Return this follower's radius, falling back to global if -1.""" + return self.radius if self.radius >= 0 else global_radius + + +# ───────────────────────────────────────────── +# Module-level settings +# ───────────────────────────────────────────── +class FollowModuleSettings: + """All leader-configurable follow settings.""" + + # Default formation angles (same layout as the old hero_formation) + DEFAULT_ANGLES = [0.0, 45.0, -45.0, 90.0, -90.0, 135.0, -135.0, 180.0, + -180.0, 225.0, -225.0, 270.0] + + def __init__(self): + # Formation + self.formation_radius: float = Range.Touch.value # distance from leader + self.follow_distance_ooc: float = FOLLOW_DISTANCE_OUT_OF_COMBAT + self.follow_distance_combat: float = MELEE_RANGE_VALUE + self.confirm_follow_point: bool = False + + # Per-follower angle configs (index = party slot) + self.follower_configs: list[FollowerConfig] = [] + for i, angle in enumerate(self.DEFAULT_ANGLES): + palette_colors = ["gw_blue", "firebrick", "gold", "gw_purple", + "gw_green", "gw_assassin", "blue", "white", + "gw_blue", "firebrick", "gold", "gw_purple"] + color = ColorPalette.GetColor(palette_colors[i % len(palette_colors)]) + self.follower_configs.append(FollowerConfig(angle, color=color)) + + # Canvas / visualization + self.show_canvas: bool = True + self.canvas_size: tuple = (400, 400) + self.scale: float = 0.3 + self.draw_area_rings: bool = True + self.draw_3d_overlay: bool = True + self.area_rings: list[RingConfig] = [ + RingConfig(Range.Touch.value / 2, ColorPalette.GetColor("gw_green"), 2), + RingConfig(Range.Touch, ColorPalette.GetColor("gw_assassin"), 2, False), + RingConfig(Range.Adjacent, ColorPalette.GetColor("gw_blue"), 2), + RingConfig(Range.Nearby, ColorPalette.GetColor("blue"), 2), + RingConfig(Range.Area, ColorPalette.GetColor("firebrick"), 2), + RingConfig(Range.Earshot, ColorPalette.GetColor("gw_purple"), 2, False), + RingConfig(Range.Spellcast, ColorPalette.GetColor("gold"), 2, False), + ] + + # UI state + self.show_config_window: bool = True + + +settings = FollowModuleSettings() + +# Map pathing quads cache (populated once per map) +_map_quads: list = [] + + +# ───────────────────────────────────────────── +# INI Persistence helpers +# ───────────────────────────────────────────── +def _ensure_ini(): + global _ini_key + if not _ini_key: + _ini_key = IniManager().AddIniHandler(INI_DIR, INI_FILENAME) + if _ini_key: + _load_settings() + + +def _save_settings(): + if not _ini_key: + return + ini = IniManager() + ini.write_key(_ini_key, "Formation", "Radius", settings.formation_radius) + ini.write_key(_ini_key, "Formation", "DistanceOOC", settings.follow_distance_ooc) + ini.write_key(_ini_key, "Formation", "DistanceCombat", settings.follow_distance_combat) + ini.write_key(_ini_key, "Formation", "ConfirmFollowPoint", settings.confirm_follow_point) + + # Per-follower angles + for i, fc in enumerate(settings.follower_configs): + ini.write_key(_ini_key, "Followers", f"Angle_{i}", fc.angle_deg) + ini.write_key(_ini_key, "Followers", f"Radius_{i}", fc.radius) + + # Canvas settings + ini.write_key(_ini_key, "Canvas", "Show", settings.show_canvas) + ini.write_key(_ini_key, "Canvas", "Scale", settings.scale) + ini.write_key(_ini_key, "Canvas", "Draw3D", settings.draw_3d_overlay) + ini.write_key(_ini_key, "Canvas", "DrawRings", settings.draw_area_rings) + + +def _load_settings(): + if not _ini_key: + return + ini = IniManager() + + settings.formation_radius = ini.read_float(_ini_key, "Formation", "Radius", Range.Touch.value) + settings.follow_distance_ooc = ini.read_float(_ini_key, "Formation", "DistanceOOC", FOLLOW_DISTANCE_OUT_OF_COMBAT) + settings.follow_distance_combat = ini.read_float(_ini_key, "Formation", "DistanceCombat", MELEE_RANGE_VALUE) + settings.confirm_follow_point = ini.read_bool(_ini_key, "Formation", "ConfirmFollowPoint", False) + + for i, fc in enumerate(settings.follower_configs): + fc.angle_deg = ini.read_float(_ini_key, "Followers", f"Angle_{i}", fc.angle_deg) + fc.radius = ini.read_float(_ini_key, "Followers", f"Radius_{i}", fc.radius) + + settings.show_canvas = ini.read_bool(_ini_key, "Canvas", "Show", True) + settings.scale = ini.read_float(_ini_key, "Canvas", "Scale", 0.3) + settings.draw_3d_overlay = ini.read_bool(_ini_key, "Canvas", "Draw3D", True) + settings.draw_area_rings = ini.read_bool(_ini_key, "Canvas", "DrawRings", True) + + +# ───────────────────────────────────────────── +# Map pathing validation +# ───────────────────────────────────────────── +def _is_position_on_map(x: float, y: float) -> bool: + """Check if a position is on a walkable map quad.""" + global _map_quads + if not settings.confirm_follow_point: + return True + if not _map_quads: + _map_quads = Map.Pathing.GetMapQuads() + for quad in _map_quads: + if Map.Pathing._point_in_quad(x, y, quad): + return True + return False + + +def reset_map_quads(): + """Call when map changes to clear cached pathing data.""" + global _map_quads + _map_quads.clear() + + +# ───────────────────────────────────────────── +# LEADER UPDATE — calculates & writes follow positions +# ───────────────────────────────────────────── +def LeaderUpdate(cached_data: CacheData): + """ + Run on the leader's client each tick. + Calculates formation positions for every follower and writes + them to shared memory via HeroAIOptionStruct.FollowPos. + """ + leader_id = GLOBAL_CACHE.Party.GetPartyLeaderID() + if Player.GetAgentID() != leader_id: + return # not leader + + leader_x, leader_y = Agent.GetXY(leader_id) + leader_angle = Agent.GetRotationAngle(leader_id) + + # Validate leader position + point_zero = (0.0, 0.0) + if Utils.Distance((leader_x, leader_y), point_zero) <= 5: + return # leader at origin, skip + + leader_options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(0) + + # Iterate over all party accounts + for acc in cached_data.party: + if not acc.IsSlotActive: + continue + if acc.PlayerID == leader_id: + continue # skip self (leader) + + # Get this follower's HeroAI options from shared memory + follower_options = GLOBAL_CACHE.ShMem.GetHeroAIOptions(acc.AccountEmail) + if follower_options is None: + continue + + if not follower_options.Following: + continue # following disabled for this follower + + # Determine follow target + follow_x = 0.0 + follow_y = 0.0 + follow_angle = leader_angle + is_following_flag = False + + if follower_options.IsFlagged: + # Follower's own flag takes priority + follow_x = follower_options.FlagPosX + follow_y = follower_options.FlagPosY + follow_angle = follower_options.FlagFacingAngle + is_following_flag = True + elif leader_options and leader_options.IsFlagged: + # Leader's flag + follow_x = leader_options.FlagPosX + follow_y = leader_options.FlagPosY + follow_angle = leader_options.FlagFacingAngle + else: + # Follow leader directly + follow_x = leader_x + follow_y = leader_y + follow_angle = leader_angle + + # Calculate formation offset position + if is_following_flag: + # Go directly to flag position + xx = follow_x + yy = follow_y + else: + # Get per-follower angle config + follower_grid_pos = acc.PartyPosition + GLOBAL_CACHE.Party.GetHeroCount() + GLOBAL_CACHE.Party.GetHenchmanCount() + if follower_grid_pos < len(settings.follower_configs): + angle_deg = settings.follower_configs[follower_grid_pos].angle_deg + else: + angle_deg = 0.0 + + # Convert angle to local offset (0°=forward) + angle_rad = Utils.DegToRad(angle_deg) + fc = settings.follower_configs[follower_grid_pos] if follower_grid_pos < len(settings.follower_configs) else None + effective_radius = fc.get_radius(settings.formation_radius) if fc else settings.formation_radius + local_x = effective_radius * math.sin(angle_rad) + local_y = effective_radius * math.cos(angle_rad) + + # Rotate into world using leader facing (same as reference) + rot_angle = follow_angle - math.pi / 2 + rot_cos = -math.cos(rot_angle) + rot_sin = -math.sin(rot_angle) + rot_x = (local_x * rot_cos) - (local_y * rot_sin) + rot_y = (local_x * rot_sin) + (local_y * rot_cos) + xx = follow_x + rot_x + yy = follow_y + rot_y + + # Validate against map pathing + if not _is_position_on_map(xx, yy): + # Fallback to direct follow position + xx = follow_x + yy = follow_y + + # Write calculated position to shared memory + # Leader-side distance gating: only update FollowPos if the follower + # is far enough from the target that they need to move + follower_x = acc.PlayerPosX + follower_y = acc.PlayerPosY + dist_to_target = Utils.Distance((xx, yy), (follower_x, follower_y)) + + # Determine appropriate follow distance threshold + if is_following_flag: + threshold = 0.0 # always move to flag + elif cached_data.data.in_aggro: + if Agent.IsMelee(acc.PlayerID): + threshold = MELEE_RANGE_VALUE + else: + threshold = RANGED_RANGE_VALUE + else: + threshold = settings.follow_distance_ooc + + if dist_to_target > threshold: + follower_options.FollowPos = Vec2f(xx, yy) + else: + follower_options.FollowPos = Vec2f(0.0, 0.0) # signal: no move needed + + +# ───────────────────────────────────────────── +# FOLLOW — follower reads assigned position and moves +# ───────────────────────────────────────────── +following_flag = False + +def Follow(cached_data: CacheData) -> bool: + """ + BT ActionNode — runs on each follower. + Reads the leader-assigned FollowPos from shared memory and moves there. + Returns True if a move was issued, False otherwise. + """ + global following_flag + + options = cached_data.account_options + if not options or not options.Following: + return False + + if not cached_data.follow_throttle_timer.IsExpired(): + return False + + # Leader doesn't follow + if Player.GetAgentID() == GLOBAL_CACHE.Party.GetPartyLeaderID(): + cached_data.follow_throttle_timer.Reset() + return False + + # Read assigned position from shared memory + follow_pos = options.FollowPos + target_x = follow_pos.x + target_y = follow_pos.y + + # (0,0) means leader says: no move needed (close enough or no valid pos) + point_zero = (0.0, 0.0) + if Utils.Distance((target_x, target_y), point_zero) <= 5: + return False + + if not Agent.IsValid(GLOBAL_CACHE.Party.GetPartyLeaderID()): + return False + + following_flag = options.IsFlagged + + # Minimal jitter threshold — the leader already did the real distance gating + my_x, my_y = Agent.GetXY(Player.GetAgentID()) + distance = Utils.Distance((target_x, target_y), (my_x, my_y)) + if distance < 50: + return False # already practically there + + # Issue move command + ActionQueueManager().ResetQueue("ACTION") + Player.Move(target_x, target_y) + return True + + +# ───────────────────────────────────────────── +# LEADER CONFIG WINDOW — UI (leader-only) +# ───────────────────────────────────────────── +def draw_follow_config(cached_data: CacheData): + """ + Leader-only config window with formation settings, + per-follower angle control, and canvas preview. + """ + _ensure_ini() + + if not settings.show_config_window: + return + + # Only show on leader client + if Player.GetAgentID() != GLOBAL_CACHE.Party.GetPartyLeaderID(): + return + + canvas_w, canvas_h = settings.canvas_size + settings_width = 420 + window_w = canvas_w + settings_width if settings.show_canvas else settings_width + window_h = canvas_h + 80 + + PyImGui.set_next_window_size( + (window_w, window_h), + PyImGui.ImGuiCond.FirstUseEver + ) + + visible, settings.show_config_window = PyImGui.begin_with_close( + "Follow Module — Leader Config", settings.show_config_window, 0 + ) + + if visible: + table_flags = ( + PyImGui.TableFlags.Borders | + PyImGui.TableFlags.SizingStretchProp + ) + + column_count = 2 if settings.show_canvas else 1 + + if PyImGui.begin_table("FollowMainTable", column_count, table_flags): + if settings.show_canvas: + PyImGui.table_setup_column( + "Canvas", + PyImGui.TableColumnFlags.WidthFixed, + settings.canvas_size[0] + 20 + ) + + PyImGui.table_setup_column( + "Settings", + PyImGui.TableColumnFlags.WidthStretch) + + if settings.show_canvas: + PyImGui.table_next_row() + PyImGui.table_next_column() + _draw_canvas(cached_data) + PyImGui.table_next_column() + else: + PyImGui.table_next_row() + PyImGui.table_next_column() + + # ── Settings panel ── + _draw_formation_settings() + _draw_custom_formation(cached_data) + _draw_canvas_settings() + _draw_ring_settings() + + PyImGui.end_table() + + # 3D overlay + if settings.draw_3d_overlay: + _draw_3d_overlay(cached_data) + + PyImGui.end() + + +# ───────────────────────────────────────────── +# Canvas drawing +# ───────────────────────────────────────────── +def _draw_canvas(cached_data: CacheData): + """Draw the 2D radar-style canvas with area rings and follower positions.""" + child_flags = ( + PyImGui.WindowFlags.NoTitleBar | + PyImGui.WindowFlags.NoResize | + PyImGui.WindowFlags.NoMove + ) + + if PyImGui.begin_child("FollowCanvas", settings.canvas_size, True, child_flags): + canvas_pos = PyImGui.get_cursor_screen_pos() + cx = canvas_pos[0] + settings.canvas_size[0] / 2 + cy = canvas_pos[1] + settings.canvas_size[1] / 2 + + # Grid + grid_color = ColorPalette.GetColor("gray").copy() + grid_color.set_a(80) + grid_step = (Range.Touch.value / 2) * settings.scale + + canvas_x, canvas_y = canvas_pos + canvas_w, canvas_h = settings.canvas_size + left, right = canvas_x, canvas_x + canvas_w + top, bottom = canvas_y, canvas_y + canvas_h + + x = cx + while x <= right: + PyImGui.draw_list_add_line(x, top, x, bottom, grid_color.to_color(), 1) + x += grid_step + x = cx - grid_step + while x >= left: + PyImGui.draw_list_add_line(x, top, x, bottom, grid_color.to_color(), 1) + x -= grid_step + y = cy + while y <= bottom: + PyImGui.draw_list_add_line(left, y, right, y, grid_color.to_color(), 1) + y += grid_step + y = cy - grid_step + while y >= top: + PyImGui.draw_list_add_line(left, y, right, y, grid_color.to_color(), 1) + y -= grid_step + + # Center marker (leader position) + touch_color = settings.area_rings[0].color.copy() + touch_color.set_a(100) + touch_radius = settings.area_rings[0].radius * settings.scale + PyImGui.draw_list_add_circle_filled(cx, cy, touch_radius, touch_color.to_color(), 64) + + # Area rings + if settings.draw_area_rings: + for ring in settings.area_rings: + if ring.show: + PyImGui.draw_list_add_circle( + cx, cy, + ring.radius * settings.scale, + ring.color.to_color(), 32, ring.thickness + ) + + # Follower formation points (real-time, rotated by player heading) + leader_angle = 0.0 + try: + leader_angle = Agent.GetRotationAngle(Player.GetAgentID()) + except Exception: + pass + + rot_angle = leader_angle - math.pi / 2 + rot_cos = -math.cos(rot_angle) + rot_sin = -math.sin(rot_angle) + + follower_radius_px = (Range.Touch.value / 2) * settings.scale + + for i, fc in enumerate(settings.follower_configs): + if i >= MAX_FOLLOWERS: + break + # Convert angle to local offset (0°=forward) + angle_rad = Utils.DegToRad(fc.angle_deg) + effective_radius = fc.get_radius(settings.formation_radius) + local_x = effective_radius * math.sin(angle_rad) + local_y = effective_radius * math.cos(angle_rad) + + # Rotate into world (same matrix as 3D overlay) + rot_x = (local_x * rot_cos) - (local_y * rot_sin) + rot_y = (local_x * rot_sin) + (local_y * rot_cos) + + # Map to canvas: flip both axes for correct game-to-screen mapping + draw_x = cx + (rot_x * settings.scale) + draw_y = cy + (-rot_y * settings.scale) + + color_fill = fc.color.copy() + color_fill.set_a(120) + + PyImGui.draw_list_add_circle_filled( + draw_x, draw_y, follower_radius_px, + color_fill.to_color(), 32 + ) + PyImGui.draw_list_add_circle( + draw_x, draw_y, follower_radius_px, + fc.color.to_color(), 32, 2 + ) + + PyImGui.end_child() + + +# ───────────────────────────────────────────── +# Settings sub-panels +# ───────────────────────────────────────────── +def _draw_formation_settings(): + """Formation parameters section.""" + if PyImGui.collapsing_header("Formation", PyImGui.TreeNodeFlags.DefaultOpen): + changed = False + + new_radius = PyImGui.slider_float( + "Formation Radius", settings.formation_radius, + 50.0, Range.Spellcast.value + ) + if new_radius != settings.formation_radius: + settings.formation_radius = new_radius + changed = True + + new_ooc = PyImGui.slider_float( + "Follow Distance (OOC)", settings.follow_distance_ooc, + 0.0, Range.Spellcast.value + ) + if new_ooc != settings.follow_distance_ooc: + settings.follow_distance_ooc = new_ooc + changed = True + + new_combat = PyImGui.slider_float( + "Follow Distance (Combat)", settings.follow_distance_combat, + 0.0, Range.Spellcast.value + ) + if new_combat != settings.follow_distance_combat: + settings.follow_distance_combat = new_combat + changed = True + + new_confirm = PyImGui.checkbox( + "Confirm Follow Point (Map Pathing)", settings.confirm_follow_point + ) + if new_confirm != settings.confirm_follow_point: + settings.confirm_follow_point = new_confirm + changed = True + + if changed: + _save_settings() + + +def _draw_custom_formation(cached_data: CacheData): + """Per-follower custom formation: angle, radius, and color per slot.""" + if PyImGui.collapsing_header("Custom Formation", PyImGui.TreeNodeFlags.DefaultOpen): + + # Build lookup: grid_pos → character name + leader_id = GLOBAL_CACHE.Party.GetPartyLeaderID() + hero_count = GLOBAL_CACHE.Party.GetHeroCount() + hench_count = GLOBAL_CACHE.Party.GetHenchmanCount() + grid_names: dict[int, str] = {} + for acc in cached_data.party: + if not acc.IsSlotActive or acc.PlayerID == leader_id: + continue + grid_pos = acc.PartyPosition + hero_count + hench_count + name = acc.CharacterName if acc.CharacterName else f"Slot {grid_pos + 1}" + grid_names[grid_pos] = name + + # ── Angle presets ── + n = min(len(settings.follower_configs), MAX_FOLLOWERS) + preset_changed = False + + PyImGui.text("Presets:") + PyImGui.same_line(0.0, 10.0) + if PyImGui.button("Even Spread"): + for i in range(n): + settings.follower_configs[i].angle_deg = (360.0 / n) * i + preset_changed = True + PyImGui.same_line(0.0, 5.0) + if PyImGui.button("V-Shape"): + v_angles = [30, -30, 60, -60, 90, -90, 120, -120, 150, -150, 180, -180] + for i in range(n): + settings.follower_configs[i].angle_deg = float(v_angles[i % len(v_angles)]) + preset_changed = True + PyImGui.same_line(0.0, 5.0) + if PyImGui.button("Line"): + for i in range(n): + settings.follower_configs[i].angle_deg = 180.0 + preset_changed = True + PyImGui.same_line(0.0, 5.0) + if PyImGui.button("Cluster"): + cluster = [0, 15, -15, 30, -30, 10, -10, 5, -5, 20, -20, 25] + for i in range(n): + settings.follower_configs[i].angle_deg = float(cluster[i % len(cluster)]) + preset_changed = True + PyImGui.same_line(0.0, 5.0) + if PyImGui.button("Semi-Circle"): + for i in range(n): + settings.follower_configs[i].angle_deg = -90.0 + (180.0 / max(n - 1, 1)) * i + preset_changed = True + PyImGui.same_line(0.0, 5.0) + if PyImGui.button("Reset Radius"): + for i in range(n): + settings.follower_configs[i].radius = -1.0 + preset_changed = True + + if preset_changed: + _save_settings() + + PyImGui.separator() + + # ── Per-follower table ── + table_flags = ( + PyImGui.TableFlags.Borders | + PyImGui.TableFlags.RowBg | + PyImGui.TableFlags.SizingStretchProp + ) + + if PyImGui.begin_table("CustomFormationTable", 4, table_flags): + PyImGui.table_setup_column("Follower", PyImGui.TableColumnFlags.WidthFixed, 100) + PyImGui.table_setup_column("Angle (°)", PyImGui.TableColumnFlags.WidthStretch) + PyImGui.table_setup_column("Radius", PyImGui.TableColumnFlags.WidthFixed, 160) + PyImGui.table_setup_column("Color", PyImGui.TableColumnFlags.WidthFixed, 50) + PyImGui.table_headers_row() + + changed = False + + for i, fc in enumerate(settings.follower_configs): + if i >= MAX_FOLLOWERS: + break + + PyImGui.table_next_row() + + # Follower name + PyImGui.table_next_column() + display_name = grid_names.get(i, f"Slot {i + 1}") + PyImGui.text(display_name) + + # Angle: slider then input on same line + PyImGui.table_next_column() + PyImGui.set_next_item_width(120) + new_angle = PyImGui.slider_float( + f"##AngleS_{i}", fc.angle_deg, -360.0, 360.0 + ) + if new_angle != fc.angle_deg: + fc.angle_deg = new_angle + changed = True + PyImGui.same_line(0.0, 4.0) + PyImGui.set_next_item_width(60) + typed_angle = PyImGui.input_float(f"##AngleI_{i}", fc.angle_deg) + if typed_angle != fc.angle_deg: + fc.angle_deg = typed_angle + changed = True + + # Radius: slider then input on same line (-1 = use global) + PyImGui.table_next_column() + display_radius = fc.radius if fc.radius >= 0 else -1.0 + PyImGui.set_next_item_width(80) + new_radius = PyImGui.slider_float( + f"##RadS_{i}", display_radius, -1.0, Range.Spellcast.value + ) + if new_radius != display_radius: + fc.radius = new_radius + changed = True + PyImGui.same_line(0.0, 4.0) + PyImGui.set_next_item_width(60) + typed_radius = PyImGui.input_float(f"##RadI_{i}", display_radius) + if typed_radius != display_radius: + fc.radius = typed_radius + changed = True + + # Color picker + PyImGui.table_next_column() + old_color = fc.color.to_tuple_normalized() + flags = ( + PyImGui.ColorEditFlags.NoInputs | + PyImGui.ColorEditFlags.NoTooltip | + PyImGui.ColorEditFlags.NoLabel | + PyImGui.ColorEditFlags.NoDragDrop | + PyImGui.ColorEditFlags.AlphaPreview + ) + new_color = PyImGui.color_edit4( + f"##FColor_{i}", old_color, + PyImGui.ColorEditFlags(flags) + ) + if new_color != old_color: + fc.color = Color.from_tuple_normalized(new_color) + + PyImGui.end_table() + + # Help text + PyImGui.text_disabled("Radius: -1 = use global Formation Radius") + + if changed: + _save_settings() + + +def _draw_canvas_settings(): + """Canvas display settings.""" + if PyImGui.collapsing_header("Canvas", PyImGui.TreeNodeFlags.DefaultOpen): + settings.show_canvas = PyImGui.checkbox("Show Canvas", settings.show_canvas) + settings.scale = PyImGui.slider_float("Scale", settings.scale, 0.05, 1.0) + settings.draw_3d_overlay = PyImGui.checkbox("Draw 3D Overlay", settings.draw_3d_overlay) + settings.draw_area_rings = PyImGui.checkbox("Draw Area Rings", settings.draw_area_rings) + + +def _draw_ring_settings(): + """Area ring configuration.""" + if not settings.draw_area_rings: + return + + if PyImGui.collapsing_header("Area Rings"): + table_flags = ( + PyImGui.TableFlags.Borders | + PyImGui.TableFlags.RowBg | + PyImGui.TableFlags.SizingStretchProp + ) + + if PyImGui.begin_table("FollowRingsTable", 4, table_flags): + PyImGui.table_setup_column("Show", PyImGui.TableColumnFlags.WidthFixed, 80) + PyImGui.table_setup_column("Radius", PyImGui.TableColumnFlags.WidthFixed, 70) + PyImGui.table_setup_column("Thickness", PyImGui.TableColumnFlags.WidthFixed, 40) + PyImGui.table_setup_column("Color", PyImGui.TableColumnFlags.WidthStretch) + PyImGui.table_headers_row() + + for i, ring in enumerate(settings.area_rings): + PyImGui.table_next_row() + PyImGui.table_next_column() + ring.show = PyImGui.checkbox(f"{ring.caption}##FRing{i}", ring.show) + PyImGui.table_next_column() + ring.radius = PyImGui.input_float(f"##FRingR{i}", ring.radius) + PyImGui.table_next_column() + ring.thickness = int(PyImGui.input_text(f"##FRingT{i}", str(ring.thickness))) + PyImGui.table_next_column() + + old_color = ring.color.to_tuple_normalized() + flags = ( + PyImGui.ColorEditFlags.NoInputs | + PyImGui.ColorEditFlags.NoTooltip | + PyImGui.ColorEditFlags.NoLabel | + PyImGui.ColorEditFlags.NoDragDrop | + PyImGui.ColorEditFlags.AlphaPreview + ) + new_color = PyImGui.color_edit4( + f"##FRingC{i}", old_color, + PyImGui.ColorEditFlags(flags) + ) + if new_color != old_color: + ring.color = Color.from_tuple_normalized(new_color) + + PyImGui.end_table() + + +# ───────────────────────────────────────────── +# 3D Overlay +# ───────────────────────────────────────────── +def _draw_3d_overlay(cached_data: CacheData): + """Draw 3D area rings and follower formation points in the game world.""" + overlay = Overlay() + overlay.BeginDraw() + + player_id = Player.GetAgentID() + player_x, player_y, player_z = Agent.GetXYZ(player_id) + leader_angle = Agent.GetRotationAngle(player_id) + + # Area rings + if settings.draw_area_rings: + for ring in settings.area_rings: + if ring.show: + overlay.DrawPoly3D( + player_x, player_y, player_z, + ring.radius, ring.color.to_color(), 64, + ring.thickness * 2 + ) + + # Follower formation points (3D rotated) + radius_3d = Range.Touch.value / 2 + + for i, fc in enumerate(settings.follower_configs): + if i >= MAX_FOLLOWERS: + break + # Convert angle to local offset (0°=forward) + angle_rad = Utils.DegToRad(fc.angle_deg) + effective_radius = fc.get_radius(settings.formation_radius) + local_x = effective_radius * math.sin(angle_rad) + local_y = effective_radius * math.cos(angle_rad) + + # Rotate into world using leader facing (same as reference) + rot_angle = leader_angle - math.pi / 2 + rot_cos = -math.cos(rot_angle) + rot_sin = -math.sin(rot_angle) + rot_x = (local_x * rot_cos) - (local_y * rot_sin) + rot_y = (local_x * rot_sin) + (local_y * rot_cos) + world_x = player_x + rot_x + world_y = player_y + rot_y + + overlay.DrawPoly3D( + world_x, world_y, player_z, + radius_3d, fc.color.to_color(), 32, 2 + ) + + overlay.EndDraw() diff --git a/Widgets/Automation/Multiboxing/HeroAI.py b/Widgets/Automation/Multiboxing/HeroAI.py index 94ec1f377..aab638f74 100644 --- a/Widgets/Automation/Multiboxing/HeroAI.py +++ b/Widgets/Automation/Multiboxing/HeroAI.py @@ -1,6 +1,5 @@ #region Imports import math -import random import sys import traceback import Py4GW @@ -16,8 +15,8 @@ from HeroAI.cache_data import CacheData from HeroAI.constants import (FOLLOW_DISTANCE_OUT_OF_COMBAT, MELEE_RANGE_VALUE, RANGED_RANGE_VALUE) -from HeroAI.globals import hero_formation from HeroAI.utils import (DistanceFromWaypoint) +from HeroAI.following import Follow, LeaderUpdate, draw_follow_config, reset_map_quads as follow_reset_map_quads from HeroAI.windows import (HeroAI_FloatingWindows ,HeroAI_Windows,) from HeroAI.ui import (draw_configure_window, draw_skip_cutscene_overlay) from Py4GWCoreLib import (GLOBAL_CACHE, Agent, ActionQueueManager, LootConfig, @@ -29,7 +28,8 @@ LOOT_THROTTLE_CHECK = ThrottledTimer(250) cached_data = CacheData() -map_quads : list[Map.Pathing.Quad] = [] +map_quads: list[Map.Pathing.Quad] = [] + #region Looting def LootingNode(cached_data: CacheData)-> BehaviorTree.NodeState: options = cached_data.account_options @@ -92,15 +92,15 @@ def HandleCombatFlagging(cached_data: CacheData): # Suspends all activity until HeroAI has made it to the flagged position # Still goes into combat as long as its within the combat follow range value of the expected flag party_number = GLOBAL_CACHE.Party.GetOwnPartyNumber() - own_options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsByPartyNumber(party_number) - leader_options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsByPartyNumber(0) + own_options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(party_number) + leader_options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(0) if not own_options: return False if own_options.IsFlagged: - own_follow_x = own_options.FlagPos.x - own_follow_y = own_options.FlagPos.y + own_follow_x = own_options.FlagPosX + own_follow_y = own_options.FlagPosY own_flag_coords = (own_follow_x, own_follow_y) if ( Utils.Distance(own_flag_coords, Agent.GetXY(Player.GetAgentID())) @@ -108,8 +108,8 @@ def HandleCombatFlagging(cached_data: CacheData): ): return True # Forces a reset on autoattack timer elif leader_options and leader_options.IsFlagged: - leader_follow_x = leader_options.AllFlag.x - leader_follow_y = leader_options.AllFlag.y + leader_follow_x = leader_options.FlagPosX + leader_follow_y = leader_options.FlagPosY leader_flag_coords = (leader_follow_x, leader_follow_y) if ( Utils.Distance(leader_flag_coords, Agent.GetXY(Player.GetAgentID())) @@ -169,79 +169,8 @@ def HandleAutoAttack(cached_data: CacheData) -> bool: #region Following -following_flag = False -last_follow_move_point: tuple[float, float] | None = None -follow_map_entry_signature: tuple[int, int, int, int] | None = None -follow_require_front_after_map_entry = False -def Follow(cached_data: CacheData) -> BehaviorTree.NodeState: - global last_follow_move_point, follow_map_entry_signature, follow_require_front_after_map_entry - - options = cached_data.account_options - if not options or not options.Following: # halt operation if following is disabled - return BehaviorTree.NodeState.FAILURE - - if not cached_data.follow_throttle_timer.IsExpired(): - return BehaviorTree.NodeState.FAILURE - - if Player.GetAgentID() == GLOBAL_CACHE.Party.GetPartyLeaderID(): - cached_data.follow_throttle_timer.Reset() - return BehaviorTree.NodeState.FAILURE - - map_sig = ( - int(Map.GetMapID()), - int(Map.GetRegion()[0]), - int(Map.GetDistrict()), - int(Map.GetLanguage()[0]), - ) - if follow_map_entry_signature != map_sig: - follow_map_entry_signature = map_sig - follow_require_front_after_map_entry = True - last_follow_move_point = None - - follow_x = float(options.FollowPos.x) - follow_y = float(options.FollowPos.y) - follow_z = int(float(getattr(options.FollowPos, "z", 0.0))) - if cached_data.data.in_aggro: - combat_threshold_raw = float(getattr(options, "FollowMoveThresholdCombat", -1.0)) - if combat_threshold_raw >= 0.0: - follow_distance = max(0.0, combat_threshold_raw) - else: - follow_distance = max(0.0, float(getattr(options, "FollowMoveThreshold", 0.0))) - else: - follow_distance = max(0.0, float(getattr(options, "FollowMoveThreshold", 0.0))) - if Utils.Distance((follow_x, follow_y), Player.GetXY()) <= follow_distance: - # Inside threshold: do not let follow preempt OOC/combat logic. - return BehaviorTree.NodeState.FAILURE - - if follow_require_front_after_map_entry: - px, py = Player.GetXY() - dx = follow_x - px - dy = follow_y - py - if abs(dx) > 0.001 or abs(dy) > 0.001: - facing = Agent.GetRotationAngle(Player.GetAgentID()) - if ((dx * math.cos(facing)) + (dy * math.sin(facing))) <= 0.0: - return BehaviorTree.NodeState.FAILURE - - xx = follow_x - yy = follow_y - if last_follow_move_point is not None: - last_x, last_y = last_follow_move_point - if abs(xx - last_x) <= 0.0001 and abs(yy - last_y) <= 0.0001: - xx += random.uniform(-5.0, 5.0) - yy += random.uniform(-5.0, 5.0) - - ActionQueueManager().ResetQueue("ACTION") - #Player.Move(xx, yy, follow_z) - Player.Move(xx, yy) - - last_follow_move_point = (xx, yy) - follow_require_front_after_map_entry = False - cached_data.follow_throttle_timer.Reset() - # In combat and out of range: fleeing/repositioning should preempt combat for this tick. - if cached_data.data.in_aggro: - return BehaviorTree.NodeState.SUCCESS - # Out of combat: keep follow non-blocking so OOC behavior can still run freely. - return BehaviorTree.NodeState.FAILURE +# Follow logic moved to HeroAI.following module +# Follow() and LeaderUpdate() are imported at the top show_debug = False @@ -267,7 +196,10 @@ def handle_UI (cached_data: CacheData): if show_debug: draw_debug_window(cached_data) - HeroAI_FloatingWindows.show_ui(cached_data) + HeroAI_FloatingWindows.show_ui(cached_data) + + # Leader-only follow config window + draw_follow_config(cached_data) def initialize(cached_data: CacheData) -> bool: if not Routines.Checks.Map.MapValid(): @@ -285,6 +217,10 @@ def initialize(cached_data: CacheData) -> bool: HeroAI_Windows.DrawFlags(cached_data) HeroAI_FloatingWindows.draw_Targeting_floating_buttons(cached_data) cached_data.UpdateCombat() + + # Leader calculates and writes follow positions for all followers + LeaderUpdate(cached_data) + return True @@ -436,7 +372,12 @@ def movement_interrupt() -> BehaviorTree.NodeState: # Follow BehaviorTree.ActionNode( name="Follow", - action_fn=lambda: Follow(cached_data), + action_fn=lambda: ( + cached_data.follow_throttle_timer.Reset() + or BehaviorTree.NodeState.SUCCESS + if Follow(cached_data) + else BehaviorTree.NodeState.FAILURE + ), ), # Combat @@ -534,6 +475,7 @@ def main(): pass else: map_quads.clear() + follow_reset_map_quads() HeroAI_BT.reset() @@ -561,4 +503,4 @@ def on_enable(): HeroAI_FloatingWindows.settings.reset() HeroAI_FloatingWindows.SETTINGS_THROTTLE.SetThrottleTime(50) -__all__ = ['main', 'configure', 'on_enable'] +__all__ = ['main', 'configure', 'on_enable'] \ No newline at end of file diff --git a/Widgets/Data/rarity_filter_data.json b/Widgets/Data/rarity_filter_data.json index 955344f33..39147e30c 100644 --- a/Widgets/Data/rarity_filter_data.json +++ b/Widgets/Data/rarity_filter_data.json @@ -1,7 +1,7 @@ { - "white": false, - "blue": false, - "purple": false, + "white": true, + "blue": true, + "purple": true, "gold": true, "green": true, "gold_coins": true From 2ad5ac60e7877f7b6524ce36f40f031eb7b33d3e Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:07:59 +0100 Subject: [PATCH 2/8] Fix Advanced Pathing UI, stability, reload loop, and slider logic --- HeroAI/following.py | 414 ++++++++- HeroAI/settings.py | 161 +++- HeroAI/windows.py | 845 ++++++++---------- Py4GWCoreLib/__init__.py | 2 + .../Bots/Missions/Core/Underworld.py | 225 +---- Widgets/Automation/Multiboxing/HeroAI.py | 16 + 6 files changed, 920 insertions(+), 743 deletions(-) diff --git a/HeroAI/following.py b/HeroAI/following.py index 632160fa9..231eba120 100644 --- a/HeroAI/following.py +++ b/HeroAI/following.py @@ -9,22 +9,27 @@ formation canvas preview, and 3D overlay visualization. """ -import math import Py4GW import PyImGui +import math +import random +import time -from Py4GWCoreLib import ( - GLOBAL_CACHE, Agent, Player, Map, Range, Utils, - ActionQueueManager, Routines, -) -from Py4GWCoreLib.Overlay import Overlay from Py4GWCoreLib.ImGui import ImGui -from Py4GWCoreLib.IniManager import IniManager +from Py4GWCoreLib.Agent import Agent +from Py4GWCoreLib.Player import Player +from Py4GWCoreLib.enums_src.GameData_enums import Range from Py4GWCoreLib.py4gwcorelib_src.Color import Color, ColorPalette -from Py4GWCoreLib.py4gwcorelib_src.Console import ConsoleLog +from Py4GWCoreLib.Overlay import Overlay +from Py4GWCoreLib.IniManager import IniManager +from Py4GWCoreLib.Pathing import AutoPathing, AStar, NavMesh +from Py4GWCoreLib import ActionQueueManager +from Py4GWCoreLib import Utils +from Py4GWCoreLib import GLOBAL_CACHE from Py4GWCoreLib.native_src.internals.types import Vec2f from HeroAI.cache_data import CacheData +from HeroAI.settings import Settings from HeroAI.constants import ( MODULE_NAME, MELEE_RANGE_VALUE, @@ -188,6 +193,11 @@ def _is_position_on_map(x: float, y: float) -> bool: global _map_quads if not settings.confirm_follow_point: return True + + # Global Toggle Check + if not Settings().advanced_pathing_enabled: + return True + if not _map_quads: _map_quads = Map.Pathing.GetMapQuads() for quad in _map_quads: @@ -198,8 +208,10 @@ def _is_position_on_map(x: float, y: float) -> bool: def reset_map_quads(): """Call when map changes to clear cached pathing data.""" - global _map_quads - _map_quads.clear() + global _map_quads, follower_states + _map_quads = [] + if 'follower_states' in globals(): + follower_states.clear() # ───────────────────────────────────────────── @@ -327,51 +339,397 @@ def LeaderUpdate(cached_data: CacheData): # ───────────────────────────────────────────── following_flag = False +class FollowerState: + def __init__(self): + self.last_pos = (0.0, 0.0) + self.last_move_time = 0.0 + self.stuck_start_time = 0.0 + self.is_stuck = False + self.path = [] + self.path_index = 0 + self.last_path_calc_time = 0.0 + self.last_target_pos = (0.0, 0.0) + self.unstuck_target = None + self.unstuck_timestamp = 0.0 + self.unstuck_attempt_count = 0 + self.last_unstuck_time = 0.0 # Track when we last successfully unstuck + +follower_states = {} # agent_id -> FollowerState + def Follow(cached_data: CacheData) -> bool: """ BT ActionNode — runs on each follower. Reads the leader-assigned FollowPos from shared memory and moves there. + Handles obstacle avoidance via local NavMesh A* and unstuck logic. Returns True if a move was issued, False otherwise. """ - global following_flag + global following_flag, follower_states options = cached_data.account_options if not options or not options.Following: return False - - if not cached_data.follow_throttle_timer.IsExpired(): + + my_agent_id = Player.GetAgentID() + if my_agent_id == GLOBAL_CACHE.Party.GetPartyLeaderID(): + # Leader logic handled elsewhere or skipped + cached_data.follow_throttle_timer.Reset() return False - # Leader doesn't follow - if Player.GetAgentID() == GLOBAL_CACHE.Party.GetPartyLeaderID(): - cached_data.follow_throttle_timer.Reset() + if not cached_data.follow_throttle_timer.IsExpired(): return False + # Check for global setting updates (e.g. toggle enabled/disabled) + Settings().check_for_updates() + + # Initialize state if needed + if my_agent_id not in follower_states: + follower_states[my_agent_id] = FollowerState() + follower_states[my_agent_id].last_update_time = 0.0 + + state = follower_states[my_agent_id] + + current_time = time.time() + + # Check for resumption after pause (e.g. toggle off/on) + # Check for resumption after pause (e.g. toggle off/on) + # Increased to 5.0s to avoid resetting on normal 1.0s throttle intervals + if (current_time - getattr(state, 'last_update_time', 0.0)) > 5.0: + # Reset stuck state + if state.stuck_start_time != 0.0: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id}: Stuck State RESET due to inactivity/pause > 5.0s", Py4GW.Console.MessageType.Info) + state.is_stuck = False + state.stuck_start_time = 0.0 + state.unstuck_target = None + state.unstuck_attempt_count = 0 + state.path = [] + + state.last_update_time = current_time + # Read assigned position from shared memory follow_pos = options.FollowPos target_x = follow_pos.x target_y = follow_pos.y - # (0,0) means leader says: no move needed (close enough or no valid pos) - point_zero = (0.0, 0.0) - if Utils.Distance((target_x, target_y), point_zero) <= 5: + # (0,0) signal check + if Utils.Distance((target_x, target_y), (0.0, 0.0)) <= 5: + state.path = [] # Clear path if stopped return False if not Agent.IsValid(GLOBAL_CACHE.Party.GetPartyLeaderID()): return False following_flag = options.IsFlagged + my_x, my_y = Agent.GetXY(my_agent_id) + my_z = Agent.GetZPlane(my_agent_id) # Z needed for pathing sometimes? mostly 2D here + + # --- STRICT DEACTIVATION FALLBACK --- + # If the module is off, bypass all custom pathing/unstuck logic and do "normal" follow. + if not Settings().advanced_pathing_enabled: + state.path = [] # Clear cached path data + state.is_stuck = False + state.stuck_start_time = 0.0 + state.unstuck_target = None + state.unstuck_attempt_count = 0 + + dist_to_target = Utils.Distance((target_x, target_y), (my_x, my_y)) + if dist_to_target < 50: + return False + + ActionQueueManager().ResetQueue("ACTION") + Player.Move(target_x, target_y) + return True + + current_time = time.time() + + # --- 0.5. VALIDATE TARGET ON NAVMESH --- + # Check if target is valid on NavMesh to prevent "Invalid start or goal trapezoid" and stuck loops + if Settings().advanced_pathing_enabled: + navmesh = AutoPathing().get_navmesh() + if navmesh: + target_id = navmesh.find_trapezoid_id_by_coord((target_x, target_y)) + if not target_id: + # Target is off-mesh (wall/obstacle). Pull towards leader until valid. + leader_id = GLOBAL_CACHE.Party.GetPartyLeaderID() + if Agent.IsValid(leader_id): + lx, ly = Agent.GetXY(leader_id) + found_valid = False + # Try 5 steps from target to leader + for i in range(1, 6): + factor = i / 5.0 + check_x = target_x + (lx - target_x) * factor + check_y = target_y + (ly - target_y) * factor + if navmesh.find_trapezoid_id_by_coord((check_x, check_y)): + target_x, target_y = check_x, check_y + found_valid = True + # Py4GW.Console.Log("HeroAI", f"Adjusted off-mesh target to valid point ({target_x:.1f}, {target_y:.1f})", Py4GW.Console.MessageType.Info) + break + + if not found_valid: + # worst case, go to leader + target_x, target_y = lx, ly + + # --- 1. UNSTUCK LOGIC --- + # Detect if we should be moving but aren't + dist_to_target = Utils.Distance((target_x, target_y), (my_x, my_y)) + + # If we are far enough to move + if dist_to_target > 50: + # Check if we moved significantly since last check + dist_moved = Utils.Distance((my_x, my_y), state.last_pos) + + # Hypersensitive Mode: If we were stuck recently (<10s), check faster! + # Normal: Check every 0.75s, Stuck after 1.5s + # Hyper: Check every 0.3s, Stuck after 0.5s + if Settings().advanced_pathing_enabled: + recidivism_memory = Settings().recidivism_memory + hypersensitive_speed = Settings().hypersensitive_speed + else: + recidivism_memory = 0.0 # Standard mode only + hypersensitive_speed = 0.75 # Standard speed + + is_recidivist = (current_time - state.last_unstuck_time) < recidivism_memory + check_interval = hypersensitive_speed if is_recidivist else 0.75 + stuck_threshold = 0.5 if is_recidivist else 1.5 + + # If we haven't moved much in a while AND we tried to move + if dist_moved < 10 and (current_time - state.last_move_time) > check_interval: + if state.stuck_start_time == 0.0: + state.stuck_start_time = current_time + # Only log info if not spamming hyper mode + if not is_recidivist: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id}: Possible stuck. Moved {dist_moved:.2f} (<10) in {(current_time - state.last_move_time):.2f}s. DistToTarget: {dist_to_target:.1f}", Py4GW.Console.MessageType.Info) + + elif (current_time - state.stuck_start_time) > stuck_threshold: + if not state.is_stuck: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id} CONFIRMED STUCK at ({my_x:.1f}, {my_y:.1f}). Initiating smart unstuck sequence.", Py4GW.Console.MessageType.Warning) + + # Recidivism Check: If we were stuck recently ( recidivism_memory: + state.unstuck_attempt_count = 0 # New incident, start from 0 + else: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id}: Recidivism detected (<{recidivism_memory}s). Resuming sequence at Attempt {state.unstuck_attempt_count}.", Py4GW.Console.MessageType.Warning) + + state.is_stuck = True + else: + if state.stuck_start_time != 0.0: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id}: Stuck State RESET. Moved {dist_moved:.1f} units.", Py4GW.Console.MessageType.Info) + + state.stuck_start_time = 0.0 # Reset if moving + if dist_moved >= 10: + if state.is_stuck: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id} unstuck successfully. Resuming pathing.", Py4GW.Console.MessageType.Info) + state.last_unstuck_time = current_time # Record success time + + state.is_stuck = False # Clear stuck status if we engaged movement + state.unstuck_target = None + + # Only reset attempt count if we've been free for > RecidivismMemory (Recidivism check) + if (current_time - state.last_unstuck_time) > recidivism_memory: + state.unstuck_attempt_count = 0 + + # LOGIC FIX: Only update last_pos and last_move_time if we actually moved significantly! + # Otherwise, we accumulate time until we trigger the stuck threshold. + if dist_moved >= 10: + state.last_pos = (my_x, my_y) + state.last_move_time = current_time + + # Log movement for debug (uncomment if needed, effectively "show coords") + if dist_moved > 0: + leader_id = GLOBAL_CACHE.Party.GetPartyLeaderID() + lx, ly = Agent.GetXY(leader_id) + Py4GW.Console.Log("HeroAI", f"Pos:({my_x:.0f},{my_y:.0f}) Target:({target_x:.0f},{target_y:.0f}) Leader:({lx:.0f},{ly:.0f}) DistT:{dist_to_target:.0f}", Py4GW.Console.MessageType.Info) + + if state.stuck_start_time == 0.0: + pass + else: + # We are close to target, so not "stuck" in a bad way, just arrived? + # Only reset if we were previously stuck to clear the flag + if state.stuck_start_time != 0.0: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id}: Stuck State RESET. Reached target range (Dist: {dist_to_target:.1f}).", Py4GW.Console.MessageType.Info) + + if state.is_stuck: + Py4GW.Console.Log("HeroAI", f"Follower {my_agent_id} reached target range. Clearing stuck status.", Py4GW.Console.MessageType.Info) + state.stuck_start_time = 0.0 + state.is_stuck = False + state.unstuck_target = None + + # Only reset attempt count if we've been free for > RecidivismMemory (Recidivism check) + recidivism_memory = Settings().recidivism_memory + if (current_time - state.last_unstuck_time) > recidivism_memory: + state.unstuck_attempt_count = 0 + + # Execute Unstuck Maneuver + if state.is_stuck: + # If we don't have a target or it's been too long, pick a new one + # Reduced retarget interval from 2.0s to 1.0s for punchier actions + if state.unstuck_target is None or (current_time - state.unstuck_timestamp) > 1.0: + # Determine strategy based on attempt count + dx = target_x - my_x + dy = target_y - my_y + dist = math.sqrt(dx*dx + dy*dy) + if dist > 0.1: + dx /= dist + dy /= dist + else: + dx, dy = 1.0, 0.0 # Default forward + + strategy = "Unknown" + radius = Settings().unstuck_radius + + # Attempt 0: Backtrack/Reverse + if state.unstuck_attempt_count == 0: + ux = my_x - dx * radius + uy = my_y - dy * radius + strategy = "Backtrack" + # Attempt 1: Backtrack + Strafe Right (Diagonal Back-Right) + elif state.unstuck_attempt_count == 1: + ux = my_x - dx * radius + dy * radius + uy = my_y - dy * radius - dx * radius + strategy = "Backtrack + Right" + # Attempt 2: Backtrack + Strafe Left (Diagonal Back-Left) + elif state.unstuck_attempt_count == 2: + ux = my_x - dx * radius - dy * radius + uy = my_y - dy * radius + dx * radius + strategy = "Backtrack + Left" + # Attempt 3: Strafe Right (Pure) + elif state.unstuck_attempt_count == 3: + ux = my_x + dy * radius + uy = my_y - dx * radius + strategy = "Strafe Right" + # Attempt 4: Strafe Left (Pure) + elif state.unstuck_attempt_count == 4: + ux = my_x - dy * radius + uy = my_y + dx * radius + strategy = "Strafe Left" + # Attempt 3+: Random Wiggle + else: + angle = random.uniform(0, 2 * math.pi) + ux = my_x + math.cos(angle) * radius + uy = my_y + math.sin(angle) * radius + strategy = "Random Wiggle" + + state.unstuck_target = (ux, uy) + state.unstuck_timestamp = current_time + state.unstuck_attempt_count += 1 + Py4GW.Console.Log("HeroAI", f"Unstuck Attempt {state.unstuck_attempt_count}: {strategy} to ({ux:.1f}, {uy:.1f})", Py4GW.Console.MessageType.Info) + + # Move to unstuck target + ux, uy = state.unstuck_target + ActionQueueManager().ResetQueue("ACTION") + Player.Move(ux, uy) + + # Reset stuck flag after a periodic attempt to verify if it worked + # Increased reset timeout from 3.0s to 8.0s to allow Backtrack/Wiggle attempts + if (current_time - state.stuck_start_time) > 8.0: + state.stuck_start_time = 0.0 # Data reset to try again or resume normal + state.is_stuck = False + state.unstuck_target = None + Py4GW.Console.Log("HeroAI", f"Unstuck: Resetting stuck state to retry normal pathing/movement.", Py4GW.Console.MessageType.Info) + + return True - # Minimal jitter threshold — the leader already did the real distance gating - my_x, my_y = Agent.GetXY(Player.GetAgentID()) - distance = Utils.Distance((target_x, target_y), (my_x, my_y)) - if distance < 50: - return False # already practically there + # --- 2. PATHING LOGIC --- + + # Check if we are close enough to just stop + if dist_to_target < 50: + return False + + # Get NavMesh for current map + navmesh = AutoPathing().get_navmesh() + + # If no navmesh, fallback to direct line interact + if not navmesh: + ActionQueueManager().ResetQueue("ACTION") + Py4GW.Console.Log("HeroAI", f"No NavMesh. Direct move to {target_x:.1f}, {target_y:.1f}", Py4GW.Console.MessageType.Info) + Player.Move(target_x, target_y) + return True + + # Check Line of Sight to target + has_los = navmesh.has_line_of_sight((my_x, my_y), (target_x, target_y)) - # Issue move command - ActionQueueManager().ResetQueue("ACTION") - Player.Move(target_x, target_y) - return True + if has_los: + # Clear path if we can go direct + state.path = [] + ActionQueueManager().ResetQueue("ACTION") + # Log direct LOS movement + Py4GW.Console.Log("HeroAI", f"Direct Move (LOS) to ({target_x:.1f}, {target_y:.1f}) Dist: {dist_to_target:.1f}", Py4GW.Console.MessageType.Info) + Player.Move(target_x, target_y) + return True + else: + # We need a path + # Recompute path if: + # 1. No path + # 2. Target moved significantly + # 3. Path is stale (optional time check) + + dist_target_moved = Utils.Distance((target_x, target_y), state.last_target_pos) + + if not state.path or dist_target_moved > 100 or state.path_index >= len(state.path): + # Calculate A* + astar = AStar(navmesh) + # AStar search is synchronous here + t_start = time.time() + success = astar.search((my_x, my_y), (target_x, target_y)) + t_end = time.time() + t_dur = t_end - t_start + + if t_dur > 0.1: + Py4GW.Console.Log("HeroAI", f"A* Pathfinding took {t_dur:.3f}s. Start:({my_x:.1f},{my_y:.1f}) End:({target_x:.1f},{target_y:.1f})", Py4GW.Console.MessageType.Warning) + + if success: + raw_path = astar.get_path() + path_str = " -> ".join([f"({p[0]:.0f},{p[1]:.0f})" for p in raw_path[:5]]) + Py4GW.Console.Log("HeroAI", f"Path found! Len:{len(raw_path)} Nodes: [{path_str} ...]", Py4GW.Console.MessageType.Info) + + # Sanity Check: If first REAL step is super far, log warning and FALLBACK + # raw_path[0] is start_pos (me), so we need to check raw_path[1] + if len(raw_path) > 1: + d1 = Utils.Distance((my_x, my_y), raw_path[1]) + if d1 > Settings().sanity_check_distance: + Py4GW.Console.Log("HeroAI", f"WARNING: First Step (Node 1) is {d1:.1f} units away! Path rejected (Ghost Wall/Mesh Error). Forcing Direct Move.", Py4GW.Console.MessageType.Warning) + # Fallback to direct move + ActionQueueManager().ResetQueue("ACTION") + Player.Move(target_x, target_y) + return True + + # Smooth path + # ... (rest of smoothing logic) + state.path = raw_path # Simplified assignment for now, smoothing can be added back if needed + state.path_index = 0 + state.last_target_pos = (target_x, target_y) + state.last_path_calc_time = current_time + else: + # Fallback if pathfinding fails (e.g. goal off mesh) + Py4GW.Console.Log("HeroAI", f"Pathfinding failed after {t_dur:.3f}s to {(target_x, target_y)}, falling back to direct move.", Py4GW.Console.MessageType.Warning) + ActionQueueManager().ResetQueue("ACTION") + Player.Move(target_x, target_y) + return True + + # Follow the path + if state.path and state.path_index < len(state.path): + # Get next waypoint + wp = state.path[state.path_index] + dist_to_wp = Utils.Distance((my_x, my_y), wp) + + # If close to waypoint, advance + if dist_to_wp < 80: + state.path_index += 1 + if state.path_index < len(state.path): + wp = state.path[state.path_index] + else: + # Reached end of path + ActionQueueManager().ResetQueue("ACTION") + Player.Move(target_x, target_y) + return True + + # Move to waypoint + ActionQueueManager().ResetQueue("ACTION") + # Log normal path movement + Py4GW.Console.Log("HeroAI", f"Moving to Path WP: ({wp[0]:.1f}, {wp[1]:.1f})", Py4GW.Console.MessageType.Info) + Player.Move(wp[0], wp[1]) + return True + + return False # ───────────────────────────────────────────── diff --git a/HeroAI/settings.py b/HeroAI/settings.py index 0e01d6409..a24386081 100644 --- a/HeroAI/settings.py +++ b/HeroAI/settings.py @@ -48,14 +48,16 @@ def to_pos_string(self) -> str: return f"{self.position[0]},{self.position[1]}" @staticmethod - def from_ini_string(identifier: str, ini_string: str) -> 'Settings.CommandHotBar': + def from_ini_string(identifier: str, ini_string: str) -> tuple['Settings.CommandHotBar', bool]: hotbar = Settings.CommandHotBar() hotbar.identifier = identifier hotbar.commands = {} + upgraded = False try: if ini_string.startswith("True") or ini_string.startswith("False"): ini_string = f"Hotbar;{Docked.Freely.name};{Alignment.TopCenter.name};{ini_string}" + upgraded = True name, docked_str, aligned_str, visible_str, button_size_str, *command_rows_str = ini_string.split(";") hotbar.name = name @@ -80,8 +82,9 @@ def from_ini_string(identifier: str, ini_string: str) -> 'Settings.CommandHotBar except Exception as e: ConsoleLog("HeroAI", f"Error parsing CommandHotBar from ini string: {e}") + upgraded = True # Assume we need to resave if it's broken - return hotbar + return hotbar, upgraded _instance = None _instance_initialized = False @@ -136,6 +139,13 @@ def __init__(self): self.ShowFloatingTargets = True self.ShowPartyPanelUI = True self.HeroPanelPositions : dict[str, Settings.HeroPanelInfo] = {} + + # Advanced Pathing Settings + self.advanced_pathing_enabled = True + self.sanity_check_distance = 400.0 + self.unstuck_radius = 80.0 + self.recidivism_memory = 10.0 + self.hypersensitive_speed = 0.3 default_hotbar = Settings.CommandHotBar("hotbar_1") @@ -213,7 +223,24 @@ def initialize_account_config(self): self.write_settings() else: self.load_settings() - + + def check_for_updates(self): + """Check if INI file has changed and reload if necessary.""" + if not os.path.exists(self.ini_path): + return + + try: + mtime = os.path.getmtime(self.ini_path) + if not hasattr(self, 'last_ini_mtime'): + self.last_ini_mtime = mtime + + if mtime > self.last_ini_mtime: + self.last_ini_mtime = mtime # Sync timestamp BEFORE loading to stop loop + self.load_settings() + # ConsoleLog("HeroAI", "Settings reloaded from disk.", Py4GW.Console.MessageType.Info) + except Exception as e: + pass + def save_settings(self): self.save_requested = True @@ -262,6 +289,13 @@ def write_settings(self): self.ini_handler.write_key("General", "ConfirmFollowPoint", str(self.ConfirmFollowPoint)) + # Advanced Pathing + self.ini_handler.write_key("AdvancedPathing", "Enabled", str(self.advanced_pathing_enabled)) + self.ini_handler.write_key("AdvancedPathing", "SanityCheckDistance", str(self.sanity_check_distance)) + self.ini_handler.write_key("AdvancedPathing", "UnstuckRadius", str(self.unstuck_radius)) + self.ini_handler.write_key("AdvancedPathing", "RecidivismMemory", str(self.recidivism_memory)) + self.ini_handler.write_key("AdvancedPathing", "HypersensitiveSpeed", str(self.hypersensitive_speed)) + for hotbar_id, hotbar in self.CommandHotBars.items(): self.ini_handler.write_key("CommandHotBars", hotbar_id, hotbar.to_ini_string()) @@ -276,56 +310,85 @@ def write_settings(self): def load_settings(self): ConsoleLog("HeroAI", "Loading HeroAI settings...") - self.ShowCommandPanel = self.ini_handler.read_bool("General", "ShowCommandPanel", True) - self.PrintDebug = self.ini_handler.read_bool("General", "PrintDebug", False) - self.ShowDebugWindow = self.ini_handler.read_bool("General", "ShowDebug", False) - self.ShowCommandPanelOnlyOnLeaderAccount = self.ini_handler.read_bool("General", "ShowCommandPanelOnlyOnLeaderAccount", True) - self.Anonymous_PanelNames = self.ini_handler.read_bool("General", "Anonymous_PanelNames", False) - - self.ShowPartyOverlay = self.ini_handler.read_bool("General", "ShowPartyOverlay", True) - self.ShowPartySearchOverlay = self.ini_handler.read_bool("General", "ShowPartySearchOverlay", True) - - self.ShowPanelOnlyOnLeaderAccount = self.ini_handler.read_bool("General", "ShowPanelOnlyOnLeaderAccount", True) - self.ShowDialogOverlay = self.ini_handler.read_bool("General", "ShowDialogOverlay", True) - - self.CombinePanels = self.ini_handler.read_bool("General", "CombinePanels", False) - self.ShowHeroPanels = self.ini_handler.read_bool("General", "ShowHeroPanels", True) - self.ShowLeaderPanel = self.ini_handler.read_bool("General", "ShowLeaderPanel", False) - - self.ShowHeroEffects = self.ini_handler.read_bool("General", "ShowHeroEffects", True) - self.ShowEffectDurations = self.ini_handler.read_bool("General", "ShowEffectDurations", True) - self.ShowShortEffectDurations = self.ini_handler.read_bool("General", "ShowShortEffectDurations", True) - self.ShowHeroUpkeeps = self.ini_handler.read_bool("General", "ShowHeroUpkeeps", True) - self.MaxEffectRows = self.ini_handler.read_int("General", "MaxEffectRows", 2) - - self.ShowHeroButtons = self.ini_handler.read_bool("General", "ShowHeroButtons", True) - self.ShowHeroBars = self.ini_handler.read_bool("General", "ShowHeroBars", True) - self.ShowFloatingTargets = self.ini_handler.read_bool("General", "ShowFloatingTargets", True) - self.ShowHeroSkills = self.ini_handler.read_bool("General", "ShowHeroSkills", True) - - self.ShowPartyPanelUI = self.ini_handler.read_bool("General", "ShowPartyPanelUI", True) - self.ShowControlPanelWindow = self.ini_handler.read_bool("General", "ShowControlPanelWindow", True) - - self.ConfirmFollowPoint = self.ini_handler.read_bool("General", "ConfirmFollowPoint", False) + # Save old state of save_requested to prevent loops + old_save_requested = self.save_requested + self.save_requested = False + + try: + self.ShowCommandPanel = self.ini_handler.read_bool("General", "ShowCommandPanel", True) + self.PrintDebug = self.ini_handler.read_bool("General", "PrintDebug", False) + self.ShowDebugWindow = self.ini_handler.read_bool("General", "ShowDebug", False) + self.ShowCommandPanelOnlyOnLeaderAccount = self.ini_handler.read_bool("General", "ShowCommandPanelOnlyOnLeaderAccount", True) + self.Anonymous_PanelNames = self.ini_handler.read_bool("General", "Anonymous_PanelNames", False) + + self.ShowPartyOverlay = self.ini_handler.read_bool("General", "ShowPartyOverlay", True) + self.ShowPartySearchOverlay = self.ini_handler.read_bool("General", "ShowPartySearchOverlay", True) + + self.ShowPanelOnlyOnLeaderAccount = self.ini_handler.read_bool("General", "ShowPanelOnlyOnLeaderAccount", True) + self.ShowDialogOverlay = self.ini_handler.read_bool("General", "ShowDialogOverlay", True) + + self.CombinePanels = self.ini_handler.read_bool("General", "CombinePanels", False) + self.ShowHeroPanels = self.ini_handler.read_bool("General", "ShowHeroPanels", True) + self.ShowLeaderPanel = self.ini_handler.read_bool("General", "ShowLeaderPanel", False) + + self.ShowHeroEffects = self.ini_handler.read_bool("General", "ShowHeroEffects", True) + self.ShowEffectDurations = self.ini_handler.read_bool("General", "ShowEffectDurations", True) + self.ShowShortEffectDurations = self.ini_handler.read_bool("General", "ShowShortEffectDurations", True) + self.ShowHeroUpkeeps = self.ini_handler.read_bool("General", "ShowHeroUpkeeps", True) + self.MaxEffectRows = self.ini_handler.read_int("General", "MaxEffectRows", 2) + + self.ShowHeroButtons = self.ini_handler.read_bool("General", "ShowHeroButtons", True) + self.ShowHeroBars = self.ini_handler.read_bool("General", "ShowHeroBars", True) + self.ShowFloatingTargets = self.ini_handler.read_bool("General", "ShowFloatingTargets", True) + self.ShowHeroSkills = self.ini_handler.read_bool("General", "ShowHeroSkills", True) + + self.ShowPartyPanelUI = self.ini_handler.read_bool("General", "ShowPartyPanelUI", True) + self.ShowControlPanelWindow = self.ini_handler.read_bool("General", "ShowControlPanelWindow", True) + + self.ConfirmFollowPoint = self.ini_handler.read_bool("General", "ConfirmFollowPoint", False) + + # Advanced Pathing + self.advanced_pathing_enabled = self.ini_handler.read_bool("AdvancedPathing", "Enabled", True) + self.sanity_check_distance = self.ini_handler.read_float("AdvancedPathing", "SanityCheckDistance", 400.0) + self.unstuck_radius = self.ini_handler.read_float("AdvancedPathing", "UnstuckRadius", 80.0) + self.recidivism_memory = self.ini_handler.read_float("AdvancedPathing", "RecidivismMemory", 10.0) + self.hypersensitive_speed = self.ini_handler.read_float("AdvancedPathing", "HypersensitiveSpeed", 0.3) + + self.CommandHotBars.clear() + self.import_command_hotbars() + + self.HeroPanelPositions.clear() + self.import_hero_panel_positions(self.account_ini_handler) + + # Restore save_requested unless a legacy upgrade actually happened + # if old_save_requested was true, we keep it true. + # if it was false, we only set it true if import sub-functions requested it. + self.save_requested = old_save_requested or self.save_requested + + # Finally ensure mtime is synced to current file state + if os.path.exists(self.ini_path): + self.last_ini_mtime = os.path.getmtime(self.ini_path) + + ConsoleLog("HeroAI", "HeroAI settings loaded successfully.", Console.MessageType.Info) + + except Exception as e: + ConsoleLog("HeroAI", f"Error loading HeroAI settings: {e}", Console.MessageType.Error) - self.CommandHotBars.clear() - self.import_command_hotbars() - - self.HeroPanelPositions.clear() - self.import_hero_panel_positions(self.account_ini_handler) - def import_hero_panel_positions(self, ini_handler: IniHandler | None): if ini_handler is None: return items = ini_handler.list_keys("HeroPanelPositions") - request_save = False + + # Internal reference check for changes + actual_changes = False for key, value in items.items(): try: parts = value.split(",") if len(parts) != 4: ConsoleLog("HeroAI", f"Legacy HeroPanelPosition format detected for {key}, upgrading...") + actual_changes = True x_str, y_str, collapsed_str, visible_str = parts[0] if len(parts) > 0 else "200", parts[1] if len(parts) > 1 else "200", "false", "true" else: x_str, y_str, collapsed_str, visible_str = parts @@ -334,27 +397,30 @@ def import_hero_panel_positions(self, ini_handler: IniHandler | None): y = int(y_str) collapsed = collapsed_str.lower() == "true" visible = visible_str and visible_str.lower() == "true" - request_save = key not in self.HeroPanelPositions or request_save self.HeroPanelPositions[key] = Settings.HeroPanelInfo(x, y, collapsed, visible) except Exception as e: ConsoleLog("HeroAI", f"Invalid format for Hero Panel of {key}. Using default.") + actual_changes = True self.HeroPanelPositions[key] = Settings.HeroPanelInfo() - if request_save: + if actual_changes: self.save_requested = True def import_command_hotbars(self): items = self.ini_handler.list_keys("CommandHotBars") positions = self.account_ini_handler.list_keys("CommandHotBars") if self.account_ini_handler is not None else {} - request_save = False + actual_changes = False for key, value in items.items(): try: - hotbar = Settings.CommandHotBar.from_ini_string(key, value) + hotbar, upgraded = Settings.CommandHotBar.from_ini_string(key, value) self.CommandHotBars[key] = hotbar + if upgraded: + actual_changes = True + if key in positions: x_str, y_str = positions[key].split(",") x = int(x_str) @@ -363,9 +429,10 @@ def import_command_hotbars(self): except Exception as e: ConsoleLog("HeroAI", f"Error loading CommandHotBar for {key}: {e}") + actual_changes = True - if request_save: - self.save_requested = True + if actual_changes: + self.save_requested = True def get_hero_panel_info(self, account_email: str) -> 'Settings.HeroPanelInfo': info = self.HeroPanelPositions.get(account_email, self.HeroPanelPositions.get(account_email.lower(), Settings.HeroPanelInfo())) diff --git a/HeroAI/windows.py b/HeroAI/windows.py index c94fd8cf6..ffa60bcb8 100644 --- a/HeroAI/windows.py +++ b/HeroAI/windows.py @@ -3,7 +3,6 @@ from Py4GWCoreLib import UIManager, ModelID, GLOBAL_CACHE, WindowFrames from Py4GWCoreLib import Agent, Player from Py4GWCoreLib import (Routines, ActionQueueManager,Key, Keystroke, ThrottledTimer) -from Py4GWCoreLib.IniManager import IniManager from HeroAI.constants import (FOLLOW_DISTANCE_OUT_OF_COMBAT, MAX_NUM_PLAYERS, MELEE_RANGE_VALUE, PARTY_WINDOW_FRAME_EXPLORABLE_OFFSETS, PARTY_WINDOW_FRAME_OUTPOST_OFFSETS, PARTY_WINDOW_HASH, RANGED_RANGE_VALUE) from Py4GWCoreLib.ImGui_src.WindowModule import WindowModule @@ -12,9 +11,12 @@ from .constants import MAX_NUM_PLAYERS, NUMBER_OF_SKILLS from .types import SkillType, SkillNature, Skilltarget -from .globals import capture_mouse_timer, show_area_rings, show_hero_follow_grid, show_distance_on_followers, show_broadcast_follow_positions, show_broadcast_follow_threshold_rings, hero_formation +from .globals import capture_mouse_timer, show_area_rings, show_hero_follow_grid, show_distance_on_followers, hero_formation from .utils import IsHeroFlagged, DrawFlagAll, DrawHeroFlag, DistanceFromWaypoint, SameMapAsAccount from HeroAI.settings import Settings +from Py4GWCoreLib.ImGui_src.Textures import ThemeTextures +from Py4GWCoreLib.ImGui_src.types import StyleTheme, ControlAppearance + from HeroAI.ui import (draw_combined_hero_panel, draw_command_panel, draw_configure_window, draw_dialog_overlay, draw_hero_panel, draw_hotbars, draw_party_overlay, draw_party_search_overlay, draw_skip_cutscene_overlay) @@ -117,17 +119,17 @@ def control_panel_case(cached_data : CacheData): if PyImGui.collapsing_header("Player Control"): for index in range(MAX_NUM_PLAYERS): account = GLOBAL_CACHE.ShMem.GetAccountDataFromPartyNumber(index) - options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsByPartyNumber(index) + options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(index) if account and not account.IsHero: - if PyImGui.tree_node(f"{account.AgentData.CharacterName}##ControlPlayer{index}"): + if PyImGui.tree_node(f"{account.CharacterName}##ControlPlayer{index}"): if options is not None: HeroAI_Windows.DrawPanelButtons(account.AccountEmail, options) PyImGui.tree_pop() else: # follower control panel - options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsFromEmail(cached_data.account_email) + options = GLOBAL_CACHE.ShMem.GetHeroAIOptions(cached_data.account_email) if options is not None: HeroAI_Windows.DrawPanelButtons(cached_data.account_email, options) @@ -209,7 +211,7 @@ def DrawEmbeddedWindow(cached_data: CacheData): @staticmethod def DistanceToDestination(cached_data: CacheData): account = GLOBAL_CACHE.ShMem.GetAccountDataFromEmail(cached_data.account_email) - options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsFromEmail(cached_data.account_email) + options = GLOBAL_CACHE.ShMem.GetHeroAIOptions(cached_data.account_email) if not account: return 0.0 @@ -217,13 +219,7 @@ def DistanceToDestination(cached_data: CacheData): if not options: return 0.0 - if options.IsFlagged: - if account.AgentPartyData.PartyPosition == 0: - destination = (options.AllFlag.x, options.AllFlag.y) - else: - destination = (options.FlagPos.x, options.FlagPos.y) - else: - destination = Agent.GetXY(GLOBAL_CACHE.Party.GetPartyLeaderID()) + destination = (options.FlagPosX, options.FlagPosY) if options.IsFlagged else Agent.GetXY(GLOBAL_CACHE.Party.GetPartyLeaderID()) return Utils.Distance(destination, Agent.GetXY(Player.GetAgentID())) @staticmethod @@ -249,7 +245,7 @@ def combined_hero_panel(own_data : AccountStruct, cached_data: CacheData): combined_identifier = "combined_hero_panel" accounts = cached_data.party.accounts.values() - if not HeroAI_FloatingWindows.settings.ShowPanelOnlyOnLeaderAccount or own_data.AgentPartyData.IsPartyLeader: + if not HeroAI_FloatingWindows.settings.ShowPanelOnlyOnLeaderAccount or own_data.PlayerIsPartyLeader: if HeroAI_FloatingWindows.settings.ShowHeroPanels: messages = GLOBAL_CACHE.ShMem.GetAllMessages() @@ -321,7 +317,7 @@ def show_ui(cached_data: CacheData): if HeroAI_FloatingWindows.settings.ShowPartySearchOverlay: draw_party_search_overlay(cached_data) - if (HeroAI_FloatingWindows.settings.ShowCommandPanel and (own_data.AgentPartyData.IsPartyLeader or not HeroAI_FloatingWindows.settings.ShowCommandPanelOnlyOnLeaderAccount) + if (HeroAI_FloatingWindows.settings.ShowCommandPanel and (own_data.PlayerIsPartyLeader or not HeroAI_FloatingWindows.settings.ShowCommandPanelOnlyOnLeaderAccount) ): draw_command_panel(HeroAI_FloatingWindows.command_panel_window, cached_data) @@ -393,278 +389,6 @@ def __init__(self, button_color:Color, hovered_color:Color, active_color:Color, show_confirm_dialog = False dialog_options = [] target_id = 0 - show_follow_formations_quick_window = False - follow_formations_ini_key = "" - follow_formations_settings_key = "" - follow_runtime_ini_key = "" - follow_runtime_ini_vars_registered = False - follow_formations_names: list[str] = [] - follow_formations_ids: list[str] = [] - follow_formations_selected_index = 0 - follow_move_threshold_default = float(Range.Area.value) - follow_move_threshold_combat = float(Range.Touch.value) - follow_move_threshold_flagged = 0.0 - follow_move_threshold_default_mode = "Area" - follow_move_threshold_combat_mode = "Touch" - follow_move_threshold_flagged_mode = "Zero" - - @staticmethod - def _follow_threshold_presets() -> list[tuple[str, float | None]]: - return [ - ("Manual", None), - ("Zero", 0.0), - (Range.Touch.name, float(Range.Touch.value)), - (Range.Adjacent.name, float(Range.Adjacent.value)), - (Range.Nearby.name, float(Range.Nearby.value)), - (Range.Area.name, float(Range.Area.value)), - (Range.Earshot.name, float(Range.Earshot.value)), - (Range.Spellcast.name, float(Range.Spellcast.value)), - ] - - @staticmethod - def _threshold_mode_index(mode_name: str) -> int: - presets = HeroAI_Windows._follow_threshold_presets() - for i, (name, _) in enumerate(presets): - if name == mode_name: - return i - return 0 - - @staticmethod - def _ensure_follow_module_ini_keys(): - im = IniManager() - if not HeroAI_Windows.follow_formations_ini_key: - HeroAI_Windows.follow_formations_ini_key = im.ensure_global_key("HeroAI", "FollowModule_Formations.ini") - if not HeroAI_Windows.follow_formations_settings_key: - HeroAI_Windows.follow_formations_settings_key = im.ensure_global_key("HeroAI", "FollowModule_Settings.ini") - if not HeroAI_Windows.follow_runtime_ini_key: - HeroAI_Windows.follow_runtime_ini_key = im.ensure_global_key("HeroAI", "FollowRuntime.ini") - if HeroAI_Windows.follow_runtime_ini_key and not HeroAI_Windows.follow_runtime_ini_vars_registered: - im.add_float(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_default", "FollowRuntime", "follow_move_threshold_default", float(Range.Area.value)) - im.add_float(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_combat", "FollowRuntime", "follow_move_threshold_combat", float(Range.Touch.value)) - im.add_float(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_flagged", "FollowRuntime", "follow_move_threshold_flagged", 0.0) - im.add_str(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_default_mode", "FollowRuntime", "follow_move_threshold_default_mode", "Area") - im.add_str(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_combat_mode", "FollowRuntime", "follow_move_threshold_combat_mode", "Touch") - im.add_str(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_flagged_mode", "FollowRuntime", "follow_move_threshold_flagged_mode", "Zero") - HeroAI_Windows.follow_runtime_ini_vars_registered = True - return bool(HeroAI_Windows.follow_formations_ini_key and HeroAI_Windows.follow_formations_settings_key) - - @staticmethod - def _load_follow_runtime_config(): - if not HeroAI_Windows._ensure_follow_module_ini_keys() or not HeroAI_Windows.follow_runtime_ini_key: - return - im = IniManager() - try: - im.reload(HeroAI_Windows.follow_runtime_ini_key) - node = im._get_node(HeroAI_Windows.follow_runtime_ini_key) - if node: - node.vars_loaded = False - im.load_once(HeroAI_Windows.follow_runtime_ini_key) - except Exception: - pass - HeroAI_Windows.follow_move_threshold_default = max( - 0.0, - float(im.getFloat(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_default", float(Range.Area.value), section="FollowRuntime")) - ) - HeroAI_Windows.follow_move_threshold_combat = max( - 0.0, - float(im.getFloat(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_combat", float(Range.Touch.value), section="FollowRuntime")) - ) - HeroAI_Windows.follow_move_threshold_flagged = max( - 0.0, - float(im.getFloat(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_flagged", 0.0, section="FollowRuntime")) - ) - HeroAI_Windows.follow_move_threshold_default_mode = str( - im.getStr(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_default_mode", "Area", section="FollowRuntime") - ) - HeroAI_Windows.follow_move_threshold_combat_mode = str( - im.getStr(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_combat_mode", "Touch", section="FollowRuntime") - ) - HeroAI_Windows.follow_move_threshold_flagged_mode = str( - im.getStr(HeroAI_Windows.follow_runtime_ini_key, "follow_move_threshold_flagged_mode", "Zero", section="FollowRuntime") - ) - - @staticmethod - def _save_follow_runtime_config(): - if not HeroAI_Windows._ensure_follow_module_ini_keys() or not HeroAI_Windows.follow_runtime_ini_key: - return - im = IniManager() - key = HeroAI_Windows.follow_runtime_ini_key - im.set(key, "follow_move_threshold_default", float(HeroAI_Windows.follow_move_threshold_default), section="FollowRuntime") - im.set(key, "follow_move_threshold_combat", float(HeroAI_Windows.follow_move_threshold_combat), section="FollowRuntime") - im.set(key, "follow_move_threshold_flagged", float(HeroAI_Windows.follow_move_threshold_flagged), section="FollowRuntime") - im.set(key, "follow_move_threshold_default_mode", str(HeroAI_Windows.follow_move_threshold_default_mode), section="FollowRuntime") - im.set(key, "follow_move_threshold_combat_mode", str(HeroAI_Windows.follow_move_threshold_combat_mode), section="FollowRuntime") - im.set(key, "follow_move_threshold_flagged_mode", str(HeroAI_Windows.follow_move_threshold_flagged_mode), section="FollowRuntime") - im.save_vars(key) - - @staticmethod - def _load_follow_formations_quick_data(): - if not HeroAI_Windows._ensure_follow_module_ini_keys(): - HeroAI_Windows.follow_formations_names = [] - HeroAI_Windows.follow_formations_ids = [] - HeroAI_Windows.follow_formations_selected_index = 0 - return - im = IniManager() - try: - im.reload(HeroAI_Windows.follow_formations_ini_key) - im.reload(HeroAI_Windows.follow_formations_settings_key) - except Exception: - pass - - count = max(0, im.read_int(HeroAI_Windows.follow_formations_ini_key, "Formations", "count", 0)) - names: list[str] = [] - ids: list[str] = [] - for i in range(count): - fid = str(im.read_key(HeroAI_Windows.follow_formations_ini_key, "Formations", f"id_{i}", "") or "").strip() - name = str(im.read_key(HeroAI_Windows.follow_formations_ini_key, "Formations", f"name_{i}", "") or "").strip() - if not fid or not name: - continue - ids.append(fid) - names.append(name) - - selected_id = str(im.read_key(HeroAI_Windows.follow_formations_settings_key, "Formations", "selected_id", "") or "").strip() - selected_index = 0 - if selected_id and ids: - try: - selected_index = ids.index(selected_id) - except ValueError: - selected_index = 0 - - HeroAI_Windows.follow_formations_names = names - HeroAI_Windows.follow_formations_ids = ids - HeroAI_Windows.follow_formations_selected_index = selected_index - - @staticmethod - def _set_selected_follow_formation(index: int): - if not HeroAI_Windows._ensure_follow_module_ini_keys(): - return - if index < 0 or index >= len(HeroAI_Windows.follow_formations_ids): - return - HeroAI_Windows.follow_formations_selected_index = index - selected_id = HeroAI_Windows.follow_formations_ids[index] - selected_name = HeroAI_Windows.follow_formations_names[index] - - im = IniManager() - key = HeroAI_Windows.follow_formations_settings_key - try: - node = im._get_node(key) - if node: - node.ini_handler.write_key("Formations", "selected_id", selected_id) - node.ini_handler.write_key("Formations", "selected", selected_name) - if hasattr(node, "cached_values") and node.cached_values is not None: - node.cached_values[("Formations", "selected_id")] = str(selected_id) - node.cached_values[("Formations", "selected")] = str(selected_name) - if hasattr(node, "pending_writes") and node.pending_writes is not None: - node.pending_writes.pop(("Formations", "selected_id"), None) - node.pending_writes.pop(("Formations", "selected"), None) - return - except Exception: - pass - im.write_key(key, "Formations", "selected_id", selected_id) - im.write_key(key, "Formations", "selected", selected_name) - - @staticmethod - def DrawFollowFormationsQuickWindow(cached_data:CacheData): - global show_broadcast_follow_positions, show_broadcast_follow_threshold_rings - if not HeroAI_Windows.show_follow_formations_quick_window: - return - - w = cached_data.HeroAI_windows.follow_formations_window - w.initialize() - if w.begin(): - HeroAI_Windows._load_follow_formations_quick_data() - HeroAI_Windows._load_follow_runtime_config() - if PyImGui.button("Refresh Formations"): - HeroAI_Windows._load_follow_formations_quick_data() - HeroAI_Windows._load_follow_runtime_config() - - if HeroAI_Windows.follow_formations_names: - idx = PyImGui.combo( - "Formation", - HeroAI_Windows.follow_formations_selected_index, - HeroAI_Windows.follow_formations_names - ) - if idx != HeroAI_Windows.follow_formations_selected_index: - HeroAI_Windows._set_selected_follow_formation(idx) - else: - PyImGui.text_disabled("No saved follow formations found.") - - show_broadcast_follow_positions = PyImGui.checkbox( - "Draw Followers FollowPos (3D)", - show_broadcast_follow_positions - ) - show_broadcast_follow_threshold_rings = PyImGui.checkbox( - "Draw Followers Threshold Rings (3D)", - show_broadcast_follow_threshold_rings - ) - presets = HeroAI_Windows._follow_threshold_presets() - preset_names = [name for name, _ in presets] - dirty_runtime_cfg = False - - d_idx = PyImGui.combo( - "Follow Threshold Preset", - HeroAI_Windows._threshold_mode_index(HeroAI_Windows.follow_move_threshold_default_mode), - preset_names - ) - d_name, d_val = presets[d_idx] - if d_name != HeroAI_Windows.follow_move_threshold_default_mode: - HeroAI_Windows.follow_move_threshold_default_mode = d_name - if d_val is not None: - HeroAI_Windows.follow_move_threshold_default = float(d_val) - dirty_runtime_cfg = True - new_default_thr = max(0.0, float(PyImGui.input_float("Follow Threshold", float(HeroAI_Windows.follow_move_threshold_default)))) - if abs(new_default_thr - HeroAI_Windows.follow_move_threshold_default) > 0.0001: - HeroAI_Windows.follow_move_threshold_default = new_default_thr - if HeroAI_Windows.follow_move_threshold_default_mode != "Manual": - HeroAI_Windows.follow_move_threshold_default_mode = "Manual" - dirty_runtime_cfg = True - - c_idx = PyImGui.combo( - "Combat Threshold Preset", - HeroAI_Windows._threshold_mode_index(HeroAI_Windows.follow_move_threshold_combat_mode), - preset_names - ) - c_name, c_val = presets[c_idx] - if c_name != HeroAI_Windows.follow_move_threshold_combat_mode: - HeroAI_Windows.follow_move_threshold_combat_mode = c_name - if c_val is not None: - HeroAI_Windows.follow_move_threshold_combat = float(c_val) - dirty_runtime_cfg = True - new_combat_thr = max(0.0, float(PyImGui.input_float("Combat Follow Threshold", float(HeroAI_Windows.follow_move_threshold_combat)))) - if abs(new_combat_thr - HeroAI_Windows.follow_move_threshold_combat) > 0.0001: - HeroAI_Windows.follow_move_threshold_combat = new_combat_thr - if HeroAI_Windows.follow_move_threshold_combat_mode != "Manual": - HeroAI_Windows.follow_move_threshold_combat_mode = "Manual" - dirty_runtime_cfg = True - - f_idx = PyImGui.combo( - "Flag Threshold Preset", - HeroAI_Windows._threshold_mode_index(HeroAI_Windows.follow_move_threshold_flagged_mode), - preset_names - ) - f_name, f_val = presets[f_idx] - if f_name != HeroAI_Windows.follow_move_threshold_flagged_mode: - HeroAI_Windows.follow_move_threshold_flagged_mode = f_name - if f_val is not None: - HeroAI_Windows.follow_move_threshold_flagged = float(f_val) - dirty_runtime_cfg = True - new_flagged_thr = max(0.0, float(PyImGui.input_float("Flag Threshold", float(HeroAI_Windows.follow_move_threshold_flagged)))) - if abs(new_flagged_thr - HeroAI_Windows.follow_move_threshold_flagged) > 0.0001: - HeroAI_Windows.follow_move_threshold_flagged = new_flagged_thr - if HeroAI_Windows.follow_move_threshold_flagged_mode != "Manual": - HeroAI_Windows.follow_move_threshold_flagged_mode = "Manual" - dirty_runtime_cfg = True - - if dirty_runtime_cfg: - HeroAI_Windows._save_follow_runtime_config() - - if Map.IsExplorable() and Player.GetAgentID() == GLOBAL_CACHE.Party.GetPartyLeaderID(): - PyImGui.separator() - if PyImGui.collapsing_header("Flagging", PyImGui.TreeNodeFlags.DefaultOpen): - HeroAI_Windows.DrawFlaggingWindow(cached_data) - - w.process_window() - w.end() @staticmethod def DrawBuffWindow(cached_data:CacheData): @@ -676,14 +400,14 @@ def DrawBuffWindow(cached_data:CacheData): account = GLOBAL_CACHE.ShMem.GetAccountDataFromPartyNumber(index) if account and account.IsSlotActive: - if Agent.IsPlayer(account.AgentData.AgentID): - player_name = Agent.GetNameByID(account.AgentData.AgentID) + if Agent.IsPlayer(account.PlayerID): + player_name = Agent.GetNameByID(account.PlayerID) else: - player_name = GLOBAL_CACHE.Party.Heroes.GetNameByAgentID(account.AgentData.AgentID) + player_name = GLOBAL_CACHE.Party.Heroes.GetNameByAgentID(account.PlayerID) if PyImGui.tree_node(f"{player_name}##DebugBuffsPlayer{index}"): # Retrieve buffs for the player - player_buffs = account.AgentData.Buffs.Buffs + player_buffs = account.PlayerBuffs headers = ["Skill ID", "Skill Name"] data = [(buff.SkillId, GLOBAL_CACHE.Skill.GetName(buff.SkillId)) for buff in player_buffs] ImGui.table(f"{player_name} Buffs", headers, data) @@ -804,62 +528,41 @@ def DrawPrioritizedSkills(cached_data:CacheData): @staticmethod def DrawFlags(cached_data:CacheData): - global show_broadcast_follow_positions, show_broadcast_follow_threshold_rings - shmem = GLOBAL_CACHE.ShMem - party = GLOBAL_CACHE.Party - party_heroes = party.Heroes - active_account_option_pairs: list[tuple[AccountStruct, HeroAIOptionStruct]] = shmem.GetAllActiveAccountHeroAIPairs(sort_results=False) - options_by_party: list[HeroAIOptionStruct | None] = [None] * MAX_NUM_PLAYERS - accounts_by_party: list[AccountStruct | None] = [None] * MAX_NUM_PLAYERS - - # Build once per frame, keyed by party position; source is active IsAccount-only. - for account, options in active_account_option_pairs: - party_index: int = account.AgentPartyData.PartyPosition - if 0 <= party_index < MAX_NUM_PLAYERS: - accounts_by_party[party_index] = account - options_by_party[party_index] = options - - leader_options: HeroAIOptionStruct | None = options_by_party[0] + leader_options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(0) - if HeroAI_Windows.capture_hero_flag: + if HeroAI_Windows.capture_hero_flag: x, y, _ = Overlay().GetMouseWorldPos() if HeroAI_Windows.capture_flag_all: DrawFlagAll(x, y) + pass + else: DrawHeroFlag(x, y) - - mouse_clicked = PyImGui.is_mouse_clicked(0) - if mouse_clicked and HeroAI_Windows.one_time_set_flag: + + if PyImGui.is_mouse_clicked(0) and HeroAI_Windows.one_time_set_flag: HeroAI_Windows.one_time_set_flag = False return - - if mouse_clicked: - capture_index = HeroAI_Windows.capture_hero_index - hero_count = party.GetHeroCount() - - if 0 < capture_index <= hero_count: - if not HeroAI_Windows.capture_flag_all: - agent_id = party_heroes.GetHeroAgentIDByPartyPosition(capture_index) - party_heroes.FlagHero(agent_id, x, y) + + if PyImGui.is_mouse_clicked(0) and not HeroAI_Windows.one_time_set_flag: + if HeroAI_Windows.capture_hero_index > 0 and HeroAI_Windows.capture_hero_index <= GLOBAL_CACHE.Party.GetHeroCount(): + if not HeroAI_Windows.capture_flag_all: + agent_id = GLOBAL_CACHE.Party.Heroes.GetHeroAgentIDByPartyPosition(HeroAI_Windows.capture_hero_index) + GLOBAL_CACHE.Party.Heroes.FlagHero(agent_id, x, y) HeroAI_Windows.one_time_set_flag = True else: - if capture_index == 0: + if HeroAI_Windows.capture_hero_index == 0: hero_ai_index = 0 - party_heroes.FlagAllHeroes(x, y) + GLOBAL_CACHE.Party.Heroes.FlagAllHeroes(x, y) else: - hero_ai_index = capture_index - hero_count - - options: HeroAIOptionStruct | None = options_by_party[hero_ai_index] if 0 <= hero_ai_index < MAX_NUM_PLAYERS else None + hero_ai_index = HeroAI_Windows.capture_hero_index - GLOBAL_CACHE.Party.GetHeroCount() + + options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(hero_ai_index) if options: - if capture_index == 0: - options.AllFlag.x = x - options.AllFlag.y = y - else: - options.FlagPos.x = x - options.FlagPos.y = y + options.FlagPosX = x + options.FlagPosY = y options.IsFlagged = True - options.FlagFacingAngle = Agent.GetRotationAngle(party.GetPartyLeaderID()) - + options.FlagFacingAngle = Agent.GetRotationAngle(GLOBAL_CACHE.Party.GetPartyLeaderID()) + HeroAI_Windows.one_time_set_flag = True HeroAI_Windows.capture_flag_all = False @@ -869,71 +572,31 @@ def DrawFlags(cached_data:CacheData): #All flag is handled by the game even with no heroes if leader_options and leader_options.IsFlagged: - DrawFlagAll(leader_options.AllFlag.x, leader_options.AllFlag.y) + DrawFlagAll(leader_options.FlagPosX, leader_options.FlagPosY) + + for i in range(1, MAX_NUM_PLAYERS): + options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(i) + account = GLOBAL_CACHE.ShMem.GetAccountDataFromPartyNumber(i) - for i in range(1, MAX_NUM_PLAYERS): - options: HeroAIOptionStruct | None = options_by_party[i] - if options is None or not options.IsFlagged: + if options is None: continue + + if options.IsFlagged and account and account.IsSlotActive and not account.IsHero: + DrawHeroFlag(options.FlagPosX, options.FlagPosY) - account: AccountStruct | None = accounts_by_party[i] - if account: - DrawHeroFlag(options.FlagPos.x, options.FlagPos.y) - - if show_broadcast_follow_positions or show_broadcast_follow_threshold_rings: - segments = 24 - Overlay().BeginDraw() - for i in range(1, MAX_NUM_PLAYERS): - options: HeroAIOptionStruct | None = options_by_party[i] - account: AccountStruct | None = accounts_by_party[i] - if options is None or account is None or not account.IsSlotActive: - continue - fx = float(options.FollowPos.x) - fy = float(options.FollowPos.y) - if abs(fx) < 0.001 and abs(fy) < 0.001: - continue - fz = Overlay().FindZ(fx, fy, 0) - if show_broadcast_follow_positions: - Overlay().DrawPoly3D( - fx, fy, fz, - radius=Range.Touch.value / 3, - color=Utils.RGBToColor(0, 255, 255, 140), - numsegments=segments, - thickness=2.0 - ) - Overlay().DrawText3D( - fx, fy, fz - 110, - f"F{i}", - color=Utils.RGBToColor(0, 255, 255, 220), - autoZ=False, centered=True, scale=1.8 - ) - if show_broadcast_follow_threshold_rings: - thr = max(0.0, float(getattr(options, "FollowMoveThreshold", 0.0))) - if thr > 0.0: - Overlay().DrawPoly3D( - fx, fy, fz, - radius=thr, - color=Utils.RGBToColor(255, 215, 0, 110), - numsegments=max(24, segments), - thickness=2.0 - ) - Overlay().EndDraw() - - if HeroAI_Windows.ClearFlags: + if HeroAI_Windows.ClearFlags: for i in range(MAX_NUM_PLAYERS): - options: HeroAIOptionStruct | None = options_by_party[i] + options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(i) if options: options.IsFlagged = False - options.FlagPos.x = 0.0 - options.FlagPos.y = 0.0 - options.AllFlag.x = 0.0 - options.AllFlag.y = 0.0 + options.FlagPosX = 0.0 + options.FlagPosY = 0.0 options.FlagFacingAngle = 0.0 - party_heroes.UnflagHero(i) + GLOBAL_CACHE.Party.Heroes.UnflagHero(i) - party_heroes.UnflagAllHeroes() + GLOBAL_CACHE.Party.Heroes.UnflagAllHeroes() HeroAI_Windows.ClearFlags = False @@ -1029,19 +692,19 @@ def _OnSameParty(self_account, candidate): if _OnSameMap(self_account, account) and not _OnSameParty(self_account, account): PyImGui.table_next_row() PyImGui.table_next_column() - if PyImGui.button(f"Invite##invite_{account.AgentData.AgentID}"): - GLOBAL_CACHE.Party.Players.InvitePlayer(account.AgentData.CharacterName) - GLOBAL_CACHE.ShMem.SendMessage(account_email, account.AccountEmail,SharedCommandType.InviteToParty, (self_account.AgentData.AgentID,0,0,0)) + if PyImGui.button(f"Invite##invite_{account.PlayerID}"): + GLOBAL_CACHE.Party.Players.InvitePlayer(account.CharacterName) + GLOBAL_CACHE.ShMem.SendMessage(account_email, account.AccountEmail,SharedCommandType.InviteToParty, (self_account.PlayerID,0,0,0)) PyImGui.table_next_column() - PyImGui.text(f"{account.AgentData.CharacterName}") + PyImGui.text(f"{account.CharacterName}") else: if not _OnSameMap(self_account, account): PyImGui.table_next_row() PyImGui.table_next_column() - if PyImGui.button(f"Summon##summon_{account.AgentData.AgentID}"): - GLOBAL_CACHE.ShMem.SendMessage(account_email, account.AccountEmail,SharedCommandType.TravelToMap, (self_account.AgentData.Map.MapID,self_account.AgentData.Map.Region,self_account.AgentData.Map.District,0)) + if PyImGui.button(f"Summon##summon_{account.PlayerID}"): + GLOBAL_CACHE.ShMem.SendMessage(account_email, account.AccountEmail,SharedCommandType.TravelToMap, (self_account.MapID,self_account.MapRegion,self_account.MapDistrict,0)) PyImGui.table_next_column() - PyImGui.text(f"{account.AgentData.CharacterName}") + PyImGui.text(f"{account.CharacterName}") PyImGui.end_table() @staticmethod @@ -1054,43 +717,37 @@ def DrawPlayersDebug(cached_data:CacheData): if PyImGui.button("Submit"): self_id = Player.GetAgentID() - account = GLOBAL_CACHE.ShMem.GetAllAccounts().AccountData[HeroAI_Windows.slot_to_write] - options = GLOBAL_CACHE.ShMem.GetAllAccounts().HeroAIOptions[HeroAI_Windows.slot_to_write] + account = GLOBAL_CACHE.ShMem.GetStruct().AccountData[HeroAI_Windows.slot_to_write] + options = GLOBAL_CACHE.ShMem.GetStruct().HeroAIOptions[HeroAI_Windows.slot_to_write] - account.AgentData.AgentID = self_id + account.PlayerID = self_id player_id = Player.GetAgentID() - account.AgentData.Energy.Regen = Agent.GetEnergyRegen(player_id) - account.AgentData.Energy.Current = Agent.GetEnergy(player_id) - account.AgentData.Energy.Max = Agent.GetMaxEnergy(player_id) - account.AgentData.Energy.Pips = Utils.calculate_energy_pips(account.AgentData.Energy.Max, account.AgentData.Energy.Regen) + account.PlayerEnergyRegen = Agent.GetEnergyRegen(player_id) + account.PlayerEnergy = Agent.GetEnergy(player_id) account.IsSlotActive = True account.IsHero = False options.IsFlagged = False - options.FlagPos.x = 0.0 - options.FlagPos.y = 0.0 - options.AllFlag.x = 0.0 - options.AllFlag.y = 0.0 + options.FlagPosX = 0.0 + options.FlagPosY = 0.0 - headers = ["Slot","PlayerID", "EnergyRegen", "Energy", "IsSlotActive", "IsHero", "IsFlagged", "FlagPosX", "FlagPosY", "AllFlagX", "AllFlagY", "LastUpdated"] + headers = ["Slot","PlayerID", "EnergyRegen", "Energy", "IsSlotActive", "IsHero", "IsFlagged", "FlagPosX", "FlagPosY", "LastUpdated"] data = [] for i in range(MAX_NUM_PLAYERS): account = GLOBAL_CACHE.ShMem.GetAccountDataFromPartyNumber(i) - options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsByPartyNumber(i) + options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(i) if account and options: data.append(( i, # Slot index - account.AgentData.AgentID, - f"{account.AgentData.Energy.Regen:.4f}", - f"{account.AgentData.Energy.Current:.4f}", + account.PlayerID, + f"{account.PlayerEnergyRegen:.4f}", + f"{account.PlayerEnergy:.4f}", account.IsSlotActive, account.IsHero, options.IsFlagged, - f"{options.FlagPos.x:.4f}", - f"{options.FlagPos.y:.4f}", - f"{options.AllFlag.x:.4f}", - f"{options.AllFlag.y:.4f}", + f"{options.FlagPosX:.4f}", + f"{options.FlagPosY:.4f}", account.LastUpdated )) @@ -1142,7 +799,7 @@ def DrawGameOptionsDebug(cached_data:CacheData): data = [] for i in range(MAX_NUM_PLAYERS): - options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsByPartyNumber(i) + options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(i) if options is None: continue @@ -1248,7 +905,7 @@ def DrawFollowDebug(cached_data:CacheData): if account and account.IsSlotActive: Overlay().BeginDraw() - player_id = account.AgentData.AgentID + player_id = account.PlayerID if player_id == Player.GetAgentID(): continue target_x, target_y, target_z = Agent.GetXYZ(player_id) @@ -1261,7 +918,9 @@ def DrawFollowDebug(cached_data:CacheData): @staticmethod def DrawOptions(cached_data:CacheData): cached_data.ui_state_data.show_classic_controls = PyImGui.checkbox("Show Classic Controls", cached_data.ui_state_data.show_classic_controls) - #TODO Select combat engine options + + if PyImGui.collapsing_header("Advanced Pathing & Unstuck"): + HeroAI_Windows.DrawAdvancedPathingOptions(cached_data) @staticmethod def DrawMessagingOptions(cached_data:CacheData): @@ -1300,7 +959,7 @@ def _post_pcon_message(params): if self_account.AccountEmail == account.AccountEmail: continue ConsoleLog("Messaging", "Pixelstacking account: " + account.AccountEmail) - GLOBAL_CACHE.ShMem.SendMessage(sender_email, account.AccountEmail, SharedCommandType.PixelStack, (self_account.AgentData.Pos.x,self_account.AgentData.Pos.y,0,0)) + GLOBAL_CACHE.ShMem.SendMessage(sender_email, account.AccountEmail, SharedCommandType.PixelStack, (self_account.PlayerPosX,self_account.PlayerPosY,0,0)) ImGui.show_tooltip("Pixel Stack (Carto Helper)") PyImGui.same_line(0,-1) @@ -1529,6 +1188,180 @@ def DrawMultiboxTools(cached_data:CacheData): cached_data.HeroAI_windows.tools_window.process_window() cached_data.HeroAI_windows.tools_window.end() + @staticmethod + def DrawColoredToggle(label: str, v: bool, width:float =0.0, height:float =0.0, disabled:bool =False) -> bool: + def group_text_with_icons(text: str): + if not text: + return [] + groups = [] + current_type = text[0] in IconsFontAwesome5.ALL_ICONS + current_run = [text[0]] + for ch in text[1:]: + is_icon = ch in IconsFontAwesome5.ALL_ICONS + if is_icon == current_type: + current_run.append(ch) + else: + groups.append((current_type, ''.join(current_run))) + current_run = [ch] + current_type = is_icon + if current_run: + groups.append((current_type, ''.join(current_run))) + return groups + + enabled = not disabled + clicked = False + if disabled: PyImGui.begin_disabled(disabled) + style = ImGui.get_style() + current_style_var = style.ButtonPadding.get_current() + btn_padding = (current_style_var.value1, current_style_var.value2 or 0) + + if current_style_var.img_style_enum: + PyImGui.push_style_var2(current_style_var.img_style_enum, btn_padding[0], btn_padding[1]) + + #NON THEMED + if style.Theme not in ImGui.Textured_Themes: + button_colors = [ + style.ToggleButtonEnabled, + style.ToggleButtonEnabledHovered, + style.ToggleButtonEnabledActive, + ] if v else [ + style.ToggleButtonDisabled, + style.ToggleButtonDisabledHovered, + style.ToggleButtonDisabledActive, + ] + + if enabled: + for button_color in button_colors: + button_color.push_color() + + # Force Green if active and non-themed + if v and enabled: + PyImGui.push_style_color(PyImGui.ImGuiCol.Button, (0.1, 0.6, 0.1, 1.0)) + PyImGui.push_style_color(PyImGui.ImGuiCol.ButtonHovered, (0.2, 0.7, 0.2, 1.0)) + PyImGui.push_style_color(PyImGui.ImGuiCol.ButtonActive, (0.05, 0.5, 0.05, 1.0)) + + clicked = PyImGui.button(label, width, height) + + if v and enabled: + PyImGui.pop_style_color(3) + + if enabled: + for button_color in button_colors: + button_color.pop_color() + + if disabled: PyImGui.end_disabled() + + if clicked: + v = not v + + if current_style_var.img_style_enum: + PyImGui.pop_style_var(1) + return v + + #THEMED + ImGui.push_style_color(PyImGui.ImGuiCol.Button, (0, 0, 0, 0)) + ImGui.push_style_color(PyImGui.ImGuiCol.ButtonHovered, (0, 0, 0, 0)) + ImGui.push_style_color(PyImGui.ImGuiCol.ButtonActive, (0, 0, 0, 0)) + ImGui.push_style_color(PyImGui.ImGuiCol.Text, (0, 0, 0, 0)) + ImGui.push_style_color(PyImGui.ImGuiCol.TextDisabled, (0, 0, 0, 0)) + clicked = PyImGui.button(label, width, height) + ImGui.pop_style_color(5) + + item_rect_min, item_rect_max, item_rect_size = ImGui.get_item_rect() + button_texture_rect = (item_rect_min[0] - 3, item_rect_min[1] - 4, item_rect_size[0] + 9, item_rect_size[1] + 11) if style.Theme is StyleTheme.Guild_Wars else (item_rect_min[0] - 6, item_rect_min[1] - 4, item_rect_size[0] + 12, item_rect_size[1] + 11) + item_rect = (*item_rect_min, *item_rect_size) + + display_label = label.split("##")[0] + + if not v: + style.Text.push_color((180, 180, 180, 200)) + + text_color = style.TextDisabled.get_current().color_int if disabled else style.Text.get_current().color_int + + def get_button_color() -> tuple: + if v: + return (20, 180, 20, 255) # Green for Active + else: + return style.ToggleButtonDisabledActive.rgb_tuple if PyImGui.is_item_active() else style.ToggleButtonDisabledHovered.rgb_tuple if PyImGui.is_item_hovered() else style.ToggleButtonDisabled.rgb_tuple + + tint = get_button_color() if enabled else style.ButtonTextureBackgroundDisabled.get_current().rgb_tuple + + ThemeTextures.Button_Background.value.get_texture().draw_in_drawlist( + button_texture_rect[:2], + button_texture_rect[2:], + tint=tint, + ) + + frame_tint = (255, 255, 255, 255) if ImGui.is_mouse_in_rect(button_texture_rect) and enabled else (200, 200, 200, 255) + ThemeTextures.Button_Frame.value.get_texture().draw_in_drawlist( + button_texture_rect[:2], + button_texture_rect[2:], + tint=frame_tint, + ) + + text_size = PyImGui.calc_text_size(display_label) + text_x = button_texture_rect[0] + ((button_texture_rect[2] - text_size[0]) / 2) + text_y = item_rect[1] + ((item_rect[3] - text_size[1]) / 2) + 2 + + PyImGui.push_clip_rect( + *item_rect, + True + ) + + default_font_size = int(PyImGui.get_text_line_height()) + fontawesome_font_size = int(default_font_size * 0.8) + offset_size = round((default_font_size - fontawesome_font_size) / 2) + + groups = group_text_with_icons(display_label) + font_awesome_string = "".join([run for is_icon, run in groups if is_icon]) + text_string = "".join([run for is_icon, run in groups if not is_icon]) + + text_size_str = PyImGui.calc_text_size(text_string) + ImGui.push_font("Regular", fontawesome_font_size) + font_awesome_text_size = PyImGui.calc_text_size(font_awesome_string) + ImGui.pop_font() + + total_text_size = (text_size_str[0] + font_awesome_text_size[0], max(text_size_str[1], font_awesome_text_size[1])) + + text_x = button_texture_rect[0] + ((button_texture_rect[2] - total_text_size[0]) / 2) + text_y = button_texture_rect[1] + ((button_texture_rect[3] - total_text_size[1]) / 2) + + offset = (0, 0) + + for is_icon, run in groups: + if is_icon: + ImGui.push_font("Regular", fontawesome_font_size) + else: + ImGui.push_font("Regular", default_font_size) + + text_size = PyImGui.calc_text_size(run) + vertical_padding = 1 if is_icon else offset_size + + PyImGui.draw_list_add_text( + text_x + offset[0], + text_y + vertical_padding, + text_color, + run, + ) + + offset = (offset[0] + text_size[0], vertical_padding) + + ImGui.pop_font() + + PyImGui.pop_clip_rect() + if not v: + style.Text.pop_color() + + if current_style_var.img_style_enum: + PyImGui.pop_style_var(1) + + if disabled:PyImGui.end_disabled() + + if clicked: + v = not v + + return v + @staticmethod def DrawPanelButtons(identifier: str, source_game_option : HeroAIOptionStruct, set_global : bool = False): style = ImGui.get_style() @@ -1542,10 +1375,10 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ return for account in accounts: - if not account or not account.IsSlotActive or account.IsHero or account.AgentPartyData.PartyID != GLOBAL_CACHE.Party.GetPartyID(): + if not account or not account.IsSlotActive or account.IsHero or account.PartyID != GLOBAL_CACHE.Party.GetPartyID(): continue - account_options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsFromEmail(account.AccountEmail) + account_options = GLOBAL_CACHE.ShMem.GetHeroAIOptions(account.AccountEmail) if not account_options: continue @@ -1562,18 +1395,20 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ ConsoleLog("HeroAI", f"Setting Skills[{skill_index}] to {game_option.Skills[skill_index]} for account {account.AccountEmail}") account_options.Skills[skill_index] = game_option.Skills[skill_index] - avail_x, avail_y = PyImGui.get_content_region_avail() - table_width = avail_x - btn_size = (table_width / 5) - 4 + style = ImGui.get_style() + table_width, _ = PyImGui.get_content_region_avail() + btn_size = (table_width / 6) - 4 skill_size = (table_width / NUMBER_OF_SKILLS) - 4 style.ItemSpacing.push_style_var(0, 0) style.CellPadding.push_style_var(2, 2) - if PyImGui.begin_table(f"GameOptionTable##{identifier}", 5, 0, table_width, btn_size + 2): + if PyImGui.begin_table(f"GameOptionTable##{identifier}", 6, 0, table_width, btn_size + 2): PyImGui.table_next_row() PyImGui.table_next_column() - Following = ImGui.toggle_button(IconsFontAwesome5.ICON_RUNNING + "##Following" + identifier, source_game_option.Following, btn_size, btn_size) + + Following = HeroAI_Windows.DrawColoredToggle(IconsFontAwesome5.ICON_RUNNING + "##Following" + identifier, source_game_option.Following, btn_size, btn_size) + if Following != source_game_option.Following: source_game_option.Following = Following @@ -1582,7 +1417,8 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ ImGui.show_tooltip("Following") PyImGui.table_next_column() - Avoidance = ImGui.toggle_button(IconsFontAwesome5.ICON_PODCAST + "##Avoidance" + identifier, source_game_option.Avoidance, btn_size, btn_size) + Avoidance = HeroAI_Windows.DrawColoredToggle(IconsFontAwesome5.ICON_PODCAST + "##Avoidance" + identifier, source_game_option.Avoidance, btn_size, btn_size) + if Avoidance != source_game_option.Avoidance: source_game_option.Avoidance = Avoidance @@ -1591,7 +1427,8 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ ImGui.show_tooltip("Avoidance") PyImGui.table_next_column() - Looting = ImGui.toggle_button(IconsFontAwesome5.ICON_COINS + "##Looting" + identifier, source_game_option.Looting, btn_size, btn_size) + Looting = HeroAI_Windows.DrawColoredToggle(IconsFontAwesome5.ICON_COINS + "##Looting" + identifier, source_game_option.Looting, btn_size, btn_size) + if Looting != source_game_option.Looting: source_game_option.Looting = Looting @@ -1600,7 +1437,8 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ ImGui.show_tooltip("Looting") PyImGui.table_next_column() - Targeting = ImGui.toggle_button(IconsFontAwesome5.ICON_BULLSEYE + "##Targeting" + identifier , source_game_option.Targeting, btn_size, btn_size) + Targeting = HeroAI_Windows.DrawColoredToggle(IconsFontAwesome5.ICON_BULLSEYE + "##Targeting" + identifier , source_game_option.Targeting, btn_size, btn_size) + if Targeting != source_game_option.Targeting: source_game_option.Targeting = Targeting @@ -1611,7 +1449,8 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ ImGui.show_tooltip("Targeting") PyImGui.table_next_column() - Combat = ImGui.toggle_button(IconsFontAwesome5.ICON_SKULL_CROSSBONES + "##Combat" + identifier, source_game_option.Combat, btn_size, btn_size) + Combat = HeroAI_Windows.DrawColoredToggle(IconsFontAwesome5.ICON_SKULL_CROSSBONES + "##Combat" + identifier, source_game_option.Combat, btn_size, btn_size) + if Combat != source_game_option.Combat: source_game_option.Combat = Combat @@ -1619,6 +1458,18 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ set_global_option(source_game_option, "Combat") ImGui.show_tooltip("Combat") + + PyImGui.table_next_column() + + # Advanced Pathing Toggle + adv_pathing = HeroAI_FloatingWindows.settings.advanced_pathing_enabled + new_adv_pathing = HeroAI_Windows.DrawColoredToggle(IconsFontAwesome5.ICON_ROUTE + "##AdvPathing" + identifier, adv_pathing, btn_size, btn_size) + + if new_adv_pathing != adv_pathing: + HeroAI_FloatingWindows.settings.advanced_pathing_enabled = new_adv_pathing + HeroAI_FloatingWindows.settings.save_settings() + + ImGui.show_tooltip("Advanced Pathing & Unstuck (Global Setting)") PyImGui.end_table() style.ButtonPadding.push_style_var(5 if style.Theme not in ImGui.Textured_Themes else 0, 3 if style.Theme not in ImGui.Textured_Themes else 2) @@ -1626,7 +1477,7 @@ def set_global_option(game_option:HeroAIOptionStruct, option_name:str="", skill_ PyImGui.table_next_row() for i in range(NUMBER_OF_SKILLS): PyImGui.table_next_column() - skill_active = ImGui.toggle_button(f"{i + 1}##Skill{i}" + identifier, source_game_option.Skills[i], skill_size, skill_size) + skill_active = HeroAI_Windows.DrawColoredToggle(f"{i + 1}##Skill{i}" + identifier, source_game_option.Skills[i], skill_size, skill_size) if skill_active != source_game_option.Skills[i]: source_game_option.Skills[i] = skill_active @@ -1691,8 +1542,7 @@ def DrawButtonBar(cached_data:CacheData): table_width = btn_size * 6 + 30 ImGui.push_font("Regular",10) - if PyImGui.begin_child("ControlPanelChild", (215, 0), False, PyImGui.WindowFlags.AlwaysAutoResize): - if PyImGui.begin_table("MessagingTable", 5): + if PyImGui.begin_table("MessagingTable", 5): PyImGui.table_next_row() PyImGui.table_next_column() if ImGui.colored_button(f"{IconsFontAwesome5.ICON_SKULL}##commands_resign", @@ -1723,7 +1573,7 @@ def DrawButtonBar(cached_data:CacheData): if self_account.AccountEmail == account.AccountEmail: continue ConsoleLog("Messaging", "Pixelstacking account: " + account.AccountEmail) - GLOBAL_CACHE.ShMem.SendMessage(sender_email, account.AccountEmail, SharedCommandType.PixelStack, (self_account.AgentData.Pos.x,self_account.AgentData.Pos.y,0,0)) + GLOBAL_CACHE.ShMem.SendMessage(sender_email, account.AccountEmail, SharedCommandType.PixelStack, (self_account.PlayerPosX,self_account.PlayerPosY,0,0)) ImGui.pop_font() ImGui.show_tooltip("Pixel Stack (Carto Helper)") ImGui.push_font("Regular",10) @@ -1790,21 +1640,21 @@ def DrawButtonBar(cached_data:CacheData): if account_data is None: return - party_id = account_data.AgentPartyData.PartyID - map_id = account_data.AgentData.Map.MapID - map_region = account_data.AgentData.Map.Region - map_district = account_data.AgentData.Map.District - map_language = account_data.AgentData.Map.Language + party_id = account_data.PartyID + map_id = account_data.MapID + map_region = account_data.MapRegion + map_district = account_data.MapDistrict + map_language = account_data.MapLanguage def on_same_map_and_party(account : AccountStruct) -> bool: - return (account.AgentPartyData.PartyID == party_id and - account.AgentData.Map.MapID == map_id and - account.AgentData.Map.Region == map_region and - account.AgentData.Map.District == map_district and - account.AgentData.Map.Language == map_language) + return (account.PartyID == party_id and + account.MapID == map_id and + account.MapRegion == map_region and + account.MapDistrict == map_district and + account.MapLanguage == map_language) all_accounts = [account for account in cached_data.party.accounts.values()] - lowest_party_index_account = min(all_accounts, key=lambda account: account.AgentPartyData.PartyPosition, default=None) + lowest_party_index_account = min(all_accounts, key=lambda account: account.PartyPosition, default=None) if lowest_party_index_account is None: return @@ -1839,25 +1689,8 @@ def on_same_map_and_party(account : AccountStruct) -> bool: ImGui.pop_font() ImGui.show_tooltip("Consumables") ImGui.push_font("Regular",10) - - PyImGui.end_table() - - PyImGui.separator() - if PyImGui.begin_table("MessagingTable_Row2", 1): - PyImGui.table_next_row() - PyImGui.table_next_column() - fv = HeroAI_Windows.show_follow_formations_quick_window - new_fv = ImGui.toggle_button( - label=f"{IconsFontAwesome5.ICON_PROJECT_DIAGRAM}##follow_formations_quick", - v=fv, - width=(btn_size * 2), - height=btn_size - ) - if new_fv != fv: - HeroAI_Windows.show_follow_formations_quick_window = new_fv - ImGui.show_tooltip("Follow Formations Quick Access") + PyImGui.end_table() - PyImGui.end_child() ImGui.pop_font() @staticmethod @@ -1878,33 +1711,31 @@ def _close_spacing(): if ImGui.Begin(ini_key=cached_data.ini_key, name="HeroAI Control Panel", p_open=True, flags=PyImGui.WindowFlags.AlwaysAutoResize): - if PyImGui.begin_child("ControlPanelChild", (200, 138), False, PyImGui.WindowFlags.AlwaysAutoResize): - style = ImGui.get_style() - style.ItemSpacing.push_style_var(2, 2) - style.CellPadding.push_style_var(2, 2) + style = ImGui.get_style() + style.ItemSpacing.push_style_var(2, 2) + style.CellPadding.push_style_var(2, 2) + + HeroAI_Windows.DrawPanelButtons(cached_data.account_email, cached_data.global_options, set_global=True) + _close_spacing() + HeroAI_Windows.DrawButtonBar(cached_data) - HeroAI_Windows.DrawPanelButtons(cached_data.account_email, cached_data.global_options, set_global=True) - _close_spacing() - HeroAI_Windows.DrawButtonBar(cached_data) - - style.CellPadding.pop_style_var() - style.ItemSpacing.pop_style_var() - PyImGui.end_child() + style.CellPadding.pop_style_var() + style.ItemSpacing.pop_style_var() PyImGui.separator() if PyImGui.tree_node("Players"): style = ImGui.get_style() style.ItemSpacing.push_style_var(2, 2) style.CellPadding.push_style_var(2, 2) - sorted_by_party_position = sorted(cached_data.party.accounts.values(), key=lambda acc: acc.AgentPartyData.PartyPosition) + sorted_by_party_position = sorted(cached_data.party.accounts.values(), key=lambda acc: acc.PartyPosition) index = 0 for account in sorted_by_party_position: - if account and account.IsSlotActive and not account.IsHero and account.AgentPartyData.PartyID == GLOBAL_CACHE.Party.GetPartyID(): + if account and account.IsSlotActive and not account.IsHero and account.PartyID == GLOBAL_CACHE.Party.GetPartyID(): index += 1 - original_game_option = cached_data.party.options.get(account.AgentData.AgentID) + original_game_option = cached_data.party.options.get(account.PlayerID) - if PyImGui.tree_node(f"{index}. {account.AgentData.CharacterName}##ControlPlayer{index}"): + if PyImGui.tree_node(f"{index}. {account.CharacterName}##ControlPlayer{index}"): if original_game_option is not None: HeroAI_Windows.DrawPanelButtons(account.AccountEmail, original_game_option) PyImGui.new_line() @@ -1914,7 +1745,63 @@ def _close_spacing(): PyImGui.tree_pop() style.CellPadding.pop_style_var() style.ItemSpacing.pop_style_var() + + PyImGui.separator() + if PyImGui.tree_node("Advanced Pathing & Unstuck"): + style = ImGui.get_style() + style.ItemSpacing.push_style_var(2, 2) + style.CellPadding.push_style_var(2, 2) + HeroAI_Windows.DrawAdvancedPathingOptions(cached_data) + style.CellPadding.pop_style_var() + style.ItemSpacing.pop_style_var() + PyImGui.tree_pop() ImGui.End(cached_data.ini_key) - HeroAI_Windows.DrawFollowFormationsQuickWindow(cached_data) + + @staticmethod + def DrawAdvancedPathingOptions(cached_data:CacheData): + adv_pathing = HeroAI_FloatingWindows.settings.advanced_pathing_enabled + new_adv_pathing = PyImGui.checkbox("Enable Advanced Pathing & Unstuck", adv_pathing) + if new_adv_pathing != adv_pathing: + HeroAI_FloatingWindows.settings.advanced_pathing_enabled = new_adv_pathing + HeroAI_FloatingWindows.settings.save_settings() + if not HeroAI_FloatingWindows.settings.advanced_pathing_enabled: + ImGui.text_colored("Feature is currently DISABLED globally.", (1.0, 0.0, 0.0, 1.0)) + else: + ImGui.text_colored("Feature is ENABLED.", (0.0, 1.0, 0.0, 1.0)) + + PyImGui.separator() + + # Sanity Check Distance + sanity_val = HeroAI_FloatingWindows.settings.sanity_check_distance + new_sanity = ImGui.slider_float("Path Reject Threshold", sanity_val, 100.0, 2000.0) + if new_sanity != sanity_val: + HeroAI_FloatingWindows.settings.sanity_check_distance = new_sanity + HeroAI_FloatingWindows.settings.save_settings() + ImGui.show_tooltip("Distance (in game units) to reject a path if the first waypoint is too far.\nPrevents 'Ghost Wall' pathfinding errors.") + + # Unstuck Radius + radius_val = HeroAI_FloatingWindows.settings.unstuck_radius + new_radius = ImGui.slider_float("Unstuck Radius", radius_val, 50.0, 300.0) + if new_radius != radius_val: + HeroAI_FloatingWindows.settings.unstuck_radius = new_radius + HeroAI_FloatingWindows.settings.save_settings() + ImGui.show_tooltip("Distance to move back during an unstuck attempt.") + + # Recidivism Memory + memory_val = HeroAI_FloatingWindows.settings.recidivism_memory + new_memory = ImGui.slider_float("Recidivism Memory (s)", memory_val, 1.0, 60.0) + if new_memory != memory_val: + HeroAI_FloatingWindows.settings.recidivism_memory = new_memory + HeroAI_FloatingWindows.settings.save_settings() + ImGui.show_tooltip("How long (in seconds) the bot remembers it was stuck.\nKeeps hypersensitive mode active to prevent loops.") + + # Hypersensitive Speed + speed_val = HeroAI_FloatingWindows.settings.hypersensitive_speed + new_speed = ImGui.slider_float("Stuck Check Interval (s)", speed_val, 0.1, 1.0) + if new_speed != speed_val: + HeroAI_FloatingWindows.settings.hypersensitive_speed = new_speed + HeroAI_FloatingWindows.settings.save_settings() + ImGui.show_tooltip("Time interval (in seconds) to check for stuck state when in hypersensitive mode.\nLower is faster reaction but higher CPU usage.") + \ No newline at end of file diff --git a/Py4GWCoreLib/__init__.py b/Py4GWCoreLib/__init__.py index 1a659b500..78b005f07 100644 --- a/Py4GWCoreLib/__init__.py +++ b/Py4GWCoreLib/__init__.py @@ -92,6 +92,7 @@ def find_system_python(): from .py4gwcorelib_src.Profiling import ProfilingRegistry, SimpleProfiler from .py4gwcorelib_src.WidgetManager import WidgetHandler, Widget +from .py4gwcorelib_src.ActionQueue import ActionQueueManager traceback = traceback math = math @@ -131,6 +132,7 @@ def find_system_python(): SimpleProfiler = SimpleProfiler WidgetHandler = WidgetHandler Widget = Widget +ActionQueueManager = ActionQueueManager diff --git a/Widgets/Automation/Bots/Missions/Core/Underworld.py b/Widgets/Automation/Bots/Missions/Core/Underworld.py index ca0ed44c4..1988c441a 100644 --- a/Widgets/Automation/Bots/Missions/Core/Underworld.py +++ b/Widgets/Automation/Bots/Missions/Core/Underworld.py @@ -1,6 +1,5 @@ from Py4GWCoreLib import Botting, Routines, Agent, AgentArray, Player, Utils, AutoPathing, GLOBAL_CACHE, ConsoleLog, Map, Pathing, FlagPreference from Sources.oazix.CustomBehaviors.primitives.botting.botting_helpers import BottingHelpers -from Sources.oazix.CustomBehaviors.primitives.botting.botting_manager import BottingManager from Sources.oazix.CustomBehaviors.primitives.parties.custom_behavior_party import CustomBehaviorParty from Sources.oazix.CustomBehaviors.primitives.botting.botting_fsm_helper import BottingFsmHelpers from Sources.oazix.CustomBehaviors.primitives.custom_behavior_loader import CustomBehaviorLoader @@ -41,148 +40,45 @@ class BotSettings: # Precomputed spread points keep Servants of Grenth flags spaced without extra imports. -def _get_custom_behavior(initialize_if_needed: bool = True): - loader = CustomBehaviorLoader() - behavior = loader.custom_combat_behavior - - if behavior is None and initialize_if_needed: - loader.initialize_custom_behavior_candidate() - behavior = loader.custom_combat_behavior - - return behavior - - -def _set_custom_utility_enabled( - enabled: bool, - *, - skill_names: tuple[str, ...] = (), - class_names: tuple[str, ...] = (), -) -> bool: - behavior = _get_custom_behavior(initialize_if_needed=True) +def _toggle_wait_if_aggro(enabled: bool) -> None: + behavior = CustomBehaviorLoader().custom_combat_behavior if behavior is None: - return False - + return for utility in behavior.get_skills_final_list(): - utility_skill_name = getattr(getattr(utility, "custom_skill", None), "skill_name", None) - utility_class_name = utility.__class__.__name__ - - if utility_skill_name in skill_names or utility_class_name in class_names: + if utility.custom_skill.skill_name == "wait_if_in_aggro": utility.is_enabled = enabled - return True - - return False - - -def _toggle_wait_if_aggro(enabled: bool) -> None: - _set_custom_utility_enabled( - enabled, - skill_names=("wait_if_in_aggro",), - class_names=("WaitIfInAggroUtility",), - ) + break def _toggle_wait_for_party(enabled: bool) -> None: - _set_custom_utility_enabled( - enabled, - skill_names=("wait_if_party_member_too_far",), - class_names=("WaitIfPartyMemberTooFarUtility",), - ) - -def _toggle_move_if_aggro(enabled: bool) -> None: - _set_custom_utility_enabled( - enabled, - skill_names=("move_to_party_member_if_in_aggro",), - class_names=("MoveToPartyMemberIfInAggroUtility",), - ) - -def _toggle_lock(enabled: bool) -> None: - _set_custom_utility_enabled( - enabled, - skill_names=("wait_if_lock_taken",), - class_names=("WaitIfLockTakenUtility",), - ) - - -def _toggle_wait_if_party_member_mana_too_low(enabled: bool) -> None: - _set_custom_utility_enabled( - enabled, - skill_names=("wait_if_party_member_mana_too_low",), - class_names=("WaitIfPartyMemberManaTooLowUtility",), - ) - - -def _setup_custom_behavior_integration(bot_instance: Botting) -> None: - behavior = _get_custom_behavior(initialize_if_needed=True) + behavior = CustomBehaviorLoader().custom_combat_behavior if behavior is None: - ConsoleLog(BOT_NAME, "[CB] Kein Custom-Behavior gefunden. Bot läuft ohne CB-Integration.", Py4GW.Console.MessageType.Warning) return + for utility in behavior.get_skills_final_list(): + if utility.custom_skill.skill_name == "wait_if_party_member_too_far": + utility.is_enabled = enabled + break - _ensure_custom_botting_skills_enabled() - BottingFsmHelpers.SetBottingBehaviorAsAggressive(bot_instance) - BottingFsmHelpers.UseCustomBehavior( - bot_instance, - on_player_critical_death=BottingHelpers.botting_unrecoverable_issue, - on_party_death=BottingHelpers.botting_unrecoverable_issue, - on_player_critical_stuck=BottingHelpers.botting_unrecoverable_issue, - ) - - -def _sync_custom_behavior_runtime() -> None: - loader = CustomBehaviorLoader() - loader.ensure_botting_daemon_running() - - behavior = loader.custom_combat_behavior +def _toggle_move_if_aggro(enabled: bool) -> None: + behavior = CustomBehaviorLoader().custom_combat_behavior if behavior is None: - loader.initialize_custom_behavior_candidate() - - -def _ensure_custom_botting_skills_enabled() -> None: - """ - Erzwingt aktivierte Botting-Skills für diesen Bot beim Start, - auch wenn sie in der globalen CB-Konfiguration zuvor deaktiviert wurden. - """ - manager = BottingManager() - - required_skill_keys = { - "WaitIfPartyMemberTooFarUtility", - "WaitIfInAggroUtility", - "MoveToPartyMemberIfInAggroUtility", - "WaitIfLockTakenUtility", - } - - changed = False - - for entry in manager.aggressive_skills: - if entry.name in required_skill_keys and not entry.enabled: - entry.enabled = True - changed = True - - if changed: - manager.save() - ConsoleLog(BOT_NAME, "[CB] Benötigte Botting-Skills wurden für diesen Bot aktiviert.", Py4GW.Console.MessageType.Info) - + return + for utility in behavior.get_skills_final_list(): + if utility.custom_skill.skill_name == "move_to_party_member_if_in_aggro": + utility.is_enabled = enabled + break -def _reactivate_custom_behavior_for_step(bot_instance: Botting, step_label: str) -> None: - """ - Re-aktiviert die benötigte CB-Integration vor jedem größeren Schritt/Questabschnitt. - """ - behavior = _get_custom_behavior(initialize_if_needed=True) +def _toggle_lock(enabled: bool) -> None: + behavior = CustomBehaviorLoader().custom_combat_behavior if behavior is None: - ConsoleLog(BOT_NAME, f"[CB] Kein Behavior für Schritt '{step_label}' verfügbar.", Py4GW.Console.MessageType.Warning) return - - _ensure_custom_botting_skills_enabled() - BottingFsmHelpers.SetBottingBehaviorAsAggressive(bot_instance) - BottingFsmHelpers.UseCustomBehavior( - bot_instance, - on_player_critical_death=BottingHelpers.botting_unrecoverable_issue, - on_party_death=BottingHelpers.botting_unrecoverable_issue, - on_player_critical_stuck=BottingHelpers.botting_unrecoverable_issue, - ) + for utility in behavior.get_skills_final_list(): + if utility.custom_skill.skill_name == "wait_if_lock_taken": + utility.is_enabled = enabled + break def _enqueue_section(bot_instance: Botting, attr_name: str, label: str, section_fn): def _queue_section(): if getattr(BotSettings, attr_name, False): - _reactivate_custom_behavior_for_step(bot_instance, label) section_fn(bot_instance) bot_instance.States.AddCustomState(_queue_section, f"[Toggle] {label}") @@ -261,7 +157,11 @@ def bot_routine(bot: Botting): global MAIN_LOOP_HEADER_NAME bot.Events.OnPartyWipeCallback(lambda: OnPartyWipe(bot)) CustomBehaviorParty().set_party_is_blessing_enabled(True) - _setup_custom_behavior_integration(bot) + BottingFsmHelpers.SetBottingBehaviorAsAggressive(bot) + bot.Templates.Routines.UseCustomBehaviors( + on_player_critical_death=BottingHelpers.botting_unrecoverable_issue, + on_party_death=BottingHelpers.botting_unrecoverable_issue, + on_player_critical_stuck=BottingHelpers.botting_unrecoverable_issue) bot.Templates.Aggressive() @@ -276,7 +176,7 @@ def bot_routine(bot: Botting): Clear_the_Chamber(bot) _enqueue_section(bot, "RestoreVale", "Restore Vale", Restore_Vale) _enqueue_section(bot, "WrathfullSpirits", "Wrathfull Spirits", Wrathfull_Spirits) - #_enqueue_section(bot, "EscortOfSouls", "Escort of Souls", Escort_of_Souls) + _enqueue_section(bot, "EscortOfSouls", "Escort of Souls", Escort_of_Souls) _enqueue_section(bot, "UnwantedGuests", "Unwanted Guests", Unwanted_Guests) _enqueue_section(bot, "RestoreWastes", "Restore Wastes", Restore_Wastes) _enqueue_section(bot, "ServantsOfGrenth", "Servants of Grenth", Servants_of_Grenth) @@ -318,12 +218,8 @@ def enable_default_party_behavior(bot_instance: Botting): def Clear_the_Chamber(bot_instance: Botting): bot_instance.States.AddHeader("Clear the Chamber") - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") CustomBehaviorParty().set_party_leader_email(Player.GetAccountEmail()) bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") - enable_default_party_behavior(bot_instance) bot_instance.Move.XYAndInteractNPC(295, 7221, "go to NPC") bot_instance.Dialogs.AtXY(295, 7221, 0x806501, "take quest") @@ -347,8 +243,6 @@ def Clear_the_Chamber(bot_instance: Botting): bot_instance.Wait.ForTime(3000) def Restore_Vale(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.RestoreVale: bot_instance.States.AddHeader("Restore Vale") BottingFsmHelpers.SetBottingBehaviorAsAggressive(bot_instance) @@ -371,8 +265,6 @@ def Restore_Vale(bot_instance: Botting): bot_instance.Wait.ForTime(3000) def Wrathfull_Spirits(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.WrathfullSpirits: bot_instance.States.AddHeader("Wrathfull Spirits") bot_instance.Move.XYAndInteractNPC(-13275, 5261, "go to NPC") @@ -397,8 +289,6 @@ def Wrathfull_Spirits(bot_instance: Botting): bot_instance.Wait.ForTime(3000) def Escort_of_Souls(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.EscortOfSouls: bot_instance.States.AddHeader("Escort of Souls") bot_instance.Wait.ForTime(5000) @@ -416,8 +306,6 @@ def Escort_of_Souls(bot_instance: Botting): bot_instance.Wait.ForTime(3000) def Unwanted_Guests(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") #This Quest is not working if BotSettings.UnwantedGuests: bot_instance.States.AddHeader("Unwanted Guests") @@ -457,8 +345,6 @@ def Unwanted_Guests(bot_instance: Botting): bot_instance.Move.XY(-6907, 7256) def Restore_Wastes(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.RestoreWastes: bot_instance.Templates.Aggressive() bot_instance.Properties.ApplyNow("pause_on_danger", "active", True) @@ -476,12 +362,11 @@ def Restore_Wastes(bot_instance: Botting): bot_instance.Wait.ForTime(3000) def Servants_of_Grenth(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.ServantsOfGrenth: bot_instance.Templates.Aggressive() bot_instance.States.AddHeader("Servants of Grenth") bot_instance.Move.XY(2700, 19952, "Servants of Grenth 1") + bot_instance.Party.FlagAllHeroes(2559, 20301) SERVANTS_OF_GRENTH_FLAG_POINTS = [ (2559, 20301), (3032, 20148), @@ -491,10 +376,6 @@ def Servants_of_Grenth(bot_instance: Botting): (3691, 19979), (2039, 20175), ] - bot_instance.States.AddCustomState( - lambda: CustomBehaviorParty().party_flagging_manager.clear_all_flags(), - "Clear Flags", - ) bot_instance.States.AddCustomState( lambda: _auto_assign_flag_emails(), "Set Flag", @@ -504,21 +385,15 @@ def Servants_of_Grenth(bot_instance: Botting): lambda i=idx, x=flag_x, y=flag_y: _set_flag_position(i, x, y), f"Set Flag {idx}", ) - def _flag_hero_by_party_pos(party_pos: int, x: float, y: float) -> None: - agent_id = GLOBAL_CACHE.Party.Heroes.GetHeroAgentIDByPartyPosition(party_pos) - if agent_id: - GLOBAL_CACHE.Party.Heroes.FlagHero(agent_id, x, y) - - hero_count = GLOBAL_CACHE.Party.GetHeroCount() - for hero_idx, (flag_x, flag_y) in enumerate(SERVANTS_OF_GRENTH_FLAG_POINTS[:hero_count], start=1): - bot_instance.States.AddCustomState( - lambda h=hero_idx, x=flag_x, y=flag_y: _flag_hero_by_party_pos(h, x, y), - f"Flag Hero {hero_idx}", - ) bot_instance.States.AddCustomState(lambda: _toggle_wait_for_party(False), "Disable WaitIfPartyMemberTooFar") bot_instance.States.AddCustomState(lambda: CustomBehaviorParty().set_party_forced_state(BehaviorState.CLOSE_TO_AGGRO),"Force Close_to_Aggro",) bot_instance.Move.XYAndInteractNPC(554, 18384, "go to NPC") bot_instance.States.AddCustomState(lambda: CustomBehaviorParty().set_party_is_following_enabled(False), "Disable Following") + bot_instance.States.AddCustomState( + lambda: CustomBehaviorParty().party_flagging_manager.clear_all_flags(), + "Clear Flags", + ) + #bot_instance.Dialogs.AtXY(5755, 12769, 0x806603, "Back to Chamber") bot_instance.Dialogs.AtXY(5755, 12769, 0x806601, "Back to Chamber") bot_instance.States.AddCustomState(lambda: CustomBehaviorParty().set_party_forced_state(None),"Release Close_to_Aggro",) @@ -527,14 +402,6 @@ def _flag_hero_by_party_pos(party_pos: int, x: float, y: float) -> None: bot_instance.States.AddCustomState(lambda: _toggle_wait_for_party(True), "Enable WaitIfPartyMemberTooFar") bot_instance.States.AddCustomState(lambda: CustomBehaviorParty().set_party_is_following_enabled(True), "Enable Following") bot_instance.Party.UnflagAllHeroes() - bot_instance.Party.FlagAllHeroes(3032, 20148) - bot_instance.Party.UnflagAllHeroes() - bot_instance.Wait.ForTime(5000) - bot_instance.Party.UnflagAllHeroes() - bot_instance.States.AddCustomState( - lambda: CustomBehaviorParty().party_flagging_manager.clear_all_flags(), - "Clear Flags", - ) bot_instance.Wait.ForTime(10000) bot_instance.Move.XYAndInteractNPC(554, 18384, "go to NPC") #bot_instance.Dialogs.AtXY(5755, 12769, 0x7F, "Back to Chamber") @@ -543,8 +410,6 @@ def _flag_hero_by_party_pos(party_pos: int, x: float, y: float) -> None: bot_instance.Wait.ForTime(3000) def Pass_The_Mountains(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.PassTheMountains: bot_instance.States.AddHeader("Pass the Mountains") bot_instance.Move.XY(-220, 1691, "Pass the Mountains 1") @@ -554,8 +419,6 @@ def Pass_The_Mountains(bot_instance: Botting): def Restore_Mountains(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.RestoreMountains: bot_instance.States.AddHeader("Restore the Mountains") bot_instance.Move.XY(7013, -7582, "Restore the Mountains 1") @@ -563,8 +426,6 @@ def Restore_Mountains(bot_instance: Botting): bot_instance.Move.XY(-8373, -5016, "Restore the Mountains 3") def Deamon_Assassin(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.DeamonAssassin: bot_instance.States.AddHeader("Deamon Assassin") bot_instance.Move.XYAndInteractNPC(-8250, -5171, "go to NPC") @@ -576,8 +437,6 @@ def Deamon_Assassin(bot_instance: Botting): #ModelID Slayer 2391 def Restore_Planes(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.RestorePlanes: bot_instance.States.AddHeader("Restore Planes") Wait_for_Spawns(bot_instance,10371, -10510) @@ -591,8 +450,6 @@ def Restore_Planes(bot_instance: Botting): Wait_for_Spawns(bot_instance,11218, -17404) def The_Four_Horsemen(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.TheFourHorsemen: bot_instance.States.AddHeader("The Four Horseman") bot_instance.Move.XY(13473, -12091, "The Four Horseman 1") @@ -634,8 +491,6 @@ def The_Four_Horsemen(bot_instance: Botting): bot_instance.States.AddCustomState(lambda: CustomBehaviorParty().set_party_is_looting_enabled(True), "Enable Looting") def Restore_Pools(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.RestorePools: bot_instance.States.AddHeader("Restore Pools") Wait_for_Spawns(bot_instance,4647, -16833) @@ -651,8 +506,6 @@ def Restore_Pools(bot_instance: Botting): bot_instance.Wait.ForTime(3000) def Terrorweb_Queen(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.TerrorwebQueen: bot_instance.States.AddHeader("Terrorweb Queen") bot_instance.Move.XYAndInteractNPC(-6961, -19499, "go to NPC") @@ -665,8 +518,6 @@ def Terrorweb_Queen(bot_instance: Botting): bot_instance.Dialogs.AtXY(-6957, -19478, 0x8B, "Back to Chamber") def Restore_Pit(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.RestorePit: bot_instance.States.AddHeader("Restore Pit") bot_instance.Move.XY(13145, -8740, "Restore Pit 1") @@ -682,8 +533,6 @@ def Restore_Pit(bot_instance: Botting): bot_instance.Wait.ForTime(3000) def Imprisoned_Spirits(bot_instance: Botting): - bot_instance.States.AddCustomState(lambda: _toggle_lock(False), "Disable Lock Wait") - bot_instance.States.AddCustomState(lambda: _toggle_wait_if_party_member_mana_too_low(False), "Disable Lock Wait") if BotSettings.ImprisonedSpirits: bot_instance.States.AddHeader("Imprisoned Spirits") bot_instance.Move.XY(12329, 4632, "Imprisoned Spirits 1") @@ -693,8 +542,6 @@ def Imprisoned_Spirits(bot_instance: Botting): #bot_instance.Dialogs.AtXY(8666, 6308, 0x806903, "Back to Chamber") bot_instance.Dialogs.AtXY(8666, 6308, 0x806901, "Back to Chamber") bot_instance.Move.XY(12329, 4632, "Imprisoned Spirits 2") - - def ResignAndRepeat(bot_instance: Botting): if BotSettings.Repeat: bot_instance.Multibox.ResignParty() @@ -748,6 +595,7 @@ def _draw_help(): PyImGui.bullet_text("You have to do the missing quests manually") PyImGui.bullet_text("Main Account sometimes leaves the team alone - Dont be the Healer") PyImGui.bullet_text("You should either have some evas or 1 melee char to trigger traps in the mountains") + PyImGui.bullet_text(r'You should change the line 39 in Sources\oazix\CustomBehaviors\skills\botting\wait_if_party_member_too_far.py with * 1.25 for faster runs') PyImGui.separator() PyImGui.bullet_text("Have fun :) - sch0l0ka") @@ -834,7 +682,6 @@ def _on_party_wipe(bot: "Botting"): def main(): - _sync_custom_behavior_runtime() bot.Update() bot.UI.draw_window() diff --git a/Widgets/Automation/Multiboxing/HeroAI.py b/Widgets/Automation/Multiboxing/HeroAI.py index aab638f74..2221fd558 100644 --- a/Widgets/Automation/Multiboxing/HeroAI.py +++ b/Widgets/Automation/Multiboxing/HeroAI.py @@ -12,6 +12,7 @@ from Py4GWCoreLib.Map import Map from Py4GWCoreLib.Player import Player from Py4GWCoreLib.routines_src.BehaviourTrees import BehaviorTree +from Py4GWCoreLib.Pathing import AutoPathing from HeroAI.cache_data import CacheData from HeroAI.constants import (FOLLOW_DISTANCE_OUT_OF_COMBAT, MELEE_RANGE_VALUE, RANGED_RANGE_VALUE) @@ -208,6 +209,12 @@ def initialize(cached_data: CacheData) -> bool: if not GLOBAL_CACHE.Party.IsPartyLoaded(): return False + # Handle map change cleanup + current_map_id = Map.GetMapID() + if not hasattr(cached_data, "last_map_id") or cached_data.last_map_id != current_map_id: + cached_data.last_map_id = current_map_id + follow_reset_map_quads() + if not Map.IsExplorable(): # halt operation if not in explorable area return False @@ -218,6 +225,15 @@ def initialize(cached_data: CacheData) -> bool: HeroAI_FloatingWindows.draw_Targeting_floating_buttons(cached_data) cached_data.UpdateCombat() + # Ensure NavMesh is loaded for follower pathfinding + if not AutoPathing().get_navmesh(): + if not getattr(AutoPathing(), "loader", None): + AutoPathing().loader = AutoPathing().load_pathing_maps() + try: + next(AutoPathing().loader) + except StopIteration: + AutoPathing().loader = None + # Leader calculates and writes follow positions for all followers LeaderUpdate(cached_data) From 0589089d7d16c53a714418c2b13f06375b764f9c Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:46:57 +0100 Subject: [PATCH 3/8] Standardized formation rotation, added radar cardinal directions and direction arrow, and implemented 8-slot limit with individual toggles --- HeroAI/following.py | 147 ++++++++++++++++++++++++++++++-------------- 1 file changed, 100 insertions(+), 47 deletions(-) diff --git a/HeroAI/following.py b/HeroAI/following.py index 231eba120..5181908f3 100644 --- a/HeroAI/following.py +++ b/HeroAI/following.py @@ -41,7 +41,7 @@ # Constants # ───────────────────────────────────────────── FOLLOW_COMBAT_DISTANCE = 25.0 # body-block close range when flagged -MAX_FOLLOWERS = 12 +MAX_FOLLOWERS = 8 # ───────────────────────────────────────────── # INI persistence @@ -71,10 +71,11 @@ def __init__(self, radius, color: Color, thickness, show=True): # ───────────────────────────────────────────── class FollowerConfig: """Per-follower formation settings controlled by the leader.""" - def __init__(self, angle_deg: float = 0.0, radius: float = -1.0, color: Color = None): + def __init__(self, angle_deg: float = 0.0, radius: float = -1.0, color: Color = None, enabled: bool = True): self.angle_deg = angle_deg self.radius = radius # -1 means use global formation_radius self.color: Color = color or ColorPalette.GetColor("white") + self.enabled = enabled def get_radius(self, global_radius: float) -> float: """Return this follower's radius, falling back to global if -1.""" @@ -88,8 +89,7 @@ class FollowModuleSettings: """All leader-configurable follow settings.""" # Default formation angles (same layout as the old hero_formation) - DEFAULT_ANGLES = [0.0, 45.0, -45.0, 90.0, -90.0, 135.0, -135.0, 180.0, - -180.0, 225.0, -225.0, 270.0] + DEFAULT_ANGLES = [0.0, 45.0, -45.0, 90.0, -90.0, 135.0, -135.0, 180.0] def __init__(self): # Formation @@ -102,8 +102,7 @@ def __init__(self): self.follower_configs: list[FollowerConfig] = [] for i, angle in enumerate(self.DEFAULT_ANGLES): palette_colors = ["gw_blue", "firebrick", "gold", "gw_purple", - "gw_green", "gw_assassin", "blue", "white", - "gw_blue", "firebrick", "gold", "gw_purple"] + "gw_green", "gw_assassin", "blue", "white"] color = ColorPalette.GetColor(palette_colors[i % len(palette_colors)]) self.follower_configs.append(FollowerConfig(angle, color=color)) @@ -157,6 +156,7 @@ def _save_settings(): for i, fc in enumerate(settings.follower_configs): ini.write_key(_ini_key, "Followers", f"Angle_{i}", fc.angle_deg) ini.write_key(_ini_key, "Followers", f"Radius_{i}", fc.radius) + ini.write_key(_ini_key, "Followers", f"Enabled_{i}", fc.enabled) # Canvas settings ini.write_key(_ini_key, "Canvas", "Show", settings.show_canvas) @@ -178,6 +178,7 @@ def _load_settings(): for i, fc in enumerate(settings.follower_configs): fc.angle_deg = ini.read_float(_ini_key, "Followers", f"Angle_{i}", fc.angle_deg) fc.radius = ini.read_float(_ini_key, "Followers", f"Radius_{i}", fc.radius) + fc.enabled = ini.read_bool(_ini_key, "Followers", f"Enabled_{i}", fc.enabled) settings.show_canvas = ini.read_bool(_ini_key, "Canvas", "Show", True) settings.scale = ini.read_float(_ini_key, "Canvas", "Scale", 0.3) @@ -283,26 +284,34 @@ def LeaderUpdate(cached_data: CacheData): else: # Get per-follower angle config follower_grid_pos = acc.PartyPosition + GLOBAL_CACHE.Party.GetHeroCount() + GLOBAL_CACHE.Party.GetHenchmanCount() + angle_deg = 0.0 + fc = None if follower_grid_pos < len(settings.follower_configs): - angle_deg = settings.follower_configs[follower_grid_pos].angle_deg + fc = settings.follower_configs[follower_grid_pos] + if not fc.enabled: + xx = follow_x + yy = follow_y + # Skip calculation if disabled, just follow directly + else: + angle_deg = fc.angle_deg + + if fc and fc.enabled: + # Convert to radians and add to leader's facing + # 0 degrees = Forward + angle_rad = Utils.DegToRad(angle_deg) + world_angle = follow_angle + angle_rad + + radius = fc.get_radius(settings.formation_radius) + + # Standard rotation mapping (0=East, pi/2=North) + rot_x = radius * math.cos(world_angle) + rot_y = radius * math.sin(world_angle) + + xx = follow_x + rot_x + yy = follow_y + rot_y else: - angle_deg = 0.0 - - # Convert angle to local offset (0°=forward) - angle_rad = Utils.DegToRad(angle_deg) - fc = settings.follower_configs[follower_grid_pos] if follower_grid_pos < len(settings.follower_configs) else None - effective_radius = fc.get_radius(settings.formation_radius) if fc else settings.formation_radius - local_x = effective_radius * math.sin(angle_rad) - local_y = effective_radius * math.cos(angle_rad) - - # Rotate into world using leader facing (same as reference) - rot_angle = follow_angle - math.pi / 2 - rot_cos = -math.cos(rot_angle) - rot_sin = -math.sin(rot_angle) - rot_x = (local_x * rot_cos) - (local_y * rot_sin) - rot_y = (local_x * rot_sin) + (local_y * rot_cos) - xx = follow_x + rot_x - yy = follow_y + rot_y + xx = follow_x + yy = follow_y # Validate against map pathing if not _is_position_on_map(xx, yy): @@ -850,12 +859,48 @@ def _draw_canvas(cached_data: CacheData): PyImGui.draw_list_add_line(left, y, right, y, grid_color.to_color(), 1) y -= grid_step + # Cardinal Directions (N, S, E, W) + text_color = ColorPalette.GetColor("gold").to_color() + # North (Top) + PyImGui.draw_list_add_text(cx - 5, top + 15, text_color, "N") + # South (Bottom) + PyImGui.draw_list_add_text(cx - 5, bottom - 30, text_color, "S") + # East (Right) + PyImGui.draw_list_add_text(right - 30, cy - 7, text_color, "E") + # West (Left) + PyImGui.draw_list_add_text(left + 20, cy - 7, text_color, "W") + # Center marker (leader position) touch_color = settings.area_rings[0].color.copy() touch_color.set_a(100) touch_radius = settings.area_rings[0].radius * settings.scale PyImGui.draw_list_add_circle_filled(cx, cy, touch_radius, touch_color.to_color(), 64) + # Facing Arrow for Leader + leader_angle = 0.0 + try: + leader_angle = Agent.GetRotationAngle(Player.GetAgentID()) + except Exception: + pass + + # Draw facing arrow (pointing from leader) + # GW1: 0=East, pi/2=North. Canvas: North=Up, East=Right. + arrow_len = touch_radius * 1.5 + tip_x = cx + (math.cos(leader_angle) * arrow_len) + tip_y = cy - (math.sin(leader_angle) * arrow_len) + + # Arrow base width + base_angle_offset = 0.5 # radians + base_len = touch_radius * 0.8 + base_L_x = cx + (math.cos(leader_angle - math.pi + base_angle_offset) * base_len) + base_L_y = cy - (math.sin(leader_angle - math.pi + base_angle_offset) * base_len) + base_R_x = cx + (math.cos(leader_angle - math.pi - base_angle_offset) * base_len) + base_R_y = cy - (math.sin(leader_angle - math.pi - base_angle_offset) * base_len) + + arrow_color = ColorPalette.GetColor("white").to_color() + PyImGui.draw_list_add_triangle_filled(tip_x, tip_y, base_L_x, base_L_y, base_R_x, base_R_y, arrow_color) + PyImGui.draw_list_add_triangle(tip_x, tip_y, base_L_x, base_L_y, base_R_x, base_R_y, ColorPalette.GetColor("black").to_color(), 1.0) + # Area rings if settings.draw_area_rings: for ring in settings.area_rings: @@ -873,28 +918,27 @@ def _draw_canvas(cached_data: CacheData): except Exception: pass - rot_angle = leader_angle - math.pi / 2 - rot_cos = -math.cos(rot_angle) - rot_sin = -math.sin(rot_angle) - follower_radius_px = (Range.Touch.value / 2) * settings.scale for i, fc in enumerate(settings.follower_configs): if i >= MAX_FOLLOWERS: break + + if not fc.enabled: + continue + # Convert angle to local offset (0°=forward) angle_rad = Utils.DegToRad(fc.angle_deg) + world_angle = leader_angle + angle_rad effective_radius = fc.get_radius(settings.formation_radius) - local_x = effective_radius * math.sin(angle_rad) - local_y = effective_radius * math.cos(angle_rad) - # Rotate into world (same matrix as 3D overlay) - rot_x = (local_x * rot_cos) - (local_y * rot_sin) - rot_y = (local_x * rot_sin) + (local_y * rot_cos) + # Standard rotation mapping (consistent with LeaderUpdate) + rot_x = effective_radius * math.cos(world_angle) + rot_y = effective_radius * math.sin(world_angle) - # Map to canvas: flip both axes for correct game-to-screen mapping + # Map to canvas draw_x = cx + (rot_x * settings.scale) - draw_y = cy + (-rot_y * settings.scale) + draw_y = cy - (rot_y * settings.scale) color_fill = fc.color.copy() color_fill.set_a(120) @@ -982,7 +1026,7 @@ def _draw_custom_formation(cached_data: CacheData): preset_changed = True PyImGui.same_line(0.0, 5.0) if PyImGui.button("V-Shape"): - v_angles = [30, -30, 60, -60, 90, -90, 120, -120, 150, -150, 180, -180] + v_angles = [30, -30, 60, -60, 90, -90, 120, -120] for i in range(n): settings.follower_configs[i].angle_deg = float(v_angles[i % len(v_angles)]) preset_changed = True @@ -993,7 +1037,7 @@ def _draw_custom_formation(cached_data: CacheData): preset_changed = True PyImGui.same_line(0.0, 5.0) if PyImGui.button("Cluster"): - cluster = [0, 15, -15, 30, -30, 10, -10, 5, -5, 20, -20, 25] + cluster = [0, 15, -15, 30, -30, 10, -10, 5] for i in range(n): settings.follower_configs[i].angle_deg = float(cluster[i % len(cluster)]) preset_changed = True @@ -1020,7 +1064,8 @@ def _draw_custom_formation(cached_data: CacheData): PyImGui.TableFlags.SizingStretchProp ) - if PyImGui.begin_table("CustomFormationTable", 4, table_flags): + if PyImGui.begin_table("CustomFormationTable", 5, table_flags): + PyImGui.table_setup_column("Slot", PyImGui.TableColumnFlags.WidthFixed, 50) PyImGui.table_setup_column("Follower", PyImGui.TableColumnFlags.WidthFixed, 100) PyImGui.table_setup_column("Angle (°)", PyImGui.TableColumnFlags.WidthStretch) PyImGui.table_setup_column("Radius", PyImGui.TableColumnFlags.WidthFixed, 160) @@ -1035,6 +1080,13 @@ def _draw_custom_formation(cached_data: CacheData): PyImGui.table_next_row() + # Slot / Enable + PyImGui.table_next_column() + new_enabled = PyImGui.checkbox(f"#{i+1}", fc.enabled) + if new_enabled != fc.enabled: + fc.enabled = new_enabled + changed = True + # Follower name PyImGui.table_next_column() display_name = grid_names.get(i, f"Slot {i + 1}") @@ -1183,18 +1235,19 @@ def _draw_3d_overlay(cached_data: CacheData): for i, fc in enumerate(settings.follower_configs): if i >= MAX_FOLLOWERS: break + + if not fc.enabled: + continue + # Convert angle to local offset (0°=forward) angle_rad = Utils.DegToRad(fc.angle_deg) + world_angle = leader_angle + angle_rad effective_radius = fc.get_radius(settings.formation_radius) - local_x = effective_radius * math.sin(angle_rad) - local_y = effective_radius * math.cos(angle_rad) - - # Rotate into world using leader facing (same as reference) - rot_angle = leader_angle - math.pi / 2 - rot_cos = -math.cos(rot_angle) - rot_sin = -math.sin(rot_angle) - rot_x = (local_x * rot_cos) - (local_y * rot_sin) - rot_y = (local_x * rot_sin) + (local_y * rot_cos) + + # Rotate into world + rot_x = effective_radius * math.cos(world_angle) + rot_y = effective_radius * math.sin(world_angle) + world_x = player_x + rot_x world_y = player_y + rot_y From b74fbf4e037f0c7b6ca70393c102c196d2c67e0f Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:11:56 +0100 Subject: [PATCH 4/8] UI Refactor: Move Follow Module to Separate Window & PyImGui Fixes --- HeroAI/following.py | 309 +++++++++++++---------- HeroAI/windows.py | 48 ++++ Widgets/Automation/Multiboxing/HeroAI.py | 4 +- 3 files changed, 226 insertions(+), 135 deletions(-) diff --git a/HeroAI/following.py b/HeroAI/following.py index 5181908f3..3cad30e35 100644 --- a/HeroAI/following.py +++ b/HeroAI/following.py @@ -97,6 +97,7 @@ def __init__(self): self.follow_distance_ooc: float = FOLLOW_DISTANCE_OUT_OF_COMBAT self.follow_distance_combat: float = MELEE_RANGE_VALUE self.confirm_follow_point: bool = False + self.follow_enabled: bool = True # Per-follower angle configs (index = party slot) self.follower_configs: list[FollowerConfig] = [] @@ -123,7 +124,7 @@ def __init__(self): ] # UI state - self.show_config_window: bool = True + self.show_config_window: bool = False settings = FollowModuleSettings() @@ -152,6 +153,8 @@ def _save_settings(): ini.write_key(_ini_key, "Formation", "DistanceCombat", settings.follow_distance_combat) ini.write_key(_ini_key, "Formation", "ConfirmFollowPoint", settings.confirm_follow_point) + ini.write_key(_ini_key, "Formation", "FollowEnabled", settings.follow_enabled) + # Per-follower angles for i, fc in enumerate(settings.follower_configs): ini.write_key(_ini_key, "Followers", f"Angle_{i}", fc.angle_deg) @@ -174,7 +177,8 @@ def _load_settings(): settings.follow_distance_ooc = ini.read_float(_ini_key, "Formation", "DistanceOOC", FOLLOW_DISTANCE_OUT_OF_COMBAT) settings.follow_distance_combat = ini.read_float(_ini_key, "Formation", "DistanceCombat", MELEE_RANGE_VALUE) settings.confirm_follow_point = ini.read_bool(_ini_key, "Formation", "ConfirmFollowPoint", False) - + settings.follow_enabled = ini.read_bool(_ini_key, "Formation", "FollowEnabled", True) + for i, fc in enumerate(settings.follower_configs): fc.angle_deg = ini.read_float(_ini_key, "Followers", f"Angle_{i}", fc.angle_deg) fc.radius = ini.read_float(_ini_key, "Followers", f"Radius_{i}", fc.radius) @@ -221,13 +225,17 @@ def reset_map_quads(): def LeaderUpdate(cached_data: CacheData): """ Run on the leader's client each tick. - Calculates formation positions for every follower and writes them to shared memory via HeroAIOptionStruct.FollowPos. """ + _ensure_ini() + + if not settings.follow_enabled: + return + leader_id = GLOBAL_CACHE.Party.GetPartyLeaderID() if Player.GetAgentID() != leader_id: return # not leader - + leader_x, leader_y = Agent.GetXY(leader_id) leader_angle = Agent.GetRotationAngle(leader_id) @@ -740,6 +748,51 @@ def Follow(cached_data: CacheData) -> bool: return False + return False + + +def draw_embedded_config(cached_data: CacheData): + """ + Renders the follow configuration UI logic without creating a new window. + Designed to be embedded within the main Control Panel. + """ + _ensure_ini() + + # Master Toggle handled by caller (windows.py), but we enable inner logic here + + if settings.show_canvas: + # If canvas is shown, we might need a child window or just columns if space permits. + # Given it's inside a tree node, columns are best. + + # Calculate available width to decide layout? + # For now, simplistic column approach similar to standalone. + table_flags = ( + PyImGui.TableFlags.Borders | + PyImGui.TableFlags.SizingStretchProp + ) + + if PyImGui.begin_table("EmbeddedFollowTable", 2, table_flags): + PyImGui.table_setup_column("Canvas", PyImGui.TableColumnFlags.WidthFixed, settings.canvas_size[0] + 20) + PyImGui.table_setup_column("Settings", PyImGui.TableColumnFlags.WidthStretch) + + PyImGui.table_next_row() + PyImGui.table_next_column() + _draw_canvas(cached_data) + + PyImGui.table_next_column() + _draw_formation_settings() + _draw_custom_formation(cached_data) + _draw_canvas_settings() + _draw_ring_settings() + + PyImGui.end_table() + else: + # No canvas, just settings list + _draw_formation_settings() + _draw_custom_formation(cached_data) + _draw_canvas_settings() + _draw_ring_settings() + # ───────────────────────────────────────────── # LEADER CONFIG WINDOW — UI (leader-only) @@ -769,7 +822,7 @@ def draw_follow_config(cached_data: CacheData): ) visible, settings.show_config_window = PyImGui.begin_with_close( - "Follow Module — Leader Config", settings.show_config_window, 0 + "Follow Module - Leader Config", settings.show_config_window, 0 ) if visible: @@ -821,138 +874,128 @@ def draw_follow_config(cached_data: CacheData): # ───────────────────────────────────────────── def _draw_canvas(cached_data: CacheData): """Draw the 2D radar-style canvas with area rings and follower positions.""" - child_flags = ( - PyImGui.WindowFlags.NoTitleBar | - PyImGui.WindowFlags.NoResize | - PyImGui.WindowFlags.NoMove - ) - - if PyImGui.begin_child("FollowCanvas", settings.canvas_size, True, child_flags): - canvas_pos = PyImGui.get_cursor_screen_pos() - cx = canvas_pos[0] + settings.canvas_size[0] / 2 - cy = canvas_pos[1] + settings.canvas_size[1] / 2 - - # Grid - grid_color = ColorPalette.GetColor("gray").copy() - grid_color.set_a(80) - grid_step = (Range.Touch.value / 2) * settings.scale - - canvas_x, canvas_y = canvas_pos - canvas_w, canvas_h = settings.canvas_size - left, right = canvas_x, canvas_x + canvas_w - top, bottom = canvas_y, canvas_y + canvas_h - - x = cx - while x <= right: - PyImGui.draw_list_add_line(x, top, x, bottom, grid_color.to_color(), 1) - x += grid_step - x = cx - grid_step - while x >= left: - PyImGui.draw_list_add_line(x, top, x, bottom, grid_color.to_color(), 1) - x -= grid_step - y = cy - while y <= bottom: - PyImGui.draw_list_add_line(left, y, right, y, grid_color.to_color(), 1) - y += grid_step - y = cy - grid_step - while y >= top: - PyImGui.draw_list_add_line(left, y, right, y, grid_color.to_color(), 1) - y -= grid_step + # Manual setup + canvas_w, canvas_h = settings.canvas_size + canvas_pos = PyImGui.get_cursor_screen_pos() + + # ── Manual Canvas Setup ── + # Note: PyImGui static methods draw to the current window's draw list. + # We cannot clip manually as push_clip_rect is not exposed. + + # Background + PyImGui.draw_list_add_rect_filled(canvas_pos[0], canvas_pos[1], canvas_pos[0]+canvas_w, canvas_pos[1]+canvas_h, Color(0,0,0,100).to_color(), 0.0, 0) + # Border + PyImGui.draw_list_add_rect(canvas_pos[0], canvas_pos[1], canvas_pos[0]+canvas_w, canvas_pos[1]+canvas_h, Color(255,255,255,100).to_color(), 0.0, 0, 1.0) + + cx = canvas_pos[0] + canvas_w / 2 + cy = canvas_pos[1] + canvas_h / 2 + + # Grid + grid_color = ColorPalette.GetColor("gray").copy() + grid_color.set_a(80) + grid_step = (Range.Touch.value / 2) * settings.scale + + canvas_x, canvas_y = canvas_pos + left, right = canvas_x, canvas_x + canvas_w + top, bottom = canvas_y, canvas_y + canvas_h + + x = cx + while x <= right: + PyImGui.draw_list_add_line(x, top, x, bottom, grid_color.to_color(), 1.0) + x += grid_step + x = cx - grid_step + while x >= left: + PyImGui.draw_list_add_line(x, top, x, bottom, grid_color.to_color(), 1.0) + x -= grid_step + y = cy + while y <= bottom: + PyImGui.draw_list_add_line(left, y, right, y, grid_color.to_color(), 1.0) + y += grid_step + y = cy - grid_step + while y >= top: + PyImGui.draw_list_add_line(left, y, right, y, grid_color.to_color(), 1.0) + y -= grid_step # Cardinal Directions (N, S, E, W) - text_color = ColorPalette.GetColor("gold").to_color() - # North (Top) - PyImGui.draw_list_add_text(cx - 5, top + 15, text_color, "N") - # South (Bottom) - PyImGui.draw_list_add_text(cx - 5, bottom - 30, text_color, "S") - # East (Right) - PyImGui.draw_list_add_text(right - 30, cy - 7, text_color, "E") - # West (Left) - PyImGui.draw_list_add_text(left + 20, cy - 7, text_color, "W") - - # Center marker (leader position) - touch_color = settings.area_rings[0].color.copy() - touch_color.set_a(100) - touch_radius = settings.area_rings[0].radius * settings.scale - PyImGui.draw_list_add_circle_filled(cx, cy, touch_radius, touch_color.to_color(), 64) - - # Facing Arrow for Leader - leader_angle = 0.0 - try: - leader_angle = Agent.GetRotationAngle(Player.GetAgentID()) - except Exception: - pass - - # Draw facing arrow (pointing from leader) - # GW1: 0=East, pi/2=North. Canvas: North=Up, East=Right. - arrow_len = touch_radius * 1.5 - tip_x = cx + (math.cos(leader_angle) * arrow_len) - tip_y = cy - (math.sin(leader_angle) * arrow_len) + # Cardinal Directions (N, S, E, W) + text_color = ColorPalette.GetColor("gold").to_color() + PyImGui.draw_list_add_text(cx - 5, top + 15, text_color, "N") + PyImGui.draw_list_add_text(cx - 5, bottom - 30, text_color, "S") + PyImGui.draw_list_add_text(right - 30, cy - 7, text_color, "E") + PyImGui.draw_list_add_text(left + 20, cy - 7, text_color, "W") + + # Center marker (leader position) + touch_color = settings.area_rings[0].color.copy() + touch_color.set_a(100) + touch_radius = settings.area_rings[0].radius * settings.scale + PyImGui.draw_list_add_circle_filled(cx, cy, touch_radius, touch_color.to_color(), 64) + + # Facing Arrow for Leader + leader_angle = 0.0 + try: + leader_angle = Agent.GetRotationAngle(Player.GetAgentID()) + except Exception: + pass + + arrow_len = touch_radius * 1.5 + tip_x = cx + (math.cos(leader_angle) * arrow_len) + tip_y = cy - (math.sin(leader_angle) * arrow_len) + + base_angle_offset = 0.5 + base_len = touch_radius * 0.8 + base_L_x = cx + (math.cos(leader_angle - math.pi + base_angle_offset) * base_len) + base_L_y = cy - (math.sin(leader_angle - math.pi + base_angle_offset) * base_len) + base_R_x = cx + (math.cos(leader_angle - math.pi - base_angle_offset) * base_len) + base_R_y = cy - (math.sin(leader_angle - math.pi - base_angle_offset) * base_len) + + arrow_color = ColorPalette.GetColor("white").to_color() + PyImGui.draw_list_add_triangle_filled(tip_x, tip_y, base_L_x, base_L_y, base_R_x, base_R_y, arrow_color) + PyImGui.draw_list_add_triangle(tip_x, tip_y, base_L_x, base_L_y, base_R_x, base_R_y, ColorPalette.GetColor("black").to_color(), 1.0) + + # Area rings + if settings.draw_area_rings: + for ring in settings.area_rings: + if ring.show: + PyImGui.draw_list_add_circle( + cx, cy, + ring.radius * settings.scale, + ring.color.to_color(), 32, float(ring.thickness) + ) + + # Follower formation points + follower_radius_px = (Range.Touch.value / 2) * settings.scale + + for i, fc in enumerate(settings.follower_configs): + if i >= MAX_FOLLOWERS: + break - # Arrow base width - base_angle_offset = 0.5 # radians - base_len = touch_radius * 0.8 - base_L_x = cx + (math.cos(leader_angle - math.pi + base_angle_offset) * base_len) - base_L_y = cy - (math.sin(leader_angle - math.pi + base_angle_offset) * base_len) - base_R_x = cx + (math.cos(leader_angle - math.pi - base_angle_offset) * base_len) - base_R_y = cy - (math.sin(leader_angle - math.pi - base_angle_offset) * base_len) + if not fc.enabled: + continue - arrow_color = ColorPalette.GetColor("white").to_color() - PyImGui.draw_list_add_triangle_filled(tip_x, tip_y, base_L_x, base_L_y, base_R_x, base_R_y, arrow_color) - PyImGui.draw_list_add_triangle(tip_x, tip_y, base_L_x, base_L_y, base_R_x, base_R_y, ColorPalette.GetColor("black").to_color(), 1.0) - - # Area rings - if settings.draw_area_rings: - for ring in settings.area_rings: - if ring.show: - PyImGui.draw_list_add_circle( - cx, cy, - ring.radius * settings.scale, - ring.color.to_color(), 32, ring.thickness - ) - - # Follower formation points (real-time, rotated by player heading) - leader_angle = 0.0 - try: - leader_angle = Agent.GetRotationAngle(Player.GetAgentID()) - except Exception: - pass - - follower_radius_px = (Range.Touch.value / 2) * settings.scale - - for i, fc in enumerate(settings.follower_configs): - if i >= MAX_FOLLOWERS: - break - - if not fc.enabled: - continue - - # Convert angle to local offset (0°=forward) - angle_rad = Utils.DegToRad(fc.angle_deg) - world_angle = leader_angle + angle_rad - effective_radius = fc.get_radius(settings.formation_radius) - - # Standard rotation mapping (consistent with LeaderUpdate) - rot_x = effective_radius * math.cos(world_angle) - rot_y = effective_radius * math.sin(world_angle) - - # Map to canvas - draw_x = cx + (rot_x * settings.scale) - draw_y = cy - (rot_y * settings.scale) - - color_fill = fc.color.copy() - color_fill.set_a(120) - - PyImGui.draw_list_add_circle_filled( - draw_x, draw_y, follower_radius_px, - color_fill.to_color(), 32 - ) - PyImGui.draw_list_add_circle( - draw_x, draw_y, follower_radius_px, - fc.color.to_color(), 32, 2 - ) - - PyImGui.end_child() + angle_rad = Utils.DegToRad(fc.angle_deg) + world_angle = leader_angle + angle_rad + effective_radius = fc.get_radius(settings.formation_radius) + + rot_x = effective_radius * math.cos(world_angle) + rot_y = effective_radius * math.sin(world_angle) + + draw_x = cx + (rot_x * settings.scale) + draw_y = cy - (rot_y * settings.scale) + + color_fill = fc.color.copy() + color_fill.set_a(120) + + PyImGui.draw_list_add_circle_filled( + draw_x, draw_y, follower_radius_px, + color_fill.to_color(), 32 + ) + PyImGui.draw_list_add_circle( + draw_x, draw_y, follower_radius_px, + fc.color.to_color(), 32, 2.0 + ) + + # Advance cursor to reserve the space we just drew on + PyImGui.dummy(settings.canvas_size[0], settings.canvas_size[1]) # ───────────────────────────────────────────── diff --git a/HeroAI/windows.py b/HeroAI/windows.py index ffa60bcb8..cdf8efb10 100644 --- a/HeroAI/windows.py +++ b/HeroAI/windows.py @@ -14,6 +14,7 @@ from .globals import capture_mouse_timer, show_area_rings, show_hero_follow_grid, show_distance_on_followers, hero_formation from .utils import IsHeroFlagged, DrawFlagAll, DrawHeroFlag, DistanceFromWaypoint, SameMapAsAccount from HeroAI.settings import Settings +from HeroAI.following import draw_embedded_config, settings as follow_settings from Py4GWCoreLib.ImGui_src.Textures import ThemeTextures from Py4GWCoreLib.ImGui_src.types import StyleTheme, ControlAppearance @@ -1755,11 +1756,58 @@ def _close_spacing(): style.CellPadding.pop_style_var() style.ItemSpacing.pop_style_var() PyImGui.tree_pop() + + PyImGui.separator() + if PyImGui.tree_node("Follow Module"): + style = ImGui.get_style() + style.ItemSpacing.push_style_var(2, 2) + style.CellPadding.push_style_var(2, 2) + + # Master Toggle + is_enabled = follow_settings.follow_enabled + new_enabled = PyImGui.checkbox("Enable Formation Logic", is_enabled) + if new_enabled != is_enabled: + follow_settings.follow_enabled = new_enabled + HeroAI_FloatingWindows.settings.save_settings() # Triggers save for all modules if they share ini logic, or we need specific save + # The follow module has its own _save_settings logic triggered by UI changes usually. + # We might need to manually trigger a save or rely on the next loop. + # For now, let's assume the follow module handles its own state or we trigger it. + # Actually, following.py saves when things change in its own UI. + # We should probably expose a save method or just let it be. + # Wait, following.py uses a local _save_settings. We can't easily call it. + # But we updated the value in the object. + # Let's force a save by dirtying it? + # following.py settings auto-save in its draw functions. + # We'll rely on draw_embedded_config to handle internal saves, + # but for this toggle we might need to be careful. + # Let's just set it. The embedded config will likely save on its own interactions. + from HeroAI.following import _save_settings as follow_save + follow_save() + + if not follow_settings.follow_enabled: + ImGui.text_colored("Formation logic is DISABLED.", (1.0, 0.0, 0.0, 1.0)) + else: + ImGui.text_colored("Formation logic is ENABLED.", (0.0, 1.0, 0.0, 1.0)) + + PyImGui.separator() + + # Refactor: Button to open separate window instead of embedding + btn_text = "Close Configuration Window" if follow_settings.show_config_window else "Open Configuration Window" + if PyImGui.button(btn_text): + follow_settings.show_config_window = not follow_settings.show_config_window + + if follow_settings.show_config_window: + ImGui.text_colored("Window is OPEN", (0.0, 1.0, 0.0, 1.0)) + + style.CellPadding.pop_style_var() + style.ItemSpacing.pop_style_var() + PyImGui.tree_pop() ImGui.End(cached_data.ini_key) @staticmethod def DrawAdvancedPathingOptions(cached_data:CacheData): + # Settings for Advanced Pathing & Unstuck module adv_pathing = HeroAI_FloatingWindows.settings.advanced_pathing_enabled new_adv_pathing = PyImGui.checkbox("Enable Advanced Pathing & Unstuck", adv_pathing) if new_adv_pathing != adv_pathing: diff --git a/Widgets/Automation/Multiboxing/HeroAI.py b/Widgets/Automation/Multiboxing/HeroAI.py index 2221fd558..d3c2442e7 100644 --- a/Widgets/Automation/Multiboxing/HeroAI.py +++ b/Widgets/Automation/Multiboxing/HeroAI.py @@ -199,8 +199,8 @@ def handle_UI (cached_data: CacheData): HeroAI_FloatingWindows.show_ui(cached_data) - # Leader-only follow config window - draw_follow_config(cached_data) + # Leader-only follow config window (Now embedded in Control Panel) + draw_follow_config(cached_data) def initialize(cached_data: CacheData) -> bool: if not Routines.Checks.Map.MapValid(): From b3d92a9c973008098fd67d6fe484a83d4308a36b Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:39:22 +0100 Subject: [PATCH 5/8] Implement Targeting System: UI, Settings, and Combat Logic + Fixes --- HeroAI/combat.py | 104 ++++++++++++++++++++++++++++++++++++++++++++- HeroAI/settings.py | 23 ++++++++++ HeroAI/windows.py | 23 ++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/HeroAI/combat.py b/HeroAI/combat.py index add4f2cb9..84dd8c266 100644 --- a/HeroAI/combat.py +++ b/HeroAI/combat.py @@ -481,9 +481,111 @@ def get_lowest_ally(): else: v_target = self.GetPartyTarget() if v_target == 0: - v_target = get_nearest_enemy() + # v_target = get_nearest_enemy() + # Targeting Mode Logic + from .settings import Settings # Lazy import because valid use is rare + + # We need to access the helper instance settings + # But settings is a singleton, so we can access it directly + # However, Settings class definition needs to be available + + # Check mode + mode = Settings().targeting_mode + + if mode == Settings.TargetingMode.Classic: + v_target = get_nearest_enemy() + elif mode == Settings.TargetingMode.Smart: + v_target = self.GetSmartTarget() + if v_target == 0: v_target = get_nearest_enemy() # Fallback + elif mode == Settings.TargetingMode.Assist: + v_target = self.GetAssistTarget() + if v_target == 0: v_target = get_nearest_enemy() # Fallback return v_target + def GetSmartTarget(self): + enemy_array = Routines.Agents.GetFilteredEnemyArray(0, 0, self.get_combat_distance()) + if not enemy_array: + return 0 + + best_target = 0 + best_score = -1000.0 + + my_id = Player.GetAgentID() + my_target = Player.GetTargetID() + my_pos = Agent.GetXY(my_id) + + for agent_id in enemy_array: + if not Agent.IsAlive(agent_id): + continue + + score = 0.0 + + # 1. Health Score (0-50 pts) - Lower health = higher score + health_pct = Agent.GetHealth(agent_id) + score += (1.0 - health_pct) * 50.0 + + # 2. Profession Score (0-100 pts) + prof, _ = Agent.GetProfession(agent_id) + if prof == "Monk" or prof == "Ritualist": + score += 100.0 + elif prof == "Mesmer" or prof == "Elementalist" or prof == "Necromancer": + score += 60.0 + + # 3. Distance Penalty + dist = Utils.Distance(my_pos, Agent.GetXY(agent_id)) + score -= dist * 0.01 # -10 pts per 1000 units + + # 4. Stickiness (Hysteresis) + if agent_id == my_target: + score += 40.0 + + if score > best_score: + best_score = score + best_target = agent_id + + return best_target + + def GetAssistTarget(self): + # 1. Check for called target first (Party Leader's target) + party_target = self.GetPartyTargetID() + if Agent.IsValid(party_target) and Agent.IsEnemy(party_target) and Agent.IsAlive(party_target): + return party_target + + # 2. Count attackers on each enemy + enemy_array = Routines.Agents.GetFilteredEnemyArray(0, 0, self.get_combat_distance()) + if not enemy_array: + return 0 + + target_counts = {} + party_members = AgentArray.GetPartyArray() + + for member_id in party_members: + if member_id == Player.GetAgentID(): + continue # Don't count self + + t_id = Agent.GetTargetID(member_id) + if t_id in enemy_array and Agent.IsAlive(t_id): # Only count if they are targeting an enemy we can see + target_counts[t_id] = target_counts.get(t_id, 0) + 1 + + # 3. Choose target with most attackers + best_target = 0 + max_attackers = 0 + + for t_id, count in target_counts.items(): + if count > max_attackers: + max_attackers = count + best_target = t_id + elif count == max_attackers and count > 0: + # Tie-breaker: Use Smart logic (Health/Dist) or just nearest + if self.GetEnergyValues(t_id) < self.GetEnergyValues(best_target): # Arbitrary tie-breaker using existing helper + best_target = t_id + + if best_target != 0: + return best_target + + # Fallback to Smart if no one is attacking anything or no consensus + return self.GetSmartTarget() + def IsPartyMember(self, agent_id): from .utils import IsPartyMember return IsPartyMember(agent_id) diff --git a/HeroAI/settings.py b/HeroAI/settings.py index a24386081..66b67ca39 100644 --- a/HeroAI/settings.py +++ b/HeroAI/settings.py @@ -7,7 +7,15 @@ from Py4GWCoreLib.py4gwcorelib_src.Console import Console, ConsoleLog from Py4GWCoreLib.py4gwcorelib_src.IniHandler import IniHandler +from Py4GWCoreLib.py4gwcorelib_src.IniHandler import IniHandler +from enum import Enum + class Settings: + class TargetingMode(Enum): + Classic = 0 + Smart = 1 + Assist = 2 + class HeroPanelInfo: def __init__(self, x: int = 200, y: int = 200, collapsed: bool = False, visible: bool = True): self.x: int = x @@ -147,6 +155,11 @@ def __init__(self): self.recidivism_memory = 10.0 self.hypersensitive_speed = 0.3 + self.hypersensitive_speed = 0.3 + + # Targeting System + self.targeting_mode = Settings.TargetingMode.Classic + default_hotbar = Settings.CommandHotBar("hotbar_1") commands = HeroAICommands() @@ -296,6 +309,9 @@ def write_settings(self): self.ini_handler.write_key("AdvancedPathing", "RecidivismMemory", str(self.recidivism_memory)) self.ini_handler.write_key("AdvancedPathing", "HypersensitiveSpeed", str(self.hypersensitive_speed)) + # Targeting System + self.ini_handler.write_key("Targeting", "Mode", str(self.targeting_mode.value)) + for hotbar_id, hotbar in self.CommandHotBars.items(): self.ini_handler.write_key("CommandHotBars", hotbar_id, hotbar.to_ini_string()) @@ -353,6 +369,13 @@ def load_settings(self): self.unstuck_radius = self.ini_handler.read_float("AdvancedPathing", "UnstuckRadius", 80.0) self.recidivism_memory = self.ini_handler.read_float("AdvancedPathing", "RecidivismMemory", 10.0) self.hypersensitive_speed = self.ini_handler.read_float("AdvancedPathing", "HypersensitiveSpeed", 0.3) + + # Targeting System + try: + mode_val = self.ini_handler.read_int("Targeting", "Mode", 0) + self.targeting_mode = Settings.TargetingMode(mode_val) + except ValueError: + self.targeting_mode = Settings.TargetingMode.Classic self.CommandHotBars.clear() self.import_command_hotbars() diff --git a/HeroAI/windows.py b/HeroAI/windows.py index cdf8efb10..1b1c4cd6b 100644 --- a/HeroAI/windows.py +++ b/HeroAI/windows.py @@ -1802,6 +1802,29 @@ def _close_spacing(): style.CellPadding.pop_style_var() style.ItemSpacing.pop_style_var() PyImGui.tree_pop() + + PyImGui.separator() + + if PyImGui.tree_node("Targeting Module"): + # Targeting Mode Selection + modes = ["Classic (Nearest)", "Smart (Weighted)", "Assist (Cluster)"] + current_mode_idx = HeroAI_FloatingWindows.settings.targeting_mode.value + + new_mode_idx = PyImGui.combo("Strategy", current_mode_idx, modes) + + if new_mode_idx != current_mode_idx: + HeroAI_FloatingWindows.settings.targeting_mode = Settings.TargetingMode(new_mode_idx) + HeroAI_FloatingWindows.settings.save_settings() + + # Description helper + if new_mode_idx == 0: + ImGui.text_colored("Default behavior. Target nearest enemy.", (0.7, 0.7, 0.7, 1.0)) + elif new_mode_idx == 1: + ImGui.text_colored("Prioritizes Healers > Casters > Low HP.", (0.7, 0.7, 0.7, 1.0)) + elif new_mode_idx == 2: + ImGui.text_colored("Focus fire on party's target.", (0.7, 0.7, 0.7, 1.0)) + + PyImGui.tree_pop() ImGui.End(cached_data.ini_key) From c0e6b78baf859293a98e5264286fe4e360f2c5d7 Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:45:13 +0100 Subject: [PATCH 6/8] Implement Targeting Mode Propagation and Sync Logic + Console Logging --- HeroAI/cache_data.py | 15 +++++++++++++++ HeroAI/combat.py | 33 ++++++++++++--------------------- HeroAI/following.py | 4 ++++ inspect_struct.py | 7 +++++++ 4 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 inspect_struct.py diff --git a/HeroAI/cache_data.py b/HeroAI/cache_data.py index 110154e9f..f39fd9ef9 100644 --- a/HeroAI/cache_data.py +++ b/HeroAI/cache_data.py @@ -214,6 +214,21 @@ def Update(self): self.account_data = GLOBAL_CACHE.ShMem.GetAccountDataFromEmail(self.account_email) or self.account_data self.account_options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsFromEmail(self.account_email) or self.account_options + # Synchronize targeting mode from leader + if not self.account_data.PlayerIsPartyLeader: + leader_acc = self.party.get_by_party_pos(0) + if leader_acc: + leader_options = self.party.options.get(leader_acc.PlayerID) + if leader_options: + leader_mode_idx = int(leader_options.FollowPos.x) + if leader_mode_idx in [0, 1, 2]: + from .settings import Settings + settings = Settings() + if leader_mode_idx != settings.targeting_mode.value: + settings.targeting_mode = Settings.TargetingMode(leader_mode_idx) + settings.save_settings() + ConsoleLog("HeroAI", f"Targeting Mode updated by Leader: {settings.targeting_mode.name}") + if self.stay_alert_timer.HasElapsed(STAY_ALERT_TIME): self.data.in_aggro = self.InAggro(AgentArray.GetEnemyArray(), Range.Earshot.value) else: diff --git a/HeroAI/combat.py b/HeroAI/combat.py index 84dd8c266..69a9caf2c 100644 --- a/HeroAI/combat.py +++ b/HeroAI/combat.py @@ -352,7 +352,16 @@ def GetAppropiateTarget(self, slot): def get_nearest_enemy(): nonlocal _nearest_enemy if _nearest_enemy is None: - _nearest_enemy = Routines.Agents.GetNearestEnemy(self.get_combat_distance()) + from .settings import Settings + mode = Settings().targeting_mode + + if mode == Settings.TargetingMode.Smart: + _nearest_enemy = self.GetSmartTarget() + elif mode == Settings.TargetingMode.Assist: + _nearest_enemy = self.GetAssistTarget() + + if not _nearest_enemy: + _nearest_enemy = Routines.Agents.GetNearestEnemy(self.get_combat_distance()) return _nearest_enemy _lowest_ally = None @@ -478,28 +487,10 @@ def get_lowest_ally(): v_target = Routines.Agents.GetLowestMinion(Range.Spellcast.value) elif target_allegiance == Skilltarget.Corpse: v_target = Routines.Agents.GetNearestCorpse(Range.Spellcast.value) - else: + else: # Fallback for generic targets v_target = self.GetPartyTarget() if v_target == 0: - # v_target = get_nearest_enemy() - # Targeting Mode Logic - from .settings import Settings # Lazy import because valid use is rare - - # We need to access the helper instance settings - # But settings is a singleton, so we can access it directly - # However, Settings class definition needs to be available - - # Check mode - mode = Settings().targeting_mode - - if mode == Settings.TargetingMode.Classic: - v_target = get_nearest_enemy() - elif mode == Settings.TargetingMode.Smart: - v_target = self.GetSmartTarget() - if v_target == 0: v_target = get_nearest_enemy() # Fallback - elif mode == Settings.TargetingMode.Assist: - v_target = self.GetAssistTarget() - if v_target == 0: v_target = get_nearest_enemy() # Fallback + v_target = get_nearest_enemy() return v_target def GetSmartTarget(self): diff --git a/HeroAI/following.py b/HeroAI/following.py index 3cad30e35..831278a50 100644 --- a/HeroAI/following.py +++ b/HeroAI/following.py @@ -245,6 +245,10 @@ def LeaderUpdate(cached_data: CacheData): return # leader at origin, skip leader_options = GLOBAL_CACHE.ShMem.GetGerHeroAIOptionsByPartyNumber(0) + + # Broadcast targeting mode to all followers via leader's FollowPos.x + if leader_options: + leader_options.FollowPos.x = float(Settings().targeting_mode.value) # Iterate over all party accounts for acc in cached_data.party: diff --git a/inspect_struct.py b/inspect_struct.py new file mode 100644 index 000000000..2268d5f51 --- /dev/null +++ b/inspect_struct.py @@ -0,0 +1,7 @@ +from Py4GWCoreLib.GlobalCache.SharedMemory import HeroAIOptionStruct +import ctypes + +struct = HeroAIOptionStruct() +print("Fields of HeroAIOptionStruct:") +for field in struct._fields_: + print(f"{field[0]}: {field[1]}") From 5223301b408edff92a061bee107816bfa53f10d7 Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:45:49 +0100 Subject: [PATCH 7/8] Cleanup temporary inspection script --- inspect_struct.py | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 inspect_struct.py diff --git a/inspect_struct.py b/inspect_struct.py deleted file mode 100644 index 2268d5f51..000000000 --- a/inspect_struct.py +++ /dev/null @@ -1,7 +0,0 @@ -from Py4GWCoreLib.GlobalCache.SharedMemory import HeroAIOptionStruct -import ctypes - -struct = HeroAIOptionStruct() -print("Fields of HeroAIOptionStruct:") -for field in struct._fields_: - print(f"{field[0]}: {field[1]}") From 50f8391bc496de8ab4706eb4e46b25ae84159f36 Mon Sep 17 00:00:00 2001 From: Dupljakus <121574106+Dupljakus@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:07:12 +0100 Subject: [PATCH 8/8] Fix combat hesitation, log spam, and GetPartyTargetID crash --- HeroAI/cache_data.py | 75 ++++- HeroAI/combat.py | 338 +++++++++++++++++++++-- HeroAI/constants.py | 4 +- HeroAI/settings.py | 8 +- Widgets/Automation/Multiboxing/HeroAI.py | 24 +- 5 files changed, 409 insertions(+), 40 deletions(-) diff --git a/HeroAI/cache_data.py b/HeroAI/cache_data.py index f39fd9ef9..3f0c3e3d0 100644 --- a/HeroAI/cache_data.py +++ b/HeroAI/cache_data.py @@ -8,7 +8,7 @@ from Py4GWCoreLib import GLOBAL_CACHE from Py4GWCoreLib import Timer, ThrottledTimer from Py4GWCoreLib import Range, Agent, ConsoleLog, Player -from Py4GWCoreLib import AgentArray, Weapon, Routines +from Py4GWCoreLib import AgentArray, Weapon, Routines, Utils, CombatEvents from Py4GWCoreLib.IniManager import IniManager INI_DIR = "HeroAI" @@ -164,6 +164,10 @@ def __init__(self, throttle_time=75): self.ui_state_data = UIStateData() self.follow_throttle_timer = ThrottledTimer(300) self.follow_throttle_timer.Start() + self.shmem_debug_throttle = ThrottledTimer(5000) + self.shmem_debug_throttle.Start() + self.aggro_debug_throttle = ThrottledTimer(2000) + self.aggro_debug_throttle.Start() self.option_show_floating_targets = True self.global_options = HeroAIOptionStruct() @@ -184,7 +188,67 @@ def reset(self): self.data.reset() def InAggro(self, enemy_array, aggro_range = Range.Earshot.value): - return Routines.Checks.Agents.InAggro(aggro_range) + # 1. Standard nearby enemy check (Local client) + if Routines.Checks.Agents.InAggro(aggro_range): + return True + + party_member_ids = AgentArray.GetAllyArray() + + # 2. Party Offensive Action Check (Compass Range / Shared Memory Sync) + # Scan party members to see if they are attacking/casting + # We use shared memory states (ModelState) to detect auto-attacks reliably across clients + acc_count = 0 + for acc in self.party: + if not acc.IsSlotActive: + continue + acc_count += 1 + + # Use shared memory flags for attacking/casting + if acc.PlayerData.AgentData.Is_Attacking or acc.PlayerData.AgentData.Is_Casting: + t_id = acc.PlayerTargetID + if Agent.IsValid(t_id) and Agent.IsLiving(t_id): + _, alleg = Agent.GetAllegiance(t_id) + if alleg == "Enemy": + if self.aggro_debug_throttle.IsExpired(): + ConsoleLog("HeroAI", f"Aggro -> Action Sync: Party Member {acc.PlayerID} (Leader: {acc.PlayerIsPartyLeader}) attacking Enemy {t_id}") + self.aggro_debug_throttle.Reset() + return True + + # Additional Check: CombatEvents (Faster than animation flags sometimes) + # This checks if the game client knows the party member is attacking someone + combat_target_id = CombatEvents.get_attack_target(acc.PlayerID) or CombatEvents.get_cast_target(acc.PlayerID) + if combat_target_id != 0: + _, alleg = Agent.GetAllegiance(combat_target_id) + if alleg == "Enemy": + if self.aggro_debug_throttle.IsExpired(): + ConsoleLog("HeroAI", f"Aggro -> CombatEvent Sync: Party Member {acc.PlayerID} attacking Enemy {combat_target_id}") + self.aggro_debug_throttle.Reset() + return True + + # 3. Local Action Check (Fallback for immediate responsiveness) + for member_id in party_member_ids: + if Agent.IsAttacking(member_id) or Agent.IsCasting(member_id): + t_id = CombatEvents.get_cast_target(member_id) or CombatEvents.get_attack_target(member_id) + if t_id != 0: + _, alleg = Agent.GetAllegiance(t_id) + if alleg == "Enemy": + if self.aggro_debug_throttle.IsExpired(): + ConsoleLog("HeroAI", f"Aggro -> Action Local: Party Member {member_id} attacking Enemy {t_id}") + self.aggro_debug_throttle.Reset() + return True + + # 4. Passive Threat Check: Scan all enemies to see if any are attacking a party member + all_enemies = AgentArray.GetEnemyArray() + for enemy_id in all_enemies: + if Utils.Distance(Agent.GetXY(Player.GetAgentID()), Agent.GetXY(enemy_id)) > Range.Compass.value: + continue + + target_id = CombatEvents.get_cast_target(enemy_id) or CombatEvents.get_attack_target(enemy_id) + if target_id in party_member_ids: + ConsoleLog("HeroAI", f"Aggro -> Threat: Enemy {enemy_id} attacking Party {target_id}") + return True + + return False def UpdateCombat(self): self.combat_handler.Update(self) @@ -210,6 +274,11 @@ def Update(self): self.party.reset() self.party.update() + if self.shmem_debug_throttle.IsExpired(): + # Periodically show shared memory status - Suppressed to reduce console spam during combat + # ConsoleLog("HeroAI", f"Shared Memory Sync: Scanning {len(self.party.accounts)} accounts.") + self.shmem_debug_throttle.Reset() + self.account_data = GLOBAL_CACHE.ShMem.GetAccountDataFromEmail(self.account_email) or self.account_data self.account_options = GLOBAL_CACHE.ShMem.GetHeroAIOptionsFromEmail(self.account_email) or self.account_options @@ -243,7 +312,7 @@ def Update(self): self.auto_attack_time = self.GetWeaponAttackAftercast() except Exception as e: - ConsoleLog(f"Update Cahe Data Error:", e) + ConsoleLog("HeroAI", f"Update Cache Data Error: {str(e)}", 2) \ No newline at end of file diff --git a/HeroAI/combat.py b/HeroAI/combat.py index 69a9caf2c..82d1a6605 100644 --- a/HeroAI/combat.py +++ b/HeroAI/combat.py @@ -1,5 +1,5 @@ import Py4GW -from Py4GWCoreLib import Player, GLOBAL_CACHE, SpiritModelID, Timer, Agent, Routines, Range, Allegiance, AgentArray +from Py4GWCoreLib import Player, GLOBAL_CACHE, SpiritModelID, Timer, Agent, Routines, Range, Allegiance, AgentArray, Utils, CombatEvents, ConsoleLog, ThrottledTimer from Py4GWCoreLib import Weapon, Effects from Py4GWCoreLib.enums import SPIRIT_BUFF_MAP, ModelID from .custom_skill import CustomSkillClass @@ -50,6 +50,7 @@ def __init__(self): self.aftercast = 0 self.aftercast_timer = Timer() self.aftercast_timer.Start() + self.movement_throttle_timer = ThrottledTimer(250) self.ping_handler = Py4GW.PingHandler() self.oldCalledTarget = 0 @@ -125,6 +126,7 @@ def __init__(self): self.junundu_tunnel = GLOBAL_CACHE.Skill.GetID("Junundu_Tunnel") def Update(self, cached_data): + self.cached_data = cached_data self.in_aggro = cached_data.data.in_aggro self.fast_casting_exists = cached_data.data.fast_casting_exists @@ -305,13 +307,30 @@ def GetPartyTargetID(self): if not GLOBAL_CACHE.Party.IsPartyLoaded(): return 0 + # 1. Called Target (Ctrl+Click) - retrieved from Local Client Memory (flawless sync not guaranteed but safe) players = GLOBAL_CACHE.Party.GetPlayers() - target = players[0].called_target_id + if players: + target = players[0].called_target_id + if Agent.IsValid(target) and Agent.IsLiving(target): + _, alleg = Agent.GetAllegiance(target) + if alleg == "Enemy": + return target + + # 2. Sync: Leader's current selected target from Shared Memory (Fastest) + leader_acc = self.cached_data.party.get_by_party_pos(0) + + if leader_acc and leader_acc.PlayerID != 0: + # Leader's Selected Target + target = leader_acc.PlayerTargetID + if Agent.IsValid(target) and Agent.IsLiving(target): + _, alleg = Agent.GetAllegiance(target) + if alleg == "Enemy": + return target + - if Agent.IsValid(target): - return target return 0 + def SafeChangeTarget(self, target_id): if Agent.IsValid(target_id): @@ -325,18 +344,113 @@ def SafeInteract(self, target_id): def GetPartyTarget(self): party_target = self.GetPartyTargetID() - if self.is_targeting_enabled and party_target != 0: + if party_target != 0: current_target = Player.GetTargetID() if current_target != party_target: - if Agent.IsLiving(party_target): - _, alliegeance = Agent.GetAllegiance(party_target) - if alliegeance != 'Ally' and alliegeance != 'NPC/Minipet' and self.is_combat_enabled: - self.SafeChangeTarget(party_target) - return party_target + self.SafeChangeTarget(party_target) + return party_target return 0 def get_combat_distance(self): - return Range.Spellcast.value if self.in_aggro else Range.Earshot.value + # Reduced from 5000.0 to 2800.0 to match Engagement Limit. + # This prevents "Generic" selectors (like GetNearestEnemyCaster) from picking targets + # that we are not allowed to charge towards. + return 2800.0 if self.in_aggro else Range.Earshot.value + + def GetWeaponRange(self, agent_id): + """ + Returns the effective range of the agent's equipped weapon. + """ + from .constants import MELEE_RANGE_VALUE, RANGED_RANGE_VALUE + from Py4GWCoreLib.enums import Weapon + + # Get the specific weapon type (Enum) from the agent + weapon_type = Agent.GetWeaponType(agent_id) + + # Ranges: + # Melee: Axe, Hammer, Daggers, Scythe, Sword + # Ranged: Bow, Spear + # Caster: Scepter, Wand, Staff (Use Spellcast range) + + if weapon_type in [Weapon.Axe, Weapon.Hammer, Weapon.Daggers, Weapon.Scythe, Weapon.Sword]: + return MELEE_RANGE_VALUE + + elif weapon_type in [Weapon.Bow, Weapon.Spear]: + return RANGED_RANGE_VALUE + + elif weapon_type in [Weapon.Scepter, Weapon.Scepter2, Weapon.Wand, Weapon.Staff, Weapon.Staff1, Weapon.Staff2, Weapon.Staff3]: + return Range.Spellcast.value # 1248.0 + + # Fallback based on broader categories if exact type fails + if Agent.IsMelee(agent_id): + return MELEE_RANGE_VALUE + elif Agent.IsRanged(agent_id): + return RANGED_RANGE_VALUE + + return Range.Spellcast.value + + # def StopMovement(self): + # """ + # Stops the character's movement by tapping the backward key. + # This is necessary because Player.Move() is "sticky". + # """ + # from Py4GWCoreLib.enums import Key + # import PyKeystroke + + # # Create a keystroke instance + # keystroke = PyKeystroke.PyScanCodeKeystroke() + + # # Tap 'S' (Backward) very briefly to break movement + # # We use PushKey for a single frame press/release usually, or explicit Press/Release + # keystroke.PushKey(Key.S.value) + + def is_valid_defensive_target(self, agent_id): + """ + An enemy is a valid target ONLY if: + 1. It is within Earshot (1000u) range. + 2. OR it is within Compass (3500u) range AND it is actively attacking a party member. + """ + if agent_id == 0: return False + + my_pos = Agent.GetXY(Player.GetAgentID()) + target_pos = Agent.GetXY(agent_id) + dist = Utils.Distance(my_pos, target_pos) + + # Rule 1: Nearby is always valid + if dist <= Range.Earshot.value: + # ConsoleLog("HeroAI", f"Target {agent_id} valid: Nearby ({int(dist)})") # Too noisy + return True + + # Rule 2: Compass range checks (3500u) + if dist <= Range.Compass.value: + # Threat check: Enemy is attacking/casting at a party member + target_of_enemy = CombatEvents.get_cast_target(agent_id) or CombatEvents.get_attack_target(agent_id) + if target_of_enemy != 0: + party_member_ids = AgentArray.GetAllyArray() + if target_of_enemy in party_member_ids: + ConsoleLog("HeroAI", f"Target {agent_id} valid: Attacking Party ({int(dist)})") + return True + + # Rule 3: Offensive Sync (Global range for party support) + # If ANY party member is attacking this specific enemy, we assist regardless of distance + # We check both local client actions AND shared memory actions for maximum reliability + party_member_ids = AgentArray.GetAllyArray() + for member_id in party_member_ids: + # Check local client action (best for immediate neighbors) + if Agent.IsAttacking(member_id) or Agent.IsCasting(member_id): + party_target = CombatEvents.get_cast_target(member_id) or CombatEvents.get_attack_target(member_id) + if party_target == agent_id: + ConsoleLog("HeroAI", f"Target {agent_id} valid: Locally Assisting {member_id} ({int(dist)})") + return True + + # Check Shared Memory states (best for cross-client sync during auto-attacks) + for acc in self.cached_data.party: + if acc.IsSlotActive and (acc.PlayerData.AgentData.Is_Attacking or acc.PlayerData.AgentData.Is_Casting): + if acc.PlayerTargetID == agent_id: + ConsoleLog("HeroAI", f"Target {agent_id} valid: Sync Assisting {acc.PlayerID} ({int(dist)})") + return True + + return False def GetAppropiateTarget(self, slot): v_target = 0 @@ -362,6 +476,10 @@ def get_nearest_enemy(): if not _nearest_enemy: _nearest_enemy = Routines.Agents.GetNearestEnemy(self.get_combat_distance()) + + # FINAL Defensive Filter: If it's not a valid defensive target, discard it + if _nearest_enemy != 0 and not self.is_valid_defensive_target(_nearest_enemy): + _nearest_enemy = 0 return _nearest_enemy _lowest_ally = None @@ -494,7 +612,10 @@ def get_lowest_ally(): return v_target def GetSmartTarget(self): - enemy_array = Routines.Agents.GetFilteredEnemyArray(0, 0, self.get_combat_distance()) + # Smart Target looks further (5000) to find the Leader's target, + # but uses scoring to reject distant non-leader targets. + search_range = 5000.0 if self.in_aggro else Range.Earshot.value + enemy_array = Routines.Agents.GetFilteredEnemyArray(0, 0, search_range) if not enemy_array: return 0 @@ -508,6 +629,10 @@ def GetSmartTarget(self): for agent_id in enemy_array: if not Agent.IsAlive(agent_id): continue + + # Filter: Is this a valid defensive target? + if not self.is_valid_defensive_target(agent_id): + continue score = 0.0 @@ -516,7 +641,7 @@ def GetSmartTarget(self): score += (1.0 - health_pct) * 50.0 # 2. Profession Score (0-100 pts) - prof, _ = Agent.GetProfession(agent_id) + prof, _ = Agent.GetProfessionNames(agent_id) if prof == "Monk" or prof == "Ritualist": score += 100.0 elif prof == "Mesmer" or prof == "Elementalist" or prof == "Necromancer": @@ -526,10 +651,45 @@ def GetSmartTarget(self): dist = Utils.Distance(my_pos, Agent.GetXY(agent_id)) score -= dist * 0.01 # -10 pts per 1000 units - # 4. Stickiness (Hysteresis) + # 3b. Leader "Danger Zone" Bonus (Protect the Leader!) + # If enemy is within 1250 units of the LEADER, give a big bonus. + # This ensures we clear the area around the leader first. + leader_acc = self.cached_data.party.get_by_party_pos(0) + if leader_acc and leader_acc.PlayerID != 0: + leader_pos = Agent.GetXY(leader_acc.PlayerID) + dist_to_leader = Utils.Distance(leader_pos, Agent.GetXY(agent_id)) + if dist_to_leader < 1250.0: + score += 1000.0 + + # 3c. Party "Danger Zone" Bonus (Protect the Team) + # If enemy is within 1250 units of ANY party member, give a moderate bonus. + # (We skip the leader here since they are handled above, but checks are cheap) + allies = AgentArray.GetAllyArray() + for ally_id in allies: + if Utils.Distance(Agent.GetXY(ally_id), Agent.GetXY(agent_id)) < 1250.0: + score += 500.0 + break # Only apply bonus once + + + # 4. Stickiness (Hysteresis) if agent_id == my_target: score += 40.0 + # 5. Leader Target Bonus & Distance Penalty + # We want to strongly prioritize the leader's target to maintain cohesion. + leader_target_id = self.GetPartyTargetID() # This fetches Leader Target or Called Target + + is_leader_target = (agent_id == leader_target_id) + + if is_leader_target: + score += 2000.0 # Massive bonus to ensure we pick leader's target + + # If target is far away and NOT the leader's target, penalize it heavily. + # This aligns with HandleCombat's engagement limit (2800) to prevent selecting targets we won't attack. + if not is_leader_target and dist > 2800.0: + score -= 5000.0 + + if score > best_score: best_score = score best_target = agent_id @@ -543,20 +703,24 @@ def GetAssistTarget(self): return party_target # 2. Count attackers on each enemy - enemy_array = Routines.Agents.GetFilteredEnemyArray(0, 0, self.get_combat_distance()) + # Search far (5000) to find assists even if they are distant + search_range = 5000.0 if self.in_aggro else Range.Earshot.value + enemy_array = Routines.Agents.GetFilteredEnemyArray(0, 0, search_range) if not enemy_array: return 0 target_counts = {} - party_members = AgentArray.GetPartyArray() + party_members = AgentArray.GetAllyArray() for member_id in party_members: if member_id == Player.GetAgentID(): continue # Don't count self - t_id = Agent.GetTargetID(member_id) + t_id = CombatEvents.get_cast_target(member_id) or CombatEvents.get_attack_target(member_id) if t_id in enemy_array and Agent.IsAlive(t_id): # Only count if they are targeting an enemy we can see - target_counts[t_id] = target_counts.get(t_id, 0) + 1 + # Filter: Is this a valid defensive target? + if self.is_valid_defensive_target(t_id): + target_counts[t_id] = target_counts.get(t_id, 0) + 1 # 3. Choose target with most attackers best_target = 0 @@ -1293,14 +1457,150 @@ def HandleCombat(self,ooc=False): is_read_to_cast, target_agent_id = self.IsReadyToCast(slot) if not is_read_to_cast: + # If we don't have a target for THIS skill, we might still have a target for another + # or we might just be waiting. But if we reach the end of the loop and no target was found + # for ANY skill, then we are not engaged. self.AdvanceSkillPointer() - return False + # If we just reached the end of the skill order, we've checked everything + if self.skill_pointer == 0: + # OLD: return False + # NEW: If we have a valid target, proceed to Movement Logic instead of giving up! + target_agent_id = Player.GetTargetID() + + # Manual validation since IsValidEnemyTarget helper doesn't exist + if not Agent.IsValid(target_agent_id) or Agent.IsDead(target_agent_id): + return False + + _, alleg = Agent.GetAllegiance(target_agent_id) + if alleg != "Enemy": + return False + + # Fall through to Movement Logic + else: + return True # Stay in combat mode while checking other skills if target_agent_id == 0: self.AdvanceSkillPointer() return False + # --- Aggro Response: Movement Logic --- + # If we have a target but we aren't "ready to cast" (likely due to range), + # let's try to move towards the target if we are in combat mode. + my_pos = Agent.GetXY(Player.GetAgentID()) + target_pos = Agent.GetXY(target_agent_id) + dist_to_target = Utils.Distance(my_pos, target_pos) + + # Check if Follow Module is busy pathing (Mutual Exclusion) + from HeroAI.following import follower_states + my_id = Player.GetAgentID() + state = follower_states.get(my_id) + + # Determine maximum effective range for this skill + try: + # Most skills are Spellcast (1000), but we check specifically if possible + skill_range = GLOBAL_CACHE.Skill.Data.GetRange(skill_id) + if skill_range == 0: + # If skill has no range (e.g. self-buff or shout), use Weapon Range + # This prevents "charging" to melee range if we have a bow + weapon_range = self.GetWeaponRange(Player.GetAgentID()) + skill_range = weapon_range + except: + skill_range = Range.Spellcast.value + + # --- Engagement Leash --- + # 1. Limit raw engagement distance (Don't charge across the map) + # 1. Limit raw engagement distance (Don't charge across the map) + # Reduced from 5000.0 to 2800.0 to prevent wandering far from current position (approx Longbow range + buffer) + engagement_limit = 2800.0 + + # 2. Leader Leash (Stay near the party leader) + leader_acc = self.cached_data.party.get_by_party_pos(0) + max_dist_from_leader = 2000.0 + + can_engage = dist_to_target <= engagement_limit + + # --- Distant Leader Assist Override --- + # If the target is the Leader's target, we allow engagement up to a much further distance (e.g. 5000). + # This ensures we assist the leader even if they are engaging from max compass range. + leader_target_id = 0 + if leader_acc: + leader_target_id = leader_acc.PlayerTargetID + + is_leader_target = (target_agent_id == leader_target_id) + + if is_leader_target: + can_engage = dist_to_target <= 5000.0 # Override engagement limit for leader assist + + # If NOT leader target, enforce stricter engagement rules. + # This prevents chasing random distant enemies. + if not is_leader_target and dist_to_target > skill_range * 1.5 and dist_to_target > engagement_limit: + can_engage = False + + if leader_acc and leader_acc.PlayerID != 0 and Agent.IsLiving(leader_acc.PlayerID): + leader_pos = Agent.GetXY(leader_acc.PlayerID) + dist_to_leader = Utils.Distance(my_pos, leader_pos) + if dist_to_leader > max_dist_from_leader: + can_engage = False + # Also check if target is way too far from leader + # Also check if target is way too far from leader + # Reduced from 5000.0 to 3500.0 to prevent chasing targets far outside the group's area of operation. + if Utils.Distance(target_pos, leader_pos) > 3500.0: + can_engage = False + + # Removed the local "Distant Target Filter" block here because it caused a deadlock. + # (The bot would select a target via GetAppropiateTarget, then reject it here, resulting in inaction). + # We now handle the distance logic via `can_engage` override above. + + # If we are out of skill range but within our safe engagement limits, move! + # If we are out of skill range but within our safe engagement limits, engage! + if dist_to_target > skill_range and can_engage: + # MOVEMENT OVERRIDE: If we want to charge, we MUST break the follow path + if state and state.path: + state.path = [] # Clear the follow path instantly + + # Use Player.Interact to move into range. This handles stopping at the correct weapon range automatically. + # We removed Player.Move() because it is sticky and causes over-running to melee. + if not Agent.IsCasting(Player.GetAgentID()): + # Define force_engage: If we are not moving, we assume this is the initial engagement + # and we want to react instantly (bypass throttle). + force_engage = not Agent.IsMoving(Player.GetAgentID()) + + # Use throttle to avoid packet spam, but allow instant first engage + if self.movement_throttle_timer.IsExpired() or force_engage: + Player.Interact(target_agent_id) + self.movement_throttle_timer.Reset() + + return True # Returning True indicates we handled the combat tick (by engaging) + elif dist_to_target > skill_range and not can_engage: + # If we are out of range and cannot safely engage, fall back to Follow logic + ConsoleLog("HeroAI", f"Combat Charge Blocked: Target {target_agent_id} Dist:{int(dist_to_target)}") + return False + + # If we are within skill range OR can_engage is blocked by pathing, + # we consider combat "Active" but might not move. + if dist_to_target <= skill_range: + # We are in range to fight. + + # We removed the manual StopMovement() call here because we now use Interact() to approach, + # which correctly stops at weapon range. Manual stopping was checking Agent.IsMoving() which + # is true during the Interact approach, causing stutter/cancellation. + + # Ensure we are attacking if not casting. + if not Agent.IsAttacking(Player.GetAgentID()) and not Agent.IsCasting(Player.GetAgentID()): + # Throttle interaction to avoid spam + if self.movement_throttle_timer.IsExpired(): + Player.Interact(target_agent_id) + self.movement_throttle_timer.Reset() + pass + else: + # We are outside range but can't/won't move (either pathing or leashed) + # If leashed, we already returned False above. + # If pathing, we fall through and might return True to keep combat "active" (blocking Follow pathing? No, that's bad). + # If Follow pathing is active, we should probably allow Follow to continue. + if not can_engage_with_movement: + return False + if not Agent.IsLiving(target_agent_id): return False diff --git a/HeroAI/constants.py b/HeroAI/constants.py index 2f55fb71f..e7dc6eb02 100644 --- a/HeroAI/constants.py +++ b/HeroAI/constants.py @@ -20,8 +20,8 @@ SUBSCRIBE_TIMEOUT_SECONDS = 500 # milliseconds """ HELPER CONSTANTS """ -MELEE_RANGE_VALUE = Range.Spellcast.value -RANGED_RANGE_VALUE = Range.Spellcast.value +MELEE_RANGE_VALUE = 180.0 # Standard melee range + small buffer +RANGED_RANGE_VALUE = 2520.0 # Longbow range + small buffer FOLLOW_DISTANCE_ON_COMBAT = Range.Spellcast.value FOLLOW_DISTANCE_OUT_OF_COMBAT = Range.Area.value diff --git a/HeroAI/settings.py b/HeroAI/settings.py index 66b67ca39..f9b3021ea 100644 --- a/HeroAI/settings.py +++ b/HeroAI/settings.py @@ -249,7 +249,7 @@ def check_for_updates(self): if mtime > self.last_ini_mtime: self.last_ini_mtime = mtime # Sync timestamp BEFORE loading to stop loop - self.load_settings() + self.load_settings(silent=True) # ConsoleLog("HeroAI", "Settings reloaded from disk.", Py4GW.Console.MessageType.Info) except Exception as e: pass @@ -324,8 +324,8 @@ def write_settings(self): self.save_requested = False - def load_settings(self): - ConsoleLog("HeroAI", "Loading HeroAI settings...") + def load_settings(self, silent=False): + if not silent: ConsoleLog("HeroAI", "Loading HeroAI settings...") # Save old state of save_requested to prevent loops old_save_requested = self.save_requested self.save_requested = False @@ -392,7 +392,7 @@ def load_settings(self): if os.path.exists(self.ini_path): self.last_ini_mtime = os.path.getmtime(self.ini_path) - ConsoleLog("HeroAI", "HeroAI settings loaded successfully.", Console.MessageType.Info) + if not silent: ConsoleLog("HeroAI", "HeroAI settings loaded successfully.", Console.MessageType.Info) except Exception as e: ConsoleLog("HeroAI", f"Error loading HeroAI settings: {e}", Console.MessageType.Error) diff --git a/Widgets/Automation/Multiboxing/HeroAI.py b/Widgets/Automation/Multiboxing/HeroAI.py index d3c2442e7..b4d0e6d2e 100644 --- a/Widgets/Automation/Multiboxing/HeroAI.py +++ b/Widgets/Automation/Multiboxing/HeroAI.py @@ -359,7 +359,7 @@ def movement_interrupt() -> BehaviorTree.NodeState: children=[ # ---------- GLOBAL HARD GUARD ---------- GlobalGuardNode, - CastingBlockNode, + # CastingBlockNode, # REMOVED: Allow HandleCombat to manage its own casting state checks # ---------- PRIORITY SELECTOR ---------- BehaviorTree.SelectorNode(name="UpdateStatusSelector", @@ -385,17 +385,6 @@ def movement_interrupt() -> BehaviorTree.NodeState: action_fn=lambda: movement_interrupt(), ), - # Follow - BehaviorTree.ActionNode( - name="Follow", - action_fn=lambda: ( - cached_data.follow_throttle_timer.Reset() - or BehaviorTree.NodeState.SUCCESS - if Follow(cached_data) - else BehaviorTree.NodeState.FAILURE - ), - ), - # Combat BehaviorTree.ActionNode( name="HandleCombat", @@ -425,6 +414,17 @@ def movement_interrupt() -> BehaviorTree.NodeState: ), ], ), + + # Follow + BehaviorTree.ActionNode( + name="Follow", + action_fn=lambda: ( + cached_data.follow_throttle_timer.Reset() + or BehaviorTree.NodeState.SUCCESS + if Follow(cached_data) + else BehaviorTree.NodeState.FAILURE + ), + ), ], ), ],