Files
wow/wow_multikey_gui.py
2026-05-12 17:21:14 +08:00

2115 lines
95 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
WoW 多功能控制器 GUI
集成:状态监控、巡逻打怪、自动打怪、任务跟随、巡逻点录制、多键控制
"""
import json
import os
import random
import sys
import time
import ctypes
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QPushButton, QCheckBox, QSpinBox, QDoubleSpinBox, QScrollArea, QTextEdit,
QMessageBox, QTabWidget, QGroupBox, QFormLayout, QLineEdit,
QComboBox, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
)
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QPainter, QPen, QColor
import win32gui
import win32api
import win32con
from pynput import keyboard
from route_profile import load_route_profile
def _config_base():
"""配置文件所在目录(支持打包 exe"""
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def get_config_path(filename):
"""解析配置文件路径。"""
base = _config_base()
p = os.path.join(base, filename)
if os.path.exists(p):
return p
if getattr(sys, 'frozen', False) and getattr(sys, '_MEIPASS', ''):
p2 = os.path.join(sys._MEIPASS, filename)
if os.path.exists(p2):
return p2
return p
CONFIG_FILE = "wow_multikey_qt.json"
RECORDER_DIR = "recorder" # 录制与巡逻配置文件存放目录
COMBAT_LOOPS_DIR = "combat_loops" # 攻击循环配置存放目录
def get_recorder_dir():
"""recorder 文件夹路径,不存在则创建"""
path = os.path.join(_config_base(), RECORDER_DIR)
os.makedirs(path, exist_ok=True)
return path
def list_recorder_json():
"""列出 recorder 文件夹下所有 .json 文件,返回 [(显示名, 完整路径), ...]"""
rec_dir = get_recorder_dir()
result = []
for name in sorted(os.listdir(rec_dir)):
if name.lower().endswith('.json'):
result.append((name, os.path.join(rec_dir, name)))
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)
os.makedirs(path, exist_ok=True)
return path
def list_combat_loop_files():
"""列出 combat_loops 下所有 .json返回 [(显示名, 完整路径), ...]"""
loop_dir = get_combat_loops_dir()
result = []
for name in sorted(os.listdir(loop_dir)):
if name.lower().endswith('.json'):
result.append((name, os.path.join(loop_dir, name)))
return result
# ============ 巡逻点预览画布 ============
class WaypointPreviewCanvas(QWidget):
"""简单的 0~100 坐标系预览画布,按顺序连线显示路径;支持滚轮缩放、鼠标拖拽平移。"""
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.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
self._dragging = False
self._last_pos = None
self.setMinimumSize(300, 300)
def set_points(self, points):
"""设置要绘制的路径点列表。"""
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):
"""重置缩放和平移到初始状态。"""
self.zoom_scale = 1.0
self.pan_x = 0.0
self.pan_y = 0.0
self.update()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = True
self._last_pos = event.position()
def mouseMoveEvent(self, event):
if self._dragging and self._last_pos is not None:
pos = event.position()
self.pan_x += pos.x() - self._last_pos.x()
self.pan_y += pos.y() - self._last_pos.y()
self._last_pos = pos
self.update()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = False
self._last_pos = None
def wheelEvent(self, event):
"""鼠标滚轮:向上放大,向下缩小。"""
dy = event.angleDelta().y()
if dy > 0:
self.zoom_scale = min(self.ZOOM_MAX, self.zoom_scale * self.ZOOM_STEP)
else:
self.zoom_scale = max(self.ZOOM_MIN, self.zoom_scale / self.ZOOM_STEP)
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
rect = self.rect().adjusted(10, 10, -10, -10)
if rect.width() <= 0 or rect.height() <= 0:
return
# 背景
painter.fillRect(self.rect(), QColor(30, 30, 30))
# 固定坐标:左上角 (0,0),右下角 (100,100)缩放以画布中心为基准pan 为拖拽偏移(像素)
base_scale = min(rect.width(), rect.height()) / 100.0
scale = base_scale * self.zoom_scale
cx, cy = rect.center().x(), rect.center().y()
def to_canvas(p):
x, y = p
px = cx + self.pan_x + (x - 50.0) * scale
py = cy + self.pan_y + (y - 50.0) * scale
return px, py
# 坐标边框(按缩放后的逻辑范围绘制)
painter.setPen(QPen(QColor(80, 80, 80), 1))
x0, y0 = to_canvas((0, 0))
x1, y1 = to_canvas((100, 100))
painter.drawRect(int(x0), int(y0), int(x1 - x0), int(y1 - y0))
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
for idx, route in enumerate(self.routes):
color = self.ROUTE_COLORS[idx % len(self.ROUTE_COLORS)]
points = route["points"]
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.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)
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"])
# ============ 多键控制 ============
class KeyConfigWidget(QWidget):
"""单个按键配置组件"""
def __init__(self, key_name, default_config):
super().__init__()
self.key_name = key_name
self.config = default_config or {'enabled': False, 'interval': 10, 'jitter': 2}
self.init_ui()
def init_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.checkbox = QCheckBox(f"按键 [{self.key_name}]")
self.checkbox.setChecked(self.config['enabled'])
layout.addWidget(self.checkbox)
layout.addWidget(QLabel("间隔:"))
self.interval_spin = QSpinBox()
self.interval_spin.setRange(1, 60)
self.interval_spin.setValue(self.config['interval'])
layout.addWidget(self.interval_spin)
layout.addWidget(QLabel(""))
layout.addWidget(QLabel("随机:"))
self.jitter_spin = QSpinBox()
self.jitter_spin.setRange(0, 10)
self.jitter_spin.setValue(self.config['jitter'])
layout.addWidget(self.jitter_spin)
layout.addWidget(QLabel(""))
layout.addStretch()
self.checkbox.toggled.connect(self.on_toggle)
self.on_toggle()
def on_toggle(self):
enabled = self.checkbox.isChecked()
self.interval_spin.setEnabled(enabled)
self.jitter_spin.setEnabled(enabled)
def get_config(self):
return {
'enabled': self.checkbox.isChecked(),
'interval': self.interval_spin.value(),
'jitter': self.jitter_spin.value()
}
class KeyWorker(QThread):
"""按键工作线程"""
log_signal = pyqtSignal(str)
def __init__(self, hwnd, key_name, config):
super().__init__()
self.hwnd = hwnd
self.key_name = key_name
self.config = config
self.running = True
def run(self):
while self.running:
if self.config['enabled']:
self.press_key()
sleep_time = self.config['interval'] + random.uniform(
-self.config['jitter'], self.config['jitter']
)
sleep_time = max(1, sleep_time)
for _ in range(int(sleep_time * 2)):
if not self.running:
break
time.sleep(0.5)
def press_key(self):
if not self.hwnd:
return
key_map = {
'空格(跳跃)': win32con.VK_SPACE,
'1': 0x31, '2': 0x32, '3': 0x33, '4': 0x34, '5': 0x35
}
vk = key_map.get(self.key_name)
if vk:
try:
win32api.PostMessage(self.hwnd, win32con.WM_KEYDOWN, vk, 0)
time.sleep(random.uniform(0.05, 0.15))
win32api.PostMessage(self.hwnd, win32con.WM_KEYUP, vk, 0)
key_type = "跳跃" if self.key_name == '空格(跳跃)' else f"技能 {self.key_name}"
self.log_signal.emit(f"➡️ 执行 {key_type}")
except Exception as e:
self.log_signal.emit(f"❌ 按键 {self.key_name} 失败: {e}")
# ============ 游戏主循环(状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 录制) ============
class GameLoopWorker(QThread):
"""游戏状态主循环:支持状态监控、巡逻打怪、自动打怪、任务跟随、飞行模式、巡逻点录制"""
state_signal = pyqtSignal(str)
log_signal = pyqtSignal(str)
stop_signal = pyqtSignal() # 炉石回城后触发,等同于按下停止按钮
def __init__(
self,
mode,
route_profile_path=None,
waypoints_path=None,
prepare_route_path=None,
vendor_path=None,
mailbox_route_path=None,
record_filename=None,
record_min_distance=None,
attack_loop_path=None,
skinning_wait_sec=None,
food_key=None,
eat_hp_threshold=None,
eat_max_wait_sec=None,
quest_follow_follow_key=None,
quest_follow_interact_key=None,
quest_follow_interval_sec=None,
flight_json_path=None,
flight_takeoff_key=None,
flight_takeoff_hold_sec=None,
flight_land_key=None,
flight_land_hold_sec=None,
resurrection_waypoints_path=None,
resurrection_route_a_path=None,
resurrection_route_b_path=None,
release_spirit_key=None,
resurrect_key=None,
enable_mouse_loot=True,
use_hardware_input=None,
use_tianya_box=None,
use_ghost_box=None,
turn_error_key=None,
turn_error_hold_sec=None,
distance_interact_pause_sec=None,
skip_prepare_route=False,
):
super().__init__()
self.mode = mode # 'monitor' | 'patrol' | 'combat' | 'quest_follow' | 'flight' | 'record'
self.running = True
self.bot_move = None
self.bot_combat = None
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
self.mailbox_route_path = mailbox_route_path
self.skip_prepare_route = bool(skip_prepare_route)
self.record_filename = record_filename or 'waypoints'
self.record_min_distance = record_min_distance
self.attack_loop_path = attack_loop_path or None
self.skinning_wait_sec = skinning_wait_sec
self.food_key = (food_key or "f1").strip().lower() or "f1"
try:
self.eat_hp_threshold = int(eat_hp_threshold)
except (TypeError, ValueError):
self.eat_hp_threshold = 30
try:
self.eat_max_wait_sec = float(eat_max_wait_sec)
except (TypeError, ValueError):
self.eat_max_wait_sec = 30.0
self.quest_follow_follow_key = (quest_follow_follow_key or "f").strip().lower() or "f"
self.quest_follow_interact_key = (quest_follow_interact_key or "4").strip().lower() or "4"
try:
self.quest_follow_interval_sec = float(quest_follow_interval_sec)
except (TypeError, ValueError):
self.quest_follow_interval_sec = 3.0
self.flight_json_path = flight_json_path
self.flight_takeoff_key = flight_takeoff_key
self.flight_takeoff_hold_sec = flight_takeoff_hold_sec
self.flight_land_key = flight_land_key
self.flight_land_hold_sec = flight_land_hold_sec
self.resurrection_waypoints_path = resurrection_waypoints_path
self.resurrection_route_a_path = resurrection_route_a_path or resurrection_waypoints_path
self.resurrection_route_b_path = resurrection_route_b_path
self.release_spirit_key = release_spirit_key
self.resurrect_key = resurrect_key
self.enable_mouse_loot = enable_mouse_loot
if use_tianya_box is None and use_ghost_box is None:
self.use_tianya_box = True if use_hardware_input is None else bool(use_hardware_input)
self.use_ghost_box = False
else:
self.use_tianya_box = bool(use_tianya_box)
self.use_ghost_box = bool(use_ghost_box)
self.turn_error_key = (turn_error_key or "s").strip().lower() or "s"
try:
self.turn_error_hold_sec = float(turn_error_hold_sec)
except (TypeError, ValueError):
self.turn_error_hold_sec = 0.8
try:
self.distance_interact_pause_sec = float(distance_interact_pause_sec)
except (TypeError, ValueError):
self.distance_interact_pause_sec = 1.0
def run(self):
try:
from game_state import parse_game_state, format_game_state_line
from hardware_control import hw_ctrl
except ImportError:
self.log_signal.emit("❌ 无法导入 game_state 模块")
return
hw_ctrl.configure(use_tianya_box=self.use_tianya_box, use_ghost_box=self.use_ghost_box)
backend_text = {
"hardware": "天涯盒子",
"hardware_unavailable": "天涯盒子(不可用)",
"tianya": "天涯盒子",
"tianya_unavailable": "天涯盒子(不可用)",
"ghost": "幽灵盒子",
"ghost_unavailable": "幽灵盒子(不可用)",
"directinput": "pydirectinput",
"directinput_unavailable": "pydirectinput(不可用)",
}.get(hw_ctrl.backend_label(), "未知")
self.log_signal.emit(f"⌨️ 控制方式: {backend_text}")
if self.mode == 'patrol':
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,
attack_loop_path=self.attack_loop_path,
skinning_wait_sec=self.skinning_wait_sec,
food_key=self.food_key,
eat_hp_threshold=self.eat_hp_threshold,
eat_max_wait_sec=self.eat_max_wait_sec,
stop_check=lambda: not self.running,
resurrection_waypoints_path=self.resurrection_waypoints_path,
resurrection_route_a_path=self.resurrection_route_a_path,
resurrection_route_b_path=self.resurrection_route_b_path,
release_spirit_key=self.release_spirit_key,
resurrect_key=self.resurrect_key,
enable_mouse_loot=self.enable_mouse_loot,
turn_error_key=self.turn_error_key,
turn_error_hold_sec=self.turn_error_hold_sec,
distance_interact_pause_sec=self.distance_interact_pause_sec,
mailbox_route_path=self.mailbox_route_path,
skip_prepare_route=self.skip_prepare_route,
)
self.bot_move._on_hearthstone_stop = self.stop_signal.emit
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:
from auto_bot import AutoBot
self.bot_combat = AutoBot(
attack_loop_path=self.attack_loop_path,
skinning_wait_sec=self.skinning_wait_sec,
enable_mouse_loot=self.enable_mouse_loot,
turn_error_key=self.turn_error_key,
turn_error_hold_sec=self.turn_error_hold_sec,
distance_interact_pause_sec=self.distance_interact_pause_sec,
)
except ImportError as e:
self.log_signal.emit(f"❌ 自动打怪依赖加载失败: {e}")
return
if self.mode == 'quest_follow':
try:
from quest_follow import QuestFollowBot
self.quest_follow_bot = QuestFollowBot(
follow_key=self.quest_follow_follow_key,
interact_key=self.quest_follow_interact_key,
interval_sec=self.quest_follow_interval_sec,
log_callback=lambda m: self.log_signal.emit(m),
)
except ImportError as e:
self.log_signal.emit(f"❌ 任务跟随依赖加载失败: {e}")
return
if self.mode == 'flight':
if not self.flight_json_path:
self.log_signal.emit("❌ 飞行模式:未选择航线 JSON")
return
try:
from flight_mode import FlightModeBot
self.flight_bot = FlightModeBot(
json_path=self.flight_json_path,
log_callback=lambda m: self.log_signal.emit(m),
takeoff_key=self.flight_takeoff_key,
takeoff_hold_sec=self.flight_takeoff_hold_sec,
land_key=self.flight_land_key,
land_hold_sec=self.flight_land_hold_sec,
)
except ImportError as e:
self.log_signal.emit(f"❌ 飞行模式依赖加载失败: {e}")
return
if self.mode == 'return_repair':
if not self.vendor_path:
self.log_signal.emit("❌ 回城修理模式:未选择修理商 JSON")
return
try:
from logistics_manager import LogisticsManager
from coordinate_patrol import CoordinatePatrol
from game_state import load_layout_config
layout = load_layout_config()
self.patrol_controller = CoordinatePatrol(
waypoints=[(0.0, 0.0)],
mount_key=str(layout.get("mount_key", "x") or "x"),
mount_hold_sec=float(layout.get("mount_hold_sec", 1.6)),
mount_retry_after_sec=float(layout.get("mount_retry_after_sec", 2.0)),
enable_mount=bool(layout.get("enable_mount", True)),
)
self.logistics_manager = LogisticsManager(route_file=self.vendor_path)
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.repair_target_key = str(layout.get("repair_target_key", "8") or "8")
self.logistics_manager.repair_interact_key = str(layout.get("repair_interact_key", "4") or "4")
self.log_signal.emit(f"⛑️ 回城修理:开始执行路径({os.path.basename(self.vendor_path)}")
get_state = lambda: parse_game_state() if self.running else None
ok = self.logistics_manager.run_repair_flow(
get_state,
self.patrol_controller,
stop_check=lambda: not self.running,
use_hearthstone=True,
)
if ok:
self.log_signal.emit("✅ 回城修理:路径完成")
else:
self.log_signal.emit("⏹️ 回城修理:已中止或未完成")
except ImportError as e:
self.log_signal.emit(f"❌ 回城修理依赖加载失败: {e}")
return
except Exception as e:
self.log_signal.emit(f"❌ 回城修理执行失败: {e}")
return
finally:
self.running = False
return
if self.mode == 'record':
try:
from recorder import WaypointRecorder
rec_dir = get_recorder_dir()
name = self.record_filename.strip() or 'waypoints'
if not name.endswith('.json'):
name += '.json'
wp_path = os.path.join(rec_dir, name)
self.recorder = WaypointRecorder(
min_distance=self.record_min_distance if self.record_min_distance is not None else 0.5,
waypoints_file=wp_path,
on_point_added=lambda pos, cnt: self.log_signal.emit(
f"📌 已记录点 {pos} | 总点数: {cnt}"
)
)
except ImportError as e:
self.log_signal.emit(f"❌ 录制依赖加载失败: {e}")
return
while self.running:
state = parse_game_state()
if state:
self.state_signal.emit(format_game_state_line(state))
if self.mode == 'patrol' and self.bot_move:
self.bot_move.execute_logic(state)
elif self.mode == 'combat' and self.bot_combat:
self.bot_combat.execute_logic(state)
elif self.mode == 'record' and self.recorder:
self.recorder.record(state)
elif self.mode == 'flight' and self.flight_bot:
self.flight_bot.execute_logic(state)
if self.mode == 'quest_follow' and self.quest_follow_bot:
self.quest_follow_bot.execute_logic(state)
time.sleep(0.1)
def save_recorder(self):
"""录制模式下保存路径。"""
if self.recorder:
return self.recorder.save()
return None
class LootPathRecorderWorker(QThread):
"""拾取轨迹录制工作线程"""
finished_signal = pyqtSignal(int)
log_signal = pyqtSignal(str)
def __init__(self, hwnd):
super().__init__()
self.hwnd = hwnd
self.running = False
self.points = []
def run(self):
if not self.hwnd:
self.log_signal.emit("❌ 未找到游戏窗口,无法录制轨迹")
return
try:
rect = win32gui.GetWindowRect(self.hwnd)
cx = rect[0] + (rect[2] - rect[0]) // 2
cy = rect[1] + (rect[3] - rect[1]) // 2
self.points = []
self.running = True
self.log_signal.emit("🔴 拾取轨迹录制中... 请在角色前方平滑移动鼠标")
while self.running:
x, y = win32api.GetCursorPos()
self.points.append((x - cx, y - cy))
time.sleep(0.02)
# 稀疏处理并保存
processed = self.points[::3]
if not processed:
self.log_signal.emit("⚠️ 未记录到有效轨迹点")
return
path = get_config_path("loot_path.json")
with open(path, 'w') as f:
json.dump(processed, f)
self.finished_signal.emit(len(processed))
self.log_signal.emit(f"✅ 拾取轨迹已保存: {len(processed)}")
except Exception as e:
self.log_signal.emit(f"❌ 轨迹录制失败: {e}")
def stop(self):
self.running = False
# ============ 主窗口 ============
class WoWMultiKeyGUI(QMainWindow):
def __init__(self):
super().__init__()
self.config_path = os.path.join(_config_base(), CONFIG_FILE)
self.config = self.load_config()
self.key_workers = {}
self.game_worker = None
self.loot_recorder_worker = None
self.pending_recorder = None # 停止录制后保留,便于保存
self.hwnd = None
self.init_ui()
self.find_wow_window()
# 加载参数配置到界面
self._load_params_config()
# 初始化全局热键监听 (F8 用于拾取录制)
self.kb_listener = keyboard.Listener(on_press=self._on_hotkey_press)
self.kb_listener.start()
def _on_hotkey_press(self, key):
"""全局热键回调"""
try:
if key == keyboard.Key.f8:
# 必须在主线程 UI 操作,但 pynput 在独立线程,这里简单判断状态
if self.loot_recorder_worker and self.loot_recorder_worker.isRunning():
# 停止录制
self.stop_loot_record()
else:
# 开始录制
self.start_loot_record()
except Exception:
pass
def init_ui(self):
self.setWindowTitle("WoW 多功能控制器")
self.setGeometry(100, 100, 780, 620)
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# 窗口状态
top = QHBoxLayout()
self.window_label = QLabel("正在查找窗口...")
top.addWidget(self.window_label)
self.refresh_btn = QPushButton("刷新窗口")
self.refresh_btn.clicked.connect(self.find_wow_window)
top.addWidget(self.refresh_btn)
top.addStretch()
layout.addLayout(top)
# 标签页
tabs = QTabWidget()
self.tabs = tabs
# Tab1: 多键控制
tab_keys = QWidget()
keys_layout = QVBoxLayout(tab_keys)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll_widget = QWidget()
scroll_layout = QVBoxLayout(scroll_widget)
self.key_widgets = {}
keys_cfg = (self.config or {}).get('keys') or {}
for key in ['空格(跳跃)', '1', '2', '3', '4', '5']:
w = KeyConfigWidget(key, keys_cfg.get(key))
scroll_layout.addWidget(w)
self.key_widgets[key] = w
scroll_layout.addStretch()
scroll.setWidget(scroll_widget)
keys_layout.addWidget(scroll)
keys_btn = QHBoxLayout()
self.keys_start_btn = QPushButton("🚀 启动多键")
self.keys_start_btn.clicked.connect(self.start_keys)
self.keys_stop_btn = QPushButton("⏹ 停止多键")
self.keys_stop_btn.clicked.connect(self.stop_keys)
self.keys_stop_btn.setEnabled(False)
keys_btn.addWidget(self.keys_start_btn)
keys_btn.addWidget(self.keys_stop_btn)
keys_btn.addStretch()
keys_layout.addLayout(keys_btn)
tabs.addTab(tab_keys, "多键控制")
# Tab2: 状态 / 巡逻 / 打怪
tab_game = QWidget()
game_layout = QVBoxLayout(tab_game)
mode_group = QGroupBox("模式")
mode_layout = QHBoxLayout(mode_group)
self.mode_btns = {}
for mid, name in [
('monitor', '状态监控'),
('patrol', '巡逻打怪'),
('combat', '自动打怪'),
('quest_follow', '任务跟随'),
('flight', '飞行模式'),
('return_repair', '回城修理'),
]:
btn = QPushButton(name)
btn.setCheckable(True)
btn.clicked.connect(lambda checked, m=mid: self.set_game_mode(m))
mode_layout.addWidget(btn)
self.mode_btns[mid] = btn
mode_layout.addStretch()
game_layout.addWidget(mode_group)
# 巡逻打怪配置(仅选择巡逻打怪模式时显示)
self.patrol_group = QGroupBox("巡逻打怪配置")
patrol_layout = QFormLayout(self.patrol_group)
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.skip_prepare_route_check = QCheckBox("跳过准备路线")
self.skip_prepare_route_check.setChecked(False)
self._refresh_recorder_combos()
refresh_btn = QPushButton("🔄 刷新列表")
refresh_btn.clicked.connect(self._refresh_recorder_combos)
patrol_layout.addRow("路线方案 JSON:", self.route_profile_combo)
patrol_layout.addRow("", self.skip_prepare_route_check)
patrol_layout.addRow("攻击循环:", self.patrol_attack_loop_combo)
patrol_layout.addRow("", refresh_btn)
self.patrol_group.setVisible(False)
game_layout.addWidget(self.patrol_group)
# 自动打怪配置(仅选择自动打怪模式时显示)
self.combat_group = QGroupBox("自动打怪配置")
combat_layout = QFormLayout(self.combat_group)
self.combat_attack_loop_combo = QComboBox()
self.combat_attack_loop_combo.setMinimumWidth(200)
combat_layout.addRow("攻击循环:", self.combat_attack_loop_combo)
self.combat_group.setVisible(False)
game_layout.addWidget(self.combat_group)
qf_cfg = ((self.config or {}).get('bot') or {}).get('quest_follow') or {}
self.quest_follow_group = QGroupBox("任务跟随配置")
qf_layout = QFormLayout(self.quest_follow_group)
self.quest_follow_follow_edit = QLineEdit()
self.quest_follow_follow_edit.setPlaceholderText("如 f跟随队友")
self.quest_follow_follow_edit.setMaxLength(8)
self.quest_follow_follow_edit.setText(str(qf_cfg.get('follow_key', 'f')).strip() or 'f')
self.quest_follow_interact_edit = QLineEdit()
self.quest_follow_interact_edit.setPlaceholderText("如 4与互动键一致")
self.quest_follow_interact_edit.setMaxLength(8)
self.quest_follow_interact_edit.setText(str(qf_cfg.get('interact_key', '4')).strip() or '4')
self.quest_follow_interval_spin = QDoubleSpinBox()
self.quest_follow_interval_spin.setRange(0.5, 120.0)
self.quest_follow_interval_spin.setSingleStep(0.5)
try:
self.quest_follow_interval_spin.setValue(float(qf_cfg.get('interval_sec', 3.0)))
except (TypeError, ValueError):
self.quest_follow_interval_spin.setValue(3.0)
self.quest_follow_interval_spin.setSuffix("")
qf_layout.addRow("跟随键:", self.quest_follow_follow_edit)
qf_layout.addRow("交互键:", self.quest_follow_interact_edit)
qf_layout.addRow("按键间隔:", self.quest_follow_interval_spin)
self.quest_follow_group.setVisible(False)
game_layout.addWidget(self.quest_follow_group)
ff_cfg = ((self.config or {}).get('bot') or {}).get('flight') or {}
self.flight_group = QGroupBox("飞行模式配置")
flight_layout = QFormLayout(self.flight_group)
self.flight_json_combo = QComboBox()
self.flight_json_combo.setMinimumWidth(200)
self._refresh_flight_json_combo()
flight_refresh_btn = QPushButton("🔄 刷新列表")
flight_refresh_btn.clicked.connect(self._refresh_flight_json_combo)
flight_layout.addRow("航线 JSON取最后一点为目的地:", self.flight_json_combo)
self.flight_takeoff_key_edit = QLineEdit()
self.flight_takeoff_key_edit.setPlaceholderText("如 space 或 空格")
self.flight_takeoff_key_edit.setMaxLength(16)
self.flight_takeoff_key_edit.setText(str(ff_cfg.get('takeoff_key', 'space')).strip() or 'space')
self.flight_takeoff_hold_spin = QDoubleSpinBox()
self.flight_takeoff_hold_spin.setRange(0.1, 120.0)
self.flight_takeoff_hold_spin.setSingleStep(0.1)
try:
self.flight_takeoff_hold_spin.setValue(float(ff_cfg.get('takeoff_hold_sec', 2.0)))
except (TypeError, ValueError):
self.flight_takeoff_hold_spin.setValue(2.0)
self.flight_takeoff_hold_spin.setSuffix("")
self.flight_land_key_edit = QLineEdit()
self.flight_land_key_edit.setPlaceholderText("如 p")
self.flight_land_key_edit.setMaxLength(16)
self.flight_land_key_edit.setText(str(ff_cfg.get('land_key', 'p')).strip() or 'p')
self.flight_land_hold_spin = QDoubleSpinBox()
self.flight_land_hold_spin.setRange(0.1, 120.0)
self.flight_land_hold_spin.setSingleStep(0.1)
try:
self.flight_land_hold_spin.setValue(float(ff_cfg.get('land_hold_sec', 2.0)))
except (TypeError, ValueError):
self.flight_land_hold_spin.setValue(2.0)
self.flight_land_hold_spin.setSuffix("")
flight_layout.addRow("起飞按键:", self.flight_takeoff_key_edit)
flight_layout.addRow("起飞按住时长:", self.flight_takeoff_hold_spin)
flight_layout.addRow("降落按键:", self.flight_land_key_edit)
flight_layout.addRow("降落按住时长:", self.flight_land_hold_spin)
flight_layout.addRow("", flight_refresh_btn)
self.flight_group.setVisible(False)
game_layout.addWidget(self.flight_group)
# 回城修理配置(在飞行模式之后新增一个独立模式)
self.repair_group = QGroupBox("回城修理配置")
repair_layout = QFormLayout(self.repair_group)
self.repair_vendor_combo = QComboBox()
self.repair_vendor_combo.setMinimumWidth(200)
self._refresh_repair_vendor_json_combo()
repair_refresh_btn = QPushButton("🔄 刷新列表")
repair_refresh_btn.clicked.connect(self._refresh_repair_vendor_json_combo)
repair_layout.addRow("修理商 JSON:", self.repair_vendor_combo)
repair_layout.addRow("", repair_refresh_btn)
self.repair_group.setVisible(False)
game_layout.addWidget(self.repair_group)
self.state_label = QLabel("状态: ---")
self.state_label.setStyleSheet("font-family: monospace; padding: 6px; background: #1e1e1e; color: #d4d4d4;")
self.state_label.setMinimumHeight(36)
game_layout.addWidget(self.state_label)
game_btn = QHBoxLayout()
self.game_start_btn = QPushButton("▶ 启动")
self.game_start_btn.clicked.connect(self.start_game_loop)
self.game_stop_btn = QPushButton("⏹ 停止")
self.game_stop_btn.clicked.connect(self.stop_game_loop)
self.game_stop_btn.setEnabled(False)
game_btn.addWidget(self.game_start_btn)
game_btn.addWidget(self.game_stop_btn)
game_btn.addStretch()
game_layout.addLayout(game_btn)
tabs.addTab(tab_game, "状态/巡逻/打怪")
# Tab3: 巡逻点录制
tab_record = QWidget()
record_layout = QVBoxLayout(tab_record)
record_name_group = QGroupBox("录制配置")
record_name_layout = QFormLayout(record_name_group)
self.record_name_edit = QLineEdit()
self.record_name_edit.setPlaceholderText(f"输入名称,保存到 {RECORDER_DIR}/ 文件夹(如 route1")
self.record_name_edit.setText("waypoints")
record_name_layout.addRow("录制名称:", self.record_name_edit)
self.record_min_distance_spin = QDoubleSpinBox()
self.record_min_distance_spin.setRange(0.1, 20.0)
self.record_min_distance_spin.setSingleStep(0.1)
self.record_min_distance_spin.setValue(0.5)
self.record_min_distance_spin.setSuffix("(游戏坐标)")
record_name_layout.addRow("最小记录间距:", self.record_min_distance_spin)
record_layout.addWidget(record_name_group)
self.record_state_label = QLabel("状态: ---")
self.record_state_label.setStyleSheet("font-family: monospace; padding: 6px; background: #1e1e1e; color: #d4d4d4;")
self.record_state_label.setMinimumHeight(36)
record_layout.addWidget(self.record_state_label)
record_btn = QHBoxLayout()
self.record_start_btn = QPushButton("▶ 开始录制")
self.record_start_btn.clicked.connect(self.start_record)
self.record_stop_btn = QPushButton("⏹ 停止录制")
self.record_stop_btn.clicked.connect(self.stop_record)
self.record_stop_btn.setEnabled(False)
self.record_save_btn = QPushButton("💾 保存路径")
self.record_save_btn.clicked.connect(self.save_recorder)
self.record_save_btn.setEnabled(False)
record_btn.addWidget(self.record_start_btn)
record_btn.addWidget(self.record_stop_btn)
record_btn.addWidget(self.record_save_btn)
record_btn.addStretch()
record_layout.addWidget(QLabel("在游戏内跑动,程序会自动记录坐标点。"))
record_layout.addLayout(record_btn)
tabs.addTab(tab_record, "巡逻点录制")
# Tab4: 巡逻点预览
tab_preview = QWidget()
preview_layout = QVBoxLayout(tab_preview)
preview_file_group = QGroupBox("巡逻点文件")
preview_file_layout = QFormLayout(preview_file_group)
self.preview_waypoints_combo = QComboBox()
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_layout.addWidget(preview_file_group)
self.preview_canvas = WaypointPreviewCanvas()
preview_layout.addWidget(self.preview_canvas, stretch=1)
preview_btn_row = QHBoxLayout()
preview_btn_row.addStretch()
self.preview_reset_btn = QPushButton("重置视图")
self.preview_reset_btn.clicked.connect(self._reset_preview_canvas)
preview_btn_row.addWidget(self.preview_reset_btn)
preview_layout.addLayout(preview_btn_row)
tabs.addTab(tab_preview, "巡逻点预览")
# Tab: 攻击循环(可自定义名称保存,巡逻/自动打怪时可选)
tab_combat_loops = QWidget()
loops_layout = QVBoxLayout(tab_combat_loops)
loops_top = QHBoxLayout()
self.combat_loops_list = QComboBox()
self.combat_loops_list.setMinimumWidth(220)
self.combat_loops_list.currentIndexChanged.connect(self._on_combat_loop_selected)
loops_btn = QPushButton("🔄 刷新")
loops_btn.clicked.connect(self._refresh_combat_loops_tab)
loops_new_btn = QPushButton("新建")
loops_new_btn.clicked.connect(self._combat_loop_new)
loops_del_btn = QPushButton("删除")
loops_del_btn.clicked.connect(self._combat_loop_delete)
loops_top.addWidget(QLabel("已保存:"))
loops_top.addWidget(self.combat_loops_list)
loops_top.addWidget(loops_btn)
loops_top.addWidget(loops_new_btn)
loops_top.addWidget(loops_del_btn)
loops_top.addStretch()
loops_layout.addLayout(loops_top)
loop_editor = QGroupBox("编辑攻击循环")
loop_editor_layout = QFormLayout(loop_editor)
self.combat_loop_name_edit = QLineEdit()
self.combat_loop_name_edit.setPlaceholderText("输入名称保存(如 默认循环)")
self.combat_loop_trigger = QDoubleSpinBox()
self.combat_loop_trigger.setRange(0.01, 1.0)
self.combat_loop_trigger.setSingleStep(0.1)
self.combat_loop_trigger.setValue(0.3)
self.combat_loop_trigger.setSuffix(" 触发概率")
self.combat_loop_hp_below = QSpinBox()
self.combat_loop_hp_below.setRange(0, 100)
self.combat_loop_hp_below.setValue(0)
self.combat_loop_hp_below.setSuffix(" %0=不启用)")
self.combat_loop_hp_key = QLineEdit()
self.combat_loop_hp_key.setPlaceholderText("如 5")
self.combat_loop_hp_key.setMaxLength(3)
self.combat_loop_mp_below = QSpinBox()
self.combat_loop_mp_below.setRange(0, 100)
self.combat_loop_mp_below.setValue(0)
self.combat_loop_mp_below.setSuffix(" %0=不启用)")
self.combat_loop_mp_key = QLineEdit()
self.combat_loop_mp_key.setPlaceholderText("如 6")
self.combat_loop_mp_key.setMaxLength(3)
self.combat_loop_steps = QTableWidget(0, 2)
self.combat_loop_steps.setHorizontalHeaderLabels(["按键", "延迟(秒)"])
self.combat_loop_steps.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.combat_loop_steps.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
loop_steps_btn = QHBoxLayout()
loop_add_row_btn = QPushButton("+ 添加一步")
loop_add_row_btn.clicked.connect(self._combat_loop_add_step)
loop_remove_row_btn = QPushButton("- 删除选中")
loop_remove_row_btn.clicked.connect(self._combat_loop_remove_step)
loop_steps_btn.addWidget(loop_add_row_btn)
loop_steps_btn.addWidget(loop_remove_row_btn)
loop_steps_btn.addStretch()
loop_editor_layout.addRow("名称:", self.combat_loop_name_edit)
loop_editor_layout.addRow("触发概率:", self.combat_loop_trigger)
loop_editor_layout.addRow("血量低于:", self.combat_loop_hp_below)
loop_editor_layout.addRow("血量按键:", self.combat_loop_hp_key)
loop_editor_layout.addRow("蓝量低于:", self.combat_loop_mp_below)
loop_editor_layout.addRow("蓝量按键:", self.combat_loop_mp_key)
loop_editor_layout.addRow("步骤:", self.combat_loop_steps)
loop_editor_layout.addRow("", loop_steps_btn)
loop_save_btn = QPushButton("💾 保存")
loop_save_btn.clicked.connect(self._combat_loop_save)
loop_editor_layout.addRow("", loop_save_btn)
loops_layout.addWidget(loop_editor)
tabs.addTab(tab_combat_loops, "攻击循环")
# Tab: 拾取录制 (新增加)
tab_loot_record = QWidget()
loot_record_layout = QVBoxLayout(tab_loot_record)
loot_record_group = QGroupBox("自定义拾取轨迹")
loot_record_inner = QVBoxLayout(loot_record_group)
loot_record_inner.addWidget(QLabel("说明:\n1. 点击下方按钮开始录制。\n2. 在游戏角色前方划出你想要的扫瞄路径。\n3. 再次点击停止录制,轨迹将自动保存。\n4. 挂机脚本将优先使用录制的路径进行拾取。"))
loot_record_btn_layout = QHBoxLayout()
self.loot_record_start_btn = QPushButton("🔴 开始录制拾取轨迹")
self.loot_record_start_btn.clicked.connect(self.start_loot_record)
self.loot_record_stop_btn = QPushButton("⏹ 停止录制")
self.loot_record_stop_btn.clicked.connect(self.stop_loot_record)
self.loot_record_stop_btn.setEnabled(False)
loot_record_btn_layout.addWidget(self.loot_record_start_btn)
loot_record_btn_layout.addWidget(self.loot_record_stop_btn)
loot_record_btn_layout.addStretch()
loot_record_inner.addLayout(loot_record_btn_layout)
loot_record_layout.addWidget(loot_record_group)
loot_record_layout.addStretch()
tabs.addTab(tab_loot_record, "拾取录制")
# Tab: 参数配置(上下两个分组)
tab_params = QWidget()
params_layout = QVBoxLayout(tab_params)
# 基础参数(截图/窗口配置)
basic_group = QGroupBox("基础参数")
basic_grid = QGridLayout(basic_group)
# 统一对齐配置
LABEL_WIDTH = 100
basic_grid.setColumnMinimumWidth(0, LABEL_WIDTH)
basic_grid.setColumnMinimumWidth(2, LABEL_WIDTH)
basic_grid.setColumnStretch(1, 1)
basic_grid.setColumnStretch(3, 1)
self.gs_pixel_size = QSpinBox()
self.gs_pixel_size.setRange(8, 32)
self.gs_pixel_size.setValue(17)
self.gs_block_start_x = QSpinBox()
self.gs_block_start_x.setRange(0, 100)
self.gs_block_start_x.setValue(30)
self.gs_scan_width = QSpinBox()
self.gs_scan_width.setRange(80, 300)
self.gs_scan_width.setValue(155)
self.gs_scan_height = QSpinBox()
self.gs_scan_height.setRange(10, 40)
self.gs_scan_height.setValue(15)
self.gs_offset_left = QSpinBox()
self.gs_offset_left.setRange(0, 100)
self.gs_offset_left.setValue(20)
self.gs_offset_top = QSpinBox()
self.gs_offset_top.setRange(0, 100)
self.gs_offset_top.setValue(45)
# 第一列
basic_grid.addWidget(QLabel("每格像素:"), 0, 0)
basic_grid.addWidget(self.gs_pixel_size, 0, 1)
basic_grid.addWidget(QLabel("起始 X:"), 1, 0)
basic_grid.addWidget(self.gs_block_start_x, 1, 1)
basic_grid.addWidget(QLabel("截图宽度:"), 2, 0)
basic_grid.addWidget(self.gs_scan_width, 2, 1)
# 第二列
basic_grid.addWidget(QLabel("截图高度:"), 0, 2)
basic_grid.addWidget(self.gs_scan_height, 0, 3)
basic_grid.addWidget(QLabel("窗口左偏移:"), 1, 2)
basic_grid.addWidget(self.gs_offset_left, 1, 3)
basic_grid.addWidget(QLabel("窗口顶偏移:"), 2, 2)
basic_grid.addWidget(self.gs_offset_top, 2, 3)
params_layout.addWidget(basic_group)
# 硬件参数
hardware_group = QGroupBox("硬件参数")
hardware_layout = QHBoxLayout(hardware_group)
self.gs_use_tianya_box = QCheckBox("使用天涯盒子")
self.gs_use_tianya_box.setChecked(True)
self.gs_use_ghost_box = QCheckBox("使用幽灵盒子")
self.gs_use_ghost_box.setChecked(False)
hardware_layout.addWidget(self.gs_use_tianya_box)
hardware_layout.addWidget(self.gs_use_ghost_box)
hardware_layout.addStretch()
params_layout.addWidget(hardware_group)
# 游戏参数
game_group = QGroupBox("游戏参数")
game_grid = QGridLayout(game_group)
# 统一对齐配置
game_grid.setColumnMinimumWidth(0, LABEL_WIDTH)
game_grid.setColumnMinimumWidth(2, LABEL_WIDTH)
game_grid.setColumnStretch(1, 1)
game_grid.setColumnStretch(3, 1)
self.skinning_wait_spin = QDoubleSpinBox()
self.skinning_wait_spin.setRange(0.1, 10.0)
self.skinning_wait_spin.setSingleStep(0.1)
self.skinning_wait_spin.setValue(1.5)
self.skinning_wait_spin.setSuffix("")
self.food_key_edit = QLineEdit()
self.food_key_edit.setPlaceholderText("如 f1")
self.food_key_edit.setMaxLength(16)
self.food_key_edit.setText("f1")
self.eat_hp_threshold_spin = QSpinBox()
self.eat_hp_threshold_spin.setRange(1, 100)
self.eat_hp_threshold_spin.setValue(30)
self.eat_hp_threshold_spin.setSuffix(" %")
self.eat_max_wait_spin = QDoubleSpinBox()
self.eat_max_wait_spin.setRange(1.0, 120.0)
self.eat_max_wait_spin.setSingleStep(1.0)
self.eat_max_wait_spin.setValue(30.0)
self.eat_max_wait_spin.setSuffix("")
self.turn_error_key_edit = QLineEdit()
self.turn_error_key_edit.setPlaceholderText("如 s")
self.turn_error_key_edit.setMaxLength(16)
self.turn_error_key_edit.setText("s")
self.turn_error_hold_spin = QDoubleSpinBox()
self.turn_error_hold_spin.setRange(0.1, 10.0)
self.turn_error_hold_spin.setSingleStep(0.1)
self.turn_error_hold_spin.setValue(0.8)
self.turn_error_hold_spin.setSuffix("")
self.distance_interact_pause_spin = QDoubleSpinBox()
self.distance_interact_pause_spin.setRange(0.1, 10.0)
self.distance_interact_pause_spin.setSingleStep(0.1)
self.distance_interact_pause_spin.setValue(1.0)
self.distance_interact_pause_spin.setSuffix("")
self.gs_hearthstone_key = QLineEdit()
self.gs_hearthstone_key.setPlaceholderText("如 b")
self.gs_hearthstone_key.setMaxLength(16)
self.gs_hearthstone_key.setText("b")
self.gs_bag_full_hearthstone = QCheckBox("包满时用炉石回城并停止")
self.gs_bag_full_hearthstone.setChecked(False)
self.gs_enable_bag_full_mail = QCheckBox("包满炉石后跑邮箱并邮寄")
self.gs_enable_bag_full_mail.setChecked(False)
self.gs_mailbox_interact_key = QLineEdit()
self.gs_mailbox_interact_key.setPlaceholderText("如 8")
self.gs_mailbox_interact_key.setMaxLength(16)
self.gs_mailbox_interact_key.setText("8")
self.gs_mail_recipient_key = QLineEdit()
self.gs_mail_recipient_key.setPlaceholderText("如 f7")
self.gs_mail_recipient_key.setMaxLength(16)
self.gs_mail_recipient_key.setText("")
self.gs_mail_send_key = QLineEdit()
self.gs_mail_send_key.setPlaceholderText("如 f8")
self.gs_mail_send_key.setMaxLength(16)
self.gs_mail_send_key.setText("f8")
self.gs_mailbox_open_wait = QDoubleSpinBox()
self.gs_mailbox_open_wait.setRange(0.1, 30.0)
self.gs_mailbox_open_wait.setSingleStep(0.1)
self.gs_mailbox_open_wait.setValue(2.0)
self.gs_mailbox_open_wait.setSuffix("")
self.gs_mail_send_wait = QDoubleSpinBox()
self.gs_mail_send_wait.setRange(0.0, 600.0)
self.gs_mail_send_wait.setSingleStep(1.0)
self.gs_mail_send_wait.setValue(60.0)
self.gs_mail_send_wait.setSuffix("")
self.gs_logistics_full_auto = QCheckBox("后勤完成后继续自动巡逻")
self.gs_logistics_full_auto.setChecked(False)
self.gs_bag_slot_threshold = QSpinBox()
self.gs_bag_slot_threshold.setRange(0, 100)
self.gs_bag_slot_threshold.setValue(2)
self.gs_bag_slot_threshold.setSuffix("")
self.gs_durability_threshold = QSpinBox()
self.gs_durability_threshold.setRange(1, 100)
self.gs_durability_threshold.setValue(20)
self.gs_durability_threshold.setSuffix(" %")
self.gs_hearthstone_wait = QDoubleSpinBox()
self.gs_hearthstone_wait.setRange(1.0, 60.0)
self.gs_hearthstone_wait.setSingleStep(0.5)
self.gs_hearthstone_wait.setValue(12.0)
self.gs_hearthstone_wait.setSuffix("")
self.gs_repair_target_key = QLineEdit()
self.gs_repair_target_key.setPlaceholderText("如 8")
self.gs_repair_target_key.setMaxLength(16)
self.gs_repair_target_key.setText("8")
self.gs_repair_interact_key = QLineEdit()
self.gs_repair_interact_key.setPlaceholderText("如 4")
self.gs_repair_interact_key.setMaxLength(16)
self.gs_repair_interact_key.setText("4")
self.gs_enable_mount = QCheckBox("启用上马")
self.gs_enable_mount.setChecked(True)
self.gs_mount_key = QLineEdit()
self.gs_mount_key.setPlaceholderText("如 x")
self.gs_mount_key.setMaxLength(16)
self.gs_mount_key.setText("x")
self.gs_mount_hold = QDoubleSpinBox()
self.gs_mount_hold.setRange(0.1, 30.0)
self.gs_mount_hold.setSingleStep(0.1)
self.gs_mount_hold.setValue(1.6)
self.gs_mount_hold.setSuffix("")
self.gs_mount_retry = QDoubleSpinBox()
self.gs_mount_retry.setRange(0.0, 60.0)
self.gs_mount_retry.setSingleStep(0.1)
self.gs_mount_retry.setValue(2.0)
self.gs_mount_retry.setSuffix("")
self.gs_release_spirit_key = QLineEdit()
self.gs_release_spirit_key.setPlaceholderText("如 9")
self.gs_release_spirit_key.setMaxLength(16)
self.gs_release_spirit_key.setText("9")
self.gs_resurrect_key = QLineEdit()
self.gs_resurrect_key.setPlaceholderText("如 0")
self.gs_resurrect_key.setMaxLength(16)
self.gs_resurrect_key.setText("0")
self.gs_enable_mouse_loot = QCheckBox("启用扫雷拾取")
self.gs_enable_mouse_loot.setChecked(True)
# 网格填充
game_grid.addWidget(QLabel("剥皮等待时间:"), 0, 0)
game_grid.addWidget(self.skinning_wait_spin, 0, 1)
game_grid.addWidget(self.gs_enable_mouse_loot, 0, 2)
game_grid.addWidget(self.gs_enable_mount, 0, 3)
game_grid.addWidget(QLabel("吃面包按键:"), 1, 0)
game_grid.addWidget(self.food_key_edit, 1, 1)
game_grid.addWidget(QLabel("上马按键:"), 1, 2)
game_grid.addWidget(self.gs_mount_key, 1, 3)
game_grid.addWidget(QLabel("吃面包血量阈值:"), 2, 0)
game_grid.addWidget(self.eat_hp_threshold_spin, 2, 1)
game_grid.addWidget(QLabel("上马按住时长:"), 2, 2)
game_grid.addWidget(self.gs_mount_hold, 2, 3)
game_grid.addWidget(QLabel("吃面包最长等待:"), 3, 0)
game_grid.addWidget(self.eat_max_wait_spin, 3, 1)
game_grid.addWidget(QLabel("上马重试间隔:"), 3, 2)
game_grid.addWidget(self.gs_mount_retry, 3, 3)
game_grid.addWidget(QLabel("炉石按键:"), 4, 0)
game_grid.addWidget(self.gs_hearthstone_key, 4, 1)
game_grid.addWidget(QLabel("炉石等待:"), 4, 2)
game_grid.addWidget(self.gs_hearthstone_wait, 4, 3)
game_grid.addWidget(QLabel("释放灵魂按键:"), 5, 0)
game_grid.addWidget(self.gs_release_spirit_key, 5, 1)
game_grid.addWidget(QLabel("复活按键:"), 5, 2)
game_grid.addWidget(self.gs_resurrect_key, 5, 3)
game_grid.addWidget(QLabel("需转身按键:"), 6, 0)
game_grid.addWidget(self.turn_error_key_edit, 6, 1)
game_grid.addWidget(QLabel("需转身按住时长:"), 6, 2)
game_grid.addWidget(self.turn_error_hold_spin, 6, 3)
game_grid.addWidget(QLabel("距离远暂停技能时长:"), 7, 0)
game_grid.addWidget(self.distance_interact_pause_spin, 7, 1)
game_grid.addWidget(self.gs_bag_full_hearthstone, 8, 0, 1, 2)
game_grid.addWidget(self.gs_enable_bag_full_mail, 8, 2, 1, 2)
game_grid.addWidget(QLabel("邮箱交互键:"), 9, 0)
game_grid.addWidget(self.gs_mailbox_interact_key, 9, 1)
game_grid.addWidget(QLabel("收信人按键:"), 9, 2)
game_grid.addWidget(self.gs_mail_recipient_key, 9, 3)
game_grid.addWidget(QLabel("邮寄宏按键:"), 10, 0)
game_grid.addWidget(self.gs_mail_send_key, 10, 1)
game_grid.addWidget(QLabel("邮箱打开等待:"), 10, 2)
game_grid.addWidget(self.gs_mailbox_open_wait, 10, 3)
game_grid.addWidget(QLabel("邮寄后等待:"), 11, 0)
game_grid.addWidget(self.gs_mail_send_wait, 11, 1)
game_grid.addWidget(self.gs_logistics_full_auto, 11, 2, 1, 2)
game_grid.addWidget(QLabel("包格触发阈值:"), 12, 0)
game_grid.addWidget(self.gs_bag_slot_threshold, 12, 1)
game_grid.addWidget(QLabel("耐久触发阈值:"), 12, 2)
game_grid.addWidget(self.gs_durability_threshold, 12, 3)
game_grid.addWidget(QLabel("修理目标键:"), 13, 0)
game_grid.addWidget(self.gs_repair_target_key, 13, 1)
game_grid.addWidget(QLabel("修理交互键:"), 13, 2)
game_grid.addWidget(self.gs_repair_interact_key, 13, 3)
params_layout.addWidget(game_group)
params_layout.addStretch()
params_save_btn = QPushButton("💾 保存配置")
params_save_btn.clicked.connect(self._save_params_config)
params_layout.addWidget(params_save_btn)
tabs.addTab(tab_params, "参数配置")
tabs.currentChanged.connect(self._on_tab_changed)
layout.addWidget(tabs)
# 日志
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setMaximumHeight(140)
layout.addWidget(self.log_text)
self.status_bar = self.statusBar()
self.status_bar.showMessage("就绪")
self._current_game_mode = None
def _load_params_config(self):
"""从 game_state_config.json + wow_multikey_qt.json 加载参数到「参数配置」界面"""
try:
from game_state import load_layout_config
cfg = load_layout_config()
self.gs_pixel_size.setValue(cfg.get('pixel_size', 17))
self.gs_block_start_x.setValue(cfg.get('block_start_x', 30))
self.gs_scan_width.setValue(cfg.get('scan_region_width', 155))
self.gs_scan_height.setValue(cfg.get('scan_region_height', 15))
self.gs_offset_left.setValue(cfg.get('offset_left', 20))
self.gs_offset_top.setValue(cfg.get('offset_top', 45))
self.gs_enable_mount.setChecked(bool(cfg.get('enable_mount', True)))
self.gs_mount_key.setText(str(cfg.get('mount_key', 'x') or 'x'))
self.gs_mount_hold.setValue(float(cfg.get('mount_hold_sec', 1.6)))
self.gs_hearthstone_key.setText(str(cfg.get('hearthstone_key', 'b') or 'b'))
self.gs_bag_full_hearthstone.setChecked(bool(cfg.get('bag_full_hearthstone', False)))
self.gs_enable_bag_full_mail.setChecked(bool(cfg.get('enable_bag_full_mail', False)))
self.gs_mailbox_interact_key.setText(str(cfg.get('mailbox_interact_key', '8') or '8'))
self.gs_mail_recipient_key.setText(str(cfg.get('mail_recipient_key', '') or ''))
self.gs_mail_send_key.setText(str(cfg.get('mail_send_key', 'f8') or 'f8'))
self.gs_mailbox_open_wait.setValue(float(cfg.get('mailbox_open_wait_sec', 2.0)))
self.gs_mail_send_wait.setValue(float(cfg.get('mail_send_wait_sec', 60.0)))
self.gs_logistics_full_auto.setChecked(bool(cfg.get('logistics_full_auto', False)))
self.gs_bag_slot_threshold.setValue(int(cfg.get('bag_slot_threshold', 2)))
durability_threshold = float(cfg.get('durability_threshold', 0.2))
if durability_threshold <= 1.0:
durability_threshold *= 100.0
self.gs_durability_threshold.setValue(int(round(durability_threshold)))
self.gs_hearthstone_wait.setValue(float(cfg.get('hearthstone_wait_sec', 12.0)))
self.gs_repair_target_key.setText(str(cfg.get('repair_target_key', '8') or '8'))
self.gs_repair_interact_key.setText(str(cfg.get('repair_interact_key', '4') or '4'))
self.gs_mount_retry.setValue(float(cfg.get('mount_retry_after_sec', 2.0)))
self.gs_release_spirit_key.setText(str(cfg.get('release_spirit_key', '9') or '9'))
self.gs_resurrect_key.setText(str(cfg.get('resurrect_key', '0') or '0'))
except Exception as e:
self.log(f"加载参数配置失败: {e}")
try:
bot_cfg = (self.config or {}).get('bot') or {}
v = bot_cfg.get('skinning_wait_sec', 1.5)
self.skinning_wait_spin.setValue(float(v))
self.food_key_edit.setText(str(bot_cfg.get('food_key', 'f1')).strip() or 'f1')
self.eat_hp_threshold_spin.setValue(int(bot_cfg.get('eat_hp_threshold', 30)))
self.eat_max_wait_spin.setValue(float(bot_cfg.get('eat_max_wait_sec', 30.0)))
self.turn_error_key_edit.setText(str(bot_cfg.get('turn_error_key', 's')).strip() or 's')
self.turn_error_hold_spin.setValue(float(bot_cfg.get('turn_error_hold_sec', 0.8)))
self.distance_interact_pause_spin.setValue(float(bot_cfg.get('distance_interact_pause_sec', 1.0)))
self.gs_enable_mouse_loot.setChecked(bool(bot_cfg.get('enable_mouse_loot', True)))
has_box_flags = ('use_tianya_box' in bot_cfg) or ('use_ghost_box' in bot_cfg)
if has_box_flags:
use_tianya_box = bool(bot_cfg.get('use_tianya_box', False))
use_ghost_box = bool(bot_cfg.get('use_ghost_box', False))
else:
use_tianya_box = bool(bot_cfg.get('use_hardware_input', True))
use_ghost_box = False
self.gs_use_tianya_box.setChecked(use_tianya_box)
self.gs_use_ghost_box.setChecked(use_ghost_box)
except Exception:
self.skinning_wait_spin.setValue(1.5)
self.food_key_edit.setText('f1')
self.eat_hp_threshold_spin.setValue(30)
self.eat_max_wait_spin.setValue(30.0)
self.turn_error_key_edit.setText('s')
self.turn_error_hold_spin.setValue(0.8)
self.distance_interact_pause_spin.setValue(1.0)
self.gs_enable_mouse_loot.setChecked(True)
self.gs_use_tianya_box.setChecked(True)
self.gs_use_ghost_box.setChecked(False)
def _save_params_config(self):
"""保存「参数配置」界面到 game_state_config.json多分辨率并写入 wow_multikey_qt.jsonbot 参数)"""
try:
from game_state import load_layout_config, save_layout_config
cfg = load_layout_config()
cfg['pixel_size'] = self.gs_pixel_size.value()
cfg['block_start_x'] = self.gs_block_start_x.value()
cfg['scan_region_width'] = self.gs_scan_width.value()
cfg['scan_region_height'] = self.gs_scan_height.value()
cfg['offset_left'] = self.gs_offset_left.value()
cfg['offset_top'] = self.gs_offset_top.value()
cfg['enable_mount'] = self.gs_enable_mount.isChecked()
cfg['mount_key'] = (self.gs_mount_key.text().strip() or 'x')
cfg['mount_hold_sec'] = float(self.gs_mount_hold.value())
cfg['mount_retry_after_sec'] = float(self.gs_mount_retry.value())
cfg['hearthstone_key'] = (self.gs_hearthstone_key.text().strip() or 'b')
cfg['bag_full_hearthstone'] = self.gs_bag_full_hearthstone.isChecked()
cfg['enable_bag_full_mail'] = self.gs_enable_bag_full_mail.isChecked()
cfg['mailbox_interact_key'] = self.gs_mailbox_interact_key.text().strip() or '8'
cfg['mail_recipient_key'] = self.gs_mail_recipient_key.text().strip()
cfg['mail_send_key'] = self.gs_mail_send_key.text().strip() or 'f8'
cfg['mailbox_open_wait_sec'] = float(self.gs_mailbox_open_wait.value())
cfg['mail_send_wait_sec'] = float(self.gs_mail_send_wait.value())
cfg['logistics_full_auto'] = self.gs_logistics_full_auto.isChecked()
cfg['bag_slot_threshold'] = int(self.gs_bag_slot_threshold.value())
cfg['durability_threshold'] = float(self.gs_durability_threshold.value()) / 100.0
cfg['hearthstone_wait_sec'] = float(self.gs_hearthstone_wait.value())
cfg['repair_target_key'] = self.gs_repair_target_key.text().strip() or '8'
cfg['repair_interact_key'] = self.gs_repair_interact_key.text().strip() or '4'
cfg['release_spirit_key'] = (self.gs_release_spirit_key.text().strip() or '9')
cfg['resurrect_key'] = (self.gs_resurrect_key.text().strip() or '0')
path = save_layout_config(cfg)
# bot 参数写入主配置文件
self.config = self.config or {}
self.config.setdefault('bot', {})
self.config['bot']['skinning_wait_sec'] = float(self.skinning_wait_spin.value())
self.config['bot']['food_key'] = self.food_key_edit.text().strip() or 'f1'
self.config['bot']['eat_hp_threshold'] = int(self.eat_hp_threshold_spin.value())
self.config['bot']['eat_max_wait_sec'] = float(self.eat_max_wait_spin.value())
self.config['bot']['turn_error_key'] = self.turn_error_key_edit.text().strip() or 's'
self.config['bot']['turn_error_hold_sec'] = float(self.turn_error_hold_spin.value())
self.config['bot']['distance_interact_pause_sec'] = float(self.distance_interact_pause_spin.value())
self.config['bot']['enable_mouse_loot'] = self.gs_enable_mouse_loot.isChecked()
use_tianya_box = self.gs_use_tianya_box.isChecked()
use_ghost_box = self.gs_use_ghost_box.isChecked()
self.config['bot']['use_tianya_box'] = use_tianya_box
self.config['bot']['use_ghost_box'] = use_ghost_box
self.config['bot']['use_hardware_input'] = use_tianya_box or use_ghost_box
self._save_main_config()
from hardware_control import hw_ctrl
hw_ctrl.configure(use_tianya_box=use_tianya_box, use_ghost_box=use_ghost_box)
self.log(f"✅ 参数配置已保存至 {path},并更新 bot 参数")
QMessageBox.information(self, "保存成功", f"参数配置已保存至:\n{path}\n\nBot 参数已写入:\n{self.config_path}")
except Exception as e:
self.log(f"❌ 保存参数配置失败: {e}")
QMessageBox.warning(self, "错误", f"保存失败: {e}")
def _refresh_attack_loop_combo(self):
"""填充巡逻打怪、自动打怪的攻击循环下拉"""
for combo in (self.patrol_attack_loop_combo, self.combat_attack_loop_combo):
combo.clear()
combo.addItem("-- 默认 --", "")
for name, path in list_combat_loop_files():
combo.addItem(name, path)
def _refresh_combat_loops_tab(self):
"""刷新攻击循环 Tab 的已保存列表"""
self.combat_loops_list.blockSignals(True)
self.combat_loops_list.clear()
self.combat_loops_list.addItem("-- 选择或新建 --", "")
for name, path in list_combat_loop_files():
self.combat_loops_list.addItem(name, path)
self.combat_loops_list.blockSignals(False)
if self.combat_loops_list.count() > 1:
self.combat_loops_list.setCurrentIndex(1)
else:
self.combat_loops_list.setCurrentIndex(0)
def _on_combat_loop_selected(self, index):
"""选择已保存的攻击循环时加载到编辑器"""
path = self.combat_loops_list.currentData()
if not path or not os.path.exists(path):
return
try:
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
name = cfg.get('name', '')
if not name and path:
name = os.path.splitext(os.path.basename(path))[0]
self.combat_loop_name_edit.setText(name)
self.combat_loop_trigger.setValue(float(cfg.get('trigger_chance', 0.3)))
self.combat_loop_hp_below.setValue(int(cfg.get('hp_below_percent', 0)) or 0)
self.combat_loop_hp_key.setText(str(cfg.get('hp_key', '')).strip())
self.combat_loop_mp_below.setValue(int(cfg.get('mp_below_percent', 0)) or 0)
self.combat_loop_mp_key.setText(str(cfg.get('mp_key', '')).strip())
steps = cfg.get('steps') or []
self.combat_loop_steps.setRowCount(len(steps))
for i, s in enumerate(steps):
self.combat_loop_steps.setItem(i, 0, QTableWidgetItem(str(s.get('key', ''))))
self.combat_loop_steps.setItem(i, 1, QTableWidgetItem(str(s.get('delay', 0.5))))
except Exception as e:
self.log(f"加载攻击循环失败: {e}")
def _combat_loop_new(self):
"""新建攻击循环(清空编辑器)"""
self.combat_loops_list.setCurrentIndex(0)
self.combat_loop_name_edit.clear()
self.combat_loop_trigger.setValue(0.3)
self.combat_loop_hp_below.setValue(0)
self.combat_loop_hp_key.clear()
self.combat_loop_mp_below.setValue(0)
self.combat_loop_mp_key.clear()
self.combat_loop_steps.setRowCount(0)
def _combat_loop_add_step(self):
"""添加一步"""
row = self.combat_loop_steps.rowCount()
self.combat_loop_steps.insertRow(row)
self.combat_loop_steps.setItem(row, 0, QTableWidgetItem("2"))
self.combat_loop_steps.setItem(row, 1, QTableWidgetItem("0.5"))
def _combat_loop_remove_step(self):
"""删除选中的行"""
rows = set(i.row() for i in self.combat_loop_steps.selectedIndexes())
for r in sorted(rows, reverse=True):
self.combat_loop_steps.removeRow(r)
def _combat_loop_save(self):
"""保存当前编辑的攻击循环到 combat_loops/<名称>.json"""
name = self.combat_loop_name_edit.text().strip()
if not name:
QMessageBox.warning(self, "提示", "请输入名称")
return
if not name.endswith('.json'):
name += '.json'
steps = []
for row in range(self.combat_loop_steps.rowCount()):
k = self.combat_loop_steps.item(row, 0)
d = self.combat_loop_steps.item(row, 1)
key = (k.text() if k else '').strip() or '2'
try:
delay = float(d.text() if d else 0.5)
except Exception:
delay = 0.5
steps.append({'key': key, 'delay': delay})
hp_below = self.combat_loop_hp_below.value()
hp_key = self.combat_loop_hp_key.text().strip()
mp_below = self.combat_loop_mp_below.value()
mp_key = self.combat_loop_mp_key.text().strip()
cfg = {
'name': os.path.splitext(name)[0],
'trigger_chance': self.combat_loop_trigger.value(),
'steps': steps,
'hp_below_percent': hp_below if hp_below and hp_key else 0,
'hp_key': hp_key if hp_below and hp_key else '',
'mp_below_percent': mp_below if mp_below and mp_key else 0,
'mp_key': mp_key if mp_below and mp_key else '',
}
path = os.path.join(get_combat_loops_dir(), name)
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
self.log(f"✅ 攻击循环已保存: {path}")
QMessageBox.information(self, "保存成功", f"已保存至 {path}")
self._refresh_combat_loops_tab()
self._refresh_attack_loop_combo()
except Exception as e:
self.log(f"❌ 保存攻击循环失败: {e}")
QMessageBox.warning(self, "错误", f"保存失败: {e}")
def _combat_loop_delete(self):
"""删除当前选中的攻击循环文件"""
path = self.combat_loops_list.currentData()
if not path or not os.path.exists(path):
QMessageBox.warning(self, "提示", "请先选择要删除的项")
return
if QMessageBox.question(self, "确认", f"确定删除 {os.path.basename(path)}") != QMessageBox.StandardButton.Yes:
return
try:
os.remove(path)
self.log(f"已删除: {path}")
self._combat_loop_new()
self._refresh_combat_loops_tab()
self._refresh_attack_loop_combo()
except Exception as e:
QMessageBox.warning(self, "错误", f"删除失败: {e}")
def _on_tab_changed(self, index):
"""切换 Tab 时加载参数或刷新下拉列表"""
if index == 1: # 状态/巡逻/打怪
self._refresh_recorder_combos()
self._refresh_flight_json_combo()
elif index == 3: # 巡逻点预览
self._refresh_preview_waypoints()
elif index == 4: # 攻击循环
self._refresh_combat_loops_tab()
elif index == 5: # 参数配置
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_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 时加载并绘制所有巡逻路线。"""
path = self.preview_waypoints_combo.currentData()
if not path or not os.path.exists(path):
self.preview_canvas.set_routes([])
return
try:
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_routes([])
def _reset_preview_canvas(self):
"""重置巡逻点预览视图(缩放 + 平移)。"""
if hasattr(self, "preview_canvas") and self.preview_canvas is not None:
self.preview_canvas.reset_view()
def _refresh_recorder_combos(self):
"""刷新路线方案和其他 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"):
self._refresh_repair_vendor_json_combo()
def _refresh_repair_vendor_json_combo(self):
"""刷新“回城修理配置”的修理商下拉框。"""
if not hasattr(self, "repair_vendor_combo"):
return
items = list_recorder_json()
self.repair_vendor_combo.blockSignals(True)
self.repair_vendor_combo.clear()
self.repair_vendor_combo.addItem("-- 请选择 --", "")
for name, path in items:
self.repair_vendor_combo.addItem(name, path)
self.repair_vendor_combo.blockSignals(False)
if self.repair_vendor_combo.count() > 1:
idx = self.repair_vendor_combo.findData(os.path.join(get_recorder_dir(), 'vendor.json'))
if idx >= 0:
self.repair_vendor_combo.setCurrentIndex(idx)
else:
self.repair_vendor_combo.setCurrentIndex(1)
def _refresh_flight_json_combo(self):
"""刷新飞行模式航线 JSON 下拉列表"""
items = list_recorder_json()
self.flight_json_combo.blockSignals(True)
self.flight_json_combo.clear()
self.flight_json_combo.addItem("-- 请选择 --", "")
for name, path in items:
self.flight_json_combo.addItem(name, path)
self.flight_json_combo.blockSignals(False)
if self.flight_json_combo.count() > 1:
self.flight_json_combo.setCurrentIndex(1)
def set_game_mode(self, mode):
if self.game_worker and self.game_worker.isRunning():
self.log("请先停止当前模式再切换")
return
for mid, btn in self.mode_btns.items():
btn.setChecked(mid == mode)
self._current_game_mode = mode
if mode == 'patrol':
self._refresh_recorder_combos()
if mode == 'patrol':
self._refresh_attack_loop_combo()
if mode == 'combat':
self._refresh_attack_loop_combo()
self.patrol_group.setVisible(mode == 'patrol')
self.combat_group.setVisible(mode == 'combat')
self.quest_follow_group.setVisible(mode == 'quest_follow')
self.flight_group.setVisible(mode == 'flight')
self.repair_group.setVisible(mode == 'return_repair')
if mode == 'flight':
self._refresh_flight_json_combo()
if mode == 'return_repair':
self._refresh_repair_vendor_json_combo()
def load_config(self):
if os.path.exists(self.config_path):
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 兼容旧格式:直接是按键配置 dict没有 keys/bot 包裹)
if isinstance(data, dict) and ('keys' not in data) and any(k in data for k in ['1', '2', '3', '4', '5', '空格(跳跃)']):
return {'keys': data, 'bot': {}}
if isinstance(data, dict):
data.setdefault('keys', {})
data.setdefault('bot', {})
return data
return {}
except Exception:
pass
return {'keys': {}, 'bot': {}}
def save_key_config(self):
self.config = self.config or {'keys': {}, 'bot': {}}
self.config['keys'] = {k: w.get_config() for k, w in self.key_widgets.items()}
self._save_main_config()
def _save_main_config(self):
"""保存 wow_multikey_qt.json多键 + bot 参数)"""
out = self.config or {'keys': {}, 'bot': {}}
out.setdefault('keys', {})
out.setdefault('bot', {})
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(out, f, indent=2, ensure_ascii=False)
def find_wow_window(self):
titles = ["魔兽世界", "World of Warcraft"]
for title in titles:
self.hwnd = win32gui.FindWindow(None, title)
if self.hwnd:
self.window_label.setText(f"✅ 已找到: {title}")
self.log(f"成功绑定窗口: {title}")
return
self.window_label.setText("❌ 未找到游戏窗口")
self.hwnd = None
self.log("未找到游戏窗口,请先启动魔兽世界")
def log(self, message):
self.log_text.append(f"[{time.strftime('%H:%M:%S')}] {message}")
def start_keys(self):
if not self.hwnd:
QMessageBox.warning(self, "警告", "未找到游戏窗口!请先启动魔兽世界。")
return
any_enabled = any(w.get_config()['enabled'] for w in self.key_widgets.values())
if not any_enabled:
QMessageBox.warning(self, "警告", "请至少启用一个按键!")
return
self.save_key_config()
for key_name, widget in self.key_widgets.items():
cfg = widget.get_config()
if cfg['enabled']:
worker = KeyWorker(self.hwnd, key_name, cfg)
worker.log_signal.connect(self.log)
worker.start()
self.key_workers[key_name] = worker
self.keys_start_btn.setEnabled(False)
self.keys_stop_btn.setEnabled(True)
self.status_bar.showMessage("🟢 多键运行中")
self.log("🎮 多键控制已启动")
def stop_keys(self):
for w in self.key_workers.values():
w.running = False
self.key_workers.clear()
self.keys_start_btn.setEnabled(True)
self.keys_stop_btn.setEnabled(False)
self.status_bar.showMessage("⏹️ 多键已停止")
self.log("🛑 多键控制已停止")
def start_game_loop(self):
mode = self._current_game_mode
if mode is None:
QMessageBox.warning(self, "提示", "请先选择模式(状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 飞行模式 / 回城修理)")
return
route_profile_path = None
waypoints_path = None
prepare_route_path = None
vendor_path = None
mailbox_route_path = None
flight_json_path = None
resurrection_route_a_path = None
resurrection_route_b_path = None
skip_prepare_route = False
if mode == 'patrol':
profile_path = self.route_profile_combo.currentData() or ""
if not profile_path:
QMessageBox.warning(self, "提示", "巡逻打怪模式需选择路线方案 JSON 文件")
return
if not os.path.exists(profile_path):
QMessageBox.warning(self, "提示", f"路线方案文件不存在: {profile_path}")
return
try:
profile = load_route_profile(profile_path)
except Exception as e:
QMessageBox.warning(self, "提示", f"路线方案 JSON 无效: {e}")
return
if not profile.get("vendor_route"):
QMessageBox.warning(self, "提示", "路线方案需包含 vendor_route 修理商路线")
return
if self.gs_enable_bag_full_mail.isChecked() and not profile.get("mailbox_route"):
QMessageBox.warning(self, "提示", "已启用包满邮寄,路线方案需包含 mailbox_route 邮箱路线")
return
route_profile_path = profile_path
skip_prepare_route = self.skip_prepare_route_check.isChecked()
attack_loop_path = None
if mode == 'patrol':
attack_loop_path = self.patrol_attack_loop_combo.currentData() or None
elif mode == 'combat':
attack_loop_path = self.combat_attack_loop_combo.currentData() or None
if attack_loop_path and not os.path.exists(attack_loop_path):
attack_loop_path = None
if mode == 'quest_follow':
self.config = self.config or {}
self.config.setdefault('bot', {})
self.config['bot']['quest_follow'] = {
'follow_key': self.quest_follow_follow_edit.text().strip() or 'f',
'interact_key': self.quest_follow_interact_edit.text().strip() or '4',
'interval_sec': float(self.quest_follow_interval_spin.value()),
}
self._save_main_config()
if mode == 'return_repair':
vp = self.repair_vendor_combo.currentData() or ""
if not vp:
QMessageBox.warning(self, "提示", "回城修理模式需选择修理商 JSON 文件")
return
if not os.path.exists(vp):
QMessageBox.warning(self, "提示", f"修理商文件不存在: {vp}")
return
vendor_path = vp
if mode == 'flight':
fp = self.flight_json_combo.currentData() or ""
if not fp:
QMessageBox.warning(self, "提示", "飞行模式需选择航线 JSON 文件")
return
if not os.path.exists(fp):
QMessageBox.warning(self, "提示", f"航线文件不存在: {fp}")
return
flight_json_path = fp
self.config = self.config or {}
self.config.setdefault('bot', {})
self.config['bot']['flight'] = {
'takeoff_key': self.flight_takeoff_key_edit.text().strip() or 'space',
'takeoff_hold_sec': float(self.flight_takeoff_hold_spin.value()),
'land_key': self.flight_land_key_edit.text().strip() or 'p',
'land_hold_sec': float(self.flight_land_hold_spin.value()),
}
self._save_main_config()
skinning_wait_sec = None
try:
skinning_wait_sec = float(((self.config or {}).get('bot') or {}).get('skinning_wait_sec', 1.5))
except Exception:
skinning_wait_sec = 1.5
try:
food_key = str(((self.config or {}).get('bot') or {}).get('food_key', 'f1')).strip() or 'f1'
except Exception:
food_key = 'f1'
try:
eat_hp_threshold = int(((self.config or {}).get('bot') or {}).get('eat_hp_threshold', 30))
except Exception:
eat_hp_threshold = 30
try:
eat_max_wait_sec = float(((self.config or {}).get('bot') or {}).get('eat_max_wait_sec', 30.0))
except Exception:
eat_max_wait_sec = 30.0
turn_error_key = self.turn_error_key_edit.text().strip() or 's'
turn_error_hold_sec = float(self.turn_error_hold_spin.value())
distance_interact_pause_sec = float(self.distance_interact_pause_spin.value())
# 从 layout_config 读取死亡/复活按键
try:
from game_state import load_layout_config
layout_cfg = load_layout_config()
release_spirit_key = str(layout_cfg.get('release_spirit_key', '9') or '9')
resurrect_key = str(layout_cfg.get('resurrect_key', '0') or '0')
except Exception:
release_spirit_key = '9'
resurrect_key = '0'
enable_mouse_loot = self.gs_enable_mouse_loot.isChecked()
use_tianya_box = self.gs_use_tianya_box.isChecked()
use_ghost_box = self.gs_use_ghost_box.isChecked()
self.game_worker = GameLoopWorker(
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,
food_key=food_key,
eat_hp_threshold=eat_hp_threshold,
eat_max_wait_sec=eat_max_wait_sec,
quest_follow_follow_key=self.quest_follow_follow_edit.text(),
quest_follow_interact_key=self.quest_follow_interact_edit.text(),
quest_follow_interval_sec=self.quest_follow_interval_spin.value(),
flight_json_path=flight_json_path,
flight_takeoff_key=self.flight_takeoff_key_edit.text(),
flight_takeoff_hold_sec=self.flight_takeoff_hold_spin.value(),
flight_land_key=self.flight_land_key_edit.text(),
flight_land_hold_sec=self.flight_land_hold_spin.value(),
resurrection_route_a_path=resurrection_route_a_path,
resurrection_route_b_path=resurrection_route_b_path,
release_spirit_key=release_spirit_key,
resurrect_key=resurrect_key,
enable_mouse_loot=enable_mouse_loot,
use_tianya_box=use_tianya_box,
use_ghost_box=use_ghost_box,
turn_error_key=turn_error_key,
turn_error_hold_sec=turn_error_hold_sec,
distance_interact_pause_sec=distance_interact_pause_sec,
skip_prepare_route=skip_prepare_route,
)
self.game_worker.state_signal.connect(self.state_label.setText)
self.game_worker.log_signal.connect(self.log)
self.game_worker.stop_signal.connect(self._stop_game_worker)
self.game_worker.finished.connect(self._on_game_loop_finished)
self.game_worker.start()
self.game_start_btn.setEnabled(False)
self.game_stop_btn.setEnabled(True)
mode_names = {
'monitor': '状态监控',
'patrol': '巡逻打怪',
'combat': '自动打怪',
'quest_follow': '任务跟随',
'flight': '飞行模式',
'return_repair': '回城修理',
}
self.status_bar.showMessage(f"🟢 {mode_names[mode]} 运行中")
self.log(f"🎮 {mode_names[mode]} 已启动")
def start_record(self):
if self.game_worker and self.game_worker.isRunning():
self.log("请先停止其他模式")
return
name = self.record_name_edit.text().strip() or "waypoints"
min_dist = None
try:
min_dist = float(self.record_min_distance_spin.value())
except Exception:
min_dist = 0.5
self.game_worker = GameLoopWorker(
mode='record',
record_filename=name,
record_min_distance=min_dist,
use_tianya_box=self.gs_use_tianya_box.isChecked(),
use_ghost_box=self.gs_use_ghost_box.isChecked(),
)
self.game_worker.state_signal.connect(self.record_state_label.setText)
self.game_worker.log_signal.connect(self.log)
self.game_worker.finished.connect(self._on_record_finished)
self.game_worker.start()
self.record_start_btn.setEnabled(False)
self.record_stop_btn.setEnabled(True)
self.record_save_btn.setEnabled(True)
self.status_bar.showMessage("🟢 巡逻点录制运行中")
self.log("🎮 巡逻点录制已启动")
def _on_record_finished(self):
self.record_start_btn.setEnabled(True)
self.record_stop_btn.setEnabled(False)
if self.game_worker and self.game_worker.recorder:
self.pending_recorder = self.game_worker.recorder
self.record_save_btn.setEnabled(bool(self.pending_recorder))
self.game_worker = None
def _on_game_loop_finished(self):
self.game_start_btn.setEnabled(True)
self.game_stop_btn.setEnabled(False)
self.status_bar.showMessage("⏹️ 已停止")
self.game_worker = None
def stop_game_loop(self):
self._stop_game_worker()
def _stop_game_worker(self):
"""统一停止游戏循环,更新所有相关 UI"""
if self.game_worker:
self.game_worker.running = False
# 额外释放一次移动按键,避免刚好卡在阻塞逻辑里导致按键滞留。
try:
if getattr(self.game_worker, "bot_move", None):
self.game_worker.bot_move.patrol_controller.stop_all()
self.game_worker.bot_move.logistics_manager.is_returning = False
if getattr(self.game_worker, "patrol_controller", None):
self.game_worker.patrol_controller.stop_all()
if getattr(self.game_worker, "logistics_manager", None):
self.game_worker.logistics_manager.is_returning = False
except Exception:
pass
# 不在这里 wait(),避免阻塞 GUI线程会在下一轮循环自然退出
self.game_start_btn.setEnabled(True)
self.game_stop_btn.setEnabled(False)
self.record_start_btn.setEnabled(True)
self.record_stop_btn.setEnabled(False)
self.record_save_btn.setEnabled(bool(self.pending_recorder))
self.state_label.setText("状态: ---")
self.record_state_label.setText("状态: ---")
self.status_bar.showMessage("⏹️ 已停止")
self.log("🛑 已停止")
def stop_record(self):
self._stop_game_worker()
def save_recorder(self):
recorder = None
if self.game_worker and self.game_worker.recorder:
recorder = self.game_worker.recorder
elif self.pending_recorder:
recorder = self.pending_recorder
if recorder:
path = recorder.save()
if path:
self.log(f"✅ 路径已保存至 {path}")
self.pending_recorder = None
self.record_save_btn.setEnabled(False)
QMessageBox.information(self, "保存成功", f"巡逻点已保存至:\n{path}")
else:
QMessageBox.warning(self, "提示", "请先进行巡逻点录制")
def start_loot_record(self):
"""开始录制拾取轨迹。"""
if not self.hwnd:
QMessageBox.warning(self, "提示", "未找到游戏窗口,无法录制轨迹")
return
if self.loot_recorder_worker and self.loot_recorder_worker.isRunning():
return
self.loot_recorder_worker = LootPathRecorderWorker(self.hwnd)
self.loot_recorder_worker.log_signal.connect(self.log)
self.loot_recorder_worker.finished_signal.connect(self._on_loot_record_finished)
self.loot_recorder_worker.start()
self.loot_record_start_btn.setEnabled(False)
self.loot_record_stop_btn.setEnabled(True)
self.status_bar.showMessage("🔴 拾取轨迹录制中...")
def stop_loot_record(self):
"""停止录制拾取轨迹。"""
if self.loot_recorder_worker:
self.loot_recorder_worker.stop()
self.loot_record_start_btn.setEnabled(True)
self.loot_record_stop_btn.setEnabled(False)
self.status_bar.showMessage("✅ 录制已结束")
def _on_loot_record_finished(self, count):
self.log(f"🎬 拾取录制完成,共记录 {count} 个有效点")
self.loot_record_start_btn.setEnabled(True)
self.loot_record_stop_btn.setEnabled(False)
def closeEvent(self, event):
running = bool(self.key_workers) or (self.game_worker and self.game_worker.isRunning())
if running:
reply = QMessageBox.question(
self, "退出", "尚有任务在运行,确认退出?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.stop_keys()
if self.game_worker:
self.game_worker.running = False
self.game_worker.wait()
event.accept()
else:
event.ignore()
else:
event.accept()
def main():
app = QApplication(sys.argv)
window = WoWMultiKeyGUI()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()