Files
wow/wow_multikey_gui.py

1214 lines
50 KiB
Python
Raw Normal View History

2026-03-18 09:04:37 +08:00
#!/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.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')
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()