Add route profile patrol selection

This commit is contained in:
王鹏
2026-05-12 17:09:19 +08:00
parent 0d493ee180
commit 2a4684e700
6 changed files with 1462 additions and 185 deletions

View File

@@ -14,6 +14,7 @@ from game_state import parse_game_state, load_layout_config
from coordinate_patrol import CoordinatePatrol from coordinate_patrol import CoordinatePatrol
from death_manager import DeathManager from death_manager import DeathManager
from logistics_manager import LogisticsManager from logistics_manager import LogisticsManager
from route_profile import load_route_profile
# 定义按键常量 # 定义按键常量
KEY_TAB = '3' KEY_TAB = '3'
@@ -239,6 +240,7 @@ class AutoBotMove:
self, self,
waypoints=None, waypoints=None,
waypoints_path=None, waypoints_path=None,
route_profile_path=None,
prepare_route_path=None, prepare_route_path=None,
vendor_path=None, vendor_path=None,
attack_loop_path=None, attack_loop_path=None,
@@ -289,11 +291,35 @@ class AutoBotMove:
self.logistics_resume_cooldown_until = 0.0 self.logistics_resume_cooldown_until = 0.0
# stop_check: 返回 True 表示需要立即停止(用于中断阻塞中的后勤/路线导航) # stop_check: 返回 True 表示需要立即停止(用于中断阻塞中的后勤/路线导航)
self._stop_check = stop_check if callable(stop_check) else (lambda: False) 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) path = waypoints_path or get_config_path(WAYPOINTS_FILE)
waypoints = load_waypoints(path) or DEFAULT_WAYPOINTS waypoints = load_waypoints(path) or DEFAULT_WAYPOINTS
self.prepare_route_waypoints = [] self.prepare_route_waypoints = []
if prepare_route_path: else:
self.prepare_route_waypoints = []
if (not route_profile_path) and prepare_route_path:
try: try:
self.prepare_route_waypoints = [ self.prepare_route_waypoints = [
(float(point[0]), float(point[1])) (float(point[0]), float(point[1]))
@@ -320,11 +346,17 @@ class AutoBotMove:
resurrection_waypoints_path=resurrection_waypoints_path, resurrection_waypoints_path=resurrection_waypoints_path,
resurrection_route_a_path=resurrection_route_a_path, resurrection_route_a_path=resurrection_route_a_path,
resurrection_route_b_path=resurrection_route_b_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, 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, 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') vendor_file = vendor_path or get_config_path('vendor.json')
self.logistics_manager = LogisticsManager(vendor_file) 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.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_key = str(layout.get("hearthstone_key", "b") or "b")
self.logistics_manager.hearthstone_cast_sec = float(layout.get("hearthstone_wait_sec", 12.0)) 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.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)) 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: def _has_prepare_route(self) -> bool:
return bool(self.prepare_route_waypoints) return bool(self.prepare_route_waypoints)
@@ -364,6 +421,7 @@ class AutoBotMove:
if self.prepare_route_waypoints: if self.prepare_route_waypoints:
print(">>> [后勤] 全自动续跑:重置准备路线,准备返回巡逻。") print(">>> [后勤] 全自动续跑:重置准备路线,准备返回巡逻。")
else: else:
self._select_random_patrol_route("后勤返回")
print(">>> [后勤] 全自动续跑:未配置准备路线,直接恢复巡逻。") print(">>> [后勤] 全自动续跑:未配置准备路线,直接恢复巡逻。")
def _snap_prepare_route_to_nearest(self, state): def _snap_prepare_route_to_nearest(self, state):
@@ -418,6 +476,7 @@ class AutoBotMove:
self.is_moving = False self.is_moving = False
self.patrol_controller.stop_all() self.patrol_controller.stop_all()
self.patrol_controller.reset_stuck() self.patrol_controller.reset_stuck()
self._select_random_patrol_route("准备路线完成")
print(">>> [准备路线] 准备路线完成,开始正式巡逻...") print(">>> [准备路线] 准备路线完成,开始正式巡逻...")
return return
@@ -443,6 +502,7 @@ class AutoBotMove:
self.is_moving = False self.is_moving = False
self.patrol_controller.stop_all() self.patrol_controller.stop_all()
self.patrol_controller.reset_stuck() self.patrol_controller.reset_stuck()
self._select_random_patrol_route("准备路线完成")
print(">>> [准备路线] 准备路线完成,开始正式巡逻...") print(">>> [准备路线] 准备路线完成,开始正式巡逻...")
def _is_effective_target(self, state) -> bool: def _is_effective_target(self, state) -> bool:

View File

@@ -2,6 +2,7 @@ import math
import time import time
from hardware_control import hw_ctrl from hardware_control import hw_ctrl
from route_profile import normalize_route_list
class DeathManager: class DeathManager:
@@ -13,16 +14,19 @@ class DeathManager:
resurrection_waypoints_path=None, resurrection_waypoints_path=None,
resurrection_route_a_path=None, resurrection_route_a_path=None,
resurrection_route_b_path=None, resurrection_route_b_path=None,
resurrection_routes=None,
release_spirit_key='9', release_spirit_key='9',
resurrect_key='0', resurrect_key='0',
): ):
self.patrol_system = patrol_system self.patrol_system = patrol_system
self.release_spirit_key = str(release_spirit_key).strip() or '9' self.release_spirit_key = str(release_spirit_key).strip() or '9'
self.resurrect_key = str(resurrect_key).strip() or '0' self.resurrect_key = str(resurrect_key).strip() or '0'
self.resurrection_route_a = self._load_waypoints( self.resurrection_routes = self._load_resurrection_routes(
resurrection_route_a_path or resurrection_waypoints_path 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.corpse_pos = None
self.graveyard_pos = None self.graveyard_pos = None
@@ -53,6 +57,29 @@ class DeathManager:
pass pass
return None 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): def _clear_death_run_state(self):
self.graveyard_pos = None self.graveyard_pos = None
self.selected_resurrection_route = None self.selected_resurrection_route = None
@@ -61,12 +88,7 @@ class DeathManager:
self._resurrection_completed = False self._resurrection_completed = False
def _build_resurrection_route_candidates(self): def _build_resurrection_route_candidates(self):
candidates = [] return list(self.resurrection_routes)
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
def _record_graveyard_pos(self, state): def _record_graveyard_pos(self, state):
if self.graveyard_pos is not None: if self.graveyard_pos is not None:

View File

@@ -3,6 +3,7 @@ import math
import os import os
import time import time
from hardware_control import hw_ctrl from hardware_control import hw_ctrl
from route_profile import normalize_points
# 修理商所在位置(游戏坐标),按实际位置修改 # 修理商所在位置(游戏坐标),按实际位置修改
VENDOR_POS = (30.08, 71.51) VENDOR_POS = (30.08, 71.51)
@@ -18,6 +19,8 @@ class LogisticsManager:
self.bag_full = False self.bag_full = False
self.is_returning = False self.is_returning = False
self.route_file = route_file or VENDOR_FILE self.route_file = route_file or VENDOR_FILE
self.route_points = []
self.route_name = ""
self.bag_full_hearthstone = False # 包满时用炉石回城而非走路修理 self.bag_full_hearthstone = False # 包满时用炉石回城而非走路修理
self.hearthstone_key = "b" # 炉石按键 self.hearthstone_key = "b" # 炉石按键
self.hearthstone_cast_sec = 12.0 # 炉石施法等待秒数 self.hearthstone_cast_sec = 12.0 # 炉石施法等待秒数
@@ -26,6 +29,8 @@ class LogisticsManager:
self.bag_slot_threshold = 2 self.bag_slot_threshold = 2
self.durability_threshold = 0.2 self.durability_threshold = 0.2
self.mailbox_route_file = os.path.join("recorder", "mailbox.json") self.mailbox_route_file = os.path.join("recorder", "mailbox.json")
self.mailbox_route_points = []
self.mailbox_route_name = ""
self.mailbox_interact_key = "8" self.mailbox_interact_key = "8"
self.mail_recipient_key = "" self.mail_recipient_key = ""
self.mail_send_key = "f8" self.mail_send_key = "f8"
@@ -46,6 +51,35 @@ class LogisticsManager:
return cwd_path return cwd_path
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 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): def _sleep_with_stop(self, seconds, stop_check=None):
end_at = time.time() + max(0.0, float(seconds)) end_at = time.time() + max(0.0, float(seconds))
while time.time() < end_at: while time.time() < end_at:
@@ -199,9 +233,14 @@ class LogisticsManager:
炉石 -> 跑到邮箱路线终点 -> 交互打开邮箱 -> 按 MailboxCourier 宏键 -> 等待后停止。 炉石 -> 跑到邮箱路线终点 -> 交互打开邮箱 -> 按 MailboxCourier 宏键 -> 等待后停止。
Python 不判断邮件是否发完,发送细节交给游戏内插件。 Python 不判断邮件是否发完,发送细节交给游戏内插件。
""" """
route_file = self._resolve_path(self.mailbox_route_file) path, route_source = self._load_configured_route(
if not route_file or not os.path.exists(route_file): self.mailbox_route_points,
print(f">>> [后勤-邮箱] 邮箱路线不存在,已停止: {route_file}") self.mailbox_route_file,
self.mailbox_route_name,
"后勤-邮箱",
)
if not path:
print(f">>> [后勤-邮箱] 邮箱路线不存在或为空,已停止: {route_source}")
self.is_returning = False self.is_returning = False
return False return False
@@ -216,20 +255,7 @@ class LogisticsManager:
self.is_returning = False self.is_returning = False
return False return False
try: print(f">>> [后勤-邮箱] 开始跑邮箱路线: {route_source}")
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}")
old_enable_mount = getattr(patrol, "enable_mount", None) old_enable_mount = getattr(patrol, "enable_mount", None)
if old_enable_mount is not None: if old_enable_mount is not None:
patrol.enable_mount = False patrol.enable_mount = False
@@ -282,9 +308,14 @@ class LogisticsManager:
耐久低修理流程: 耐久低修理流程:
炉石 -> 从修理路线最近点接入 -> 到达 NPC -> 按目标键 -> 按交互/修理键。 炉石 -> 从修理路线最近点接入 -> 到达 NPC -> 按目标键 -> 按交互/修理键。
""" """
route_file = self._resolve_path(self.route_file) path, route_source = self._load_configured_route(
if not route_file or not os.path.exists(route_file): self.route_points,
print(f">>> [后勤-修理] 修理路线不存在,已停止: {route_file}") self.route_file,
self.route_name,
"后勤-修理",
)
if not path:
print(f">>> [后勤-修理] 修理路线不存在或为空,已停止: {route_source}")
self.is_returning = False self.is_returning = False
return False return False
@@ -299,20 +330,7 @@ class LogisticsManager:
self.is_returning = False self.is_returning = False
return False return False
try: print(f">>> [后勤-修理] 开始跑修理路线: {route_source}")
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}")
ok = patrol.navigate_path( ok = patrol.navigate_path(
get_state, get_state,
path, path,

File diff suppressed because it is too large Load Diff

103
route_profile.py Normal file
View File

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

View File

@@ -23,6 +23,7 @@ import win32gui
import win32api import win32api
import win32con import win32con
from pynput import keyboard from pynput import keyboard
from route_profile import load_route_profile
def _config_base(): def _config_base():
@@ -67,6 +68,19 @@ def list_recorder_json():
return result 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(): def get_combat_loops_dir():
"""combat_loops 文件夹路径,不存在则创建""" """combat_loops 文件夹路径,不存在则创建"""
path = os.path.join(_config_base(), COMBAT_LOOPS_DIR) path = os.path.join(_config_base(), COMBAT_LOOPS_DIR)
@@ -93,10 +107,20 @@ class WaypointPreviewCanvas(QWidget):
ZOOM_MIN = 0.2 ZOOM_MIN = 0.2
ZOOM_MAX = 10.0 ZOOM_MAX = 10.0
ZOOM_STEP = 1.15 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): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.points = [] # [(x,y), ...],游戏坐标 self.routes = [] # [{"name": str, "points": [(x,y), ...]}, ...]
self.zoom_scale = 1.0 # 1.0 = 0~100 刚好铺满画布 self.zoom_scale = 1.0 # 1.0 = 0~100 刚好铺满画布
self.pan_x = 0.0 self.pan_x = 0.0
self.pan_y = 0.0 self.pan_y = 0.0
@@ -106,7 +130,24 @@ class WaypointPreviewCanvas(QWidget):
def set_points(self, points): 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() self.update()
def reset_view(self): def reset_view(self):
@@ -171,35 +212,51 @@ class WaypointPreviewCanvas(QWidget):
x1, y1 = to_canvas((100, 100)) x1, y1 = to_canvas((100, 100))
painter.drawRect(int(x0), int(y0), int(x1 - x0), int(y1 - y0)) 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.setPen(QPen(QColor(200, 200, 200), 1))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "无巡逻点数据") painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "无巡逻点数据")
return return
# 绘制路径连线 for idx, route in enumerate(self.routes):
painter.setPen(QPen(QColor(0, 200, 255), 2)) color = self.ROUTE_COLORS[idx % len(self.ROUTE_COLORS)]
for i in range(len(self.points) - 1): points = route["points"]
x1, y1 = to_canvas(self.points[i])
x2, y2 = to_canvas(self.points[i + 1]) 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)) painter.drawLine(int(x1), int(y1), int(x2), int(y2))
# 绘制所有巡逻点 painter.setPen(QPen(color, 1))
painter.setPen(QPen(QColor(255, 255, 0), 1)) painter.setBrush(color)
painter.setBrush(QColor(255, 255, 0)) for p in points:
for p in self.points:
px, py = to_canvas(p) px, py = to_canvas(p)
painter.drawEllipse(int(px) - 2, int(py) - 2, 4, 4) painter.drawEllipse(int(px) - 2, int(py) - 2, 4, 4)
# 起点与终点标记(覆盖在普通点之上) start_x, start_y = to_canvas(points[0])
start_x, start_y = to_canvas(self.points[0]) end_x, end_y = to_canvas(points[-1])
end_x, end_y = to_canvas(self.points[-1]) painter.setPen(QPen(QColor(245, 245, 245), 2))
painter.setPen(QPen(QColor(0, 255, 0), 2)) painter.setBrush(color)
painter.setBrush(QColor(0, 255, 0)) painter.drawEllipse(int(start_x) - 5, int(start_y) - 5, 10, 10)
painter.drawEllipse(int(start_x) - 4, int(start_y) - 4, 8, 8) painter.setPen(QPen(QColor(30, 30, 30), 2))
painter.setBrush(color)
painter.drawEllipse(int(end_x) - 5, int(end_y) - 5, 10, 10)
painter.setPen(QPen(QColor(255, 80, 80), 2)) legend_x = rect.left() + 12
painter.setBrush(QColor(255, 80, 80)) legend_y = rect.top() + 12
painter.drawEllipse(int(end_x) - 4, int(end_y) - 4, 8, 8) 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__( def __init__(
self, self,
mode, mode,
route_profile_path=None,
waypoints_path=None, waypoints_path=None,
prepare_route_path=None, prepare_route_path=None,
vendor_path=None, vendor_path=None,
@@ -342,6 +400,7 @@ class GameLoopWorker(QThread):
self.quest_follow_bot = None self.quest_follow_bot = None
self.flight_bot = None self.flight_bot = None
self.recorder = None self.recorder = None
self.route_profile_path = route_profile_path
self.waypoints_path = waypoints_path self.waypoints_path = waypoints_path
self.prepare_route_path = prepare_route_path self.prepare_route_path = prepare_route_path
self.vendor_path = vendor_path self.vendor_path = vendor_path
@@ -417,6 +476,7 @@ class GameLoopWorker(QThread):
try: try:
from auto_bot_move import AutoBotMove, load_waypoints from auto_bot_move import AutoBotMove, load_waypoints
self.bot_move = AutoBotMove( self.bot_move = AutoBotMove(
route_profile_path=self.route_profile_path,
waypoints_path=self.waypoints_path, waypoints_path=self.waypoints_path,
prepare_route_path=self.prepare_route_path, prepare_route_path=self.prepare_route_path,
vendor_path=self.vendor_path, vendor_path=self.vendor_path,
@@ -441,6 +501,9 @@ class GameLoopWorker(QThread):
except ImportError as e: except ImportError as e:
self.log_signal.emit(f"❌ 巡逻打怪依赖加载失败: {e}") self.log_signal.emit(f"❌ 巡逻打怪依赖加载失败: {e}")
return return
except Exception as e:
self.log_signal.emit(f"❌ 路线方案加载失败: {e}")
return
if self.mode == 'combat': if self.mode == 'combat':
try: try:
@@ -738,30 +801,14 @@ class WoWMultiKeyGUI(QMainWindow):
# 巡逻打怪配置(仅选择巡逻打怪模式时显示) # 巡逻打怪配置(仅选择巡逻打怪模式时显示)
self.patrol_group = QGroupBox("巡逻打怪配置") self.patrol_group = QGroupBox("巡逻打怪配置")
patrol_layout = QFormLayout(self.patrol_group) patrol_layout = QFormLayout(self.patrol_group)
self.waypoints_combo = QComboBox() self.route_profile_combo = QComboBox()
self.waypoints_combo.setMinimumWidth(200) self.route_profile_combo.setMinimumWidth(240)
self.vendor_combo = QComboBox()
self.vendor_combo.setMinimumWidth(200)
self.mailbox_route_combo = QComboBox()
self.mailbox_route_combo.setMinimumWidth(200)
self.patrol_attack_loop_combo = QComboBox() self.patrol_attack_loop_combo = QComboBox()
self.patrol_attack_loop_combo.setMinimumWidth(200) self.patrol_attack_loop_combo.setMinimumWidth(200)
self.prepare_route_combo = QComboBox()
self.prepare_route_combo.setMinimumWidth(200)
self._refresh_recorder_combos() self._refresh_recorder_combos()
refresh_btn = QPushButton("🔄 刷新列表") refresh_btn = QPushButton("🔄 刷新列表")
refresh_btn.clicked.connect(self._refresh_recorder_combos) refresh_btn.clicked.connect(self._refresh_recorder_combos)
patrol_layout.addRow("准备路线 JSON:", self.prepare_route_combo) patrol_layout.addRow("路线方案 JSON:", self.route_profile_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("攻击循环:", self.patrol_attack_loop_combo) patrol_layout.addRow("攻击循环:", self.patrol_attack_loop_combo)
patrol_layout.addRow("", refresh_btn) patrol_layout.addRow("", refresh_btn)
self.patrol_group.setVisible(False) self.patrol_group.setVisible(False)
@@ -930,7 +977,7 @@ class WoWMultiKeyGUI(QMainWindow):
self.preview_waypoints_combo.setMinimumWidth(220) self.preview_waypoints_combo.setMinimumWidth(220)
self._refresh_preview_waypoints() self._refresh_preview_waypoints()
self.preview_waypoints_combo.currentIndexChanged.connect(self._on_preview_waypoints_changed) 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) preview_layout.addWidget(preview_file_group)
self.preview_canvas = WaypointPreviewCanvas() self.preview_canvas = WaypointPreviewCanvas()
@@ -1570,35 +1617,34 @@ class WoWMultiKeyGUI(QMainWindow):
self._load_params_config() self._load_params_config()
def _refresh_preview_waypoints(self): def _refresh_preview_waypoints(self):
"""刷新巡逻点预览下拉列表""" """刷新路线方案预览下拉列表"""
self.preview_waypoints_combo.blockSignals(True) self.preview_waypoints_combo.blockSignals(True)
self.preview_waypoints_combo.clear() self.preview_waypoints_combo.clear()
self.preview_waypoints_combo.addItem("-- 请选择 --", "") 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.addItem(name, path)
self.preview_waypoints_combo.blockSignals(False) self.preview_waypoints_combo.blockSignals(False)
if self.preview_waypoints_combo.count() > 1: if self.preview_waypoints_combo.count() > 1:
self.preview_waypoints_combo.setCurrentIndex(1) self.preview_waypoints_combo.setCurrentIndex(1)
def _on_preview_waypoints_changed(self, index): def _on_preview_waypoints_changed(self, index):
"""选择巡逻点 JSON 时加载并绘制在画布上""" """选择路线方案 JSON 时加载并绘制所有巡逻路线。"""
path = self.preview_waypoints_combo.currentData() path = self.preview_waypoints_combo.currentData()
if not path or not os.path.exists(path): if not path or not os.path.exists(path):
self.preview_canvas.set_points([]) self.preview_canvas.set_routes([])
return return
try: try:
with open(path, "r", encoding="utf-8") as f: profile = load_route_profile(path)
data = json.load(f) routes = profile.get("patrol_routes") or []
# 期望格式:[[x,y], ...] 或 [[x,y, ...], ...] total_points = sum(len(route.get("points") or []) for route in routes)
points = [] self.preview_canvas.set_routes(routes)
for item in data: self.log(
if isinstance(item, (list, tuple)) and len(item) >= 2: f"已加载路线方案预览: {profile.get('name')} "
points.append((float(item[0]), float(item[1]))) f"{len(routes)} 条巡逻路线,共 {total_points} 点)"
self.preview_canvas.set_points(points) )
self.log(f"已加载巡逻点预览: {path} (共 {len(points)} 点)")
except Exception as e: except Exception as e:
self.log(f"加载巡逻点预览失败: {e}") self.log(f"加载路线方案预览失败: {e}")
self.preview_canvas.set_points([]) self.preview_canvas.set_routes([])
def _reset_preview_canvas(self): def _reset_preview_canvas(self):
"""重置巡逻点预览视图(缩放 + 平移)。""" """重置巡逻点预览视图(缩放 + 平移)。"""
@@ -1606,51 +1652,19 @@ class WoWMultiKeyGUI(QMainWindow):
self.preview_canvas.reset_view() self.preview_canvas.reset_view()
def _refresh_recorder_combos(self): def _refresh_recorder_combos(self):
"""刷新巡逻点、修理商、复活点路线下拉列表""" """刷新路线方案和其他 recorder 下拉列表"""
items = list_recorder_json() if hasattr(self, "route_profile_combo"):
combos_with_default = [ self.route_profile_combo.blockSignals(True)
(self.waypoints_combo, 'waypoints.json'), self.route_profile_combo.clear()
(self.vendor_combo, 'vendor.json'), 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"): if hasattr(self, "repair_vendor_combo"):
combos_with_default.append((self.repair_vendor_combo, 'vendor.json')) self._refresh_repair_vendor_json_combo()
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)
def _refresh_repair_vendor_json_combo(self): def _refresh_repair_vendor_json_combo(self):
"""刷新“回城修理配置”的修理商下拉框。""" """刷新“回城修理配置”的修理商下拉框。"""
@@ -1785,6 +1799,7 @@ class WoWMultiKeyGUI(QMainWindow):
if mode is None: if mode is None:
QMessageBox.warning(self, "提示", "请先选择模式(状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 飞行模式 / 回城修理)") QMessageBox.warning(self, "提示", "请先选择模式(状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 飞行模式 / 回城修理)")
return return
route_profile_path = None
waypoints_path = None waypoints_path = None
prepare_route_path = None prepare_route_path = None
vendor_path = None vendor_path = None
@@ -1793,46 +1808,25 @@ class WoWMultiKeyGUI(QMainWindow):
resurrection_route_a_path = None resurrection_route_a_path = None
resurrection_route_b_path = None resurrection_route_b_path = None
if mode == 'patrol': if mode == 'patrol':
prep = self.prepare_route_combo.currentData() or "" profile_path = self.route_profile_combo.currentData() or ""
wp = self.waypoints_combo.currentData() or "" if not profile_path:
vp = self.vendor_combo.currentData() or "" QMessageBox.warning(self, "提示", "巡逻打怪模式需选择路线方案 JSON 文件")
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 文件")
return return
if not vp: if not os.path.exists(profile_path):
QMessageBox.warning(self, "提示", "巡逻打怪模式需选择修理商 JSON 文件") QMessageBox.warning(self, "提示", f"路线方案文件不存在: {profile_path}")
return return
if not os.path.exists(wp): try:
QMessageBox.warning(self, "提示", f"巡逻点文件不存在: {wp}") profile = load_route_profile(profile_path)
except Exception as e:
QMessageBox.warning(self, "提示", f"路线方案 JSON 无效: {e}")
return return
if not os.path.exists(vp): if not profile.get("vendor_route"):
QMessageBox.warning(self, "提示", f"修理商文件不存在: {vp}") QMessageBox.warning(self, "提示", "路线方案需包含 vendor_route 修理商路线")
return return
if prep and not os.path.exists(prep): if self.gs_enable_bag_full_mail.isChecked() and not profile.get("mailbox_route"):
QMessageBox.warning(self, "提示", f"准备路线文件不存在: {prep}") QMessageBox.warning(self, "提示", "已启用包满邮寄,路线方案需包含 mailbox_route 邮箱路线")
return return
if self.gs_enable_bag_full_mail.isChecked(): route_profile_path = profile_path
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
attack_loop_path = None attack_loop_path = None
if mode == 'patrol': if mode == 'patrol':
attack_loop_path = self.patrol_attack_loop_combo.currentData() or None 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() use_ghost_box = self.gs_use_ghost_box.isChecked()
self.game_worker = GameLoopWorker( 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, mailbox_route_path=mailbox_route_path,
attack_loop_path=attack_loop_path, attack_loop_path=attack_loop_path,
skinning_wait_sec=skinning_wait_sec, skinning_wait_sec=skinning_wait_sec,