Skip to content

Commit 7dcb51c

Browse files
committed
feat: 不再适配模拟器
1 parent 237a54e commit 7dcb51c

9 files changed

Lines changed: 353 additions & 284 deletions

File tree

custom/app.py

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
用法:
44
uv run python -m custom.app --profile test_30f_1280x720.json
5-
uv run python -m custom.app --profile test_30f_1280x720.json --mode adb
65
76
启动后:
87
- 悬浮窗显示实时帧/计时器
@@ -24,7 +23,6 @@
2423
from PySide6.QtCore import QThread, QTimer # noqa: E402
2524
from PySide6.QtWidgets import QApplication # noqa: E402
2625

27-
from custom import config # noqa: E402
2826
from custom.core.battle.action import ActionType # noqa: E402
2927
from custom.core.timing import calibration # noqa: E402
3028
from custom.measure.api_server import ApiServer # noqa: E402
@@ -56,13 +54,6 @@ def _key_pressed(vk: int) -> bool:
5654
return False
5755

5856

59-
# 模拟器 ROI(CostBarRuler 原值)
60-
_ROI_ADB = {
61-
"X1_OFFSET_FROM_RIGHT": config.REF_WIDTH - 1740,
62-
"Y1_OFFSET_FROM_BOTTOM": config.REF_HEIGHT - 810,
63-
"Y2_OFFSET_FROM_BOTTOM": config.REF_HEIGHT - 817,
64-
}
65-
6657
# 热键防抖
6758
_key_states: dict[int, bool] = {_VK_F8: False, _VK_F9: False, _VK_F10: False}
6859

@@ -90,33 +81,9 @@ def _connect_win32(Toolkit):
9081
return ctrl
9182

9283

93-
def _connect_adb(Toolkit):
94-
from maa.controller import AdbController
95-
96-
devices = Toolkit.find_adb_devices()
97-
if not devices:
98-
logger.error("未找到 ADB 设备")
99-
return None
100-
d = devices[0]
101-
logger.info("ADB 设备: %s @ %s", d.name, d.address)
102-
ctrl = AdbController(
103-
adb_path=d.adb_path,
104-
address=d.address,
105-
screencap_methods=d.screencap_methods,
106-
input_methods=d.input_methods,
107-
config=d.config,
108-
)
109-
ctrl.post_connection().wait()
110-
for key, val in _ROI_ADB.items():
111-
setattr(config, key, val)
112-
logger.info("已切换到模拟器 ROI")
113-
return ctrl
114-
115-
11684
def main() -> int:
11785
parser = argparse.ArgumentParser(description="ArknightsAutoOperator 集成应用")
11886
parser.add_argument("--profile", required=True, help="校准文件名")
119-
parser.add_argument("--mode", choices=["win32", "adb"], default="win32")
12087
parser.add_argument("--port", type=int, default=2606)
12188
parser.add_argument("--no-api", action="store_true")
12289
parser.add_argument("--no-editor", action="store_true")
@@ -133,7 +100,7 @@ def main() -> int:
133100
data = calibration.load(args.profile)
134101
logger.info("校准 %s:%d 档 (%s)", args.profile, len(data.profiles), data.detection_mode)
135102

136-
controller = _connect_adb(Toolkit) if args.mode == "adb" else _connect_win32(Toolkit)
103+
controller = _connect_win32(Toolkit)
137104
if controller is None:
138105
return 2
139106

custom/config.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,6 @@
5252
MOUSE_WAIT_MS = 100
5353
GENERAL_WAIT_MS = 300
5454

55-
# PC frame-step timing (AFA hotkey_actions.ahk): resume then pause for ~1 frame.
56-
# Intentionally < (1000/30)=33.3ms to avoid advancing two frames under jitter.
57-
PC_STEP_1X_MS = 30 # 1 logical frame at 1x speed
58-
PC_STEP_BULLET_MS = 165 # 1 logical frame at 0.2x (bullet-time) speed
59-
PC_KEY_DELAY_MS = 50
60-
6155
# --- Game native keybindings (PC client, Arknights.exe) ---
6256
VK_SPACE = 0x20 # pause
6357
VK_ESCAPE = 0x1B # menu / resume

custom/core/afa_hotkey.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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)

custom/core/avatar.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import json
1212
import logging
1313
import time
14+
import unicodedata
1415
from pathlib import Path
1516
from typing import TYPE_CHECKING
1617

@@ -48,6 +49,18 @@ def _get_char_id(oper_name: str) -> str:
4849
return mapping.get(oper_name, "")
4950

5051

52+
def _normalize_name(name: str) -> str:
53+
"""归一化干员名用于匹配(OCR 结果可能与原名有字形差异)。
54+
55+
- NFKC:上标 ²→2、Ⅲ→III、全角字母数字→半角。
56+
- 去除空白。
57+
"""
58+
if not name:
59+
return ""
60+
name = unicodedata.normalize("NFKC", name)
61+
return name.replace(" ", "").replace(" ", "")
62+
63+
5164
def detect_slots(
5265
context: Context,
5366
image: np.ndarray,
@@ -174,14 +187,13 @@ def locate_oper(
174187
time.sleep(0.3)
175188

176189
if name:
177-
# 存头像(从原始 deployment 截图截取,不是详情页)
178-
_save_avatar_from_image(image, slot, name)
190+
matched = _normalize_name(name) == _normalize_name(oper_name)
191+
# 匹配则用原名存(保证 char_id 正确),否则用 OCR 名存(供后续复用)
192+
_save_avatar_from_image(image, slot, oper_name if matched else name)
179193

180-
if name == oper_name:
181-
cx = click_x
182-
cy = click_y
183-
logger.info("找到目标干员 %s 在槽位 %d", oper_name, i)
184-
return (cx / w, cy / h)
194+
if matched:
195+
logger.info("找到目标干员 %s 在槽位 %d(OCR=%s)", oper_name, i, name)
196+
return (click_x / w, click_y / h)
185197

186198
logger.error("未找到干员 %s", oper_name)
187199
return None

custom/core/battle/action.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Action:
4040
alias: str | None = None
4141

4242
# 运行时填充
43+
target_frame: int = 0 # 目标累计帧(executor 用)
4344
tile_pos: tuple[int, int] | None = None # (col, row)
4445
avatar_pos: tuple[float, float] | None = None # 屏幕比例 (x, y)
4546
view_pos_front: tuple[float, float] | None = None

0 commit comments

Comments
 (0)