Files
wow/wow_multikey_gui.py

2015 lines
90 KiB
Python
Raw Normal View History

2026-03-18 09:04:37 +08:00
#!/usr/bin/env python3
"""
WoW 多功能控制器 GUI
2026-03-20 10:11:05 +08:00
集成状态监控巡逻打怪自动打怪任务跟随巡逻点录制多键控制
2026-03-18 09:04:37 +08:00
"""
import json
import os
import random
import sys
import time
import ctypes
2026-03-18 09:04:37 +08:00
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
2026-03-18 09:04:37 +08:00
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
2026-03-18 09:04:37 +08:00
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 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
def __init__(self, parent=None):
super().__init__(parent)
self.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.points = [(float(p[0]), float(p[1])) for p in points] if points else []
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))
if not self.points:
painter.setPen(QPen(QColor(200, 200, 200), 1))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "无巡逻点数据")
return
# 绘制路径连线
painter.setPen(QPen(QColor(0, 200, 255), 2))
for i in range(len(self.points) - 1):
x1, y1 = to_canvas(self.points[i])
x2, y2 = to_canvas(self.points[i + 1])
painter.drawLine(int(x1), int(y1), int(x2), int(y2))
# 绘制所有巡逻点
painter.setPen(QPen(QColor(255, 255, 0), 1))
painter.setBrush(QColor(255, 255, 0))
for p in self.points:
px, py = to_canvas(p)
painter.drawEllipse(int(px) - 2, int(py) - 2, 4, 4)
# 起点与终点标记(覆盖在普通点之上)
start_x, start_y = to_canvas(self.points[0])
end_x, end_y = to_canvas(self.points[-1])
painter.setPen(QPen(QColor(0, 255, 0), 2))
painter.setBrush(QColor(0, 255, 0))
painter.drawEllipse(int(start_x) - 4, int(start_y) - 4, 8, 8)
painter.setPen(QPen(QColor(255, 80, 80), 2))
painter.setBrush(QColor(255, 80, 80))
painter.drawEllipse(int(end_x) - 4, int(end_y) - 4, 8, 8)
# ============ 多键控制 ============
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}")
2026-03-20 10:11:05 +08:00
# ============ 游戏主循环(状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 录制) ============
2026-03-18 09:04:37 +08:00
class GameLoopWorker(QThread):
2026-03-23 16:05:27 +08:00
"""游戏状态主循环:支持状态监控、巡逻打怪、自动打怪、任务跟随、飞行模式、巡逻点录制"""
2026-03-18 09:04:37 +08:00
state_signal = pyqtSignal(str)
log_signal = pyqtSignal(str)
2026-04-10 14:36:04 +08:00
stop_signal = pyqtSignal() # 炉石回城后触发,等同于按下停止按钮
2026-03-18 09:04:37 +08:00
def __init__(
self,
mode,
waypoints_path=None,
2026-04-29 23:15:10 +08:00
prepare_route_path=None,
2026-03-18 09:04:37 +08:00
vendor_path=None,
2026-05-07 08:33:10 +08:00
mailbox_route_path=None,
2026-03-18 09:04:37 +08:00
record_filename=None,
record_min_distance=None,
attack_loop_path=None,
skinning_wait_sec=None,
2026-03-25 10:51:25 +08:00
food_key=None,
eat_hp_threshold=None,
eat_max_wait_sec=None,
2026-03-20 10:11:05 +08:00
quest_follow_follow_key=None,
quest_follow_interact_key=None,
quest_follow_interval_sec=None,
2026-03-23 16:05:27 +08:00
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,
2026-04-20 15:53:32 +08:00
use_hardware_input=True,
2026-04-21 11:56:55 +08:00
turn_error_key=None,
turn_error_hold_sec=None,
distance_interact_pause_sec=None,
2026-03-18 09:04:37 +08:00
):
super().__init__()
2026-03-23 16:05:27 +08:00
self.mode = mode # 'monitor' | 'patrol' | 'combat' | 'quest_follow' | 'flight' | 'record'
2026-03-18 09:04:37 +08:00
self.running = True
self.bot_move = None
self.bot_combat = None
2026-03-20 10:11:05 +08:00
self.quest_follow_bot = None
2026-03-23 16:05:27 +08:00
self.flight_bot = None
2026-03-18 09:04:37 +08:00
self.recorder = None
self.waypoints_path = waypoints_path
2026-04-29 23:15:10 +08:00
self.prepare_route_path = prepare_route_path
2026-03-18 09:04:37 +08:00
self.vendor_path = vendor_path
2026-05-07 08:33:10 +08:00
self.mailbox_route_path = mailbox_route_path
2026-03-18 09:04:37 +08:00
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
2026-03-25 10:51:25 +08:00
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
2026-03-20 10:11:05 +08:00
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
2026-03-23 16:05:27 +08:00
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
2026-04-20 15:53:32 +08:00
self.use_hardware_input = bool(use_hardware_input)
2026-04-21 11:56:55 +08:00
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
2026-03-18 09:04:37 +08:00
def run(self):
try:
from game_state import parse_game_state, format_game_state_line
2026-04-20 15:53:32 +08:00
from hardware_control import hw_ctrl
2026-03-18 09:04:37 +08:00
except ImportError:
self.log_signal.emit("❌ 无法导入 game_state 模块")
return
2026-04-20 15:53:32 +08:00
hw_ctrl.configure(use_hardware_input=self.use_hardware_input)
backend_text = {
"hardware": "硬件",
"hardware_unavailable": "硬件(不可用)",
"directinput": "pydirectinput",
"directinput_unavailable": "pydirectinput(不可用)",
}.get(hw_ctrl.backend_label(), "未知")
self.log_signal.emit(f"⌨️ 控制方式: {backend_text}")
2026-03-18 09:04:37 +08:00
if self.mode == 'patrol':
try:
from auto_bot_move import AutoBotMove, load_waypoints
self.bot_move = AutoBotMove(
waypoints_path=self.waypoints_path,
2026-04-29 23:15:10 +08:00
prepare_route_path=self.prepare_route_path,
2026-03-18 09:04:37 +08:00
vendor_path=self.vendor_path,
attack_loop_path=self.attack_loop_path,
skinning_wait_sec=self.skinning_wait_sec,
2026-03-25 10:51:25 +08:00
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,
2026-04-21 11:56:55 +08:00
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,
2026-05-07 08:33:10 +08:00
mailbox_route_path=self.mailbox_route_path,
2026-03-18 09:04:37 +08:00
)
2026-04-10 14:36:04 +08:00
self.bot_move._on_hearthstone_stop = self.stop_signal.emit
2026-03-18 09:04:37 +08:00
except ImportError 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,
2026-04-21 11:56:55 +08:00
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,
2026-03-18 09:04:37 +08:00
)
except ImportError as e:
self.log_signal.emit(f"❌ 自动打怪依赖加载失败: {e}")
return
2026-03-20 10:11:05 +08:00
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
2026-03-23 16:05:27 +08:00
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
2026-03-26 10:41:04 +08:00
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)),
2026-04-10 14:36:04 +08:00
enable_mount=bool(layout.get("enable_mount", True)),
2026-03-26 10:41:04 +08:00
)
self.logistics_manager = LogisticsManager(route_file=self.vendor_path)
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_route1_round(
get_state,
self.patrol_controller,
route_file=self.vendor_path,
)
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
2026-03-18 09:04:37 +08:00
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))
2026-03-18 09:04:37 +08:00
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)
2026-03-23 16:05:27 +08:00
elif self.mode == 'flight' and self.flight_bot:
self.flight_bot.execute_logic(state)
2026-03-20 10:11:05 +08:00
if self.mode == 'quest_follow' and self.quest_follow_bot:
self.quest_follow_bot.execute_logic(state)
2026-03-18 09:04:37 +08:00
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
2026-03-18 09:04:37 +08:00
# ============ 主窗口 ============
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
2026-03-18 09:04:37 +08:00
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
2026-03-18 09:04:37 +08:00
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', '自动打怪'),
2026-03-20 10:11:05 +08:00
('quest_follow', '任务跟随'),
2026-03-23 16:05:27 +08:00
('flight', '飞行模式'),
2026-03-26 10:41:04 +08:00
('return_repair', '回城修理'),
2026-03-18 09:04:37 +08:00
]:
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.waypoints_combo = QComboBox()
self.waypoints_combo.setMinimumWidth(200)
self.vendor_combo = QComboBox()
self.vendor_combo.setMinimumWidth(200)
2026-05-07 08:33:10 +08:00
self.mailbox_route_combo = QComboBox()
self.mailbox_route_combo.setMinimumWidth(200)
2026-03-18 09:04:37 +08:00
self.patrol_attack_loop_combo = QComboBox()
self.patrol_attack_loop_combo.setMinimumWidth(200)
2026-04-29 23:15:10 +08:00
self.prepare_route_combo = QComboBox()
self.prepare_route_combo.setMinimumWidth(200)
2026-03-18 09:04:37 +08:00
self._refresh_recorder_combos()
refresh_btn = QPushButton("🔄 刷新列表")
refresh_btn.clicked.connect(self._refresh_recorder_combos)
2026-04-29 23:15:10 +08:00
patrol_layout.addRow("准备路线 JSON:", self.prepare_route_combo)
2026-03-18 09:04:37 +08:00
patrol_layout.addRow("巡逻点 JSON:", self.waypoints_combo)
patrol_layout.addRow("修理商 JSON:", self.vendor_combo)
2026-05-07 08:33:10 +08:00
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)
2026-04-29 23:15:10 +08:00
self._refresh_recorder_combos()
patrol_layout.addRow("复活路线 A JSON:", self.resurrection_route_a_combo)
patrol_layout.addRow("复活路线 B JSON:", self.resurrection_route_b_combo)
2026-03-18 09:04:37 +08:00
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)
2026-03-20 10:11:05 +08:00
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)
2026-03-23 16:05:27 +08:00
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)
2026-03-26 10:41:04 +08:00
# 回城修理配置(在飞行模式之后新增一个独立模式)
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)
2026-03-18 09:04:37 +08:00
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: 参数配置(上下两个分组)
2026-03-18 09:04:37 +08:00
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)
2026-03-18 09:04:37 +08:00
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)
# 游戏参数
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)
2026-03-18 09:04:37 +08:00
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("")
2026-03-25 10:51:25 +08:00
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("")
2026-04-21 11:56:55 +08:00
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("")
2026-04-10 14:36:04 +08:00
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)
2026-05-07 08:33:10 +08:00
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("")
2026-04-10 14:36:04 +08:00
self.gs_enable_mount = QCheckBox("启用上马")
self.gs_enable_mount.setChecked(True)
2026-03-23 09:50:08 +08:00
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)
2026-04-20 15:53:32 +08:00
self.gs_use_hardware_input = QCheckBox("启用硬件控制(关闭则用 pydirectinput")
self.gs_use_hardware_input.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_release_spirit_key, 4, 3)
2026-04-21 11:56:55 +08:00
game_grid.addWidget(QLabel("需转身按键:"), 5, 0)
game_grid.addWidget(self.turn_error_key_edit, 5, 1)
game_grid.addWidget(QLabel("需转身按住时长:"), 6, 0)
game_grid.addWidget(self.turn_error_hold_spin, 6, 1)
game_grid.addWidget(QLabel("距离远暂停技能时长:"), 7, 0)
game_grid.addWidget(self.distance_interact_pause_spin, 7, 1)
2026-04-21 11:56:55 +08:00
game_grid.addWidget(self.gs_use_hardware_input, 8, 0, 1, 2)
2026-04-21 11:56:55 +08:00
game_grid.addWidget(QLabel("复活按键:"), 6, 2)
game_grid.addWidget(self.gs_resurrect_key, 6, 3)
2026-05-07 08:33:10 +08:00
game_grid.addWidget(self.gs_bag_full_hearthstone, 9, 0, 1, 2)
game_grid.addWidget(self.gs_enable_bag_full_mail, 9, 2, 1, 2)
game_grid.addWidget(QLabel("邮箱交互键:"), 10, 0)
game_grid.addWidget(self.gs_mailbox_interact_key, 10, 1)
game_grid.addWidget(QLabel("收信人按键:"), 10, 2)
game_grid.addWidget(self.gs_mail_recipient_key, 10, 3)
game_grid.addWidget(QLabel("邮寄宏按键:"), 11, 0)
game_grid.addWidget(self.gs_mail_send_key, 11, 1)
game_grid.addWidget(QLabel("邮箱打开等待:"), 11, 2)
game_grid.addWidget(self.gs_mailbox_open_wait, 11, 3)
game_grid.addWidget(QLabel("邮寄后等待:"), 12, 0)
game_grid.addWidget(self.gs_mail_send_wait, 12, 1)
params_layout.addWidget(game_group)
params_layout.addStretch()
2026-03-18 09:04:37 +08:00
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))
2026-05-07 08:33:10 +08:00
self.gs_enable_mount.setChecked(bool(cfg.get('enable_mount', True)))
2026-03-23 09:50:08 +08:00
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)))
2026-04-10 14:36:04 +08:00
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)))
2026-05-07 08:33:10 +08:00
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)))
2026-03-23 09:50:08 +08:00
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'))
2026-03-18 09:04:37 +08:00
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))
2026-03-25 10:51:25 +08:00
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)))
2026-04-21 11:56:55 +08:00
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)))
2026-04-20 15:53:32 +08:00
self.gs_use_hardware_input.setChecked(bool(bot_cfg.get('use_hardware_input', True)))
2026-03-18 09:04:37 +08:00
except Exception:
self.skinning_wait_spin.setValue(1.5)
2026-03-25 10:51:25 +08:00
self.food_key_edit.setText('f1')
self.eat_hp_threshold_spin.setValue(30)
self.eat_max_wait_spin.setValue(30.0)
2026-04-21 11:56:55 +08:00
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)
2026-04-20 15:53:32 +08:00
self.gs_use_hardware_input.setChecked(True)
2026-03-18 09:04:37 +08:00
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()
2026-05-07 08:33:10 +08:00
cfg['enable_mount'] = self.gs_enable_mount.isChecked()
2026-03-23 09:50:08 +08:00
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())
2026-04-10 14:36:04 +08:00
cfg['hearthstone_key'] = (self.gs_hearthstone_key.text().strip() or 'b')
cfg['bag_full_hearthstone'] = self.gs_bag_full_hearthstone.isChecked()
2026-05-07 08:33:10 +08:00
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['release_spirit_key'] = (self.gs_release_spirit_key.text().strip() or '9')
cfg['resurrect_key'] = (self.gs_resurrect_key.text().strip() or '0')
2026-03-18 09:04:37 +08:00
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())
2026-03-25 10:51:25 +08:00
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())
2026-04-21 11:56:55 +08:00
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()
2026-04-20 15:53:32 +08:00
self.config['bot']['use_hardware_input'] = self.gs_use_hardware_input.isChecked()
2026-03-18 09:04:37 +08:00
self._save_main_config()
2026-04-20 15:53:32 +08:00
from hardware_control import hw_ctrl
hw_ctrl.configure(use_hardware_input=self.gs_use_hardware_input.isChecked())
2026-03-18 09:04:37 +08:00
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()
2026-03-23 16:05:27 +08:00
self._refresh_flight_json_combo()
2026-03-18 09:04:37 +08:00
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_recorder_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_points([])
return
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# 期望格式:[[x,y], ...] 或 [[x,y, ...], ...]
points = []
for item in data:
if isinstance(item, (list, tuple)) and len(item) >= 2:
points.append((float(item[0]), float(item[1])))
self.preview_canvas.set_points(points)
self.log(f"已加载巡逻点预览: {path} (共 {len(points)} 点)")
except Exception as e:
self.log(f"加载巡逻点预览失败: {e}")
self.preview_canvas.set_points([])
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):
"""刷新巡逻点、修理商、复活点路线下拉列表"""
2026-03-18 09:04:37 +08:00
items = list_recorder_json()
combos_with_default = [
(self.waypoints_combo, 'waypoints.json'),
(self.vendor_combo, 'vendor.json'),
]
2026-03-26 10:41:04 +08:00
if hasattr(self, "repair_vendor_combo"):
combos_with_default.append((self.repair_vendor_combo, 'vendor.json'))
2026-05-07 08:33:10 +08:00
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)
2026-03-18 09:04:37 +08:00
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)
# 复活路线下拉(无默认值,可置空)
2026-04-29 23:15:10 +08:00
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()
2026-04-29 23:15:10 +08:00
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)
2026-03-18 09:04:37 +08:00
2026-03-26 10:41:04 +08:00
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)
2026-03-23 16:05:27 +08:00
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)
2026-03-18 09:04:37 +08:00
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')
2026-03-20 10:11:05 +08:00
self.quest_follow_group.setVisible(mode == 'quest_follow')
2026-03-23 16:05:27 +08:00
self.flight_group.setVisible(mode == 'flight')
2026-03-26 10:41:04 +08:00
self.repair_group.setVisible(mode == 'return_repair')
2026-03-23 16:05:27 +08:00
if mode == 'flight':
self._refresh_flight_json_combo()
2026-03-26 10:41:04 +08:00
if mode == 'return_repair':
self._refresh_repair_vendor_json_combo()
2026-03-18 09:04:37 +08:00
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:
2026-03-26 10:41:04 +08:00
QMessageBox.warning(self, "提示", "请先选择模式(状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 飞行模式 / 回城修理)")
2026-03-18 09:04:37 +08:00
return
waypoints_path = None
2026-04-29 23:15:10 +08:00
prepare_route_path = None
2026-03-18 09:04:37 +08:00
vendor_path = None
2026-05-07 08:33:10 +08:00
mailbox_route_path = None
2026-03-23 16:05:27 +08:00
flight_json_path = None
resurrection_route_a_path = None
resurrection_route_b_path = None
2026-03-18 09:04:37 +08:00
if mode == 'patrol':
2026-04-29 23:15:10 +08:00
prep = self.prepare_route_combo.currentData() or ""
2026-03-18 09:04:37 +08:00
wp = self.waypoints_combo.currentData() or ""
vp = self.vendor_combo.currentData() or ""
2026-05-07 08:33:10 +08:00
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 ""
2026-03-18 09:04:37 +08:00
if not wp:
QMessageBox.warning(self, "提示", "巡逻打怪模式需选择巡逻点 JSON 文件")
return
if not vp:
QMessageBox.warning(self, "提示", "巡逻打怪模式需选择修理商 JSON 文件")
return
if not os.path.exists(wp):
QMessageBox.warning(self, "提示", f"巡逻点文件不存在: {wp}")
return
if not os.path.exists(vp):
QMessageBox.warning(self, "提示", f"修理商文件不存在: {vp}")
return
2026-04-29 23:15:10 +08:00
if prep and not os.path.exists(prep):
QMessageBox.warning(self, "提示", f"准备路线文件不存在: {prep}")
return
2026-05-07 08:33:10 +08:00
if self.gs_enable_bag_full_mail.isChecked():
if not mp:
QMessageBox.warning(self, "提示", "已启用包满邮寄,请选择邮箱路线 JSON 文件")
return
if not os.path.exists(mp):
QMessageBox.warning(self, "提示", f"邮箱路线文件不存在: {mp}")
return
if route_a and not os.path.exists(route_a):
QMessageBox.warning(self, "提示", f"复活路线 A 文件不存在: {route_a}")
return
if route_b and not os.path.exists(route_b):
QMessageBox.warning(self, "提示", f"复活路线 B 文件不存在: {route_b}")
return
2026-03-18 09:04:37 +08:00
waypoints_path = wp
2026-04-29 23:15:10 +08:00
prepare_route_path = prep or None
2026-03-18 09:04:37 +08:00
vendor_path = vp
2026-05-07 08:33:10 +08:00
mailbox_route_path = mp or None
resurrection_route_a_path = route_a or None
resurrection_route_b_path = route_b or None
2026-03-18 09:04:37 +08:00
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
2026-03-20 10:11:05 +08:00
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()
2026-03-26 10:41:04 +08:00
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
2026-03-23 16:05:27 +08:00
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()
2026-03-18 09:04:37 +08:00
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
2026-03-25 10:51:25 +08:00
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
2026-04-21 11:56:55 +08:00
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())
2026-03-18 09:04:37 +08:00
# 从 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()
2026-04-20 15:53:32 +08:00
use_hardware_input = self.gs_use_hardware_input.isChecked()
2026-03-18 09:04:37 +08:00
self.game_worker = GameLoopWorker(
2026-04-29 23:15:10 +08:00
mode, waypoints_path=waypoints_path, prepare_route_path=prepare_route_path, vendor_path=vendor_path,
2026-05-07 08:33:10 +08:00
mailbox_route_path=mailbox_route_path,
2026-03-18 09:04:37 +08:00
attack_loop_path=attack_loop_path,
skinning_wait_sec=skinning_wait_sec,
2026-03-25 10:51:25 +08:00
food_key=food_key,
eat_hp_threshold=eat_hp_threshold,
eat_max_wait_sec=eat_max_wait_sec,
2026-03-20 10:11:05 +08:00
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(),
2026-03-23 16:05:27 +08:00
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,
2026-04-20 15:53:32 +08:00
use_hardware_input=use_hardware_input,
2026-04-21 11:56:55 +08:00
turn_error_key=turn_error_key,
turn_error_hold_sec=turn_error_hold_sec,
distance_interact_pause_sec=distance_interact_pause_sec,
2026-03-18 09:04:37 +08:00
)
self.game_worker.state_signal.connect(self.state_label.setText)
self.game_worker.log_signal.connect(self.log)
2026-04-10 14:36:04 +08:00
self.game_worker.stop_signal.connect(self._stop_game_worker)
2026-03-18 09:04:37 +08:00
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)
2026-03-20 10:11:05 +08:00
mode_names = {
'monitor': '状态监控',
'patrol': '巡逻打怪',
'combat': '自动打怪',
'quest_follow': '任务跟随',
2026-03-23 16:05:27 +08:00
'flight': '飞行模式',
2026-03-26 10:41:04 +08:00
'return_repair': '回城修理',
2026-03-20 10:11:05 +08:00
}
2026-03-18 09:04:37 +08:00
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)
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
2026-03-25 10:51:25 +08:00
# 额外释放一次移动按键,避免刚好卡在阻塞逻辑里导致按键滞留。
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
2026-03-26 10:41:04 +08:00
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
2026-03-25 10:51:25 +08:00
except Exception:
pass
2026-03-18 09:04:37 +08:00
# 不在这里 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)
2026-03-18 09:04:37 +08:00
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()