From 188073f4d98719b122e5769281ecacaed9cfa612 Mon Sep 17 00:00:00 2001 From: Claret Date: Fri, 21 Aug 2026 14:02:11 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(engine):=20=E4=BD=BF=E7=94=A8=E8=A1=8C?= =?UTF-8?q?=E9=AB=98=E8=AE=A1=E7=AE=97=E7=BF=BB=E9=A1=B5=E5=88=B0=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=BA=95=E9=83=A8=E6=97=B6=E7=9A=84=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E8=A1=8C=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/test_inventory_page_flip.py | 212 ++++++++++++++++++ .../core/scanner/engine.py | 22 +- 2 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 scripts/test_inventory_page_flip.py diff --git a/scripts/test_inventory_page_flip.py b/scripts/test_inventory_page_flip.py new file mode 100644 index 0000000..085271d --- /dev/null +++ b/scripts/test_inventory_page_flip.py @@ -0,0 +1,212 @@ +"""在游戏窗口中安全测试仓库翻页与基质识别。 + +该脚本直接运行正式的 ``DraggableScannerEngine``,但把宝藏和养成材料的 +处理动作都强制设为 KEEP,因此不会锁定、解锁、弃用或取消弃用基质。 + +使用前请在游戏中打开:贵重品库 -> 武器基质,并把列表滚动到顶部。 + +运行方式: + uv run python scripts/test_inventory_page_flip.py + +扫描过程中可按 Ctrl+Shift+Q 请求停止。也可把鼠标迅速移到屏幕左上角, +触发 PyAutoGUI 的紧急停止机制。 +""" + +from __future__ import annotations + +import argparse +import sys +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from endfield_essence_recognizer.schemas.user_setting import UserSetting + + +STOP_HOTKEY = "ctrl+shift+q" + + +@dataclass(frozen=True) +class ReadOnlySettingManager: + """向扫描引擎提供一份固定的、无状态修改动作的设置。""" + + setting: UserSetting + + def get_user_setting(self) -> UserSetting: + return self.setting + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="在终末地游戏界面中测试正式的自动翻页和基质识别代码。" + ) + parser.add_argument( + "--countdown", + type=int, + default=5, + metavar="SECONDS", + help="确认后等待多少秒再开始(默认:5)", + ) + parser.add_argument( + "--no-row-align", + action="store_true", + help="关闭正式扫描默认启用的翻页后网格行对齐修正", + ) + parser.add_argument( + "--overscroll-fix", + action="store_true", + help="启用实验性的重复首行滚动过量修正", + ) + parser.add_argument( + "--yes", + action="store_true", + help="不等待回车确认,适合已经准备好游戏界面时使用", + ) + args = parser.parse_args() + if args.countdown < 0: + parser.error("--countdown 不能小于 0") + return args + + +def build_context(): + """使用与正式程序相同的工厂创建全部识别器。""" + from endfield_essence_recognizer.core.scanner.context import ScannerContext + from endfield_essence_recognizer.dependencies.recognition import ( + get_abandon_status_recognizer_dep, + get_attribute_level_recognizer_dep, + get_attribute_recognizer_dep, + get_lock_status_recognizer_dep, + get_rarity_recognizer_dep, + get_ui_scene_recognizer_dep, + ) + from endfield_essence_recognizer.dependencies.services import get_static_game_data + + return ScannerContext( + attr_recognizer=get_attribute_recognizer_dep(), + attr_level_recognizer=get_attribute_level_recognizer_dep(), + abandon_status_recognizer=get_abandon_status_recognizer_dep(), + lock_status_recognizer=get_lock_status_recognizer_dep(), + rarity_recognizer=get_rarity_recognizer_dep(), + ui_scene_recognizer=get_ui_scene_recognizer_dep(), + static_game_data=get_static_game_data(), + ) + + +def make_read_only_settings( + *, row_align: bool, overscroll_fix: bool +) -> ReadOnlySettingManager: + from endfield_essence_recognizer.schemas.user_setting import Action, UserSetting + + setting = UserSetting( + auto_page_flip=True, + fix_grid_row_offset_after_page_flip=row_align, + fix_page_flip_overscroll=overscroll_fix, + treasure_action=Action.KEEP, + trash_action=Action.KEEP, + ) + return ReadOnlySettingManager(setting) + + +def print_countdown(seconds: int) -> None: + for remaining in range(seconds, 0, -1): + print(f"将在 {remaining} 秒后开始……", flush=True) + time.sleep(1) + + +def main() -> int: + args = parse_args() + + # 延迟导入:这样执行 --help 时无需初始化 Windows 自动化和识别模型。 + import keyboard + import pyautogui + + from endfield_essence_recognizer.core.layout.factory import ( + build_resolution_profile, + ) + from endfield_essence_recognizer.core.scanner.engine import ( + DraggableScannerEngine, + ) + from endfield_essence_recognizer.core.window.adapter import WindowActionsAdapter + from endfield_essence_recognizer.core.window.scaling import ( + create_scaling_wrappers, + ) + from endfield_essence_recognizer.dependencies.window import ( + get_game_window_manager, + ) + + pyautogui.FAILSAFE = True + window_manager = get_game_window_manager() + if not window_manager.target_exists: + print('未找到标题为 "Endfield" 的游戏窗口。请先启动游戏再运行脚本。') + return 2 + + adapter = WindowActionsAdapter(window_manager) + image_source, window_actions = create_scaling_wrappers(adapter, adapter) + logical_width, logical_height = image_source.get_client_size() + profile = build_resolution_profile(logical_width, logical_height) + + print( + f"已找到游戏窗口:实际 {image_source.physical_size[0]}x" + f"{image_source.physical_size[1]},识别坐标 {logical_width}x{logical_height}。" + ) + print("请确认:") + print(" 1. 游戏已打开“贵重品库 -> 武器基质”页面;") + print(" 2. 基质列表已经滚动到最顶部;") + print(" 3. 当前没有另一个识别扫描正在运行。") + print("测试只会选择基质和拖动列表,不会改变锁定或弃用状态。") + print(f"扫描期间按 {STOP_HOTKEY.upper()} 可停止。") + + if not args.yes: + try: + input("准备好后按 Enter;按 Ctrl+C 取消:") + except (EOFError, KeyboardInterrupt): + print("已取消。") + return 130 + + print("正在加载识别器……", flush=True) + context = build_context() + settings = make_read_only_settings( + row_align=not args.no_row_align, + overscroll_fix=args.overscroll_fix, + ) + engine = DraggableScannerEngine( + ctx=context, + image_source=image_source, + window_actions=window_actions, + user_setting_manager=settings, # type: ignore[arg-type] + profile=profile, + ) + + stop_event = threading.Event() + hotkey_handle = None + try: + hotkey_handle = keyboard.add_hotkey(STOP_HOTKEY, stop_event.set) + except Exception as exc: + print(f"警告:无法注册全局停止快捷键({exc})。仍可使用鼠标左上角急停。") + + try: + print_countdown(args.countdown) + engine.execute(stop_event) + except KeyboardInterrupt: + stop_event.set() + print("收到 Ctrl+C,测试已停止。") + return 130 + except pyautogui.FailSafeException: + stop_event.set() + print("已触发鼠标左上角急停,测试已停止。") + return 130 + finally: + if hotkey_handle is not None: + keyboard.remove_hotkey(hotkey_handle) + + if stop_event.is_set(): + print("测试已按请求停止。") + else: + print("翻页与识别测试完成。详细过程请查看上方日志。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/endfield_essence_recognizer/core/scanner/engine.py b/src/endfield_essence_recognizer/core/scanner/engine.py index 76615e4..8efeb54 100644 --- a/src/endfield_essence_recognizer/core/scanner/engine.py +++ b/src/endfield_essence_recognizer/core/scanner/engine.py @@ -829,9 +829,10 @@ def _execute_grid_scan(self, stop_event: threading.Event) -> None: logger.info(f"开始扫描第 {page_count} 页基质...") if is_last_page and page_count > 1: - # 最后一页:根据渐进滚动比例计算需要跳过的行数 + # 最后一页:根据渐进滚动距离计算需要跳过的行数 + row_height = icon_y_list[1] - icon_y_list[0] skip_rows = self._calculate_skip_rows( - progressive_drag_distance, max_drag_distance, total_rows + progressive_drag_distance, row_height, total_rows ) start_row = min(skip_rows, total_rows - 1) logger.info( @@ -1184,7 +1185,7 @@ def on_step(step_index: int, screen_x: int, screen_y: int) -> bool: if row_height > 0: scrolled_rows = actual_distance / row_height # 额外滚动一整行,乘以 1.2 系数确保滚动到位 - extra_drag = int(row_height * 1.2) + extra_drag = row_height logger.info( f"已滚动 {scrolled_rows:.1f} 行,额外滚动一整行 {extra_drag}px 确保到位" ) @@ -1205,7 +1206,7 @@ def on_step(step_index: int, screen_x: int, screen_y: int) -> bool: def _calculate_skip_rows( self, actual_drag: int, - max_drag: int, + row_height: int, total_rows: int, ) -> int: """ @@ -1213,26 +1214,25 @@ def _calculate_skip_rows( Args: actual_drag: 实际滚动距离 - max_drag: 最大滚动距离(完整一页的距离) - total_rows: 总行数 + row_height: 行高 + total_rows: 当前页总行数 Returns: 需要跳过的行数 """ - if max_drag <= 0 or actual_drag <= 0: + if row_height <= 0 or actual_drag <= 0: return 0 # 计算比例,使用 ceil 向上取整确保滚动到位 # 例如:2.1、2.5、2.9 都会取整为 3,多滚动一行确保内容完全滚出 # 松开鼠标后页面会自动回弹到正确位置 # 减 1 抵消 _progressive_drag 中 row_height * 1.2 额外拖拽造成的多算 - ratio = actual_drag / max_drag - scrolled_rows = max(0, math.ceil(ratio * total_rows) - 1) + scrolled_rows = max(0, math.ceil(actual_drag / row_height) - 1) skip_rows = total_rows - scrolled_rows logger.info( - f"计算跳过行数: 比例={ratio:.2f}, 实际滚动={actual_drag}px, 最大={max_drag}px, " - f"滚动行数={ratio * total_rows:.1f}→{scrolled_rows}, 跳过={skip_rows}行" + f"计算跳过行数: 实际滚动={actual_drag}px, 行高={row_height}px, " + f"滚动行数={actual_drag / row_height:.1f}→{scrolled_rows}, 跳过={skip_rows}行" ) return skip_rows From fdec7563b8ca627b334918442125010d7b66b7e4 Mon Sep 17 00:00:00 2001 From: Claret Date: Fri, 21 Aug 2026 14:30:01 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(engine):=20=E7=AE=80=E5=8D=95=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E8=AE=A1=E7=AE=97=E6=BB=9A=E5=8A=A8=E8=A1=8C=E6=95=B0?= =?UTF-8?q?=E7=9A=84=E6=97=A5=E5=BF=97=E8=BE=93=E5=87=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/endfield_essence_recognizer/core/scanner/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/endfield_essence_recognizer/core/scanner/engine.py b/src/endfield_essence_recognizer/core/scanner/engine.py index 8efeb54..c16086e 100644 --- a/src/endfield_essence_recognizer/core/scanner/engine.py +++ b/src/endfield_essence_recognizer/core/scanner/engine.py @@ -1231,8 +1231,8 @@ def _calculate_skip_rows( skip_rows = total_rows - scrolled_rows logger.info( - f"计算跳过行数: 实际滚动={actual_drag}px, 行高={row_height}px, " - f"滚动行数={actual_drag / row_height:.1f}→{scrolled_rows}, 跳过={skip_rows}行" + f"计算跳过行数: 实际滚动={actual_drag}px,行高={row_height}px," + f"滚动行数={actual_drag / row_height:.3f}→{scrolled_rows},跳过={skip_rows}行" ) return skip_rows From a620b0e6b1cf2050f1773dc5bc27c1bcf37886ce Mon Sep 17 00:00:00 2001 From: Jiu_book_ <863050404@qq.com> Date: Sat, 22 Aug 2026 01:10:59 +0800 Subject: [PATCH 3/4] =?UTF-8?q?chore(engine):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=E6=97=A5=E5=BF=97=E7=B2=BE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正 188073f 遗留的过时注释 同步更新 _calculate_skip_rows 的 docstring 与行内注释 同时把 _progressive_drag 中日志精度从 .1f 统一为.3f 与 _calculate_skip_rows 的日志格式一致。 --- src/endfield_essence_recognizer/core/scanner/engine.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/endfield_essence_recognizer/core/scanner/engine.py b/src/endfield_essence_recognizer/core/scanner/engine.py index c16086e..8412556 100644 --- a/src/endfield_essence_recognizer/core/scanner/engine.py +++ b/src/endfield_essence_recognizer/core/scanner/engine.py @@ -1184,10 +1184,10 @@ def on_step(step_index: int, screen_x: int, screen_y: int) -> bool: # 因为检测点可能正好在倒数第二行,需要确保最后一行完全滚出 if row_height > 0: scrolled_rows = actual_distance / row_height - # 额外滚动一整行,乘以 1.2 系数确保滚动到位 + # 额外滚动一整行确保滚动到位 extra_drag = row_height logger.info( - f"已滚动 {scrolled_rows:.1f} 行,额外滚动一整行 {extra_drag}px 确保到位" + f"已滚动 {scrolled_rows:.3f} 行,额外滚动一整行 {extra_drag}px 确保到位" ) # 等待惯性滚动停止 self._window_actions.wait(0.5) @@ -1210,7 +1210,7 @@ def _calculate_skip_rows( total_rows: int, ) -> int: """ - 根据渐进滚动比例计算需要跳过的行数。 + 根据渐进滚动距离与行高计算需要跳过的行数。 Args: actual_drag: 实际滚动距离 @@ -1223,10 +1223,10 @@ def _calculate_skip_rows( if row_height <= 0 or actual_drag <= 0: return 0 - # 计算比例,使用 ceil 向上取整确保滚动到位 + # 计算滚动行数,使用 ceil 向上取整确保滚动到位 # 例如:2.1、2.5、2.9 都会取整为 3,多滚动一行确保内容完全滚出 # 松开鼠标后页面会自动回弹到正确位置 - # 减 1 抵消 _progressive_drag 中 row_height * 1.2 额外拖拽造成的多算 + # 减 1 抵消 _progressive_drag 中额外滚动一整行造成的多算 scrolled_rows = max(0, math.ceil(actual_drag / row_height) - 1) skip_rows = total_rows - scrolled_rows From f257ea51cb01644f11e995eb3ae17a608b1ff41c Mon Sep 17 00:00:00 2001 From: Jiu_book_ <863050404@qq.com> Date: Sat, 22 Aug 2026 01:11:37 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(engine):=20=E4=BF=AE=E5=A4=8D=E8=A1=8C?= =?UTF-8?q?=E9=AB=98=E8=AE=A1=E7=AE=97=E5=9C=A8=E8=A1=8C=E6=95=B0=E4=B8=8D?= =?UTF-8?q?=E8=B6=B3=E6=97=B6=E8=B6=8A=E7=95=8C=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 _execute_grid_scan 中两处无保护的 icon_y_list[1] 索引 --- src/endfield_essence_recognizer/core/scanner/engine.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/endfield_essence_recognizer/core/scanner/engine.py b/src/endfield_essence_recognizer/core/scanner/engine.py index 8412556..94d246f 100644 --- a/src/endfield_essence_recognizer/core/scanner/engine.py +++ b/src/endfield_essence_recognizer/core/scanner/engine.py @@ -830,7 +830,9 @@ def _execute_grid_scan(self, stop_event: threading.Event) -> None: if is_last_page and page_count > 1: # 最后一页:根据渐进滚动距离计算需要跳过的行数 - row_height = icon_y_list[1] - icon_y_list[0] + row_height = ( + icon_y_list[1] - icon_y_list[0] if len(icon_y_list) > 1 else 0 + ) skip_rows = self._calculate_skip_rows( progressive_drag_distance, row_height, total_rows ) @@ -851,7 +853,9 @@ def _execute_grid_scan(self, stop_event: threading.Event) -> None: 0, stop_event, user_setting, icon_x_list, icon_y_list ) if all_dup and user_setting.fix_page_flip_overscroll: - row_height = icon_y_list[1] - icon_y_list[0] + row_height = ( + icon_y_list[1] - icon_y_list[0] if len(icon_y_list) > 1 else 0 + ) adjust_distance = round(row_height * 3 / 4) self._correct_overscroll(drag_start, adjust_distance) logger.info(