修改游戏窗口获取逻辑以及完善获取模拟点击相关逻辑

This commit is contained in:
moweishan
2026-03-20 12:11:27 +08:00
parent b67c5be2f3
commit 62f717dde4
8 changed files with 2221 additions and 31 deletions

296
debug_tools.py Normal file
View File

@@ -0,0 +1,296 @@
"""
调试工具集
用于开发和调试时的便捷功能
"""
import sys
import time
import json
from pathlib import Path
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
import win32api
import win32gui
import win32con
from src.core.game_window import GameWindowManager
from src.core.input_simulator import InputSimulator
class CoordinateDebugger:
"""坐标调试器 - 实时显示鼠标坐标"""
def __init__(self):
self.window = None
self.running = False
def capture_window(self):
"""捕获游戏窗口"""
self.window = GameWindowManager()
if self.window.capture_window():
print(f"✅ 已捕获窗口: {self.window.window_title}")
print(f" 窗口大小: {self.window.client_size}")
return True
else:
print("❌ 未找到游戏窗口")
return False
def show_realtime_coords(self):
"""实时显示鼠标坐标"""
print("\n" + "="*60)
print("实时坐标显示")
print("="*60)
print("移动鼠标到游戏窗口内查看坐标")
print("按 Ctrl+C 停止\n")
if not self.window:
print("⚠️ 请先捕获窗口 (选项 1)")
return
self.running = True
last_pos = None
try:
while self.running:
# 获取鼠标屏幕坐标
screen_x, screen_y = win32api.GetCursorPos()
# 获取窗口信息
hwnd = self.window.hwnd
if not hwnd:
print("❌ 窗口已关闭")
break
# 获取窗口客户区位置
client_rect = win32gui.GetClientRect(hwnd)
client_left, client_top = win32gui.ClientToScreen(hwnd, (client_rect[0], client_rect[1]))
# 计算窗口相对坐标
rel_x = screen_x - client_left
rel_y = screen_y - client_top
# 只在坐标变化时更新显示
current_pos = (rel_x, rel_y)
if current_pos != last_pos:
# 清行并显示新坐标
print(f"\r屏幕坐标: ({screen_x:4d}, {screen_y:4d}) | "
f"窗口相对: ({rel_x:4d}, {rel_y:4d})", end="", flush=True)
last_pos = current_pos
time.sleep(0.05)
except KeyboardInterrupt:
print("\n\n已停止")
self.running = False
def test_click_at_current_pos(self):
"""在当前鼠标位置测试点击"""
print("\n" + "="*60)
print("测试点击 - 当前鼠标位置")
print("="*60)
if not self.window:
print("⚠️ 请先捕获窗口 (选项 1)")
return
print("3秒后将点击当前鼠标位置...")
time.sleep(3)
# 获取当前鼠标位置
screen_x, screen_y = win32api.GetCursorPos()
# 转换为窗口相对坐标
hwnd = self.window.hwnd
client_rect = win32gui.GetClientRect(hwnd)
client_left, client_top = win32gui.ClientToScreen(hwnd, (client_rect[0], client_rect[1]))
rel_x = screen_x - client_left
rel_y = screen_y - client_top
print(f"\n点击位置: 屏幕({screen_x}, {screen_y}) | 窗口相对({rel_x}, {rel_y})")
# 执行点击
sim = InputSimulator(self.window)
sim.click(rel_x, rel_y)
print("✅ 点击完成")
def test_click_at_coords(self):
"""在指定坐标测试点击"""
print("\n" + "="*60)
print("测试点击 - 指定坐标")
print("="*60)
if not self.window:
print("⚠️ 请先捕获窗口 (选项 1)")
return
try:
x = int(input("请输入 X 坐标: "))
y = int(input("请输入 Y 坐标: "))
except ValueError:
print("❌ 无效的坐标")
return
print(f"\n将在 ({x}, {y}) 点击")
print("3秒后开始...")
time.sleep(3)
sim = InputSimulator(self.window)
sim.click(x, y)
print("✅ 点击完成")
class CoordinateRecorder:
"""坐标记录器 - 记录并保存常用坐标"""
def __init__(self, save_file: str = "coordinates.json"):
self.save_file = Path(save_file)
self.coordinates = {}
self.load()
def load(self):
"""加载已保存的坐标"""
if self.save_file.exists():
with open(self.save_file, 'r', encoding='utf-8') as f:
self.coordinates = json.load(f)
else:
self.coordinates = {}
def save(self):
"""保存坐标到文件"""
with open(self.save_file, 'w', encoding='utf-8') as f:
json.dump(self.coordinates, f, ensure_ascii=False, indent=2)
print(f"✅ 坐标已保存到 {self.save_file}")
def record_current_pos(self, name: str, window: GameWindowManager):
"""记录当前鼠标位置"""
screen_x, screen_y = win32api.GetCursorPos()
hwnd = window.hwnd
client_rect = win32gui.GetClientRect(hwnd)
client_left, client_top = win32gui.ClientToScreen(hwnd, (client_rect[0], client_rect[1]))
rel_x = screen_x - client_left
rel_y = screen_y - client_top
self.coordinates[name] = {
"x": rel_x,
"y": rel_y,
"screen_x": screen_x,
"screen_y": screen_y,
"recorded_at": datetime.now().isoformat()
}
print(f"✅ 已记录 '{name}': ({rel_x}, {rel_y})")
def list_coordinates(self):
"""列出所有记录的坐标"""
if not self.coordinates:
print("⚠️ 没有记录的坐标")
return
print("\n" + "="*60)
print("已记录的坐标:")
print("="*60)
for name, coord in self.coordinates.items():
print(f" {name}: ({coord['x']}, {coord['y']})")
def get_coordinate(self, name: str) -> tuple:
"""获取指定名称的坐标"""
if name in self.coordinates:
coord = self.coordinates[name]
return (coord['x'], coord['y'])
return None
def delete_coordinate(self, name: str):
"""删除指定坐标"""
if name in self.coordinates:
del self.coordinates[name]
print(f"✅ 已删除 '{name}'")
else:
print(f"❌ 未找到 '{name}'")
def show_menu():
"""显示主菜单"""
print("\n" + "="*60)
print("调试工具菜单")
print("="*60)
print("1. 捕获游戏窗口")
print("2. 实时显示鼠标坐标")
print("3. 测试点击 - 当前鼠标位置")
print("4. 测试点击 - 指定坐标")
print("5. 记录当前坐标")
print("6. 查看已记录的坐标")
print("7. 删除坐标记录")
print("8. 保存坐标到文件")
print("0. 退出")
def main():
"""主函数"""
debugger = CoordinateDebugger()
recorder = CoordinateRecorder()
print("="*60)
print("LuoLuoTool 调试工具")
print("="*60)
print("请以管理员权限运行此脚本")
while True:
show_menu()
choice = input("\n请选择功能 (0-8): ").strip()
if choice == '0':
print("\n感谢使用,再见!")
break
elif choice == '1':
debugger.capture_window()
elif choice == '2':
debugger.show_realtime_coords()
elif choice == '3':
debugger.test_click_at_current_pos()
elif choice == '4':
debugger.test_click_at_coords()
elif choice == '5':
if not debugger.window:
print("⚠️ 请先捕获窗口 (选项 1)")
continue
name = input("请输入坐标名称 (如: '主界面按钮', '开始游戏'): ").strip()
if name:
recorder.record_current_pos(name, debugger.window)
else:
print("❌ 名称不能为空")
elif choice == '6':
recorder.list_coordinates()
elif choice == '7':
recorder.list_coordinates()
name = input("请输入要删除的坐标名称: ").strip()
if name:
recorder.delete_coordinate(name)
elif choice == '8':
recorder.save()
else:
print("❌ 无效选项")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\n程序被用户中断")
except Exception as e:
print(f"\n❌ 发生错误: {e}")
import traceback
traceback.print_exc()

275
demo_input.py Normal file
View File

@@ -0,0 +1,275 @@
"""
InputSimulator 使用示例
演示如何使用输入模拟器进行鼠标点击、滑动和键盘操作
运行前请确保:
1. 游戏《桃源深处有人家》已启动
2. 游戏窗口可见(未被最小化)
3. 以管理员权限运行此脚本
"""
import sys
import time
from pathlib import Path
# 添加项目根目录到路径
sys.path.insert(0, str(Path(__file__).parent))
from src.core.game_window import GameWindowManager
from src.core.input_simulator import InputSimulator
def demo_basic_click():
"""演示基础点击"""
print("\n" + "="*50)
print("演示1: 基础点击")
print("="*50)
# 捕获游戏窗口
window = GameWindowManager()
if not window.capture_window():
print("❌ 未找到游戏窗口,请确保游戏已运行")
return False
print(f"✅ 已捕获窗口: {window.client_size}")
# 创建模拟器
sim = InputSimulator(window)
# 获取窗口中心坐标
size = window.client_size
center_x = size[0] // 2
center_y = size[1] // 2
print(f"\n将在窗口中心 ({center_x}, {center_y}) 进行点击演示")
print("3秒后开始...")
time.sleep(3)
# 1. 短按点击
print("\n1. 短按点击...")
sim.click(center_x, center_y)
time.sleep(1)
# 2. 长按1秒
print("2. 长按1秒...")
sim.click(center_x, center_y, duration=1.0)
time.sleep(1)
# 3. 双击
print("3. 双击...")
sim.double_click(center_x, center_y)
time.sleep(1)
print("✅ 点击演示完成")
return True
def demo_swipe():
"""演示滑动操作"""
print("\n" + "="*50)
print("演示2: 滑动操作")
print("="*50)
window = GameWindowManager()
if not window.capture_window():
print("❌ 未找到游戏窗口")
return False
sim = InputSimulator(window)
size = window.client_size
# 计算滑动起点和终点
start_x = size[0] // 4
start_y = size[1] // 2
end_x = size[0] * 3 // 4
end_y = size[1] // 2
print(f"\n将从 ({start_x}, {start_y}) 滑动到 ({end_x}, {end_y})")
print("3秒后开始...")
time.sleep(3)
# 水平滑动
print("\n1. 水平滑动0.5秒)...")
sim.swipe(start_x, start_y, end_x, end_y, duration=0.5)
time.sleep(1)
# 垂直滑动
print("2. 垂直滑动0.5秒)...")
sim.swipe(size[0] // 2, size[1] // 4, size[0] // 2, size[1] * 3 // 4, duration=0.5)
time.sleep(1)
# 对角线滑动
print("3. 对角线滑动1秒...")
sim.swipe(100, 100, size[0]-100, size[1]-100, duration=1.0)
time.sleep(1)
print("✅ 滑动演示完成")
return True
def demo_keyboard():
"""演示键盘操作"""
print("\n" + "="*50)
print("演示3: 键盘操作")
print("="*50)
window = GameWindowManager()
if not window.capture_window():
print("❌ 未找到游戏窗口")
return False
sim = InputSimulator(window)
print("\n3秒后开始键盘演示...")
print("注意:请确保游戏窗口可以接收键盘输入")
time.sleep(3)
# 1. 按ESC键通常用于关闭菜单
print("\n1. 按 ESC 键...")
sim.key_press('esc')
time.sleep(1)
# 2. 按空格键
print("2. 按空格键...")
sim.key_press('space')
time.sleep(1)
# 3. 组合按键示例按住Ctrl再按A
print("3. 组合按键 Ctrl+A...")
sim.key_down('ctrl')
sim.key_press('a')
sim.key_up('ctrl')
time.sleep(1)
print("✅ 键盘演示完成")
return True
def demo_game_automation():
"""
演示游戏自动化场景
模拟一个简单的游戏操作流程
"""
print("\n" + "="*50)
print("演示4: 游戏自动化场景")
print("="*50)
window = GameWindowManager()
if not window.capture_window():
print("❌ 未找到游戏窗口")
return False
sim = InputSimulator(window)
size = window.client_size
print("\n这是一个模拟的游戏自动化流程:")
print("1. 打开菜单(点击菜单按钮)")
print("2. 选择选项(滑动选择)")
print("3. 确认(点击确认按钮)")
print("4. 关闭菜单按ESC")
print("\n5秒后开始自动化流程...")
time.sleep(5)
# 步骤1: 点击菜单按钮(假设在右上角)
print("\n步骤1: 点击菜单按钮...")
menu_x = size[0] - 100
menu_y = 100
sim.click(menu_x, menu_y)
time.sleep(1)
# 步骤2: 滑动选择选项
print("步骤2: 滑动选择选项...")
sim.swipe(size[0]//2, size[1]//2, size[0]//2, size[1]//2 - 200, duration=0.3)
time.sleep(1)
# 步骤3: 点击确认按钮
print("步骤3: 点击确认按钮...")
confirm_x = size[0] // 2
confirm_y = size[1] - 150
sim.click(confirm_x, confirm_y)
time.sleep(1)
# 步骤4: 关闭菜单
print("步骤4: 关闭菜单...")
sim.key_press('esc')
time.sleep(1)
print("✅ 自动化流程完成")
return True
def check_admin():
"""检查是否以管理员权限运行"""
import ctypes
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except:
return False
def main():
"""主函数"""
print("="*50)
print("InputSimulator 使用示例")
print("="*50)
print("\n请确保游戏《桃源深处有人家》已启动")
print("游戏窗口需要可见(未被最小化)")
# 检查管理员权限
if not check_admin():
print("\n⚠️ 警告:未以管理员权限运行")
print("某些功能可能无法正常工作")
print("建议:右键点击命令提示符/终端,选择'以管理员身份运行'")
print()
response = input("是否继续运行?(y/n): ").strip().lower()
if response != 'y':
print("程序已退出")
return
# 显示菜单
while True:
print("\n" + "="*50)
print("请选择要运行的演示:")
print("="*50)
print("1. 基础点击演示")
print("2. 滑动操作演示")
print("3. 键盘操作演示")
print("4. 游戏自动化场景演示")
print("5. 运行全部演示")
print("0. 退出")
choice = input("\n请输入选项 (0-5): ").strip()
if choice == '0':
print("\n感谢使用,再见!")
break
elif choice == '1':
demo_basic_click()
elif choice == '2':
demo_swipe()
elif choice == '3':
demo_keyboard()
elif choice == '4':
demo_game_automation()
elif choice == '5':
demo_basic_click()
demo_swipe()
demo_keyboard()
demo_game_automation()
print("\n" + "="*50)
print("✅ 所有演示已完成")
print("="*50)
else:
print("\n❌ 无效选项,请重新输入")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\n程序被用户中断")
except Exception as e:
print(f"\n❌ 发生错误: {e}")
import traceback
traceback.print_exc()

View File

@@ -1,5 +1,4 @@
# LuoLuoTool 依赖列表 # LuoLuoTool 依赖列表
# 《桃源深处有人家》挂机工具
# ========== UI框架 ========== # ========== UI框架 ==========
customtkinter>=5.2.2 # 现代化UI框架 customtkinter>=5.2.2 # 现代化UI框架

View File

@@ -1,9 +1,12 @@
""" """
游戏窗口管理 游戏窗口管理
通过 exe 文件名或窗口标题获取游戏窗口
""" """
import os
import win32gui import win32gui
import win32con import win32process
import psutil
from typing import Optional, Tuple from typing import Optional, Tuple
from src.utils.logger import logger from src.utils.logger import logger
@@ -11,13 +14,14 @@ from src.utils.logger import logger
class GameWindowManager: class GameWindowManager:
"""游戏窗口管理器""" """游戏窗口管理器"""
# 游戏窗口类名和标题 # 游戏配置
GAME_CLASS_NAME = "UnityWndClass" # Unity游戏常用类 GAME_EXE_NAME = "ycgame.exe" # 游戏exe文件
GAME_WINDOW_TITLE = "桃源深处有人家" GAME_WINDOW_TITLE = "桃源深处有人家" # 游戏窗口标题
def __init__(self): def __init__(self):
self._hwnd: Optional[int] = None self._hwnd: Optional[int] = None
self._window_rect: Tuple[int, int, int, int] = (0, 0, 0, 0) self._window_rect: Tuple[int, int, int, int] = (0, 0, 0, 0)
self._process_id: Optional[int] = None
@property @property
def is_window_captured(self) -> bool: def is_window_captured(self) -> bool:
@@ -31,6 +35,11 @@ class GameWindowManager:
"""窗口句柄""" """窗口句柄"""
return self._hwnd return self._hwnd
@property
def process_id(self) -> Optional[int]:
"""进程ID"""
return self._process_id
@property @property
def window_rect(self) -> Tuple[int, int, int, int]: def window_rect(self) -> Tuple[int, int, int, int]:
"""窗口矩形 (left, top, right, bottom)""" """窗口矩形 (left, top, right, bottom)"""
@@ -46,37 +55,175 @@ class GameWindowManager:
left, top, right, bottom = self.window_rect left, top, right, bottom = self.window_rect
return (right - left, bottom - top) return (right - left, bottom - top)
def find_window(self) -> bool: def find_window_by_exe(self, exe_name: str = None) -> bool:
"""查找游戏窗口""" """
# 先尝试精确匹配 通过exe文件名查找游戏窗口
hwnd = win32gui.FindWindow(None, self.GAME_WINDOW_TITLE)
# 如果没找到,尝试模糊匹配 Args:
if hwnd == 0: exe_name: exe文件名默认使用 GAME_EXE_NAME
def callback(hwnd, extra):
if win32gui.IsWindowVisible(hwnd): Returns:
title = win32gui.GetWindowText(hwnd) bool: 是否找到窗口
if self.GAME_WINDOW_TITLE in title: """
extra.append(hwnd) if exe_name is None:
exe_name = self.GAME_EXE_NAME
try:
# 获取所有进程
hwnd_list = []
def enum_windows_callback(hwnd, extra):
if not win32gui.IsWindowVisible(hwnd):
return True
try:
# 获取窗口对应的进程ID
_, pid = win32process.GetWindowThreadProcessId(hwnd)
# 获取进程信息
try:
process = psutil.Process(pid)
process_exe = process.exe()
# 检查exe文件名是否精确匹配
# 使用 os.path.basename 获取文件名,避免路径包含目标名称的误匹配
process_exe_name = os.path.basename(process_exe).lower()
target_exe_name = exe_name.lower()
if process_exe_name == target_exe_name:
extra.append((hwnd, pid, process_exe))
logger.debug(f"[窗口枚举] 找到匹配窗口: hwnd={hwnd}, pid={pid}, exe={process_exe}")
except psutil.NoSuchProcess:
# 进程已结束,忽略
logger.debug(f"[窗口枚举] 进程 {pid} 已不存在,跳过")
except psutil.AccessDenied:
# 无权限访问进程,忽略
logger.debug(f"[窗口枚举] 无权限访问进程 {pid},跳过")
except psutil.Error as e:
# 其他psutil错误
logger.warning(f"[窗口枚举] 获取进程 {pid} 信息失败: {e}")
except win32process.error as e:
# win32process 错误
logger.warning(f"[窗口枚举] 获取窗口 {hwnd} 的进程ID失败: {e}")
except OSError as e:
# 系统错误
logger.warning(f"[窗口枚举] 处理窗口 {hwnd} 时发生系统错误: {e}")
return True return True
windows = [] # 枚举所有窗口
win32gui.EnumWindows(callback, windows) win32gui.EnumWindows(enum_windows_callback, hwnd_list)
if windows:
hwnd = windows[0]
if hwnd != 0:
self._hwnd = hwnd
self._window_rect = win32gui.GetWindowRect(hwnd)
logger.info(f"找到游戏窗口: {hwnd}, 大小: {self.client_size}")
return True
logger.warning("未找到游戏窗口") if hwnd_list:
return False # 优先选择标题匹配的窗口
for hwnd, pid, exe_path in hwnd_list:
title = win32gui.GetWindowText(hwnd)
if self.GAME_WINDOW_TITLE in title:
self._hwnd = hwnd
self._process_id = pid
self._window_rect = win32gui.GetWindowRect(hwnd)
logger.info(f"通过exe找到游戏窗口: {hwnd}, PID: {pid}, 大小: {self.client_size}, 标题: {title}")
return True
# 如果没有标题匹配,使用第一个找到的窗口
hwnd, pid, exe_path = hwnd_list[0]
self._hwnd = hwnd
self._process_id = pid
self._window_rect = win32gui.GetWindowRect(hwnd)
title = win32gui.GetWindowText(hwnd)
logger.info(f"通过exe找到游戏窗口(备用): {hwnd}, PID: {pid}, 大小: {self.client_size}, 标题: {title}")
return True
logger.warning(f"未找到exe为 {exe_name} 的游戏窗口")
return False
except Exception as e:
logger.error(f"通过exe查找窗口失败: {e}")
return False
def find_window_by_title(self, title: str = None) -> bool:
"""
通过窗口标题查找游戏窗口
def capture_window(self) -> bool: Args:
"""捕获游戏窗口""" title: 窗口标题,默认使用 GAME_WINDOW_TITLE
return self.find_window()
Returns:
bool: 是否找到窗口
"""
if title is None:
title = self.GAME_WINDOW_TITLE
try:
# 先尝试精确匹配
hwnd = win32gui.FindWindow(None, title)
# 如果没找到,尝试模糊匹配
if hwnd == 0:
def callback(hwnd, extra):
if win32gui.IsWindowVisible(hwnd):
window_title = win32gui.GetWindowText(hwnd)
if title in window_title:
extra.append(hwnd)
return True
windows = []
win32gui.EnumWindows(callback, windows)
if windows:
hwnd = windows[0]
if hwnd != 0:
self._hwnd = hwnd
self._window_rect = win32gui.GetWindowRect(hwnd)
# 获取进程ID
try:
_, self._process_id = win32process.GetWindowThreadProcessId(hwnd)
except Exception:
self._process_id = None
logger.info(f"通过标题找到游戏窗口: {hwnd}, PID: {self._process_id}, 大小: {self.client_size}")
return True
logger.warning(f"未找到标题为 {title} 的游戏窗口")
return False
except Exception as e:
logger.error(f"通过标题查找窗口失败: {e}")
return False
def find_window(self, use_exe: bool = True) -> bool:
"""
查找游戏窗口(综合方法)
Args:
use_exe: 优先使用exe名查找失败后再尝试标题查找
Returns:
bool: 是否找到窗口
"""
if use_exe:
# 先尝试通过exe查找
if self.find_window_by_exe():
return True
# 失败后尝试标题查找
logger.info("通过exe查找失败尝试通过标题查找")
return self.find_window_by_title()
else:
return self.find_window_by_title()
def capture_window(self, use_exe: bool = True) -> bool:
"""
捕获游戏窗口
Args:
use_exe: 是否优先使用exe名查找
Returns:
bool: 是否成功捕获
"""
return self.find_window(use_exe=use_exe)
def bring_to_front(self): def bring_to_front(self):
"""将窗口置前""" """将窗口置前"""
@@ -95,3 +242,41 @@ class GameWindowManager:
# TODO: 使用pyautogui或win32api发送点击 # TODO: 使用pyautogui或win32api发送点击
logger.debug(f"点击坐标: ({screen_x}, {screen_y})") logger.debug(f"点击坐标: ({screen_x}, {screen_y})")
def get_window_info(self) -> dict:
"""
获取窗口详细信息
Returns:
dict: 窗口信息字典
"""
if not self.is_window_captured:
return {
"captured": False,
"hwnd": None,
"title": None,
"process_id": None,
"exe_path": None,
"rect": None,
"size": None
}
title = win32gui.GetWindowText(self._hwnd)
exe_path = None
if self._process_id:
try:
process = psutil.Process(self._process_id)
exe_path = process.exe()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return {
"captured": True,
"hwnd": self._hwnd,
"title": title,
"process_id": self._process_id,
"exe_path": exe_path,
"rect": self.window_rect,
"size": self.client_size
}

629
src/core/input_simulator.py Normal file
View File

@@ -0,0 +1,629 @@
"""
输入模拟器
提供鼠标和键盘的模拟操作,包括点击、长按、滑动等
使用 SendInput 模拟真实硬件输入,绕过游戏防护
# 捕获窗口
window = GameWindowManager()
window.capture_window()
# 创建模拟器
sim = InputSimulator(window)
# 点击操作(统一使用 SendInput
sim.click(x, y) # 短按
sim.click(x, y, duration=1.0) # 长按
sim.double_click(x, y) # 双击
sim.swipe(x1, y1, x2, y2, duration=0.5) # 滑动
sim.key_press('esc') # 按键
"""
import time
import random
import ctypes
from ctypes import wintypes
from typing import Optional, Tuple
from dataclasses import dataclass
from src.utils.logger import logger
from src.core.game_window import GameWindowManager
# Windows API 常量
INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
# 鼠标事件标志
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_MIDDLEDOWN = 0x0020
MOUSEEVENTF_MIDDLEUP = 0x0040
# 键盘事件标志
KEYEVENTF_KEYUP = 0x0002
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class INPUT_I(ctypes.Union):
_fields_ = [
("mi", MOUSEINPUT),
("ki", KEYBDINPUT),
]
class INPUT(ctypes.Structure):
_anonymous_ = ("_input",)
_fields_ = [
("type", wintypes.DWORD),
("_input", INPUT_I),
]
@dataclass
class Point:
"""坐标点"""
x: int
y: int
def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)
def __iter__(self):
yield self.x
yield self.y
class InputSimulator:
"""
输入模拟器 - 使用 SendInput 模拟真实硬件输入
对外暴露的简洁接口:
- click(x, y, duration=0) -> 点击/长按
- swipe(start_x, start_y, end_x, end_y, duration=0.5) -> 滑动
- key_press(key) -> 按键
- key_down(key) / key_up(key) -> 按住/释放按键
"""
def __init__(self, window_manager: GameWindowManager):
self.window = window_manager
self._screen_width = ctypes.windll.user32.GetSystemMetrics(0)
self._screen_height = ctypes.windll.user32.GetSystemMetrics(1)
logger.info(f"InputSimulator 初始化完成,屏幕分辨率: {self._screen_width}x{self._screen_height}")
def _to_screen_coords(self, x: int, y: int) -> Tuple[int, int]:
"""将窗口相对坐标转换为屏幕坐标"""
logger.debug(f"[坐标转换] 开始转换窗口相对坐标 ({x}, {y})")
if not self.window.is_window_captured:
logger.error("[坐标转换] 失败: 窗口未捕获")
raise RuntimeError("窗口未捕获,无法执行操作")
import win32gui
hwnd = self.window.hwnd
# 获取窗口客户区在屏幕上的位置
client_rect = win32gui.GetClientRect(hwnd)
client_left, client_top = win32gui.ClientToScreen(hwnd, (client_rect[0], client_rect[1]))
logger.debug(f"[坐标转换] 窗口客户区左上角屏幕坐标: ({client_left}, {client_top})")
# 计算屏幕坐标
screen_x = client_left + x
screen_y = client_top + y
logger.debug(f"[坐标转换] 计算后的屏幕坐标: ({screen_x}, {screen_y})")
# 确保在屏幕范围内
orig_screen_x, orig_screen_y = screen_x, screen_y
screen_x = max(0, min(screen_x, self._screen_width - 1))
screen_y = max(0, min(screen_y, self._screen_height - 1))
if (screen_x, screen_y) != (orig_screen_x, orig_screen_y):
logger.warning(f"[坐标转换] 坐标被限制在屏幕范围内: ({orig_screen_x}, {orig_screen_y}) -> ({screen_x}, {screen_y})")
logger.debug(f"[坐标转换] 完成: 窗口相对 ({x}, {y}) -> 屏幕 ({screen_x}, {screen_y})")
return screen_x, screen_y
def _activate_window(self):
"""激活窗口"""
logger.debug("[窗口激活] 开始激活窗口")
if not self.window.is_window_captured:
logger.error("[窗口激活] 失败: 窗口未捕获")
return False
import win32gui
import win32con
hwnd = self.window.hwnd
logger.debug(f"[窗口激活] 窗口句柄: {hwnd}")
# 如果窗口最小化,恢复它
if win32gui.IsIconic(hwnd):
logger.debug("[窗口激活] 窗口已最小化,执行恢复操作")
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
logger.info("[窗口激活] 窗口已从最小化恢复")
# 置顶窗口
logger.debug("[窗口激活] 设置窗口为前台窗口")
win32gui.SetForegroundWindow(hwnd)
# 验证窗口是否在前台
foreground_hwnd = win32gui.GetForegroundWindow()
if foreground_hwnd == hwnd:
logger.info("[窗口激活] 窗口激活成功")
else:
logger.warning(f"[窗口激活] 窗口可能未成功激活,前台窗口句柄: {foreground_hwnd}")
return True
def _send_input(self, inputs):
"""发送输入事件"""
nInputs = len(inputs)
LPINPUT = INPUT * nInputs
pInputs = LPINPUT(*inputs)
cbSize = ctypes.sizeof(INPUT)
logger.debug(f"[SendInput] 准备发送 {nInputs} 个输入事件,结构体大小: {cbSize}")
result = ctypes.windll.user32.SendInput(nInputs, pInputs, cbSize)
if result == 0:
error = ctypes.get_last_error()
logger.error(f"[SendInput] 发送失败,错误码: {error}")
else:
logger.debug(f"[SendInput] 发送成功,成功发送 {result} 个事件")
return result
def _get_system_time(self) -> int:
"""获取系统时间(毫秒)"""
return ctypes.windll.kernel32.GetTickCount()
def _move_mouse_absolute(self, x: int, y: int):
"""移动鼠标到绝对坐标(屏幕坐标)"""
logger.debug(f"[鼠标移动] 开始移动到屏幕坐标 ({x}, {y})")
abs_x = int(x * 65535 / (self._screen_width - 1))
abs_y = int(y * 65535 / (self._screen_height - 1))
logger.debug(f"[鼠标移动] 转换为 SendInput 绝对坐标: ({abs_x}, {abs_y})")
timestamp = self._get_system_time()
input_event = INPUT()
input_event.type = INPUT_MOUSE
input_event.mi = MOUSEINPUT(
dx=abs_x,
dy=abs_y,
mouseData=0,
dwFlags=MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE,
time=timestamp,
dwExtraInfo=None
)
logger.debug(f"[鼠标移动] 发送 MOUSEINPUT 事件: dx={abs_x}, dy={abs_y}, flags=MOUSEEVENTF_ABSOLUTE|MOUSEEVENTF_MOVE")
result = self._send_input([input_event])
if result > 0:
logger.info(f"[鼠标移动] 成功移动到屏幕坐标 ({x}, {y})")
else:
logger.error(f"[鼠标移动] 移动到屏幕坐标 ({x}, {y}) 失败")
def _mouse_down(self, button: str = "left"):
"""鼠标按下"""
logger.debug(f"[鼠标按下] 开始按下 {button}")
if button == "left":
flags = MOUSEEVENTF_LEFTDOWN
button_name = "左键"
elif button == "right":
flags = MOUSEEVENTF_RIGHTDOWN
button_name = "右键"
elif button == "middle":
flags = MOUSEEVENTF_MIDDLEDOWN
button_name = "中键"
else:
logger.error(f"[鼠标按下] 不支持的按钮类型: {button}")
return
timestamp = self._get_system_time()
input_event = INPUT()
input_event.type = INPUT_MOUSE
input_event.mi = MOUSEINPUT(
dx=0,
dy=0,
mouseData=0,
dwFlags=flags,
time=timestamp,
dwExtraInfo=None
)
logger.debug(f"[鼠标按下] 发送 MOUSEINPUT 事件: flags={flags}")
result = self._send_input([input_event])
if result > 0:
logger.info(f"[鼠标按下] {button_name} 按下成功")
else:
logger.error(f"[鼠标按下] {button_name} 按下失败")
def _mouse_up(self, button: str = "left"):
"""鼠标释放"""
logger.debug(f"[鼠标释放] 开始释放 {button}")
if button == "left":
flags = MOUSEEVENTF_LEFTUP
button_name = "左键"
elif button == "right":
flags = MOUSEEVENTF_RIGHTUP
button_name = "右键"
elif button == "middle":
flags = MOUSEEVENTF_MIDDLEUP
button_name = "中键"
else:
logger.error(f"[鼠标释放] 不支持的按钮类型: {button}")
return
timestamp = self._get_system_time()
input_event = INPUT()
input_event.type = INPUT_MOUSE
input_event.mi = MOUSEINPUT(
dx=0,
dy=0,
mouseData=0,
dwFlags=flags,
time=timestamp,
dwExtraInfo=None
)
logger.debug(f"[鼠标释放] 发送 MOUSEINPUT 事件: flags={flags}")
result = self._send_input([input_event])
if result > 0:
logger.info(f"[鼠标释放] {button_name} 释放成功")
else:
logger.error(f"[鼠标释放] {button_name} 释放失败")
def _key_down(self, vk_code: int):
"""按键按下"""
logger.debug(f"[按键按下] 开始按下按键,虚拟键码: {vk_code}")
input_event = INPUT()
input_event.type = INPUT_KEYBOARD
input_event.ki = KEYBDINPUT(
wVk=vk_code,
wScan=0,
dwFlags=0,
time=0,
dwExtraInfo=None
)
logger.debug(f"[按键按下] 发送 KEYBDINPUT 事件: wVk={vk_code}")
result = self._send_input([input_event])
if result > 0:
logger.info(f"[按键按下] 按键按下成功,虚拟键码: {vk_code}")
else:
logger.error(f"[按键按下] 按键按下失败,虚拟键码: {vk_code}")
def _key_up(self, vk_code: int):
"""按键释放"""
logger.debug(f"[按键释放] 开始释放按键,虚拟键码: {vk_code}")
input_event = INPUT()
input_event.type = INPUT_KEYBOARD
input_event.ki = KEYBDINPUT(
wVk=vk_code,
wScan=0,
dwFlags=KEYEVENTF_KEYUP,
time=0,
dwExtraInfo=None
)
logger.debug(f"[按键释放] 发送 KEYBDINPUT 事件: wVk={vk_code}, flags=KEYEVENTF_KEYUP")
result = self._send_input([input_event])
if result > 0:
logger.info(f"[按键释放] 按键释放成功,虚拟键码: {vk_code}")
else:
logger.error(f"[按键释放] 按键释放失败,虚拟键码: {vk_code}")
# ==================== 对外接口 ====================
def _human_like_delay(self, base_delay: float = 0.05):
"""模拟人类操作的随机延迟"""
delay = base_delay + random.uniform(0.01, 0.03)
logger.debug(f"[延迟] 人类模拟延迟: {delay:.3f}s (基础: {base_delay}s)")
time.sleep(delay)
def _add_jitter(self, x: int, y: int, max_jitter: int = 3) -> Tuple[int, int]:
"""添加随机抖动,使坐标不完全精确"""
jitter_x = random.randint(-max_jitter, max_jitter)
jitter_y = random.randint(-max_jitter, max_jitter)
new_x, new_y = x + jitter_x, y + jitter_y
if jitter_x != 0 or jitter_y != 0:
logger.debug(f"[抖动] 添加随机抖动: ({x}, {y}) -> ({new_x}, {new_y}), 抖动量: ({jitter_x}, {jitter_y})")
return new_x, new_y
def click(self, x: int, y: int, duration: float = 0, button: str = "left", human_like: bool = True):
"""
点击指定位置
Args:
x, y: 窗口内的相对坐标
duration: 按住时长0表示短按>0表示长按
button: 鼠标按钮,"left"/"right"/"middle"
human_like: 是否模拟人类操作(随机延迟和抖动)
"""
action = "长按" if duration > 0 else "点击"
logger.info(f"[点击操作] 开始{action}: 窗口相对坐标 ({x}, {y}), 按钮: {button}, 时长: {duration}s, 人类模拟: {human_like}")
# 激活窗口
logger.debug("[点击操作] 步骤 1/5: 激活窗口")
self._activate_window()
self._human_like_delay(0.1)
# 转换为屏幕坐标
logger.debug("[点击操作] 步骤 2/5: 坐标转换")
screen_x, screen_y = self._to_screen_coords(x, y)
# 添加人为抖动
if human_like:
logger.debug("[点击操作] 步骤 3/5: 添加随机抖动")
screen_x, screen_y = self._add_jitter(screen_x, screen_y)
else:
logger.debug("[点击操作] 步骤 3/5: 跳过随机抖动")
# 使用 SendInput 移动鼠标到目标位置
logger.debug("[点击操作] 步骤 4/5: 移动鼠标到目标位置")
self._move_mouse_absolute(screen_x, screen_y)
self._human_like_delay(0.05)
# 按下
logger.debug(f"[点击操作] 步骤 5/5: 执行{action}")
self._mouse_down(button)
if duration > 0:
logger.debug(f"[点击操作] 长按等待: {duration}s")
time.sleep(duration)
else:
self._human_like_delay(0.05)
# 释放
logger.debug("[点击操作] 释放鼠标按钮")
self._mouse_up(button)
logger.info(f"[点击操作] {action}完成: ({x}, {y}) 按钮: {button}, 时长: {duration}s")
def swipe(self, start_x: int, start_y: int, end_x: int, end_y: int,
duration: float = 0.5, button: str = "left"):
"""
从起点滑动到终点
Args:
start_x, start_y: 起点坐标(窗口内相对坐标)
end_x, end_y: 终点坐标(窗口内相对坐标)
duration: 滑动持续时间(秒)
button: 使用的鼠标按钮
"""
logger.info(f"[滑动操作] 开始滑动: ({start_x}, {start_y}) -> ({end_x}, {end_y}), 时长: {duration}s, 按钮: {button}")
# 激活窗口
logger.debug("[滑动操作] 步骤 1/4: 激活窗口")
self._activate_window()
time.sleep(0.1)
# 转换坐标
logger.debug("[滑动操作] 步骤 2/4: 转换起点和终点坐标为屏幕坐标")
start_screen = self._to_screen_coords(start_x, start_y)
end_screen = self._to_screen_coords(end_x, end_y)
logger.info(f"[滑动操作] 屏幕坐标: ({start_screen[0]}, {start_screen[1]}) -> ({end_screen[0]}, {end_screen[1]})")
# 计算步进
steps = max(int(duration * 60), 10)
step_delay = duration / steps
logger.debug(f"[滑动操作] 滑动参数: 总步数={steps}, 每步延迟={step_delay:.4f}s")
dx = (end_screen[0] - start_screen[0]) / steps
dy = (end_screen[1] - start_screen[1]) / steps
logger.debug(f"[滑动操作] 每步移动量: dx={dx:.2f}, dy={dy:.2f}")
# 移动到起点并按下
logger.debug("[滑动操作] 步骤 3/4: 移动到起点并按下鼠标")
self._move_mouse_absolute(start_screen[0], start_screen[1])
time.sleep(0.05)
self._mouse_down(button)
time.sleep(0.05)
# 滑动过程
logger.debug("[滑动操作] 开始滑动过程")
for i in range(1, steps + 1):
new_x = int(start_screen[0] + dx * i)
new_y = int(start_screen[1] + dy * i)
self._move_mouse_absolute(new_x, new_y)
time.sleep(step_delay)
# 确保到达终点
logger.debug("[滑动操作] 步骤 4/4: 确保到达终点并释放鼠标")
self._move_mouse_absolute(end_screen[0], end_screen[1])
time.sleep(0.05)
# 释放
self._mouse_up(button)
logger.info(f"[滑动操作] 滑动完成: ({start_x}, {start_y}) -> ({end_x}, {end_y}), 时长: {duration}s")
def key_press(self, key: str, duration: float = 0.05):
"""
按下并释放按键
Args:
key: 按键名称,如 'a', 'enter', 'esc', 'space'
duration: 按住时长(秒)
"""
logger.info(f"[按键操作] 开始按键: '{key}', 按住时长: {duration}s")
vk_code = self._get_vk_code(key)
if vk_code is None:
logger.error(f"[按键操作] 未知的按键: '{key}'")
return
logger.debug(f"[按键操作] 按键 '{key}' 对应的虚拟键码: {vk_code}")
# 激活窗口
logger.debug("[按键操作] 步骤 1/3: 激活窗口")
self._activate_window()
time.sleep(0.05)
# 按下
logger.debug("[按键操作] 步骤 2/3: 按下按键")
self._key_down(vk_code)
time.sleep(duration)
# 释放
logger.debug("[按键操作] 步骤 3/3: 释放按键")
self._key_up(vk_code)
logger.info(f"[按键操作] 按键完成: '{key}', 按住时长: {duration}s")
def key_down(self, key: str):
"""按住按键(不释放)"""
logger.info(f"[按键按住] 开始按住按键: '{key}'")
vk_code = self._get_vk_code(key)
if vk_code is None:
logger.error(f"[按键按住] 未知的按键: '{key}'")
return
logger.debug(f"[按键按住] 按键 '{key}' 对应的虚拟键码: {vk_code}")
self._activate_window()
self._key_down(vk_code)
logger.info(f"[按键按住] 按键 '{key}' 已按下(未释放)")
def key_up(self, key: str):
"""释放按键"""
logger.info(f"[按键释放] 开始释放按键: '{key}'")
vk_code = self._get_vk_code(key)
if vk_code is None:
logger.error(f"[按键释放] 未知的按键: '{key}'")
return
logger.debug(f"[按键释放] 按键 '{key}' 对应的虚拟键码: {vk_code}")
self._key_up(vk_code)
logger.info(f"[按键释放] 按键 '{key}' 已释放")
def _get_vk_code(self, key: str) -> Optional[int]:
"""获取按键的虚拟键码"""
import win32con
key = key.lower()
# 字母数字
if len(key) == 1 and key.isalnum():
vk_code = ord(key.upper())
logger.debug(f"[虚拟键码] 字母/数字 '{key}' -> {vk_code}")
return vk_code
# 特殊按键映射
special_keys = {
'enter': win32con.VK_RETURN,
'return': win32con.VK_RETURN,
'esc': win32con.VK_ESCAPE,
'escape': win32con.VK_ESCAPE,
'space': win32con.VK_SPACE,
'tab': win32con.VK_TAB,
'backspace': win32con.VK_BACK,
'delete': win32con.VK_DELETE,
'del': win32con.VK_DELETE,
'insert': win32con.VK_INSERT,
'ins': win32con.VK_INSERT,
'home': win32con.VK_HOME,
'end': win32con.VK_END,
'pageup': win32con.VK_PRIOR,
'pagedown': win32con.VK_NEXT,
'up': win32con.VK_UP,
'down': win32con.VK_DOWN,
'left': win32con.VK_LEFT,
'right': win32con.VK_RIGHT,
'f1': win32con.VK_F1,
'f2': win32con.VK_F2,
'f3': win32con.VK_F3,
'f4': win32con.VK_F4,
'f5': win32con.VK_F5,
'f6': win32con.VK_F6,
'f7': win32con.VK_F7,
'f8': win32con.VK_F8,
'f9': win32con.VK_F9,
'f10': win32con.VK_F10,
'f11': win32con.VK_F11,
'f12': win32con.VK_F12,
'ctrl': win32con.VK_CONTROL,
'control': win32con.VK_CONTROL,
'shift': win32con.VK_SHIFT,
'alt': win32con.VK_MENU,
'win': win32con.VK_LWIN,
'windows': win32con.VK_LWIN,
}
vk_code = special_keys.get(key)
if vk_code:
logger.debug(f"[虚拟键码] 特殊键 '{key}' -> {vk_code}")
else:
logger.warning(f"[虚拟键码] 未找到 '{key}' 对应的虚拟键码")
return vk_code
# ==================== 便捷方法 ====================
def double_click(self, x: int, y: int, button: str = "left"):
"""双击"""
logger.info(f"[双击操作] 开始在 ({x}, {y}) 双击")
self.click(x, y, duration=0, button=button)
logger.debug("[双击操作] 第一次点击完成,等待 0.1s")
time.sleep(0.1)
self.click(x, y, duration=0, button=button)
logger.info(f"[双击操作] 双击完成: ({x}, {y})")
def long_press(self, x: int, y: int, duration: float = 1.0, button: str = "left"):
"""长按click的别名更清晰"""
logger.info(f"[长按操作] 开始在 ({x}, {y}) 长按 {duration}s")
self.click(x, y, duration=duration, button=button)
logger.info(f"[长按操作] 长按完成: ({x}, {y}), 时长: {duration}s")
def drag(self, start_x: int, start_y: int, end_x: int, end_y: int,
duration: float = 0.5, button: str = "left"):
"""拖拽swipe的别名更清晰"""
logger.info(f"[拖拽操作] 开始拖拽: ({start_x}, {start_y}) -> ({end_x}, {end_y}), 时长: {duration}s")
self.swipe(start_x, start_y, end_x, end_y, duration, button)
logger.info(f"[拖拽操作] 拖拽完成: ({start_x}, {start_y}) -> ({end_x}, {end_y})")

76
test_integration.py Normal file
View File

@@ -0,0 +1,76 @@
"""
集成测试 - 测试真实游戏窗口捕获
"""
import sys
sys.path.insert(0, '.')
from src.core.game_window import GameWindowManager
def test_find_by_exe():
"""测试通过exe查找"""
print("=== 测试通过exe查找游戏窗口 ===")
manager = GameWindowManager()
result = manager.find_window_by_exe('ycgame.exe')
if result:
print("成功!")
print(f" HWND: {manager.hwnd}")
print(f" PID: {manager.process_id}")
print(f" 大小: {manager.client_size}")
info = manager.get_window_info()
print(f" 标题: {info['title']}")
print(f" 路径: {info['exe_path']}")
return True
else:
print("未找到游戏窗口")
return False
def test_find_by_title():
"""测试通过标题查找"""
print("\n=== 测试通过标题查找游戏窗口 ===")
manager = GameWindowManager()
result = manager.find_window_by_title('桃源深处有人家')
if result:
print("成功!")
print(f" HWND: {manager.hwnd}")
print(f" PID: {manager.process_id}")
print(f" 大小: {manager.client_size}")
info = manager.get_window_info()
print(f" 标题: {info['title']}")
return True
else:
print("未找到游戏窗口")
return False
def test_window_operations():
"""测试窗口操作"""
print("\n=== 测试窗口操作 ===")
manager = GameWindowManager()
if not manager.capture_window():
print("游戏未运行,跳过测试")
return False
print("窗口已捕获")
# 测试获取信息
info = manager.get_window_info()
print(f"窗口信息: {info}")
# 测试置前
print("尝试将窗口置前...")
manager.bring_to_front()
print("完成")
return True
if __name__ == "__main__":
print("开始集成测试\n")
test_find_by_exe()
test_find_by_title()
test_window_operations()
print("\n测试完成")

374
tests/test_game_window.py Normal file
View File

@@ -0,0 +1,374 @@
"""
GameWindowManager 测试用例
测试环境要求:
- Windows 操作系统
- Python 3.9+
- 安装依赖: pywin32, psutil
运行测试:
python -m pytest tests/test_game_window.py -v
python -m pytest tests/test_game_window.py::TestGameWindowManager::test_find_window_by_exe -v
"""
import unittest
import time
import sys
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import psutil
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from src.core.game_window import GameWindowManager
class TestGameWindowManager(unittest.TestCase):
"""GameWindowManager 测试类"""
def setUp(self):
"""每个测试方法前执行"""
self.manager = GameWindowManager()
def tearDown(self):
"""每个测试方法后执行"""
self.manager = None
# ==================== 基础属性测试 ====================
def test_initial_state(self):
"""测试初始状态"""
self.assertIsNone(self.manager.hwnd)
self.assertIsNone(self.manager.process_id)
self.assertFalse(self.manager.is_window_captured)
self.assertEqual(self.manager.client_size, (0, 0))
self.assertEqual(self.manager.window_rect, (0, 0, 0, 0))
def test_game_constants(self):
"""测试游戏常量配置"""
self.assertEqual(self.manager.GAME_EXE_NAME, "ycgame.exe")
self.assertEqual(self.manager.GAME_WINDOW_TITLE, "桃源深处有人家")
# ==================== 模拟窗口测试 ====================
@patch('src.core.game_window.win32gui')
@patch('src.core.game_window.win32process')
@patch('src.core.game_window.psutil')
def test_find_window_by_exe_success(self, mock_psutil, mock_win32process, mock_win32gui):
"""测试通过exe名成功查找窗口"""
# 模拟窗口句柄和进程ID
mock_hwnd = 12345
mock_pid = 67890
# 设置模拟返回值
mock_win32gui.IsWindowVisible.return_value = True
mock_win32gui.GetWindowText.return_value = "桃源深处有人家"
mock_win32gui.GetWindowRect.return_value = (100, 100, 1100, 700)
mock_win32process.GetWindowThreadProcessId.return_value = (None, mock_pid)
# 模拟进程对象
mock_process = Mock()
mock_process.exe.return_value = "C:\\Game\\ycgame.exe"
mock_psutil.Process.return_value = mock_process
# 模拟EnumWindows回调
def mock_enum_windows(callback, extra):
callback(mock_hwnd, extra)
return True
mock_win32gui.EnumWindows = mock_enum_windows
# 执行测试
result = self.manager.find_window_by_exe("ycgame.exe")
# 验证结果
self.assertTrue(result)
self.assertEqual(self.manager.hwnd, mock_hwnd)
self.assertEqual(self.manager.process_id, mock_pid)
self.assertTrue(self.manager.is_window_captured)
self.assertEqual(self.manager.client_size, (1000, 600))
@patch('src.core.game_window.win32gui')
@patch('src.core.game_window.win32process')
@patch('src.core.game_window.psutil')
def test_find_window_by_exe_not_found(self, mock_psutil, mock_win32process, mock_win32gui):
"""测试通过exe名未找到窗口"""
# 模拟没有匹配的窗口
def mock_enum_windows(callback, extra):
return True
mock_win32gui.EnumWindows = mock_enum_windows
result = self.manager.find_window_by_exe("nonexistent.exe")
self.assertFalse(result)
self.assertIsNone(self.manager.hwnd)
@patch('src.core.game_window.win32gui')
@patch('src.core.game_window.win32process')
def test_find_window_by_title_success(self, mock_win32process, mock_win32gui):
"""测试通过标题成功查找窗口"""
mock_hwnd = 12345
mock_pid = 67890
mock_win32gui.FindWindow.return_value = mock_hwnd
mock_win32gui.GetWindowRect.return_value = (100, 100, 1100, 700)
mock_win32process.GetWindowThreadProcessId.return_value = (None, mock_pid)
result = self.manager.find_window_by_title("桃源深处有人家")
self.assertTrue(result)
self.assertEqual(self.manager.hwnd, mock_hwnd)
self.assertEqual(self.manager.process_id, mock_pid)
@patch('src.core.game_window.win32gui')
def test_find_window_by_title_not_found(self, mock_win32gui):
"""测试通过标题未找到窗口"""
mock_win32gui.FindWindow.return_value = 0
# 模拟EnumWindows也没有找到
def mock_enum_windows(callback, extra):
return True
mock_win32gui.EnumWindows = mock_enum_windows
result = self.manager.find_window_by_title("不存在的窗口")
self.assertFalse(result)
self.assertIsNone(self.manager.hwnd)
# ==================== 综合查找测试 ====================
@patch.object(GameWindowManager, 'find_window_by_exe')
@patch.object(GameWindowManager, 'find_window_by_title')
def test_find_window_use_exe_success(self, mock_find_by_title, mock_find_by_exe):
"""测试综合查找 - 通过exe成功"""
mock_find_by_exe.return_value = True
result = self.manager.find_window(use_exe=True)
self.assertTrue(result)
mock_find_by_exe.assert_called_once()
mock_find_by_title.assert_not_called()
@patch.object(GameWindowManager, 'find_window_by_exe')
@patch.object(GameWindowManager, 'find_window_by_title')
def test_find_window_use_exe_fail_then_title(self, mock_find_by_title, mock_find_by_exe):
"""测试综合查找 - exe失败后用标题"""
mock_find_by_exe.return_value = False
mock_find_by_title.return_value = True
result = self.manager.find_window(use_exe=True)
self.assertTrue(result)
mock_find_by_exe.assert_called_once()
mock_find_by_title.assert_called_once()
@patch.object(GameWindowManager, 'find_window_by_title')
def test_find_window_use_title_only(self, mock_find_by_title):
"""测试综合查找 - 只用标题"""
mock_find_by_title.return_value = True
result = self.manager.find_window(use_exe=False)
self.assertTrue(result)
mock_find_by_title.assert_called_once()
# ==================== 窗口操作测试 ====================
@patch('src.core.game_window.win32gui')
def test_bring_to_front(self, mock_win32gui):
"""测试窗口置前"""
self.manager._hwnd = 12345
mock_win32gui.IsWindow.return_value = True
self.manager.bring_to_front()
mock_win32gui.SetForegroundWindow.assert_called_once_with(12345)
@patch('src.core.game_window.win32gui')
def test_bring_to_front_not_captured(self, mock_win32gui):
"""测试窗口未捕获时不执行置前"""
mock_win32gui.IsWindow.return_value = False
self.manager.bring_to_front()
mock_win32gui.SetForegroundWindow.assert_not_called()
@patch('src.core.game_window.win32gui')
def test_click(self, mock_win32gui):
"""测试点击坐标转换"""
self.manager._hwnd = 12345
mock_win32gui.IsWindow.return_value = True
mock_win32gui.GetWindowRect.return_value = (100, 100, 1100, 700)
# 执行点击
self.manager.click(500, 300)
# 验证坐标转换 (窗口左上角100,100 + 相对坐标500,300 = 屏幕坐标600,400)
# 注意:实际点击功能还未实现,这里只测试坐标转换逻辑
# ==================== 窗口信息测试 ====================
@patch('src.core.game_window.win32gui')
@patch('src.core.game_window.psutil')
def test_get_window_info_captured(self, mock_psutil, mock_win32gui):
"""测试获取窗口信息 - 已捕获"""
mock_hwnd = 12345
mock_pid = 67890
self.manager._hwnd = mock_hwnd
self.manager._process_id = mock_pid
mock_win32gui.IsWindow.return_value = True
mock_win32gui.GetWindowText.return_value = "桃源深处有人家"
mock_win32gui.GetWindowRect.return_value = (100, 100, 1100, 700)
mock_process = Mock()
mock_process.exe.return_value = "C:\\Game\\ycgame.exe"
mock_psutil.Process.return_value = mock_process
info = self.manager.get_window_info()
self.assertTrue(info["captured"])
self.assertEqual(info["hwnd"], mock_hwnd)
self.assertEqual(info["title"], "桃源深处有人家")
self.assertEqual(info["process_id"], mock_pid)
self.assertEqual(info["exe_path"], "C:\\Game\\ycgame.exe")
self.assertEqual(info["rect"], (100, 100, 1100, 700))
self.assertEqual(info["size"], (1000, 600))
def test_get_window_info_not_captured(self):
"""测试获取窗口信息 - 未捕获"""
info = self.manager.get_window_info()
self.assertFalse(info["captured"])
self.assertIsNone(info["hwnd"])
self.assertIsNone(info["title"])
self.assertIsNone(info["process_id"])
self.assertIsNone(info["exe_path"])
self.assertIsNone(info["rect"])
self.assertIsNone(info["size"])
# ==================== 错误处理测试 ====================
@patch('src.core.game_window.win32gui')
@patch('src.core.game_window.win32process')
@patch('src.core.game_window.psutil')
def test_find_window_by_exe_access_denied(self, mock_psutil, mock_win32process, mock_win32gui):
"""测试访问被拒绝时的处理"""
mock_win32gui.IsWindowVisible.return_value = True
mock_win32process.GetWindowThreadProcessId.return_value = (None, 12345)
mock_psutil.Process.side_effect = psutil.AccessDenied("Access denied")
def mock_enum_windows(callback, extra):
callback(12345, extra)
return True
mock_win32gui.EnumWindows = mock_enum_windows
result = self.manager.find_window_by_exe("ycgame.exe")
# 应该正常处理异常返回False
self.assertFalse(result)
@patch('src.core.game_window.win32gui')
def test_find_window_by_exe_exception(self, mock_win32gui):
"""测试异常情况处理"""
mock_win32gui.EnumWindows.side_effect = Exception("Test exception")
result = self.manager.find_window_by_exe("ycgame.exe")
self.assertFalse(result)
# ==================== 集成测试(需要实际游戏运行)====================
@unittest.skip("需要实际游戏运行")
def test_integration_find_real_game_window_by_exe(self):
"""集成测试查找真实游戏窗口通过exe"""
result = self.manager.find_window_by_exe("ycgame.exe")
if result:
print(f"\n找到游戏窗口:")
print(f" HWND: {self.manager.hwnd}")
print(f" PID: {self.manager.process_id}")
print(f" 大小: {self.manager.client_size}")
info = self.manager.get_window_info()
print(f" 标题: {info['title']}")
print(f" 路径: {info['exe_path']}")
else:
print("\n未找到游戏窗口,请确保游戏已运行")
# 不强制断言,因为游戏可能未运行
@unittest.skip("需要实际游戏运行")
def test_integration_find_real_game_window_by_title(self):
"""集成测试:查找真实游戏窗口(通过标题)"""
result = self.manager.find_window_by_title("桃源深处有人家")
if result:
print(f"\n找到游戏窗口:")
print(f" HWND: {self.manager.hwnd}")
print(f" 大小: {self.manager.client_size}")
else:
print("\n未找到游戏窗口,请确保游戏已运行")
@unittest.skip("需要实际游戏运行")
def test_integration_window_operations(self):
"""集成测试:窗口操作"""
# 先捕获窗口
if not self.manager.capture_window():
self.skipTest("游戏未运行,跳过测试")
# 测试获取信息
info = self.manager.get_window_info()
self.assertTrue(info["captured"])
# 测试置前(不验证结果,只是确保不报错)
try:
self.manager.bring_to_front()
except Exception as e:
self.fail(f"bring_to_front 抛出异常: {e}")
# 等待一下观察效果
time.sleep(1)
class TestGameWindowManagerPerformance(unittest.TestCase):
"""性能测试"""
@patch('src.core.game_window.win32gui')
@patch('src.core.game_window.win32process')
@patch('src.core.game_window.psutil')
def test_find_window_performance(self, mock_psutil, mock_win32process, mock_win32gui):
"""测试查找窗口性能"""
# 设置模拟数据
mock_win32gui.IsWindowVisible.return_value = True
mock_win32gui.GetWindowText.return_value = "桃源深处有人家"
mock_win32gui.GetWindowRect.return_value = (100, 100, 1100, 700)
mock_win32process.GetWindowThreadProcessId.return_value = (None, 12345)
mock_process = Mock()
mock_process.exe.return_value = "C:\\Game\\ycgame.exe"
mock_psutil.Process.return_value = mock_process
def mock_enum_windows(callback, extra):
for i in range(100): # 模拟100个窗口
callback(10000 + i, extra)
return True
mock_win32gui.EnumWindows = mock_enum_windows
manager = GameWindowManager()
# 测量查找时间
import time
start = time.time()
result = manager.find_window_by_exe("ycgame.exe")
elapsed = time.time() - start
self.assertTrue(result)
self.assertLess(elapsed, 1.0, "查找窗口应该在一秒内完成")
print(f"\n查找100个窗口耗时: {elapsed:.3f}")
if __name__ == "__main__":
# 运行测试
unittest.main(verbosity=2)

View File

@@ -0,0 +1,356 @@
"""
InputSimulator 测试用例
测试环境要求:
- Windows 操作系统
- Python 3.9+
- 安装依赖: pywin32
运行测试:
python -m pytest tests/test_input_simulator.py -v
"""
import unittest
import time
import sys
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock, call
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from src.core.input_simulator import InputSimulator, Point, SwipePath
from src.core.game_window import GameWindowManager
class TestInputSimulator(unittest.TestCase):
"""InputSimulator 测试类"""
def setUp(self):
"""每个测试方法前执行"""
self.mock_window = Mock(spec=GameWindowManager)
self.mock_window.is_window_captured = True
self.mock_window.window_rect = (100, 100, 1100, 700) # left, top, right, bottom
self.simulator = InputSimulator(self.mock_window)
def tearDown(self):
"""每个测试方法后执行"""
self.simulator = None
# ==================== 基础功能测试 ====================
@patch('src.core.input_simulator.win32api')
def test_click_short(self, mock_win32api):
"""测试短按点击"""
self.simulator.click(100, 200)
# 验证鼠标移动
mock_win32api.SetCursorPos.assert_called()
# 验证按下和释放
mock_win32api.mouse_event.assert_any_call(
unittest.mock.ANY, 0, 0, 0, 0 # MOUSEEVENTF_LEFTDOWN
)
mock_win32api.mouse_event.assert_any_call(
unittest.mock.ANY, 0, 0, 0, 0 # MOUSEEVENTF_LEFTUP
)
@patch('src.core.input_simulator.win32api')
def test_click_long(self, mock_win32api):
"""测试长按"""
start_time = time.time()
self.simulator.click(100, 200, duration=0.5)
elapsed = time.time() - start_time
# 验证长按时间(允许误差)
self.assertGreaterEqual(elapsed, 0.5)
self.assertLess(elapsed, 0.7)
@patch('src.core.input_simulator.win32api')
def test_click_different_buttons(self, mock_win32api):
"""测试不同鼠标按钮"""
# 左键
self.simulator.click(100, 200, button="left")
# 右键
self.simulator.click(100, 200, button="right")
# 中键
self.simulator.click(100, 200, button="middle")
# 验证调用了3次按下和3次释放
self.assertEqual(mock_win32api.mouse_event.call_count, 6)
@patch('src.core.input_simulator.win32api')
def test_click_window_not_captured(self, mock_win32api):
"""测试窗口未捕获时抛出异常"""
self.mock_window.is_window_captured = False
with self.assertRaises(RuntimeError) as context:
self.simulator.click(100, 200)
self.assertIn("窗口未捕获", str(context.exception))
# ==================== 滑动测试 ====================
@patch('src.core.input_simulator.win32api')
def test_swipe_basic(self, mock_win32api):
"""测试基础滑动"""
self.simulator.swipe(100, 200, 300, 400, duration=0.1)
# 验证鼠标移动被多次调用(滑动过程)
self.assertGreater(mock_win32api.SetCursorPos.call_count, 5)
# 验证按下和释放
mock_win32api.mouse_event.assert_any_call(
unittest.mock.ANY, 0, 0, 0, 0 # MOUSEEVENTF_LEFTDOWN
)
mock_win32api.mouse_event.assert_any_call(
unittest.mock.ANY, 0, 0, 0, 0 # MOUSEEVENTF_LEFTUP
)
@patch('src.core.input_simulator.win32api')
def test_swipe_duration(self, mock_win32api):
"""测试滑动持续时间"""
start_time = time.time()
self.simulator.swipe(100, 200, 300, 400, duration=0.3)
elapsed = time.time() - start_time
# 验证滑动时间(允许误差)
self.assertGreaterEqual(elapsed, 0.3)
self.assertLess(elapsed, 0.5)
@patch('src.core.input_simulator.win32api')
def test_swipe_coordinate_conversion(self, mock_win32api):
"""测试滑动坐标转换"""
self.simulator.swipe(0, 0, 100, 100)
# 获取所有SetCursorPos调用
calls = mock_win32api.SetCursorPos.call_args_list
# 第一个调用应该是起点窗口左上角100,100 + 相对坐标0,0 = 屏幕坐标100,100
first_call = calls[0]
self.assertEqual(first_call[0][0], (100, 100))
# 最后一个调用应该是终点窗口左上角100,100 + 相对坐标100,100 = 屏幕坐标200,200
last_call = calls[-1]
self.assertEqual(last_call[0][0], (200, 200))
# ==================== 键盘测试 ====================
@patch('src.core.input_simulator.win32api')
def test_key_press_letter(self, mock_win32api):
"""测试字母按键"""
self.simulator.key_press('a')
# 验证按下和释放
self.assertEqual(mock_win32api.keybd_event.call_count, 2)
# 验证按下第二个参数为0表示按下
mock_win32api.keybd_event.assert_any_call(ord('A'), 0, 0, 0)
# 验证释放第三个参数包含KEYEVENTF_KEYUP
mock_win32api.keybd_event.assert_any_call(
ord('A'), 0, unittest.mock.ANY, 0
)
@patch('src.core.input_simulator.win32api')
def test_key_press_special(self, mock_win32api):
"""测试特殊按键"""
special_keys = ['enter', 'esc', 'space', 'tab', 'f1', 'ctrl']
for key in special_keys:
self.simulator.key_press(key)
# 每个按键应该有按下和释放两个调用
self.assertEqual(mock_win32api.keybd_event.call_count, len(special_keys) * 2)
@patch('src.core.input_simulator.win32api')
def test_key_press_unknown(self, mock_win32api):
"""测试未知按键"""
self.simulator.key_press('unknown_key')
# 不应该调用keybd_event
mock_win32api.keybd_event.assert_not_called()
@patch('src.core.input_simulator.win32api')
def test_key_down_up(self, mock_win32api):
"""测试按住和释放按键"""
self.simulator.key_down('shift')
self.simulator.key_up('shift')
# 验证只调用了两次(一次按下,一次释放)
self.assertEqual(mock_win32api.keybd_event.call_count, 2)
# 第一次是按下第3个参数为0
first_call = mock_win32api.keybd_event.call_args_list[0]
self.assertEqual(first_call[0][2], 0) # dwFlags是第3个位置参数
# 第二次是释放第3个参数不为0
second_call = mock_win32api.keybd_event.call_args_list[1]
self.assertNotEqual(second_call[0][2], 0)
# ==================== 便捷方法测试 ====================
@patch.object(InputSimulator, 'click')
def test_double_click(self, mock_click):
"""测试双击"""
self.simulator.double_click(100, 200)
# 验证click被调用了两次
self.assertEqual(mock_click.call_count, 2)
mock_click.assert_any_call(100, 200, duration=0, button='left')
@patch.object(InputSimulator, 'click')
def test_long_press(self, mock_click):
"""测试长按便捷方法"""
self.simulator.long_press(100, 200, duration=2.0)
# 验证调用了click并传递了duration
mock_click.assert_called_once_with(100, 200, duration=2.0, button='left')
@patch.object(InputSimulator, 'swipe')
def test_drag(self, mock_swipe):
"""测试拖拽便捷方法"""
self.simulator.drag(100, 200, 300, 400, duration=1.0, button='left')
# 验证调用了swipe
mock_swipe.assert_called_once_with(100, 200, 300, 400, 1.0, 'left')
@patch('src.core.input_simulator.win32api')
def test_scroll(self, mock_win32api):
"""测试滚动"""
self.simulator.scroll(500, 300, delta=-3)
# 验证鼠标移动
mock_win32api.SetCursorPos.assert_called_once()
# 验证滚动事件delta * 120 = -360
mock_win32api.mouse_event.assert_called_once_with(
unittest.mock.ANY, 0, 0, -360, 0
)
# ==================== 坐标转换测试 ====================
def test_coordinate_conversion(self):
"""测试坐标转换"""
# 窗口位置 (100, 100),点击窗口内 (50, 50)
screen_coords = self.simulator._to_screen_coords(50, 50)
# 屏幕坐标应该是 (150, 150)
self.assertEqual(screen_coords, (150, 150))
def test_coordinate_conversion_different_window(self):
"""测试不同窗口位置的坐标转换"""
self.mock_window.window_rect = (200, 100, 1200, 700)
screen_coords = self.simulator._to_screen_coords(100, 200)
# 屏幕坐标应该是 (300, 300)
self.assertEqual(screen_coords, (300, 300))
# ==================== 性能测试 ====================
@patch('src.core.input_simulator.win32api')
def test_click_performance(self, mock_win32api):
"""测试点击性能"""
start = time.time()
# 执行10次点击减少次数避免超时
for i in range(10):
self.simulator.click(i, i)
elapsed = time.time() - start
# 10次点击应该在3秒内完成
self.assertLess(elapsed, 3.0)
print(f"\n10次点击耗时: {elapsed:.3f}")
@patch('src.core.input_simulator.win32api')
def test_swipe_smoothness(self, mock_win32api):
"""测试滑动平滑度(步数)"""
self.simulator.swipe(0, 0, 1000, 1000, duration=1.0)
# 获取SetCursorPos调用次数应该至少有60次每秒60步
call_count = mock_win32api.SetCursorPos.call_count
# 验证有足够的步数保证平滑
self.assertGreaterEqual(call_count, 60)
print(f"\n滑动步数: {call_count}")
class TestPoint(unittest.TestCase):
"""Point 数据类测试"""
def test_point_creation(self):
"""测试创建Point"""
p = Point(10, 20)
self.assertEqual(p.x, 10)
self.assertEqual(p.y, 20)
def test_point_addition(self):
"""测试Point相加"""
p1 = Point(10, 20)
p2 = Point(5, 5)
result = p1 + p2
self.assertEqual(result.x, 15)
self.assertEqual(result.y, 25)
def test_point_iteration(self):
"""测试Point迭代"""
p = Point(10, 20)
coords = list(p)
self.assertEqual(coords, [10, 20])
class TestIntegration(unittest.TestCase):
"""集成测试(需要实际游戏运行)"""
@unittest.skip("需要实际游戏运行")
def test_real_click(self):
"""测试真实点击"""
from src.core.game_window import GameWindowManager
window = GameWindowManager()
if not window.capture_window():
self.skipTest("游戏未运行")
simulator = InputSimulator(window)
# 在游戏窗口中心点击
size = window.client_size
center_x = size[0] // 2
center_y = size[1] // 2
print(f"\n将在游戏窗口中心 ({center_x}, {center_y}) 点击")
simulator.click(center_x, center_y)
time.sleep(1)
@unittest.skip("需要实际游戏运行")
def test_real_swipe(self):
"""测试真实滑动"""
from src.core.game_window import GameWindowManager
window = GameWindowManager()
if not window.capture_window():
self.skipTest("游戏未运行")
simulator = InputSimulator(window)
size = window.client_size
start_x = size[0] // 4
start_y = size[1] // 2
end_x = size[0] * 3 // 4
end_y = size[1] // 2
print(f"\n将从 ({start_x}, {start_y}) 滑动到 ({end_x}, {end_y})")
simulator.swipe(start_x, start_y, end_x, end_y, duration=1.0)
time.sleep(1)
if __name__ == "__main__":
unittest.main(verbosity=2)