diff --git a/debug_tools.py b/debug_tools.py new file mode 100644 index 0000000..2050743 --- /dev/null +++ b/debug_tools.py @@ -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() diff --git a/demo_input.py b/demo_input.py new file mode 100644 index 0000000..f627795 --- /dev/null +++ b/demo_input.py @@ -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() diff --git a/requirements.txt b/requirements.txt index da00a4c..aafb475 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ # LuoLuoTool 依赖列表 -# 《桃源深处有人家》挂机工具 # ========== UI框架 ========== customtkinter>=5.2.2 # 现代化UI框架 diff --git a/src/core/game_window.py b/src/core/game_window.py index 5d739fd..63522ac 100644 --- a/src/core/game_window.py +++ b/src/core/game_window.py @@ -1,9 +1,12 @@ """ 游戏窗口管理 +通过 exe 文件名或窗口标题获取游戏窗口 """ +import os import win32gui -import win32con +import win32process +import psutil from typing import Optional, Tuple from src.utils.logger import logger @@ -11,13 +14,14 @@ from src.utils.logger import logger class GameWindowManager: """游戏窗口管理器""" - # 游戏窗口类名和标题 - GAME_CLASS_NAME = "UnityWndClass" # Unity游戏常用类名 - GAME_WINDOW_TITLE = "桃源深处有人家" + # 游戏配置 + GAME_EXE_NAME = "ycgame.exe" # 游戏exe文件名 + GAME_WINDOW_TITLE = "桃源深处有人家" # 游戏窗口标题 def __init__(self): self._hwnd: Optional[int] = None self._window_rect: Tuple[int, int, int, int] = (0, 0, 0, 0) + self._process_id: Optional[int] = None @property def is_window_captured(self) -> bool: @@ -31,6 +35,11 @@ class GameWindowManager: """窗口句柄""" return self._hwnd + @property + def process_id(self) -> Optional[int]: + """进程ID""" + return self._process_id + @property def window_rect(self) -> Tuple[int, int, int, int]: """窗口矩形 (left, top, right, bottom)""" @@ -46,37 +55,175 @@ class GameWindowManager: left, top, right, bottom = self.window_rect return (right - left, bottom - top) - def find_window(self) -> bool: - """查找游戏窗口""" - # 先尝试精确匹配 - hwnd = win32gui.FindWindow(None, self.GAME_WINDOW_TITLE) + def find_window_by_exe(self, exe_name: str = None) -> bool: + """ + 通过exe文件名查找游戏窗口 - # 如果没找到,尝试模糊匹配 - if hwnd == 0: - def callback(hwnd, extra): - if win32gui.IsWindowVisible(hwnd): - title = win32gui.GetWindowText(hwnd) - if self.GAME_WINDOW_TITLE in title: - extra.append(hwnd) + Args: + exe_name: exe文件名,默认使用 GAME_EXE_NAME + + Returns: + bool: 是否找到窗口 + """ + 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 - windows = [] - win32gui.EnumWindows(callback, windows) - 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 + # 枚举所有窗口 + win32gui.EnumWindows(enum_windows_callback, hwnd_list) - logger.warning("未找到游戏窗口") - return False + if hwnd_list: + # 优先选择标题匹配的窗口 + 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: - """捕获游戏窗口""" - return self.find_window() + Args: + title: 窗口标题,默认使用 GAME_WINDOW_TITLE + + 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): """将窗口置前""" @@ -95,3 +242,41 @@ class GameWindowManager: # TODO: 使用pyautogui或win32api发送点击 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 + } diff --git a/src/core/input_simulator.py b/src/core/input_simulator.py new file mode 100644 index 0000000..7af92d0 --- /dev/null +++ b/src/core/input_simulator.py @@ -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})") diff --git a/test_integration.py b/test_integration.py new file mode 100644 index 0000000..9e1c859 --- /dev/null +++ b/test_integration.py @@ -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测试完成") diff --git a/tests/test_game_window.py b/tests/test_game_window.py new file mode 100644 index 0000000..a11a6e3 --- /dev/null +++ b/tests/test_game_window.py @@ -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) diff --git a/tests/test_input_simulator.py b/tests/test_input_simulator.py new file mode 100644 index 0000000..e0c26e7 --- /dev/null +++ b/tests/test_input_simulator.py @@ -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)