Files
wow/wow_multikey_gui.py
2026-03-20 10:11:05 +08:00

1284 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""
WoW 多功能控制器 GUI
集成:状态监控、巡逻打怪、自动打怪、任务跟随、巡逻点录制、多键控制
"""
import json
import os
import random
import sys
import time
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,
quest_follow_follow_key=None,
quest_follow_interact_key=None,
quest_follow_interval_sec=None,
):
super().__init__()
self.mode = mode # 'monitor' | 'patrol' | 'combat' | 'quest_follow' | 'record'
self.running = True
self.bot_move = None
self.bot_combat = None
self.quest_follow_bot = 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
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
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 == 'quest_follow':
try:
from quest_follow import QuestFollowBot
self.quest_follow_bot = QuestFollowBot(
follow_key=self.quest_follow_follow_key,
interact_key=self.quest_follow_interact_key,
interval_sec=self.quest_follow_interval_sec,
log_callback=lambda m: self.log_signal.emit(m),
)
except ImportError as e:
self.log_signal.emit(f"❌ 任务跟随依赖加载失败: {e}")
return
if self.mode == '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)
if self.mode == 'quest_follow' and self.quest_follow_bot:
self.quest_follow_bot.execute_logic(state)
time.sleep(0.1)
def save_recorder(self):
"""录制模式下保存路径。"""
if self.recorder:
return self.recorder.save()
return None
# ============ 主窗口 ============
class 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', '自动打怪'),
('quest_follow', '任务跟随'),
]:
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)
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)
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.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()
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')
self.quest_follow_group.setVisible(mode == 'quest_follow')
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
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()
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,
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(),
)
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': '自动打怪',
'quest_follow': '任务跟随',
}
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()