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:
285
auto_bot_move.py
285
auto_bot_move.py
@@ -3,8 +3,11 @@ import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import pydirectinput
|
||||
|
||||
import math
|
||||
import ctypes
|
||||
import cv2
|
||||
import numpy as np
|
||||
from hardware_control import hw_ctrl
|
||||
from game_state import parse_game_state, load_layout_config
|
||||
from coordinate_patrol import CoordinatePatrol
|
||||
from death_manager import DeathManager
|
||||
@@ -20,6 +23,7 @@ DEFAULT_EAT_MAX_WAIT_SEC = 30.0
|
||||
|
||||
# 巡逻点配置文件
|
||||
WAYPOINTS_FILE = 'waypoints.json'
|
||||
WIN_TITLE = "魔兽世界"
|
||||
|
||||
# 默认巡逻航点(waypoints.json 不存在或无效时使用)
|
||||
DEFAULT_WAYPOINTS = [(23.8, 71.0), (27.0, 79.0), (31.0, 72.0)]
|
||||
@@ -81,6 +85,88 @@ def load_attack_loop(path):
|
||||
return None
|
||||
|
||||
|
||||
class CursorManager:
|
||||
"""通过图像识别判断鼠标图标类型"""
|
||||
def __init__(self):
|
||||
self.templates = {}
|
||||
self.handle_cache = {} # 句柄 -> 类型缓存
|
||||
self._load_templates()
|
||||
|
||||
def _load_templates(self):
|
||||
# 强制使用纯相对路径,不带盘符前缀,由 Python 自动处理 CWD
|
||||
cursor_dir = os.path.join('images', 'cursor')
|
||||
files = {
|
||||
'Point': 'Point.PNG',
|
||||
'Attack': 'Attack.PNG',
|
||||
'LootAll': 'LootAll.PNG',
|
||||
'Skin': 'Skin.PNG'
|
||||
}
|
||||
for name, fname in files.items():
|
||||
path = os.path.join(cursor_dir, fname)
|
||||
# 记录尝试读取的路径
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
# 使用 np.fromfile 读取为字节流,再解码,完美避开 OpenCV 的 imread 编码 Bug
|
||||
img_array = np.fromfile(path, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED)
|
||||
if img is not None:
|
||||
if img.shape[2] == 3:
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
|
||||
self.templates[name] = img
|
||||
except Exception as e:
|
||||
print(f">>> [CursorMgr] 加载 {fname} 失败: {e}")
|
||||
else:
|
||||
print(f">>> [CursorMgr] 找不到文件: {path}")
|
||||
|
||||
def get_type(self, hcursor):
|
||||
if not hcursor or hcursor == 0: return 'Other', 0.0
|
||||
if hcursor in self.handle_cache: return self.handle_cache[hcursor]
|
||||
res = self._identify(hcursor)
|
||||
self.handle_cache[hcursor] = res
|
||||
return res
|
||||
|
||||
def _identify(self, hcursor):
|
||||
import win32ui
|
||||
try:
|
||||
# 捕获 48x48 区域,留出缩放空间
|
||||
width, height = 48, 48
|
||||
hdc = win32gui.GetDC(0)
|
||||
dc = win32ui.CreateDCFromHandle(hdc)
|
||||
memdc = dc.CreateCompatibleDC()
|
||||
bitmap = win32ui.CreateBitmap()
|
||||
bitmap.CreateCompatibleBitmap(dc, width, height)
|
||||
memdc.SelectObject(bitmap)
|
||||
win32gui.DrawIconEx(memdc.GetSafeHdc(), 0, 0, hcursor, width, height, 0, 0, win32con.DI_NORMAL)
|
||||
bits = bitmap.GetBitmapBits(True)
|
||||
target = np.frombuffer(bits, dtype='uint8').reshape(height, width, 4)
|
||||
memdc.DeleteDC()
|
||||
win32gui.ReleaseDC(0, hdc)
|
||||
|
||||
best_name = 'Other'
|
||||
max_score = 0.0
|
||||
|
||||
# 多尺度匹配:尝试 0.8, 1.0, 1.2 倍缩放,解决 UI 缩放问题
|
||||
scales = [0.8, 1.0, 1.2]
|
||||
for name, temp in self.templates.items():
|
||||
t_h, t_w = temp.shape[:2]
|
||||
for s in scales:
|
||||
s_w, s_h = int(t_w * s), int(t_h * s)
|
||||
if s_w > width or s_h > height: continue
|
||||
|
||||
res_temp = cv2.resize(temp, (s_w, s_h))
|
||||
res = cv2.matchTemplate(target, res_temp, cv2.TM_CCOEFF_NORMED)
|
||||
_, score, _, _ = cv2.minMaxLoc(res)
|
||||
if score > max_score:
|
||||
max_score = score
|
||||
best_name = name
|
||||
|
||||
if max_score > 0.2:
|
||||
print(f">>>> [雷达识别] 目标: {best_name} | 最高分: {max_score:.3f}")
|
||||
return best_name, max_score
|
||||
except Exception as e:
|
||||
return 'Other', 0.0
|
||||
|
||||
|
||||
class AutoBotMove:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -96,14 +182,20 @@ class AutoBotMove:
|
||||
resurrection_waypoints_path=None,
|
||||
release_spirit_key=None,
|
||||
resurrect_key=None,
|
||||
enable_mouse_loot=True,
|
||||
):
|
||||
self.last_tab_time = 0
|
||||
self.last_interaction_time = 0 # 记录上一次按互动键的时间
|
||||
self.last_target_hp = 0 # 记录上一次的目标血量
|
||||
self._has_braked_for_target = False # 是否已经执行过刹车
|
||||
self.is_running = True
|
||||
self.is_moving = False
|
||||
self.target_acquired_time = None # 本次获得目标的时间,用于仅在做「跑向怪」时做卡死检测
|
||||
# 记录上一帧是否处于战斗/有目标,用于检测“刚刚脱战”的瞬间
|
||||
self._was_in_combat_or_target = False
|
||||
self.skinning_wait_sec = float(skinning_wait_sec) if skinning_wait_sec is not None else 1.5
|
||||
self.enable_mouse_loot = enable_mouse_loot
|
||||
self.cursor_mgr = CursorManager()
|
||||
self.food_key = (food_key or DEFAULT_FOOD_KEY).strip().lower() or DEFAULT_FOOD_KEY
|
||||
self.eat_hp_threshold = int(eat_hp_threshold) if eat_hp_threshold is not None else DEFAULT_EAT_HP_THRESHOLD
|
||||
self.eat_max_wait_sec = float(eat_max_wait_sec) if eat_max_wait_sec is not None else DEFAULT_EAT_MAX_WAIT_SEC
|
||||
@@ -161,15 +253,102 @@ class AutoBotMove:
|
||||
def execute_disengage_loot(self):
|
||||
"""从有战斗/目标切换到完全脱战的瞬间,执行拾取 + 剥皮。"""
|
||||
try:
|
||||
# 拾取
|
||||
pydirectinput.press(KEY_LOOT)
|
||||
time.sleep(0.5)
|
||||
# 剥皮
|
||||
pydirectinput.press(KEY_LOOT)
|
||||
time.sleep(self.skinning_wait_sec)
|
||||
# 1. 拟人化鼠标扫雷补漏拾取(根据开关决定是否执行)
|
||||
if self.enable_mouse_loot:
|
||||
self.mouse_sweep_loot()
|
||||
else:
|
||||
# 关闭扫雷时,执行基础的交互拾取
|
||||
hw_ctrl.press(KEY_LOOT)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2. 最后补漏剥皮(针对脚下尸体)
|
||||
# hw_ctrl.press(KEY_LOOT)
|
||||
# time.sleep(self.skinning_wait_sec + 0.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def mouse_sweep_loot(self):
|
||||
"""支持图标识别的高精度扫雷拾取。"""
|
||||
if random.random() < 0.1: return False
|
||||
hwnd = win32gui.FindWindow(None, WIN_TITLE)
|
||||
if not hwnd: return False
|
||||
|
||||
try:
|
||||
rect = win32gui.GetWindowRect(hwnd)
|
||||
left, top, right, bottom = rect
|
||||
center_x = left + (right - left) // 2
|
||||
center_y = top + (bottom - top) // 2
|
||||
|
||||
# 1. 强制“角落校准”采样
|
||||
win32api.SetCursorPos((left + 50, top + 50))
|
||||
time.sleep(0.2)
|
||||
_, default_hcursor, _ = win32gui.GetCursorInfo()
|
||||
|
||||
# 2. 获取扫瞄路径点位
|
||||
path_points = []
|
||||
path_file = get_config_path("loot_path.json")
|
||||
if os.path.exists(path_file):
|
||||
with open(path_file, 'r') as f:
|
||||
path_points = json.load(f)
|
||||
else:
|
||||
# 降级方案:生成半椭圆点位
|
||||
x_scale, y_scale = 1.8, 0.8
|
||||
for r in range(50, (bottom-top)//2, 40):
|
||||
angles = range(180, 360, 5) if (r//40)%2==0 else range(360, 180, -5)
|
||||
for a in angles:
|
||||
rad = math.radians(a)
|
||||
path_points.append((int(r * math.cos(rad) * x_scale), int(r * math.sin(rad) * y_scale)))
|
||||
|
||||
# 3. 开始沿路径扫瞄
|
||||
start_time = time.time()
|
||||
looted_positions = []
|
||||
|
||||
for dx, dy in path_points:
|
||||
if time.time() - start_time > 15.0: break
|
||||
|
||||
target_x = center_x + dx + random.randint(-5, 5)
|
||||
target_y = center_y + dy + random.randint(-5, 5)
|
||||
|
||||
if not (left+10 < target_x < right-10 and top+10 < target_y < bottom-10): continue
|
||||
if any(math.dist((target_x, target_y), pos) < 30 for pos in looted_positions): continue
|
||||
|
||||
win32api.SetCursorPos((target_x, target_y))
|
||||
time.sleep(0.02)
|
||||
|
||||
_, hcursor, _ = win32gui.GetCursorInfo()
|
||||
if hcursor != 0 and hcursor != default_hcursor:
|
||||
# 识别图标类型
|
||||
ctype_name, score = self.cursor_mgr.get_type(hcursor)
|
||||
|
||||
if score > 0.7 and ctype_name in ['LootAll', 'Skin']:
|
||||
print(f">>> [扫雷] 识别成功: {ctype_name} (得分: {score:.3f}), 执行右键点击")
|
||||
hw_ctrl.right_click()
|
||||
looted_positions.append((target_x, target_y))
|
||||
|
||||
# 根据图标类型决定等待时间
|
||||
wait_sec = 1.3 if ctype_name == 'LootAll' else self.skinning_wait_sec
|
||||
time.sleep(wait_sec)
|
||||
|
||||
# 等待指针恢复
|
||||
wait_start = time.time()
|
||||
while time.time() - wait_start < 0.8:
|
||||
_, curr_h, _ = win32gui.GetCursorInfo()
|
||||
check_name, _ = self.cursor_mgr.get_type(curr_h)
|
||||
if check_name == 'Point': break
|
||||
time.sleep(0.1)
|
||||
time.sleep(random.uniform(0.1, 0.2))
|
||||
elif score > 0.4:
|
||||
# 记录一下高分但未达到门槛的识别,方便调试
|
||||
print(f">>> [扫雷] 疑似图标: {ctype_name} (得分: {score:.3f} < 门槛 0.7)")
|
||||
|
||||
if self._should_stop(): break
|
||||
|
||||
win32api.SetCursorPos((center_x, center_y))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f">>> [扫雷拾取] 出错: {e}")
|
||||
return False
|
||||
|
||||
def execute_combat_logic(self, state):
|
||||
if self.attack_loop_config:
|
||||
cfg = self.attack_loop_config
|
||||
@@ -177,29 +356,29 @@ class AutoBotMove:
|
||||
return
|
||||
hp = state.get('hp')
|
||||
if hp is not None and cfg.get('hp_below_percent') and cfg.get('hp_key') and hp < cfg['hp_below_percent']:
|
||||
pydirectinput.press(cfg['hp_key'])
|
||||
hw_ctrl.press(cfg['hp_key'])
|
||||
time.sleep(0.2)
|
||||
mp = state.get('mp')
|
||||
if mp is not None and cfg.get('mp_below_percent') and cfg.get('mp_key') and mp < cfg['mp_below_percent']:
|
||||
pydirectinput.press(cfg['mp_key'])
|
||||
hw_ctrl.press(cfg['mp_key'])
|
||||
time.sleep(0.2)
|
||||
|
||||
# 每次攻击前选中目标
|
||||
# pydirectinput.press(KEY_LOOT)
|
||||
# hw_ctrl.press(KEY_LOOT)
|
||||
for step in cfg['steps']:
|
||||
key = step.get('key') or KEY_ATTACK
|
||||
delay = float(step.get('delay', 0.5))
|
||||
pydirectinput.press(key)
|
||||
hw_ctrl.press(key)
|
||||
time.sleep(delay)
|
||||
return
|
||||
# 默认:模拟手动按键的节奏
|
||||
if random.random() < 0.3:
|
||||
pydirectinput.press(KEY_ATTACK)
|
||||
hw_ctrl.press(KEY_ATTACK)
|
||||
|
||||
def _start_eating(self):
|
||||
"""开始就地吃面包恢复。"""
|
||||
self._eating_started_at = time.time()
|
||||
pydirectinput.press(self.food_key)
|
||||
hw_ctrl.press(self.food_key)
|
||||
|
||||
def _should_keep_eating(self, state) -> bool:
|
||||
"""
|
||||
@@ -250,7 +429,8 @@ class AutoBotMove:
|
||||
self.patrol_controller.reset_stuck()
|
||||
# 勾选"包满炉石回城":按炉石后触发停止回调
|
||||
if self.logistics_manager.bag_full_hearthstone:
|
||||
self.logistics_manager.use_hearthstone_and_stop()
|
||||
get_state_fn = (lambda: None if self._should_stop() else parse_game_state())
|
||||
self.logistics_manager.use_hearthstone_and_stop(get_state=get_state_fn)
|
||||
if callable(getattr(self, '_on_hearthstone_stop', None)):
|
||||
self._on_hearthstone_stop()
|
||||
return
|
||||
@@ -261,7 +441,9 @@ class AutoBotMove:
|
||||
|
||||
# 3. 战斗/有目标:停止移动,执行攻击逻辑;仅在「跑向怪」短窗口内做卡死检测
|
||||
effective_target = self._is_effective_target(state)
|
||||
# 核心修改:只要还在战斗中,就不算“完全脱战”,即使当前没目标(可能正在 Tab 找下一个)
|
||||
in_combat_or_target = bool(state['combat'] or effective_target)
|
||||
|
||||
if in_combat_or_target:
|
||||
# 被动进战时立即打断进食等待,转入正常战斗流程
|
||||
self._eating_started_at = None
|
||||
@@ -271,41 +453,52 @@ class AutoBotMove:
|
||||
self.patrol_controller.stop_all()
|
||||
self.is_moving = False
|
||||
self.patrol_controller.reset_stuck()
|
||||
# 跑向怪阶段的卡死检测:只在目标血量为 100% 时认为是在“刚刚开怪、接近怪物”,避免残血目标也触发跑向怪卡死逻辑
|
||||
target_hp = state.get('target_hp')
|
||||
if effective_target and target_hp is not None and target_hp >= 100:
|
||||
try:
|
||||
if self.patrol_controller.stuck_handler.check_stuck(
|
||||
state, self.patrol_controller.last_turn_end_time
|
||||
):
|
||||
print(">>> [战斗] 跑向怪时可能卡住障碍物,执行脱困")
|
||||
self.patrol_controller.stop_all()
|
||||
self.patrol_controller.stuck_handler.resolve_stuck()
|
||||
self.patrol_controller.reset_stuck()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# 只有在“有效目标”成立时才真正执行攻击按键
|
||||
|
||||
# 只有在有“活的目标”时才执行攻击和交互
|
||||
if effective_target:
|
||||
self.execute_combat_logic(state)
|
||||
target_hp = state.get('target_hp', 0)
|
||||
if target_hp > 0:
|
||||
now = time.time()
|
||||
# 识别是否切换了新目标(血量跳变)
|
||||
is_new_target = (target_hp > self.last_target_hp + 5)
|
||||
if is_new_target:
|
||||
self._has_braked_for_target = False
|
||||
|
||||
# 1. 掉血刹车逻辑:第一次发现目标掉血且正在移动,按一下 S 停住
|
||||
if not self._has_braked_for_target and 0 < target_hp < self.last_target_hp:
|
||||
hw_ctrl.keyDown('s')
|
||||
time.sleep(0.05)
|
||||
hw_ctrl.keyUp('s')
|
||||
self._has_braked_for_target = True
|
||||
|
||||
# 2. 交互键(KEY_LOOT)按键策略:
|
||||
cooldown = 2.0 if not state['combat'] else 6.0
|
||||
if is_new_target or (now - self.last_interaction_time > cooldown):
|
||||
hw_ctrl.press(KEY_LOOT)
|
||||
self.last_interaction_time = now
|
||||
|
||||
self.last_target_hp = target_hp
|
||||
# 执行正常的攻击循环
|
||||
self.execute_combat_logic(state)
|
||||
else:
|
||||
self.last_target_hp = 0
|
||||
self._has_braked_for_target = False
|
||||
|
||||
self._was_in_combat_or_target = True
|
||||
return
|
||||
else:
|
||||
# 从“有战斗/目标”切换到“完全脱战”的瞬间:尝试智能接回前方最近巡逻点
|
||||
# 只有当 state['combat'] 和 effective_target 均为 False 时,才执行脱战逻辑
|
||||
if self._was_in_combat_or_target:
|
||||
# 执行脱战拾取
|
||||
# 此时已彻底脱战,执行拾取
|
||||
self.execute_disengage_loot()
|
||||
x, y = state.get('x'), state.get('y')
|
||||
if x is not None and y is not None:
|
||||
try:
|
||||
current_pos = (float(x), float(y))
|
||||
# max_ahead: 前方查看 10 个点;max_dist: 最大允许接入距离 10
|
||||
self.patrol_controller.snap_to_forward_waypoint(
|
||||
current_pos, max_ahead=10, max_dist=10.0, skip_current=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.target_acquired_time = None # 脱战/无目标时清零,下次获得目标再计时
|
||||
|
||||
# 扫尾动作执行完后,本 tick 强制结束,防止立即按下 Tab
|
||||
self._was_in_combat_or_target = False
|
||||
self.target_acquired_time = None
|
||||
self.last_tab_time = time.time() + 1.0 # 给找怪增加 1 秒额外冷却
|
||||
return
|
||||
|
||||
self.target_acquired_time = None
|
||||
self._was_in_combat_or_target = False
|
||||
|
||||
# 4. 脱战低血量:就地吃面包(最多等待 30 秒或回满)
|
||||
@@ -325,7 +518,7 @@ class AutoBotMove:
|
||||
|
||||
# 5. 顺便每隔几秒按一下 Tab(主动找怪)
|
||||
if not effective_target and (time.time() - self.last_tab_time > 2.0):
|
||||
pydirectinput.press(KEY_TAB)
|
||||
hw_ctrl.press(KEY_TAB)
|
||||
self.last_tab_time = time.time()
|
||||
|
||||
# 在 main 循环中使用:从 game_state 获取 state
|
||||
@@ -351,4 +544,4 @@ if __name__ == "__main__":
|
||||
bot.execute_logic(state)
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n已停止。")
|
||||
print("\n已停止。")
|
||||
|
||||
Reference in New Issue
Block a user