#!/usr/bin/env python3 """ WoW 多功能控制器 GUI 集成:状态监控、巡逻打怪、自动打怪、巡逻点录制、多键控制 """ import json import os import random import sys import time from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, 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 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}") # ============ 游戏主循环(状态监控 / 巡逻打怪 / 自动打怪 / 录制) ============ class GameLoopWorker(QThread): """游戏状态主循环:支持状态监控、巡逻打怪、自动打怪、巡逻点录制""" state_signal = pyqtSignal(str) log_signal = pyqtSignal(str) def __init__( self, mode, waypoints_path=None, vendor_path=None, record_filename=None, record_min_distance=None, attack_loop_path=None, skinning_wait_sec=None, ): super().__init__() self.mode = mode # 'monitor' | 'patrol' | 'combat' | 'record' self.running = True self.bot_move = None self.bot_combat = None self.recorder = None self.waypoints_path = waypoints_path self.vendor_path = vendor_path 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 def run(self): try: from game_state import parse_game_state except ImportError: self.log_signal.emit("❌ 无法导入 game_state 模块") return if self.mode == 'patrol': try: from auto_bot_move import AutoBotMove, load_waypoints self.bot_move = AutoBotMove( waypoints_path=self.waypoints_path, vendor_path=self.vendor_path, attack_loop_path=self.attack_loop_path, skinning_wait_sec=self.skinning_wait_sec, ) 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, ) except ImportError as e: self.log_signal.emit(f"❌ 自动打怪依赖加载失败: {e}") 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: death_txt = ('存活', '尸体', '灵魂')[state.get('death_state', 0)] target_hp = state.get('target_hp') if target_hp is not None: hp_part = f"血:{state['hp']}% 目标血:{target_hp}% 法:{state['mp']}%" else: hp_part = f"血:{state['hp']}% 法:{state['mp']}%" state_str = ( f"{hp_part} | " f"战斗:{'Y' if state['combat'] else 'N'} 目标:{'Y' if state['target'] else 'N'} | " f"空格:{state.get('free_slots', 0)} 耐久:{state.get('durability', 0):.0%} {death_txt} | " f"x:{state['x']} y:{state['y']} 朝向:{state['facing']:.1f}°" ) self.state_signal.emit(state_str) 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) time.sleep(0.1) def save_recorder(self): """录制模式下保存路径。""" if self.recorder: return self.recorder.save() return None # ============ 主窗口 ============ 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.pending_recorder = None # 停止录制后保留,便于保存 self.hwnd = None self.init_ui() self.find_wow_window() 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', '自动打怪'), ]: 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) self.patrol_attack_loop_combo = QComboBox() self.patrol_attack_loop_combo.setMinimumWidth(200) self._refresh_recorder_combos() refresh_btn = QPushButton("🔄 刷新列表") refresh_btn.clicked.connect(self._refresh_recorder_combos) patrol_layout.addRow("巡逻点 JSON:", self.waypoints_combo) patrol_layout.addRow("修理商 JSON:", self.vendor_combo) 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) 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_params = QWidget() params_layout = QVBoxLayout(tab_params) params_content = QWidget() params_row = QHBoxLayout(params_content) params_left = QFormLayout() params_right = QFormLayout() 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) params_left.addRow("每格像素 (pixel_size):", self.gs_pixel_size) params_left.addRow("起始 X (block_start_x):", self.gs_block_start_x) params_left.addRow("截图宽度 (scan_region_width):", self.gs_scan_width) params_left.addRow("截图高度 (scan_region_height):", self.gs_scan_height) params_left.addRow("窗口左偏移 (offset_left):", self.gs_offset_left) params_left.addRow("窗口顶偏移 (offset_top):", self.gs_offset_top) # Bot 参数(保存到 wow_multikey_qt.json) 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(" 秒") params_right.addRow("剥皮等待时间:", self.skinning_wait_spin) params_row.addLayout(params_left) params_row.addLayout(params_right) params_layout.addWidget(params_content) 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)) 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)) except Exception: self.skinning_wait_spin.setValue(1.5) def _save_params_config(self): """保存「参数配置」界面到 game_state_config.json(多分辨率)并写入 wow_multikey_qt.json(bot 参数)""" 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() 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._save_main_config() 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() 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): """刷新巡逻点、修理商下拉列表""" items = list_recorder_json() for combo, default_name in [(self.waypoints_combo, 'waypoints.json'), (self.vendor_combo, 'vendor.json')]: 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) 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') 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 waypoints_path = None vendor_path = None if mode == 'patrol': wp = self.waypoints_combo.currentData() or "" vp = self.vendor_combo.currentData() or "" 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 waypoints_path = wp vendor_path = vp 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 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 self.game_worker = GameLoopWorker( mode, waypoints_path=waypoints_path, vendor_path=vendor_path, attack_loop_path=attack_loop_path, skinning_wait_sec=skinning_wait_sec, ) self.game_worker.state_signal.connect(self.state_label.setText) self.game_worker.log_signal.connect(self.log) 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': '自动打怪'} 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 # 不在这里 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 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()