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

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 依赖列表
# 《桃源深处有人家》挂机工具
# ========== UI框架 ==========
customtkinter>=5.2.2 # 现代化UI框架

View File

@@ -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
}

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)