diff --git a/auto_bot_move.py b/auto_bot_move.py index 6c0a095..ce885bc 100644 --- a/auto_bot_move.py +++ b/auto_bot_move.py @@ -14,6 +14,7 @@ from game_state import parse_game_state, load_layout_config from coordinate_patrol import CoordinatePatrol from death_manager import DeathManager from logistics_manager import LogisticsManager +from route_profile import load_route_profile # 定义按键常量 KEY_TAB = '3' @@ -239,6 +240,7 @@ class AutoBotMove: self, waypoints=None, waypoints_path=None, + route_profile_path=None, prepare_route_path=None, vendor_path=None, attack_loop_path=None, @@ -289,11 +291,35 @@ class AutoBotMove: self.logistics_resume_cooldown_until = 0.0 # stop_check: 返回 True 表示需要立即停止(用于中断阻塞中的后勤/路线导航) self._stop_check = stop_check if callable(stop_check) else (lambda: False) - if waypoints is None: + + self.route_profile = None + self.route_profile_name = "" + self.patrol_routes = [] + self.current_patrol_route_name = "" + + if route_profile_path: + self.route_profile = load_route_profile(route_profile_path) + self.route_profile_name = self.route_profile.get("name") or "" + self.patrol_routes = list(self.route_profile.get("patrol_routes") or []) + if self.patrol_routes: + waypoints = self.patrol_routes[0]["points"] + prepare_route = self.route_profile.get("prepare_route") + if prepare_route: + self.prepare_route_waypoints = list(prepare_route["points"]) + else: + self.prepare_route_waypoints = [] + print( + f">>> [路线方案] 已加载: {self.route_profile_name}," + f"巡逻路线 {len(self.patrol_routes)} 条" + ) + elif waypoints is None: path = waypoints_path or get_config_path(WAYPOINTS_FILE) waypoints = load_waypoints(path) or DEFAULT_WAYPOINTS - self.prepare_route_waypoints = [] - if prepare_route_path: + self.prepare_route_waypoints = [] + else: + self.prepare_route_waypoints = [] + + if (not route_profile_path) and prepare_route_path: try: self.prepare_route_waypoints = [ (float(point[0]), float(point[1])) @@ -320,11 +346,17 @@ class AutoBotMove: resurrection_waypoints_path=resurrection_waypoints_path, resurrection_route_a_path=resurrection_route_a_path, resurrection_route_b_path=resurrection_route_b_path, + resurrection_routes=( + self.route_profile.get("resurrection_routes") if self.route_profile else None + ), release_spirit_key=layout.get('release_spirit_key', '9') if release_spirit_key is None else release_spirit_key, resurrect_key=layout.get('resurrect_key', '0') if resurrect_key is None else resurrect_key, ) vendor_file = vendor_path or get_config_path('vendor.json') self.logistics_manager = LogisticsManager(vendor_file) + if self.route_profile: + self.logistics_manager.set_repair_route(self.route_profile.get("vendor_route")) + self.logistics_manager.set_mailbox_route(self.route_profile.get("mailbox_route")) self.logistics_manager.bag_full_hearthstone = bool(layout.get("bag_full_hearthstone", False)) self.logistics_manager.hearthstone_key = str(layout.get("hearthstone_key", "b") or "b") self.logistics_manager.hearthstone_cast_sec = float(layout.get("hearthstone_wait_sec", 12.0)) @@ -345,6 +377,31 @@ class AutoBotMove: self.logistics_manager.mailbox_open_wait_sec = float(layout.get("mailbox_open_wait_sec", 2.0)) self.logistics_manager.mail_send_wait_sec = float(layout.get("mail_send_wait_sec", 60.0)) + if self.patrol_routes and self.prepare_route_completed: + self._select_random_patrol_route("启动") + + def _select_random_patrol_route(self, reason=""): + if not self.patrol_routes: + return False + route = random.choice(self.patrol_routes) + points = list(route.get("points") or []) + if not points: + return False + self.current_patrol_route_name = str(route.get("name") or "未命名巡逻路线") + reverse_route = random.choice((False, True)) + direction_name = "逆向" if reverse_route else "正向" + if reverse_route: + points = list(reversed(points)) + self.patrol_controller.waypoints = points + self.patrol_controller.current_index = 0 + self.patrol_controller.reset_stuck() + prefix = f"{reason}," if reason else "" + print( + f">>> [巡逻路线] {prefix}随机选择: " + f"{self.current_patrol_route_name},方向: {direction_name}({len(points)} 点)" + ) + return True + def _has_prepare_route(self) -> bool: return bool(self.prepare_route_waypoints) @@ -364,6 +421,7 @@ class AutoBotMove: if self.prepare_route_waypoints: print(">>> [后勤] 全自动续跑:重置准备路线,准备返回巡逻。") else: + self._select_random_patrol_route("后勤返回") print(">>> [后勤] 全自动续跑:未配置准备路线,直接恢复巡逻。") def _snap_prepare_route_to_nearest(self, state): @@ -418,6 +476,7 @@ class AutoBotMove: self.is_moving = False self.patrol_controller.stop_all() self.patrol_controller.reset_stuck() + self._select_random_patrol_route("准备路线完成") print(">>> [准备路线] 准备路线完成,开始正式巡逻...") return @@ -443,6 +502,7 @@ class AutoBotMove: self.is_moving = False self.patrol_controller.stop_all() self.patrol_controller.reset_stuck() + self._select_random_patrol_route("准备路线完成") print(">>> [准备路线] 准备路线完成,开始正式巡逻...") def _is_effective_target(self, state) -> bool: diff --git a/death_manager.py b/death_manager.py index a6c51fa..c3dc209 100644 --- a/death_manager.py +++ b/death_manager.py @@ -2,6 +2,7 @@ import math import time from hardware_control import hw_ctrl +from route_profile import normalize_route_list class DeathManager: @@ -13,16 +14,19 @@ class DeathManager: resurrection_waypoints_path=None, resurrection_route_a_path=None, resurrection_route_b_path=None, + resurrection_routes=None, release_spirit_key='9', resurrect_key='0', ): self.patrol_system = patrol_system self.release_spirit_key = str(release_spirit_key).strip() or '9' self.resurrect_key = str(resurrect_key).strip() or '0' - self.resurrection_route_a = self._load_waypoints( - resurrection_route_a_path or resurrection_waypoints_path + self.resurrection_routes = self._load_resurrection_routes( + resurrection_routes, + resurrection_waypoints_path=resurrection_waypoints_path, + resurrection_route_a_path=resurrection_route_a_path, + resurrection_route_b_path=resurrection_route_b_path, ) - self.resurrection_route_b = self._load_waypoints(resurrection_route_b_path) self.corpse_pos = None self.graveyard_pos = None @@ -53,6 +57,29 @@ class DeathManager: pass return None + def _load_resurrection_routes( + self, + resurrection_routes=None, + resurrection_waypoints_path=None, + resurrection_route_a_path=None, + resurrection_route_b_path=None, + ): + routes = [] + for route in normalize_route_list(resurrection_routes, "复活路线"): + routes.append((route["name"], route["points"])) + if routes: + return routes + + legacy_routes = [ + ("A", resurrection_route_a_path or resurrection_waypoints_path), + ("B", resurrection_route_b_path), + ] + for name, path in legacy_routes: + points = self._load_waypoints(path) + if points: + routes.append((name, points)) + return routes + def _clear_death_run_state(self): self.graveyard_pos = None self.selected_resurrection_route = None @@ -61,12 +88,7 @@ class DeathManager: self._resurrection_completed = False def _build_resurrection_route_candidates(self): - candidates = [] - if self.resurrection_route_a: - candidates.append(("A", self.resurrection_route_a)) - if self.resurrection_route_b: - candidates.append(("B", self.resurrection_route_b)) - return candidates + return list(self.resurrection_routes) def _record_graveyard_pos(self, state): if self.graveyard_pos is not None: diff --git a/logistics_manager.py b/logistics_manager.py index 89280ab..3d71a81 100644 --- a/logistics_manager.py +++ b/logistics_manager.py @@ -3,6 +3,7 @@ import math import os import time from hardware_control import hw_ctrl +from route_profile import normalize_points # 修理商所在位置(游戏坐标),按实际位置修改 VENDOR_POS = (30.08, 71.51) @@ -18,6 +19,8 @@ class LogisticsManager: self.bag_full = False self.is_returning = False self.route_file = route_file or VENDOR_FILE + self.route_points = [] + self.route_name = "" self.bag_full_hearthstone = False # 包满时用炉石回城而非走路修理 self.hearthstone_key = "b" # 炉石按键 self.hearthstone_cast_sec = 12.0 # 炉石施法等待秒数 @@ -26,6 +29,8 @@ class LogisticsManager: self.bag_slot_threshold = 2 self.durability_threshold = 0.2 self.mailbox_route_file = os.path.join("recorder", "mailbox.json") + self.mailbox_route_points = [] + self.mailbox_route_name = "" self.mailbox_interact_key = "8" self.mail_recipient_key = "" self.mail_send_key = "f8" @@ -46,6 +51,35 @@ class LogisticsManager: return cwd_path return os.path.join(os.path.dirname(os.path.abspath(__file__)), path) + def set_repair_route(self, route): + route = route or {} + self.route_name = str(route.get("name") or "修理商路线").strip() + self.route_points = normalize_points(route.get("points") or []) + + def set_mailbox_route(self, route): + route = route or {} + self.mailbox_route_name = str(route.get("name") or "邮箱路线").strip() + self.mailbox_route_points = normalize_points(route.get("points") or []) + + def _load_configured_route(self, configured_points, route_file, source_name, label): + points = normalize_points(configured_points or []) + if points: + return points, source_name or label + + resolved_file = self._resolve_path(route_file) + if not resolved_file or not os.path.exists(resolved_file): + return None, resolved_file + + try: + with open(resolved_file, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + print(f">>> [{label}] 路线读取失败: {exc}") + return None, resolved_file + + points = normalize_points(data) + return points, resolved_file + def _sleep_with_stop(self, seconds, stop_check=None): end_at = time.time() + max(0.0, float(seconds)) while time.time() < end_at: @@ -199,9 +233,14 @@ class LogisticsManager: 炉石 -> 跑到邮箱路线终点 -> 交互打开邮箱 -> 按 MailboxCourier 宏键 -> 等待后停止。 Python 不判断邮件是否发完,发送细节交给游戏内插件。 """ - route_file = self._resolve_path(self.mailbox_route_file) - if not route_file or not os.path.exists(route_file): - print(f">>> [后勤-邮箱] 邮箱路线不存在,已停止: {route_file}") + path, route_source = self._load_configured_route( + self.mailbox_route_points, + self.mailbox_route_file, + self.mailbox_route_name, + "后勤-邮箱", + ) + if not path: + print(f">>> [后勤-邮箱] 邮箱路线不存在或为空,已停止: {route_source}") self.is_returning = False return False @@ -216,20 +255,7 @@ class LogisticsManager: self.is_returning = False return False - try: - with open(route_file, "r", encoding="utf-8") as f: - path = json.load(f) - except Exception as exc: - print(f">>> [后勤-邮箱] 邮箱路线读取失败: {exc}") - self.is_returning = False - return False - - if not path: - print(f">>> [后勤-邮箱] 邮箱路线为空,已停止: {route_file}") - self.is_returning = False - return False - - print(f">>> [后勤-邮箱] 开始跑邮箱路线: {route_file}") + print(f">>> [后勤-邮箱] 开始跑邮箱路线: {route_source}") old_enable_mount = getattr(patrol, "enable_mount", None) if old_enable_mount is not None: patrol.enable_mount = False @@ -282,9 +308,14 @@ class LogisticsManager: 耐久低修理流程: 炉石 -> 从修理路线最近点接入 -> 到达 NPC -> 按目标键 -> 按交互/修理键。 """ - route_file = self._resolve_path(self.route_file) - if not route_file or not os.path.exists(route_file): - print(f">>> [后勤-修理] 修理路线不存在,已停止: {route_file}") + path, route_source = self._load_configured_route( + self.route_points, + self.route_file, + self.route_name, + "后勤-修理", + ) + if not path: + print(f">>> [后勤-修理] 修理路线不存在或为空,已停止: {route_source}") self.is_returning = False return False @@ -299,20 +330,7 @@ class LogisticsManager: self.is_returning = False return False - try: - with open(route_file, "r", encoding="utf-8") as f: - path = json.load(f) - except Exception as exc: - print(f">>> [后勤-修理] 修理路线读取失败: {exc}") - self.is_returning = False - return False - - if not path: - print(f">>> [后勤-修理] 修理路线为空,已停止: {route_file}") - self.is_returning = False - return False - - print(f">>> [后勤-修理] 开始跑修理路线: {route_file}") + print(f">>> [后勤-修理] 开始跑修理路线: {route_source}") ok = patrol.navigate_path( get_state, path, diff --git a/recorder/祖达克刷怪方案.json b/recorder/祖达克刷怪方案.json new file mode 100644 index 0000000..32c693f --- /dev/null +++ b/recorder/祖达克刷怪方案.json @@ -0,0 +1,1079 @@ +{ + "name": "祖达克刷怪方案", + "prepare_route": { + "name": "祖达克准备", + "points": [ + [ + 40.72, + 66.5 + ], + [ + 40.7, + 65.99 + ], + [ + 40.68, + 65.48 + ], + [ + 40.57, + 64.99 + ], + [ + 40.33, + 64.54 + ], + [ + 40.39, + 64.01 + ], + [ + 40.4, + 63.47 + ], + [ + 40.39, + 62.92 + ], + [ + 40.41, + 62.36 + ], + [ + 40.42, + 61.84 + ], + [ + 40.41, + 61.31 + ], + [ + 40.42, + 60.77 + ], + [ + 40.42, + 60.24 + ], + [ + 40.39, + 59.72 + ], + [ + 40.41, + 59.16 + ], + [ + 40.44, + 58.62 + ], + [ + 40.45, + 58.07 + ], + [ + 40.43, + 57.51 + ], + [ + 40.43, + 56.96 + ], + [ + 40.41, + 56.44 + ], + [ + 40.42, + 55.9 + ], + [ + 40.42, + 55.35 + ], + [ + 40.43, + 54.82 + ], + [ + 40.44, + 54.27 + ], + [ + 40.44, + 53.72 + ], + [ + 40.42, + 53.17 + ], + [ + 40.4, + 52.65 + ], + [ + 40.41, + 52.1 + ], + [ + 40.44, + 51.55 + ], + [ + 40.46, + 51.02 + ], + [ + 40.49, + 50.46 + ], + [ + 40.9, + 50.14 + ], + [ + 41.43, + 50.04 + ], + [ + 41.97, + 50.01 + ], + [ + 42.5, + 49.94 + ], + [ + 43.03, + 49.87 + ], + [ + 43.5, + 49.69 + ], + [ + 43.83, + 49.29 + ] + ] + }, + "patrol_routes": [ + { + "name": "西莱图斯祭坛巡逻", + "points": [ + [ + 44.37, + 34.77 + ], + [ + 44.37, + 35.32 + ], + [ + 44.39, + 35.82 + ], + [ + 44.4, + 36.32 + ], + [ + 44.41, + 36.82 + ], + [ + 44.42, + 37.32 + ], + [ + 44.44, + 37.82 + ], + [ + 44.46, + 38.38 + ], + [ + 44.47, + 38.94 + ], + [ + 44.49, + 39.44 + ], + [ + 44.5, + 39.94 + ], + [ + 44.52, + 40.5 + ], + [ + 44.53, + 41 + ], + [ + 44.55, + 41.55 + ], + [ + 44.56, + 42.1 + ], + [ + 44.67, + 42.62 + ], + [ + 44.71, + 43.12 + ], + [ + 44.71, + 43.67 + ], + [ + 44.68, + 44.17 + ], + [ + 44.66, + 44.67 + ], + [ + 44.63, + 45.17 + ], + [ + 44.6, + 45.71 + ], + [ + 44.58, + 46.26 + ], + [ + 44.55, + 46.76 + ], + [ + 44.52, + 47.26 + ], + [ + 44.54, + 47.82 + ], + [ + 44.43, + 48.35 + ], + [ + 44.26, + 48.85 + ], + [ + 44.02, + 49.29 + ], + [ + 43.73, + 49.75 + ], + [ + 43.35, + 50.13 + ], + [ + 42.92, + 49.87 + ], + [ + 42.81, + 49.35 + ], + [ + 42.77, + 48.81 + ], + [ + 42.71, + 48.26 + ], + [ + 42.66, + 47.71 + ], + [ + 42.6, + 47.15 + ], + [ + 42.56, + 46.6 + ], + [ + 42.56, + 46.09 + ], + [ + 42.57, + 45.55 + ], + [ + 42.58, + 45.05 + ], + [ + 42.58, + 44.54 + ], + [ + 42.59, + 44.04 + ], + [ + 42.6, + 43.52 + ], + [ + 42.6, + 42.97 + ], + [ + 42.62, + 42.47 + ], + [ + 42.64, + 41.97 + ], + [ + 42.66, + 41.42 + ], + [ + 42.68, + 40.87 + ], + [ + 42.69, + 40.32 + ], + [ + 42.7, + 39.82 + ], + [ + 42.7, + 39.32 + ], + [ + 42.7, + 38.82 + ], + [ + 42.71, + 38.27 + ], + [ + 42.71, + 37.77 + ], + [ + 42.72, + 37.25 + ], + [ + 42.73, + 36.75 + ], + [ + 42.76, + 36.2 + ], + [ + 42.89, + 35.7 + ], + [ + 43.25, + 35.3 + ], + [ + 44.37, + 34.77 + ] + ] + }, + { + "name": "冰冷裂口巡逻", + "points": [ + [ + 32.54, + 41.22 + ], + [ + 32.67, + 40.7 + ], + [ + 32.85, + 40.19 + ], + [ + 32.98, + 39.67 + ], + [ + 32.84, + 39.14 + ], + [ + 32.35, + 39.26 + ], + [ + 32.17, + 39.76 + ], + [ + 31.98, + 40.25 + ], + [ + 31.77, + 40.75 + ], + [ + 31.57, + 41.23 + ], + [ + 32.54, + 41.22 + ] + ] + }, + { + "name": "银色前线基地巡逻", + "points": [ + [ + 85.31, + 74.79 + ], + [ + 85, + 74.37 + ], + [ + 84.71, + 73.94 + ], + [ + 84.4, + 73.49 + ], + [ + 84.11, + 73.07 + ], + [ + 83.81, + 72.65 + ], + [ + 83.52, + 73.08 + ], + [ + 83.44, + 73.59 + ], + [ + 83.54, + 74.12 + ], + [ + 83.58, + 74.66 + ], + [ + 83.68, + 75.18 + ], + [ + 83.77, + 75.71 + ], + [ + 83.87, + 76.23 + ], + [ + 83.97, + 76.76 + ], + [ + 84.36, + 76.44 + ], + [ + 84.62, + 75.99 + ], + [ + 84.89, + 75.56 + ], + [ + 85.31, + 74.79 + ] + ] + } + ], + "resurrection_routes": [ + { + "name": "复活点A", + "points": [ + [ + 37.4, + 59.01 + ], + [ + 37.75, + 58.64 + ], + [ + 37.78, + 58.13 + ], + [ + 37.82, + 57.58 + ], + [ + 37.86, + 57.04 + ], + [ + 37.93, + 56.49 + ], + [ + 38.13, + 55.99 + ], + [ + 38.37, + 55.52 + ], + [ + 38.61, + 55.06 + ], + [ + 38.86, + 54.6 + ], + [ + 39.11, + 54.15 + ], + [ + 39.36, + 53.7 + ], + [ + 39.58, + 53.22 + ], + [ + 39.82, + 52.75 + ], + [ + 40.07, + 52.27 + ], + [ + 40.39, + 51.81 + ], + [ + 40.54, + 51.3 + ], + [ + 40.5, + 50.79 + ], + [ + 40.63, + 50.29 + ], + [ + 41.14, + 50.18 + ], + [ + 41.67, + 50.17 + ], + [ + 42.2, + 50.16 + ], + [ + 42.73, + 50.15 + ], + [ + 43.2, + 49.95 + ], + [ + 43.57, + 49.55 + ], + [ + 43.89, + 49.1 + ], + [ + 44.18, + 48.65 + ], + [ + 44.3, + 48.15 + ] + ] + }, + { + "name": "复活点B", + "points": [ + [ + 53.79, + 56.17 + ], + [ + 53.5, + 55.74 + ], + [ + 53.29, + 55.25 + ], + [ + 53.12, + 54.73 + ], + [ + 52.97, + 54.2 + ], + [ + 52.82, + 53.71 + ], + [ + 52.6, + 53.23 + ], + [ + 52.39, + 52.74 + ], + [ + 52.18, + 52.24 + ], + [ + 51.96, + 51.74 + ], + [ + 51.75, + 51.24 + ], + [ + 51.54, + 50.74 + ], + [ + 51.32, + 50.23 + ], + [ + 51.1, + 49.73 + ], + [ + 50.88, + 49.22 + ], + [ + 50.7, + 48.69 + ], + [ + 50.62, + 48.17 + ], + [ + 50.55, + 47.67 + ], + [ + 50.5, + 47.16 + ], + [ + 50.41, + 46.66 + ], + [ + 50.32, + 46.16 + ], + [ + 50.23, + 45.65 + ], + [ + 50.13, + 45.15 + ], + [ + 49.9, + 44.69 + ], + [ + 49.66, + 44.22 + ], + [ + 49.41, + 43.73 + ], + [ + 49.16, + 43.26 + ], + [ + 48.91, + 42.76 + ], + [ + 48.67, + 42.3 + ], + [ + 48.26, + 41.95 + ], + [ + 47.8, + 41.75 + ], + [ + 47.3, + 41.61 + ], + [ + 46.78, + 41.5 + ], + [ + 46.25, + 41.49 + ] + ] + } + ], + "vendor_route": { + "name": "祖达克修理", + "points": [ + [ + 44.09, + 48.87 + ], + [ + 43.61, + 49.7 + ], + [ + 43.18, + 49.96 + ], + [ + 42.7, + 50.23 + ], + [ + 42.32, + 50.6 + ], + [ + 41.99, + 50.99 + ], + [ + 41.65, + 51.38 + ], + [ + 41.31, + 51.75 + ], + [ + 40.92, + 52.1 + ], + [ + 40.56, + 52.45 + ], + [ + 40.42, + 52.94 + ], + [ + 40.44, + 53.44 + ], + [ + 40.44, + 53.99 + ], + [ + 40.45, + 54.52 + ], + [ + 40.44, + 55.08 + ], + [ + 40.44, + 55.61 + ], + [ + 40.44, + 56.16 + ], + [ + 40.43, + 56.72 + ], + [ + 40.43, + 57.25 + ], + [ + 40.43, + 57.77 + ], + [ + 40.42, + 58.31 + ], + [ + 40.42, + 58.81 + ], + [ + 40.42, + 59.36 + ], + [ + 40.41, + 59.9 + ], + [ + 40.41, + 60.4 + ], + [ + 40.41, + 60.96 + ], + [ + 40.4, + 61.49 + ], + [ + 40.4, + 62.04 + ], + [ + 40.4, + 62.54 + ], + [ + 40.39, + 63.08 + ], + [ + 40.39, + 63.62 + ], + [ + 40.39, + 64.15 + ], + [ + 40.63, + 64.6 + ], + [ + 40.99, + 65.01 + ] + ] + }, + "mailbox_route": { + "name": "祖达克邮箱路线", + "points": [ + [ + 44.09, + 48.87 + ], + [ + 43.61, + 49.7 + ], + [ + 43.18, + 49.96 + ], + [ + 42.7, + 50.23 + ], + [ + 42.32, + 50.6 + ], + [ + 41.99, + 50.99 + ], + [ + 41.65, + 51.38 + ], + [ + 41.31, + 51.75 + ], + [ + 40.92, + 52.1 + ], + [ + 40.56, + 52.45 + ], + [ + 40.42, + 52.94 + ], + [ + 40.44, + 53.44 + ], + [ + 40.44, + 53.99 + ], + [ + 40.45, + 54.52 + ], + [ + 40.44, + 55.08 + ], + [ + 40.44, + 55.61 + ], + [ + 40.44, + 56.16 + ], + [ + 40.43, + 56.72 + ], + [ + 40.43, + 57.25 + ], + [ + 40.43, + 57.77 + ], + [ + 40.42, + 58.31 + ], + [ + 40.42, + 58.81 + ], + [ + 40.42, + 59.36 + ], + [ + 40.41, + 59.9 + ], + [ + 40.41, + 60.4 + ], + [ + 40.41, + 60.96 + ], + [ + 40.4, + 61.49 + ], + [ + 40.4, + 62.04 + ], + [ + 40.4, + 62.54 + ], + [ + 40.39, + 63.08 + ], + [ + 40.39, + 63.62 + ], + [ + 40.39, + 64.15 + ], + [ + 40.63, + 64.6 + ], + [ + 40.99, + 65.01 + ] + ] + } +} \ No newline at end of file diff --git a/route_profile.py b/route_profile.py new file mode 100644 index 0000000..c321655 --- /dev/null +++ b/route_profile.py @@ -0,0 +1,103 @@ +import json +import os + + +def normalize_points(raw_points): + """Return route points as [(x, y), ...], dropping malformed entries.""" + points = [] + if not isinstance(raw_points, list): + return points + + for point in raw_points: + if not isinstance(point, (list, tuple)) or len(point) < 2: + continue + try: + points.append((float(point[0]), float(point[1]))) + except (TypeError, ValueError): + continue + return points + + +def _looks_like_points(raw_value): + if not isinstance(raw_value, list) or not raw_value: + return False + first = raw_value[0] + if not isinstance(first, (list, tuple)) or len(first) < 2: + return False + try: + float(first[0]) + float(first[1]) + except (TypeError, ValueError): + return False + return True + + +def normalize_route(raw_route, fallback_name=""): + """Normalize {"name": ..., "points": ...} into a route dict.""" + if raw_route is None: + return None + + if isinstance(raw_route, dict): + name = str(raw_route.get("name") or fallback_name or "未命名路线").strip() + raw_points = ( + raw_route.get("points") + or raw_route.get("waypoints") + or raw_route.get("path") + or [] + ) + else: + name = str(fallback_name or "未命名路线").strip() + raw_points = raw_route + + points = normalize_points(raw_points) + if not points: + return None + return {"name": name or fallback_name or "未命名路线", "points": points} + + +def normalize_route_list(raw_routes, fallback_prefix): + routes = [] + if raw_routes is None: + return routes + + if _looks_like_points(raw_routes): + route = normalize_route(raw_routes, fallback_prefix) + return [route] if route else [] + + if isinstance(raw_routes, dict): + raw_routes = raw_routes.get("routes") or raw_routes.get("items") or [] + + if not isinstance(raw_routes, list): + return routes + + for idx, raw_route in enumerate(raw_routes, start=1): + route = normalize_route(raw_route, f"{fallback_prefix}{idx}") + if route: + routes.append(route) + return routes + + +def normalize_route_profile(data, fallback_name=""): + if not isinstance(data, dict): + raise ValueError("路线方案 JSON 必须是对象格式") + + name = str(data.get("name") or fallback_name or "未命名方案").strip() + patrol_routes = normalize_route_list(data.get("patrol_routes"), "巡逻路线") + if not patrol_routes: + raise ValueError("路线方案必须包含至少一条 patrol_routes") + + return { + "name": name or fallback_name or "未命名方案", + "prepare_route": normalize_route(data.get("prepare_route"), "准备路线"), + "patrol_routes": patrol_routes, + "resurrection_routes": normalize_route_list(data.get("resurrection_routes"), "复活路线"), + "vendor_route": normalize_route(data.get("vendor_route"), "修理商路线"), + "mailbox_route": normalize_route(data.get("mailbox_route"), "邮箱路线"), + } + + +def load_route_profile(path): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + fallback_name = os.path.splitext(os.path.basename(path))[0] + return normalize_route_profile(data, fallback_name=fallback_name) diff --git a/wow_multikey_gui.py b/wow_multikey_gui.py index bf25a49..80dcf6d 100644 --- a/wow_multikey_gui.py +++ b/wow_multikey_gui.py @@ -23,6 +23,7 @@ import win32gui import win32api import win32con from pynput import keyboard +from route_profile import load_route_profile def _config_base(): @@ -67,6 +68,19 @@ def list_recorder_json(): return result +def list_route_profile_json(): + """列出 recorder 下的大路线方案 JSON,显示名使用 JSON 内的 name。""" + result = [] + for filename, path in list_recorder_json(): + try: + profile = load_route_profile(path) + except Exception: + continue + display_name = str(profile.get("name") or os.path.splitext(filename)[0]).strip() + result.append((display_name or filename, path)) + return result + + def get_combat_loops_dir(): """combat_loops 文件夹路径,不存在则创建""" path = os.path.join(_config_base(), COMBAT_LOOPS_DIR) @@ -93,10 +107,20 @@ class WaypointPreviewCanvas(QWidget): ZOOM_MIN = 0.2 ZOOM_MAX = 10.0 ZOOM_STEP = 1.15 + ROUTE_COLORS = [ + QColor(0, 200, 255), + QColor(255, 214, 0), + QColor(70, 220, 120), + QColor(255, 105, 180), + QColor(255, 150, 60), + QColor(160, 130, 255), + QColor(90, 220, 210), + QColor(255, 100, 100), + ] def __init__(self, parent=None): super().__init__(parent) - self.points = [] # [(x,y), ...],游戏坐标 + self.routes = [] # [{"name": str, "points": [(x,y), ...]}, ...] self.zoom_scale = 1.0 # 1.0 = 0~100 刚好铺满画布 self.pan_x = 0.0 self.pan_y = 0.0 @@ -106,7 +130,24 @@ class WaypointPreviewCanvas(QWidget): def set_points(self, points): """设置要绘制的路径点列表。""" - self.points = [(float(p[0]), float(p[1])) for p in points] if points else [] + self.set_routes([{"name": "巡逻路线", "points": points or []}]) + + def set_routes(self, routes): + """设置多条巡逻路线,并用不同颜色绘制。""" + normalized = [] + for idx, route in enumerate(routes or [], start=1): + raw_points = route.get("points") if isinstance(route, dict) else [] + points = [] + for point in raw_points or []: + try: + points.append((float(point[0]), float(point[1]))) + except (TypeError, ValueError, IndexError): + continue + if not points: + continue + name = str(route.get("name") or f"巡逻路线{idx}").strip() + normalized.append({"name": name or f"巡逻路线{idx}", "points": points}) + self.routes = normalized self.update() def reset_view(self): @@ -171,35 +212,51 @@ class WaypointPreviewCanvas(QWidget): x1, y1 = to_canvas((100, 100)) painter.drawRect(int(x0), int(y0), int(x1 - x0), int(y1 - y0)) - if not self.points: + all_points = [] + for route in self.routes: + all_points.extend(route["points"]) + + if not all_points: painter.setPen(QPen(QColor(200, 200, 200), 1)) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "无巡逻点数据") return - # 绘制路径连线 - painter.setPen(QPen(QColor(0, 200, 255), 2)) - for i in range(len(self.points) - 1): - x1, y1 = to_canvas(self.points[i]) - x2, y2 = to_canvas(self.points[i + 1]) - painter.drawLine(int(x1), int(y1), int(x2), int(y2)) + for idx, route in enumerate(self.routes): + color = self.ROUTE_COLORS[idx % len(self.ROUTE_COLORS)] + points = route["points"] - # 绘制所有巡逻点 - painter.setPen(QPen(QColor(255, 255, 0), 1)) - painter.setBrush(QColor(255, 255, 0)) - for p in self.points: - px, py = to_canvas(p) - painter.drawEllipse(int(px) - 2, int(py) - 2, 4, 4) + painter.setPen(QPen(color, 2)) + for i in range(len(points) - 1): + x1, y1 = to_canvas(points[i]) + x2, y2 = to_canvas(points[i + 1]) + painter.drawLine(int(x1), int(y1), int(x2), int(y2)) - # 起点与终点标记(覆盖在普通点之上) - start_x, start_y = to_canvas(self.points[0]) - end_x, end_y = to_canvas(self.points[-1]) - painter.setPen(QPen(QColor(0, 255, 0), 2)) - painter.setBrush(QColor(0, 255, 0)) - painter.drawEllipse(int(start_x) - 4, int(start_y) - 4, 8, 8) + painter.setPen(QPen(color, 1)) + painter.setBrush(color) + for p in points: + px, py = to_canvas(p) + painter.drawEllipse(int(px) - 2, int(py) - 2, 4, 4) - painter.setPen(QPen(QColor(255, 80, 80), 2)) - painter.setBrush(QColor(255, 80, 80)) - painter.drawEllipse(int(end_x) - 4, int(end_y) - 4, 8, 8) + start_x, start_y = to_canvas(points[0]) + end_x, end_y = to_canvas(points[-1]) + painter.setPen(QPen(QColor(245, 245, 245), 2)) + painter.setBrush(color) + painter.drawEllipse(int(start_x) - 5, int(start_y) - 5, 10, 10) + painter.setPen(QPen(QColor(30, 30, 30), 2)) + painter.setBrush(color) + painter.drawEllipse(int(end_x) - 5, int(end_y) - 5, 10, 10) + + legend_x = rect.left() + 12 + legend_y = rect.top() + 12 + painter.setPen(QPen(QColor(230, 230, 230), 1)) + for idx, route in enumerate(self.routes[:12]): + color = self.ROUTE_COLORS[idx % len(self.ROUTE_COLORS)] + y = legend_y + idx * 18 + painter.setBrush(color) + painter.setPen(QPen(color, 1)) + painter.drawRect(legend_x, y + 4, 10, 10) + painter.setPen(QPen(QColor(230, 230, 230), 1)) + painter.drawText(legend_x + 16, y + 14, route["name"]) # ============ 多键控制 ============ @@ -302,6 +359,7 @@ class GameLoopWorker(QThread): def __init__( self, mode, + route_profile_path=None, waypoints_path=None, prepare_route_path=None, vendor_path=None, @@ -342,6 +400,7 @@ class GameLoopWorker(QThread): self.quest_follow_bot = None self.flight_bot = None self.recorder = None + self.route_profile_path = route_profile_path self.waypoints_path = waypoints_path self.prepare_route_path = prepare_route_path self.vendor_path = vendor_path @@ -417,6 +476,7 @@ class GameLoopWorker(QThread): try: from auto_bot_move import AutoBotMove, load_waypoints self.bot_move = AutoBotMove( + route_profile_path=self.route_profile_path, waypoints_path=self.waypoints_path, prepare_route_path=self.prepare_route_path, vendor_path=self.vendor_path, @@ -441,6 +501,9 @@ class GameLoopWorker(QThread): except ImportError as e: self.log_signal.emit(f"❌ 巡逻打怪依赖加载失败: {e}") return + except Exception as e: + self.log_signal.emit(f"❌ 路线方案加载失败: {e}") + return if self.mode == 'combat': try: @@ -738,30 +801,14 @@ class WoWMultiKeyGUI(QMainWindow): # 巡逻打怪配置(仅选择巡逻打怪模式时显示) self.patrol_group = QGroupBox("巡逻打怪配置") patrol_layout = QFormLayout(self.patrol_group) - self.waypoints_combo = QComboBox() - self.waypoints_combo.setMinimumWidth(200) - self.vendor_combo = QComboBox() - self.vendor_combo.setMinimumWidth(200) - self.mailbox_route_combo = QComboBox() - self.mailbox_route_combo.setMinimumWidth(200) + self.route_profile_combo = QComboBox() + self.route_profile_combo.setMinimumWidth(240) self.patrol_attack_loop_combo = QComboBox() self.patrol_attack_loop_combo.setMinimumWidth(200) - self.prepare_route_combo = QComboBox() - self.prepare_route_combo.setMinimumWidth(200) self._refresh_recorder_combos() refresh_btn = QPushButton("🔄 刷新列表") refresh_btn.clicked.connect(self._refresh_recorder_combos) - patrol_layout.addRow("准备路线 JSON:", self.prepare_route_combo) - patrol_layout.addRow("巡逻点 JSON:", self.waypoints_combo) - patrol_layout.addRow("修理商 JSON:", self.vendor_combo) - patrol_layout.addRow("邮箱路线 JSON:", self.mailbox_route_combo) - self.resurrection_route_a_combo = QComboBox() - self.resurrection_route_a_combo.setMinimumWidth(200) - self.resurrection_route_b_combo = QComboBox() - self.resurrection_route_b_combo.setMinimumWidth(200) - self._refresh_recorder_combos() - patrol_layout.addRow("复活路线 A JSON:", self.resurrection_route_a_combo) - patrol_layout.addRow("复活路线 B JSON:", self.resurrection_route_b_combo) + patrol_layout.addRow("路线方案 JSON:", self.route_profile_combo) patrol_layout.addRow("攻击循环:", self.patrol_attack_loop_combo) patrol_layout.addRow("", refresh_btn) self.patrol_group.setVisible(False) @@ -930,7 +977,7 @@ class WoWMultiKeyGUI(QMainWindow): self.preview_waypoints_combo.setMinimumWidth(220) self._refresh_preview_waypoints() self.preview_waypoints_combo.currentIndexChanged.connect(self._on_preview_waypoints_changed) - preview_file_layout.addRow("选择 JSON:", self.preview_waypoints_combo) + preview_file_layout.addRow("路线方案 JSON:", self.preview_waypoints_combo) preview_layout.addWidget(preview_file_group) self.preview_canvas = WaypointPreviewCanvas() @@ -1570,35 +1617,34 @@ class WoWMultiKeyGUI(QMainWindow): self._load_params_config() def _refresh_preview_waypoints(self): - """刷新巡逻点预览下拉列表""" + """刷新路线方案预览下拉列表""" self.preview_waypoints_combo.blockSignals(True) self.preview_waypoints_combo.clear() self.preview_waypoints_combo.addItem("-- 请选择 --", "") - for name, path in list_recorder_json(): + for name, path in list_route_profile_json(): self.preview_waypoints_combo.addItem(name, path) self.preview_waypoints_combo.blockSignals(False) if self.preview_waypoints_combo.count() > 1: self.preview_waypoints_combo.setCurrentIndex(1) def _on_preview_waypoints_changed(self, index): - """选择巡逻点 JSON 时加载并绘制在画布上""" + """选择路线方案 JSON 时加载并绘制所有巡逻路线。""" path = self.preview_waypoints_combo.currentData() if not path or not os.path.exists(path): - self.preview_canvas.set_points([]) + self.preview_canvas.set_routes([]) return try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - # 期望格式:[[x,y], ...] 或 [[x,y, ...], ...] - points = [] - for item in data: - if isinstance(item, (list, tuple)) and len(item) >= 2: - points.append((float(item[0]), float(item[1]))) - self.preview_canvas.set_points(points) - self.log(f"已加载巡逻点预览: {path} (共 {len(points)} 点)") + profile = load_route_profile(path) + routes = profile.get("patrol_routes") or [] + total_points = sum(len(route.get("points") or []) for route in routes) + self.preview_canvas.set_routes(routes) + self.log( + f"已加载路线方案预览: {profile.get('name')} " + f"({len(routes)} 条巡逻路线,共 {total_points} 点)" + ) except Exception as e: - self.log(f"加载巡逻点预览失败: {e}") - self.preview_canvas.set_points([]) + self.log(f"加载路线方案预览失败: {e}") + self.preview_canvas.set_routes([]) def _reset_preview_canvas(self): """重置巡逻点预览视图(缩放 + 平移)。""" @@ -1606,51 +1652,19 @@ class WoWMultiKeyGUI(QMainWindow): self.preview_canvas.reset_view() def _refresh_recorder_combos(self): - """刷新巡逻点、修理商、复活点路线下拉列表""" - items = list_recorder_json() - combos_with_default = [ - (self.waypoints_combo, 'waypoints.json'), - (self.vendor_combo, 'vendor.json'), - ] + """刷新路线方案和其他 recorder 下拉列表。""" + if hasattr(self, "route_profile_combo"): + self.route_profile_combo.blockSignals(True) + self.route_profile_combo.clear() + self.route_profile_combo.addItem("-- 请选择 --", "") + for name, path in list_route_profile_json(): + self.route_profile_combo.addItem(name, path) + if self.route_profile_combo.count() > 1: + self.route_profile_combo.setCurrentIndex(1) + self.route_profile_combo.blockSignals(False) + if hasattr(self, "repair_vendor_combo"): - combos_with_default.append((self.repair_vendor_combo, 'vendor.json')) - if hasattr(self, "mailbox_route_combo"): - self.mailbox_route_combo.blockSignals(True) - self.mailbox_route_combo.clear() - self.mailbox_route_combo.addItem("-- 置空(不跑邮箱路线) --", "") - for name, path in items: - self.mailbox_route_combo.addItem(name, path) - idx = self.mailbox_route_combo.findData(os.path.join(get_recorder_dir(), 'mailbox.json')) - if idx >= 0: - self.mailbox_route_combo.setCurrentIndex(idx) - self.mailbox_route_combo.blockSignals(False) - for combo, default_name in combos_with_default: - combo.blockSignals(True) - combo.clear() - combo.addItem("-- 请选择 --", "") - for name, path in items: - combo.addItem(name, path) - idx = combo.findData(os.path.join(get_recorder_dir(), default_name)) - if idx >= 0: - combo.setCurrentIndex(idx) - elif combo.count() > 1: - combo.setCurrentIndex(1) - combo.blockSignals(False) - # 复活路线下拉(无默认值,可置空) - for combo_name in ( - "prepare_route_combo", - "resurrection_route_a_combo", - "resurrection_route_b_combo", - ): - if hasattr(self, combo_name): - combo = getattr(self, combo_name) - combo.blockSignals(True) - combo.clear() - empty_text = "-- 置空(直接巡逻) --" if combo_name == "prepare_route_combo" else "-- 置空(直接跑尸体) --" - combo.addItem(empty_text, "") - for name, path in items: - combo.addItem(name, path) - combo.blockSignals(False) + self._refresh_repair_vendor_json_combo() def _refresh_repair_vendor_json_combo(self): """刷新“回城修理配置”的修理商下拉框。""" @@ -1785,6 +1799,7 @@ class WoWMultiKeyGUI(QMainWindow): if mode is None: QMessageBox.warning(self, "提示", "请先选择模式(状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 飞行模式 / 回城修理)") return + route_profile_path = None waypoints_path = None prepare_route_path = None vendor_path = None @@ -1793,46 +1808,25 @@ class WoWMultiKeyGUI(QMainWindow): resurrection_route_a_path = None resurrection_route_b_path = None if mode == 'patrol': - prep = self.prepare_route_combo.currentData() or "" - wp = self.waypoints_combo.currentData() or "" - vp = self.vendor_combo.currentData() or "" - mp = self.mailbox_route_combo.currentData() or "" - route_a = self.resurrection_route_a_combo.currentData() or "" - route_b = self.resurrection_route_b_combo.currentData() or "" - if not wp: - QMessageBox.warning(self, "提示", "巡逻打怪模式需选择巡逻点 JSON 文件") + profile_path = self.route_profile_combo.currentData() or "" + if not profile_path: + QMessageBox.warning(self, "提示", "巡逻打怪模式需选择路线方案 JSON 文件") return - if not vp: - QMessageBox.warning(self, "提示", "巡逻打怪模式需选择修理商 JSON 文件") + if not os.path.exists(profile_path): + QMessageBox.warning(self, "提示", f"路线方案文件不存在: {profile_path}") return - if not os.path.exists(wp): - QMessageBox.warning(self, "提示", f"巡逻点文件不存在: {wp}") + try: + profile = load_route_profile(profile_path) + except Exception as e: + QMessageBox.warning(self, "提示", f"路线方案 JSON 无效: {e}") return - if not os.path.exists(vp): - QMessageBox.warning(self, "提示", f"修理商文件不存在: {vp}") + if not profile.get("vendor_route"): + QMessageBox.warning(self, "提示", "路线方案需包含 vendor_route 修理商路线") return - if prep and not os.path.exists(prep): - QMessageBox.warning(self, "提示", f"准备路线文件不存在: {prep}") + if self.gs_enable_bag_full_mail.isChecked() and not profile.get("mailbox_route"): + QMessageBox.warning(self, "提示", "已启用包满邮寄,路线方案需包含 mailbox_route 邮箱路线") return - if self.gs_enable_bag_full_mail.isChecked(): - if not mp: - QMessageBox.warning(self, "提示", "已启用包满邮寄,请选择邮箱路线 JSON 文件") - return - if not os.path.exists(mp): - QMessageBox.warning(self, "提示", f"邮箱路线文件不存在: {mp}") - return - if route_a and not os.path.exists(route_a): - QMessageBox.warning(self, "提示", f"复活路线 A 文件不存在: {route_a}") - return - if route_b and not os.path.exists(route_b): - QMessageBox.warning(self, "提示", f"复活路线 B 文件不存在: {route_b}") - return - waypoints_path = wp - prepare_route_path = prep or None - vendor_path = vp - mailbox_route_path = mp or None - resurrection_route_a_path = route_a or None - resurrection_route_b_path = route_b or None + route_profile_path = profile_path attack_loop_path = None if mode == 'patrol': attack_loop_path = self.patrol_attack_loop_combo.currentData() or None @@ -1916,7 +1910,8 @@ class WoWMultiKeyGUI(QMainWindow): use_ghost_box = self.gs_use_ghost_box.isChecked() self.game_worker = GameLoopWorker( - mode, waypoints_path=waypoints_path, prepare_route_path=prepare_route_path, vendor_path=vendor_path, + mode, route_profile_path=route_profile_path, + waypoints_path=waypoints_path, prepare_route_path=prepare_route_path, vendor_path=vendor_path, mailbox_route_path=mailbox_route_path, attack_loop_path=attack_loop_path, skinning_wait_sec=skinning_wait_sec,