1214 lines
50 KiB
Python
1214 lines
50 KiB
Python
|
|
#!/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()
|