|
| 1 | +"""AFA(明日方舟帧操小助手)热键驱动。 |
| 2 | +
|
| 3 | +AFA 是独立常驻的 AutoHotkey 工具,注册全局热键完成暂停/步进/技能/撤退的复杂按键时序。 |
| 4 | +本模块不复刻 AFA 的时序,而是通过 SendInput 模拟按下 AFA 的热键,让 AFA 代为执行。 |
| 5 | +
|
| 6 | +前提(来自 reference/arknights-frame-assistant-main/src/lib/hotkey_control.ahk): |
| 7 | +- 热键注册为 ``HotIfWinActive("ahk_exe Arknights.exe")`` → 游戏窗口必须前台激活才生效。 |
| 8 | +- 热键未加 ``$`` 屏蔽符 → SendInput 模拟的按键能触发 AFA 热键。 |
| 9 | +- 步进/暂停选中/暂停技能/暂停撤退内部检查 ``IsMouseInClient()`` → |
| 10 | + 发这些键前真实光标必须在游戏客户区内。 |
| 11 | +
|
| 12 | +AFA 默认热键(reference/.../config.ahk): |
| 13 | + F = 按下暂停(ActionPressPause → ESC 脉冲 → 游戏暂停) |
| 14 | + Space = 松开暂停(ActionReleasePause → Space 脉冲 → 游戏恢复) |
| 15 | + R = 前进 33ms(Action33ms → 步进 1 帧,需游戏已暂停) |
| 16 | + W = 暂停选中(ActionPauseSelect → 暂停下选中光标处单位) |
| 17 | + S = 单位技能(ActionSkill → 发 E,需先 W 选中) |
| 18 | + A = 单位撤退(ActionRetreat → 发 Q,需先 W 选中) |
| 19 | +
|
| 20 | +技能/撤退流程:暂停态下 move_cursor 到单位 → W 选中 → S(技能)/A(撤退)。 |
| 21 | +S/A 用前台 Send 发 E/Q,暂停态有效(不同于 MAA PostMessage 的后台按键)。 |
| 22 | +
|
| 23 | +注意:Action33ms 假设调用时游戏已处于暂停状态。调用方必须自行维护暂停状态机 |
| 24 | +(见 executor 的 pause invariant),不能盲目发 R。 |
| 25 | +""" |
| 26 | + |
| 27 | +from __future__ import annotations |
| 28 | + |
| 29 | +import ctypes |
| 30 | +import ctypes.wintypes as wintypes |
| 31 | +import logging |
| 32 | +import sys |
| 33 | + |
| 34 | +logger = logging.getLogger(__name__) |
| 35 | + |
| 36 | +_is_windows = sys.platform == "win32" |
| 37 | + |
| 38 | +# --- AFA 默认热键(虚拟键码) --- |
| 39 | +VK_F = 0x46 |
| 40 | +VK_SPACE = 0x20 |
| 41 | +VK_R = 0x52 |
| 42 | +VK_W = 0x57 |
| 43 | +VK_S = 0x53 # 单位技能(ActionSkill → 发 E) |
| 44 | +VK_A = 0x41 # 单位撤退(ActionRetreat → 发 Q) |
| 45 | + |
| 46 | +# 鼠标侧键(SendInput mouseData 的 wButton 值) |
| 47 | +XBUTTON1 = 0x0001 # 后侧键 → AFA 暂停撤退 |
| 48 | +XBUTTON2 = 0x0002 # 前侧键 → AFA 暂停技能 |
| 49 | + |
| 50 | +# SendInput 常量 |
| 51 | +_INPUT_KEYBOARD = 1 |
| 52 | +_INPUT_MOUSE = 2 |
| 53 | +_KEYEVENTF_KEYUP = 0x0002 |
| 54 | +_MOUSEEVENTF_XDOWN = 0x0080 |
| 55 | +_MOUSEEVENTF_XUP = 0x0100 |
| 56 | + |
| 57 | + |
| 58 | +class _MOUSEINPUT(ctypes.Structure): |
| 59 | + _fields_ = [ |
| 60 | + ("dx", wintypes.LONG), |
| 61 | + ("dy", wintypes.LONG), |
| 62 | + ("mouseData", wintypes.DWORD), |
| 63 | + ("dwFlags", wintypes.DWORD), |
| 64 | + ("time", wintypes.DWORD), |
| 65 | + ("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)), |
| 66 | + ] |
| 67 | + |
| 68 | + |
| 69 | +class _KEYBDINPUT(ctypes.Structure): |
| 70 | + _fields_ = [ |
| 71 | + ("wVk", wintypes.WORD), |
| 72 | + ("wScan", wintypes.WORD), |
| 73 | + ("dwFlags", wintypes.DWORD), |
| 74 | + ("time", wintypes.DWORD), |
| 75 | + ("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)), |
| 76 | + ] |
| 77 | + |
| 78 | + |
| 79 | +class _INPUT_UNION(ctypes.Union): |
| 80 | + _fields_ = [ |
| 81 | + ("ki", _KEYBDINPUT), |
| 82 | + ("mi", _MOUSEINPUT), |
| 83 | + ] |
| 84 | + |
| 85 | + |
| 86 | +class _INPUT(ctypes.Structure): |
| 87 | + _anonymous_ = ("u",) |
| 88 | + _fields_ = [ |
| 89 | + ("type", wintypes.DWORD), |
| 90 | + ("u", _INPUT_UNION), |
| 91 | + ] |
| 92 | + |
| 93 | + |
| 94 | +if _is_windows: |
| 95 | + _user32 = ctypes.windll.user32 # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue] |
| 96 | + # SendInput |
| 97 | + _user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(_INPUT), ctypes.c_int] |
| 98 | + _user32.SendInput.restype = wintypes.UINT |
| 99 | + # SetCursorPos |
| 100 | + _user32.SetCursorPos.argtypes = [ctypes.c_int, ctypes.c_int] |
| 101 | + _user32.SetCursorPos.restype = wintypes.BOOL |
| 102 | + # 窗口枚举/标题 |
| 103 | + _user32.EnumWindows.argtypes = [ctypes.c_void_p, wintypes.LPARAM] |
| 104 | + _user32.EnumWindows.restype = wintypes.BOOL |
| 105 | + _user32.GetWindowTextLengthW.argtypes = [wintypes.HWND] |
| 106 | + _user32.GetWindowTextLengthW.restype = ctypes.c_int |
| 107 | + _user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] |
| 108 | + _user32.GetWindowTextW.restype = ctypes.c_int |
| 109 | + _user32.IsWindowVisible.argtypes = [wintypes.HWND] |
| 110 | + _user32.IsWindowVisible.restype = wintypes.BOOL |
| 111 | + # 窗口几何/前台 |
| 112 | + _user32.GetClientRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] |
| 113 | + _user32.ClientToScreen.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.POINT)] |
| 114 | + _user32.SetForegroundWindow.argtypes = [wintypes.HWND] |
| 115 | + _user32.SetForegroundWindow.restype = wintypes.BOOL |
| 116 | + _user32.IsIconic.argtypes = [wintypes.HWND] |
| 117 | + _user32.IsIconic.restype = wintypes.BOOL |
| 118 | + _user32.ShowWindowAsync.argtypes = [wintypes.HWND, ctypes.c_int] |
| 119 | + _user32.ShowWindowAsync.restype = wintypes.BOOL |
| 120 | + |
| 121 | + |
| 122 | +def _send_keyboard(vk: int, up: bool) -> None: |
| 123 | + inp = _INPUT() |
| 124 | + inp.type = _INPUT_KEYBOARD |
| 125 | + inp.ki.wVk = vk |
| 126 | + inp.ki.dwFlags = _KEYEVENTF_KEYUP if up else 0 |
| 127 | + _user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(_INPUT)) |
| 128 | + |
| 129 | + |
| 130 | +def _send_mouse_xbutton(button: int, up: bool) -> None: |
| 131 | + inp = _INPUT() |
| 132 | + inp.type = _INPUT_MOUSE |
| 133 | + inp.mi.mouseData = button |
| 134 | + inp.mi.dwFlags = _MOUSEEVENTF_XUP if up else _MOUSEEVENTF_XDOWN |
| 135 | + _user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(_INPUT)) |
| 136 | + |
| 137 | + |
| 138 | +def tap_key(vk: int) -> None: |
| 139 | + """按下并松开一个键盘键(触发 AFA 热键)。""" |
| 140 | + if not _is_windows: |
| 141 | + return |
| 142 | + _send_keyboard(vk, up=False) |
| 143 | + _send_keyboard(vk, up=True) |
| 144 | + |
| 145 | + |
| 146 | +def tap_mouse_xbutton(button: int) -> None: |
| 147 | + """按下并松开一个鼠标侧键(XBUTTON1/XBUTTON2)。""" |
| 148 | + if not _is_windows: |
| 149 | + return |
| 150 | + _send_mouse_xbutton(button, up=False) |
| 151 | + _send_mouse_xbutton(button, up=True) |
| 152 | + |
| 153 | + |
| 154 | +def find_game_window(title_substr: str = "明日方舟") -> int | None: |
| 155 | + """按窗口标题包含的字串查找游戏窗口 HWND。""" |
| 156 | + if not _is_windows: |
| 157 | + return None |
| 158 | + EnumWindowsProc = ctypes.WINFUNCTYPE( # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue] |
| 159 | + wintypes.BOOL, wintypes.HWND, wintypes.LPARAM |
| 160 | + ) |
| 161 | + found: list[int] = [] |
| 162 | + |
| 163 | + def _cb(hwnd: int, _lparam: int) -> int: |
| 164 | + length = _user32.GetWindowTextLengthW(hwnd) |
| 165 | + if length > 0: |
| 166 | + buf = ctypes.create_unicode_buffer(length + 1) |
| 167 | + _user32.GetWindowTextW(hwnd, buf, length + 1) |
| 168 | + if title_substr in buf.value and _user32.IsWindowVisible(hwnd): |
| 169 | + found.append(hwnd) |
| 170 | + return 1 # 继续枚举 |
| 171 | + |
| 172 | + _user32.EnumWindows(EnumWindowsProc(_cb), 0) |
| 173 | + return found[0] if found else None |
| 174 | + |
| 175 | + |
| 176 | +def activate(hwnd: int) -> None: |
| 177 | + """把游戏窗口拉到前台(AFA 热键要求游戏前台激活)。""" |
| 178 | + if not _is_windows: |
| 179 | + return |
| 180 | + if _user32.IsIconic(hwnd): # 最小化则先还原 |
| 181 | + _user32.ShowWindowAsync(hwnd, 9) # SW_RESTORE |
| 182 | + _user32.SetForegroundWindow(hwnd) |
| 183 | + |
| 184 | + |
| 185 | +def client_to_screen_ratio(hwnd: int, rx: float, ry: float) -> tuple[int, int]: |
| 186 | + """客户区比例 (0-1) → 屏幕坐标 (px)。 |
| 187 | +
|
| 188 | + Args: |
| 189 | + hwnd: 游戏窗口句柄。 |
| 190 | + rx, ry: 客户区内归一化坐标(与投影 view_pos 一致)。 |
| 191 | + """ |
| 192 | + rect = wintypes.RECT() |
| 193 | + _user32.GetClientRect(hwnd, ctypes.byref(rect)) |
| 194 | + width = rect.right - rect.left |
| 195 | + height = rect.bottom - rect.top |
| 196 | + pt = wintypes.POINT(int(rx * width), int(ry * height)) |
| 197 | + _user32.ClientToScreen(hwnd, ctypes.byref(pt)) |
| 198 | + return pt.x, pt.y |
| 199 | + |
| 200 | + |
| 201 | +def move_cursor(hwnd: int, rx: float, ry: float) -> None: |
| 202 | + """把真实光标移到游戏客户区的 (rx, ry) 比例位置(AFA IsMouseInClient 要求)。""" |
| 203 | + if not _is_windows: |
| 204 | + return |
| 205 | + x, y = client_to_screen_ratio(hwnd, rx, ry) |
| 206 | + _user32.SetCursorPos(x, y) |
0 commit comments