add 硬件控制模块 (hardware_control.py) 并修复游戏状态扫描区域宽度

- 新增 wyhkm.dll 硬件盒子 COM 接口封装,支持键盘鼠标控制
- 修复 game_state_config.json 中 scan_region_width 过小导致截图越界的问题
- 添加鼠标路径录制器、硬件测试脚本等工具
- 更新多项配置默认值

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
王鹏
2026-04-15 12:15:00 +08:00
parent b4de5278ed
commit 33dc741fd9
203 changed files with 12197 additions and 247 deletions

View File

@@ -9,9 +9,10 @@ import os
import random
import sys
import time
import ctypes
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QPushButton, QCheckBox, QSpinBox, QDoubleSpinBox, QScrollArea, QTextEdit,
QMessageBox, QTabWidget, QGroupBox, QFormLayout, QLineEdit,
QComboBox, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
@@ -21,6 +22,7 @@ from PyQt6.QtGui import QPainter, QPen, QColor
import win32gui
import win32api
import win32con
from pynput import keyboard
def _config_base():
@@ -320,6 +322,7 @@ class GameLoopWorker(QThread):
resurrection_waypoints_path=None,
release_spirit_key=None,
resurrect_key=None,
enable_mouse_loot=True,
):
super().__init__()
self.mode = mode # 'monitor' | 'patrol' | 'combat' | 'quest_follow' | 'flight' | 'record'
@@ -358,6 +361,7 @@ class GameLoopWorker(QThread):
self.resurrection_waypoints_path = resurrection_waypoints_path
self.release_spirit_key = release_spirit_key
self.resurrect_key = resurrect_key
self.enable_mouse_loot = enable_mouse_loot
def run(self):
try:
@@ -381,6 +385,7 @@ class GameLoopWorker(QThread):
resurrection_waypoints_path=self.resurrection_waypoints_path,
release_spirit_key=self.release_spirit_key,
resurrect_key=self.resurrect_key,
enable_mouse_loot=self.enable_mouse_loot,
)
self.bot_move._on_hearthstone_stop = self.stop_signal.emit
except ImportError as e:
@@ -393,6 +398,7 @@ class GameLoopWorker(QThread):
self.bot_combat = AutoBot(
attack_loop_path=self.attack_loop_path,
skinning_wait_sec=self.skinning_wait_sec,
enable_mouse_loot=self.enable_mouse_loot,
)
except ImportError as e:
self.log_signal.emit(f"❌ 自动打怪依赖加载失败: {e}")
@@ -513,6 +519,54 @@ class GameLoopWorker(QThread):
return None
class LootPathRecorderWorker(QThread):
"""拾取轨迹录制工作线程"""
finished_signal = pyqtSignal(int)
log_signal = pyqtSignal(str)
def __init__(self, hwnd):
super().__init__()
self.hwnd = hwnd
self.running = False
self.points = []
def run(self):
if not self.hwnd:
self.log_signal.emit("❌ 未找到游戏窗口,无法录制轨迹")
return
try:
rect = win32gui.GetWindowRect(self.hwnd)
cx = rect[0] + (rect[2] - rect[0]) // 2
cy = rect[1] + (rect[3] - rect[1]) // 2
self.points = []
self.running = True
self.log_signal.emit("🔴 拾取轨迹录制中... 请在角色前方平滑移动鼠标")
while self.running:
x, y = win32api.GetCursorPos()
self.points.append((x - cx, y - cy))
time.sleep(0.02)
# 稀疏处理并保存
processed = self.points[::3]
if not processed:
self.log_signal.emit("⚠️ 未记录到有效轨迹点")
return
path = get_config_path("loot_path.json")
with open(path, 'w') as f:
json.dump(processed, f)
self.finished_signal.emit(len(processed))
self.log_signal.emit(f"✅ 拾取轨迹已保存: {len(processed)}")
except Exception as e:
self.log_signal.emit(f"❌ 轨迹录制失败: {e}")
def stop(self):
self.running = False
# ============ 主窗口 ============
class WoWMultiKeyGUI(QMainWindow):
@@ -522,12 +576,31 @@ class WoWMultiKeyGUI(QMainWindow):
self.config = self.load_config()
self.key_workers = {}
self.game_worker = None
self.loot_recorder_worker = None
self.pending_recorder = None # 停止录制后保留,便于保存
self.hwnd = None
self.init_ui()
self.find_wow_window()
# 初始化全局热键监听 (F8 用于拾取录制)
self.kb_listener = keyboard.Listener(on_press=self._on_hotkey_press)
self.kb_listener.start()
def _on_hotkey_press(self, key):
"""全局热键回调"""
try:
if key == keyboard.Key.f8:
# 必须在主线程 UI 操作,但 pynput 在独立线程,这里简单判断状态
if self.loot_recorder_worker and self.loot_recorder_worker.isRunning():
# 停止录制
self.stop_loot_record()
else:
# 开始录制
self.start_loot_record()
except Exception:
pass
def init_ui(self):
self.setWindowTitle("WoW 多功能控制器")
self.setGeometry(100, 100, 780, 620)
@@ -870,15 +943,41 @@ class WoWMultiKeyGUI(QMainWindow):
loops_layout.addWidget(loop_editor)
tabs.addTab(tab_combat_loops, "攻击循环")
# Tab: 拾取录制 (新增加)
tab_loot_record = QWidget()
loot_record_layout = QVBoxLayout(tab_loot_record)
loot_record_group = QGroupBox("自定义拾取轨迹")
loot_record_inner = QVBoxLayout(loot_record_group)
loot_record_inner.addWidget(QLabel("说明:\n1. 点击下方按钮开始录制。\n2. 在游戏角色前方划出你想要的扫瞄路径。\n3. 再次点击停止录制,轨迹将自动保存。\n4. 挂机脚本将优先使用录制的路径进行拾取。"))
loot_record_btn_layout = QHBoxLayout()
self.loot_record_start_btn = QPushButton("🔴 开始录制拾取轨迹")
self.loot_record_start_btn.clicked.connect(self.start_loot_record)
self.loot_record_stop_btn = QPushButton("⏹ 停止录制")
self.loot_record_stop_btn.clicked.connect(self.stop_loot_record)
self.loot_record_stop_btn.setEnabled(False)
loot_record_btn_layout.addWidget(self.loot_record_start_btn)
loot_record_btn_layout.addWidget(self.loot_record_stop_btn)
loot_record_btn_layout.addStretch()
loot_record_inner.addLayout(loot_record_btn_layout)
loot_record_layout.addWidget(loot_record_group)
loot_record_layout.addStretch()
tabs.addTab(tab_loot_record, "拾取录制")
# Tab: 参数配置(上下两个分组)
tab_params = QWidget()
params_layout = QVBoxLayout(tab_params)
# 基础参数(截图/窗口配置)
basic_group = QGroupBox("基础参数")
basic_layout = QHBoxLayout(basic_group)
basic_left = QFormLayout()
basic_right = QFormLayout()
basic_grid = QGridLayout(basic_group)
# 统一对齐配置
LABEL_WIDTH = 100
basic_grid.setColumnMinimumWidth(0, LABEL_WIDTH)
basic_grid.setColumnMinimumWidth(2, LABEL_WIDTH)
basic_grid.setColumnStretch(1, 1)
basic_grid.setColumnStretch(3, 1)
self.gs_pixel_size = QSpinBox()
self.gs_pixel_size.setRange(8, 32)
self.gs_pixel_size.setValue(17)
@@ -897,21 +996,33 @@ class WoWMultiKeyGUI(QMainWindow):
self.gs_offset_top = QSpinBox()
self.gs_offset_top.setRange(0, 100)
self.gs_offset_top.setValue(45)
basic_left.addRow("每格像素 (pixel_size):", self.gs_pixel_size)
basic_left.addRow("起始 X (block_start_x):", self.gs_block_start_x)
basic_left.addRow("截图宽度 (scan_region_width):", self.gs_scan_width)
basic_right.addRow("截图高度 (scan_region_height):", self.gs_scan_height)
basic_right.addRow("窗口左偏移 (offset_left):", self.gs_offset_left)
basic_right.addRow("窗口顶偏移 (offset_top):", self.gs_offset_top)
basic_layout.addLayout(basic_left)
basic_layout.addLayout(basic_right)
# 第一列
basic_grid.addWidget(QLabel("每格像素:"), 0, 0)
basic_grid.addWidget(self.gs_pixel_size, 0, 1)
basic_grid.addWidget(QLabel("起始 X:"), 1, 0)
basic_grid.addWidget(self.gs_block_start_x, 1, 1)
basic_grid.addWidget(QLabel("截图宽度:"), 2, 0)
basic_grid.addWidget(self.gs_scan_width, 2, 1)
# 第二列
basic_grid.addWidget(QLabel("截图高度:"), 0, 2)
basic_grid.addWidget(self.gs_scan_height, 0, 3)
basic_grid.addWidget(QLabel("窗口左偏移:"), 1, 2)
basic_grid.addWidget(self.gs_offset_left, 1, 3)
basic_grid.addWidget(QLabel("窗口顶偏移:"), 2, 2)
basic_grid.addWidget(self.gs_offset_top, 2, 3)
params_layout.addWidget(basic_group)
# 游戏参数
game_group = QGroupBox("游戏参数")
game_layout = QHBoxLayout(game_group)
game_left = QFormLayout()
game_right = QFormLayout()
game_grid = QGridLayout(game_group)
# 统一对齐配置
game_grid.setColumnMinimumWidth(0, LABEL_WIDTH)
game_grid.setColumnMinimumWidth(2, LABEL_WIDTH)
game_grid.setColumnStretch(1, 1)
game_grid.setColumnStretch(3, 1)
self.skinning_wait_spin = QDoubleSpinBox()
self.skinning_wait_spin.setRange(0.1, 10.0)
self.skinning_wait_spin.setSingleStep(0.1)
@@ -960,20 +1071,39 @@ class WoWMultiKeyGUI(QMainWindow):
self.gs_resurrect_key.setPlaceholderText("如 0")
self.gs_resurrect_key.setMaxLength(16)
self.gs_resurrect_key.setText("0")
game_left.addRow("剥皮等待时间:", self.skinning_wait_spin)
game_left.addRow("吃面包按键:", self.food_key_edit)
game_left.addRow("吃面包血量阈值:", self.eat_hp_threshold_spin)
game_left.addRow("吃面包最长等待:", self.eat_max_wait_spin)
game_left.addRow("炉石按键:", self.gs_hearthstone_key)
game_left.addRow("", self.gs_bag_full_hearthstone)
game_right.addRow("是否上马:", self.gs_enable_mount)
game_right.addRow("上马按键:", self.gs_mount_key)
game_right.addRow("上马按住时长:", self.gs_mount_hold)
game_right.addRow("上马重试间隔:", self.gs_mount_retry)
game_right.addRow("释放灵魂按键:", self.gs_release_spirit_key)
game_right.addRow("复活按键:", self.gs_resurrect_key)
game_layout.addLayout(game_left)
game_layout.addLayout(game_right)
self.gs_enable_mouse_loot = QCheckBox("启用扫雷拾取")
self.gs_enable_mouse_loot.setChecked(True)
# 网格填充
game_grid.addWidget(QLabel("剥皮等待时间:"), 0, 0)
game_grid.addWidget(self.skinning_wait_spin, 0, 1)
game_grid.addWidget(self.gs_enable_mouse_loot, 0, 2)
game_grid.addWidget(self.gs_enable_mount, 0, 3)
game_grid.addWidget(QLabel("吃面包按键:"), 1, 0)
game_grid.addWidget(self.food_key_edit, 1, 1)
game_grid.addWidget(QLabel("上马按键:"), 1, 2)
game_grid.addWidget(self.gs_mount_key, 1, 3)
game_grid.addWidget(QLabel("吃面包血量阈值:"), 2, 0)
game_grid.addWidget(self.eat_hp_threshold_spin, 2, 1)
game_grid.addWidget(QLabel("上马按住时长:"), 2, 2)
game_grid.addWidget(self.gs_mount_hold, 2, 3)
game_grid.addWidget(QLabel("吃面包最长等待:"), 3, 0)
game_grid.addWidget(self.eat_max_wait_spin, 3, 1)
game_grid.addWidget(QLabel("上马重试间隔:"), 3, 2)
game_grid.addWidget(self.gs_mount_retry, 3, 3)
game_grid.addWidget(QLabel("炉石按键:"), 4, 0)
game_grid.addWidget(self.gs_hearthstone_key, 4, 1)
game_grid.addWidget(QLabel("释放灵魂按键:"), 4, 2)
game_grid.addWidget(self.gs_release_spirit_key, 4, 3)
game_grid.addWidget(self.gs_bag_full_hearthstone, 5, 1)
game_grid.addWidget(QLabel("复活按键:"), 5, 2)
game_grid.addWidget(self.gs_resurrect_key, 5, 3)
params_layout.addWidget(game_group)
params_layout.addStretch()
@@ -1025,11 +1155,13 @@ class WoWMultiKeyGUI(QMainWindow):
self.food_key_edit.setText(str(bot_cfg.get('food_key', 'f1')).strip() or 'f1')
self.eat_hp_threshold_spin.setValue(int(bot_cfg.get('eat_hp_threshold', 30)))
self.eat_max_wait_spin.setValue(float(bot_cfg.get('eat_max_wait_sec', 30.0)))
self.gs_enable_mouse_loot.setChecked(bool(bot_cfg.get('enable_mouse_loot', True)))
except Exception:
self.skinning_wait_spin.setValue(1.5)
self.food_key_edit.setText('f1')
self.eat_hp_threshold_spin.setValue(30)
self.eat_max_wait_spin.setValue(30.0)
self.gs_enable_mouse_loot.setChecked(True)
def _save_params_config(self):
"""保存「参数配置」界面到 game_state_config.json多分辨率并写入 wow_multikey_qt.jsonbot 参数)"""
@@ -1058,6 +1190,7 @@ class WoWMultiKeyGUI(QMainWindow):
self.config['bot']['food_key'] = self.food_key_edit.text().strip() or 'f1'
self.config['bot']['eat_hp_threshold'] = int(self.eat_hp_threshold_spin.value())
self.config['bot']['eat_max_wait_sec'] = float(self.eat_max_wait_spin.value())
self.config['bot']['enable_mouse_loot'] = self.gs_enable_mouse_loot.isChecked()
self._save_main_config()
self.log(f"✅ 参数配置已保存至 {path},并更新 bot 参数")
@@ -1505,6 +1638,8 @@ class WoWMultiKeyGUI(QMainWindow):
release_spirit_key = '9'
resurrect_key = '0'
enable_mouse_loot = self.gs_enable_mouse_loot.isChecked()
self.game_worker = GameLoopWorker(
mode, waypoints_path=waypoints_path, vendor_path=vendor_path,
attack_loop_path=attack_loop_path,
@@ -1523,6 +1658,7 @@ class WoWMultiKeyGUI(QMainWindow):
resurrection_waypoints_path=resurrection_waypoints_path,
release_spirit_key=release_spirit_key,
resurrect_key=resurrect_key,
enable_mouse_loot=enable_mouse_loot,
)
self.game_worker.state_signal.connect(self.state_label.setText)
self.game_worker.log_signal.connect(self.log)
@@ -1625,6 +1761,36 @@ class WoWMultiKeyGUI(QMainWindow):
else:
QMessageBox.warning(self, "提示", "请先进行巡逻点录制")
def start_loot_record(self):
"""开始录制拾取轨迹。"""
if not self.hwnd:
QMessageBox.warning(self, "提示", "未找到游戏窗口,无法录制轨迹")
return
if self.loot_recorder_worker and self.loot_recorder_worker.isRunning():
return
self.loot_recorder_worker = LootPathRecorderWorker(self.hwnd)
self.loot_recorder_worker.log_signal.connect(self.log)
self.loot_recorder_worker.finished_signal.connect(self._on_loot_record_finished)
self.loot_recorder_worker.start()
self.loot_record_start_btn.setEnabled(False)
self.loot_record_stop_btn.setEnabled(True)
self.status_bar.showMessage("🔴 拾取轨迹录制中...")
def stop_loot_record(self):
"""停止录制拾取轨迹。"""
if self.loot_recorder_worker:
self.loot_recorder_worker.stop()
self.loot_record_start_btn.setEnabled(True)
self.loot_record_stop_btn.setEnabled(False)
self.status_bar.showMessage("✅ 录制已结束")
def _on_loot_record_finished(self, count):
self.log(f"🎬 拾取录制完成,共记录 {count} 个有效点")
self.loot_record_start_btn.setEnabled(True)
self.loot_record_stop_btn.setEnabled(False)
def closeEvent(self, event):
running = bool(self.key_workers) or (self.game_worker and self.game_worker.isRunning())
if running: