From c00a6ad0068159da0f4df2ebe68b1a5ad3c14a95 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:32:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(screen):=20=E6=B7=BB=E5=8A=A0=20Screen?= =?UTF-8?q?=20Scope=20=E6=9C=BA=E5=88=B6=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=85=A8=E5=B1=80/=E5=B1=80=E9=83=A8=20screen=20=E5=88=86?= =?UTF-8?q?=E5=B1=82=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScreenInfo 新增 app_id 字段,YAML 中声明所属应用(空=全局,非空=局部) - ScreenContext 新增 enter_scope/exit_scope API,自动从 app_id 推导活跃范围 - screen_utils BFS 匹配适配活跃范围,跳过非活跃 screen 的 OCR/模板匹配 - Application 生命周期自动进入/退出 scope 向后兼容:未设 app_id 时行为不变,渐进迁移无风险 --- docs/develop/screen_scope_design.md | 190 ++++++++++++++++++ .../base/operation/application_base.py | 8 + src/one_dragon/base/screen/screen_info.py | 3 + src/one_dragon/base/screen/screen_loader.py | 102 ++++++++-- src/one_dragon/base/screen/screen_utils.py | 18 +- 5 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 docs/develop/screen_scope_design.md diff --git a/docs/develop/screen_scope_design.md b/docs/develop/screen_scope_design.md new file mode 100644 index 0000000000..9b6226a62c --- /dev/null +++ b/docs/develop/screen_scope_design.md @@ -0,0 +1,190 @@ +# Screen Scope 设计方案 + +## 1. 背景与问题 + +### 现状 +- 所有 70 个 screen 注册在全局 `ScreenContext` 中 +- 应用通过硬编码字符串引用 screen(如 `'迷失之地-入口'`) +- `round_by_goto_screen` 调用 `get_match_screen_name` 时,BFS 遍历可能扫描全部 70 个 screen +- 每次 screen 匹配涉及 OCR 或模板匹配,开销大 + +### 三个核心问题 + +| 问题 | 原因 | 影响 | +|------|------|------| +| 插件无法管理 screen | screen 只从 `assets/game_data/screen_info/` 加载,无注入点 | 第三方插件无法定义画面 | +| 全局污染 | 所有 screen 同一命名空间,BFS 搜索和路由计算混杂不相关 screen | 识别变慢 | +| 全局跳转慢 | `round_by_goto_screen` 无范围限制,BFS 可能检查大量无关 screen | 每轮匹配耗时高 | + +## 2. 设计方案 + +### 核心概念:全局 screen 与局部 screen + +``` +┌─────────────────────────────────────────────┐ +│ ScreenContext │ +│ │ +│ ┌────────────────────┐ ┌───────────────┐ │ +│ │ 全局 screen │ │ 局部 screen │ │ +│ │ (app_id 为空) │ │ (app_id 非空) │ │ +│ │ │ │ │ │ +│ │ 菜单 │ │ 迷失之地-入口 │ │ +│ │ 大世界-普通 │ │ 迷失之地-大世界│ │ +│ │ 快捷手册-训练 │ │ 零号空洞-入口 │ │ +│ │ 快捷手册-作战 │ │ 随便观-入口 │ │ +│ │ ... │ │ ... │ │ +│ └────────────────────┘ └───────────────┘ │ +│ │ +│ 活跃范围 = 全局 ∪ 当前应用的局部 │ +└─────────────────────────────────────────────┘ +``` + +- **全局 screen**:YAML 中 `app_id` 为空。始终参与匹配,如菜单、大世界、快捷手册等导航骨架 +- **局部 screen**:YAML 中 `app_id` 非空。仅在对应应用运行时参与匹配 + +### 自动推导机制 + +1. **`reload()`** 后自动计算全局集合:`_global_screen_names = {无 app_id 的 screen}` +2. **应用启动** 时 `enter_scope(app_id)`,自动收集 `app_id` 匹配的局部 screen +3. **应用停止** 时 `exit_scope()`,恢复全量匹配 + +无需代码中手动维护字符串列表。 + +## 3. 数据模型变更 + +### ScreenInfo 新增 `app_id` 字段 + +```yaml +# 全局 screen(不设 app_id 或为空) +- screen_id: menu + screen_name: 菜单 + pc_alt: false + area_list: ... + +# 局部 screen(设置 app_id) +- screen_id: lost_void_entry + screen_name: 迷失之地-入口 + app_id: lost_void # ← 与 Application.app_id 一致 + pc_alt: false + area_list: ... +``` + +`app_id` 默认为空字符串,完全向后兼容。 + +## 4. API 设计 + +### ScreenContext + +```python +class ScreenContext: + # ---- Screen Scope 管理 ---- + + def enter_scope(self, app_id: str) -> None: + """进入应用 scope + 活跃范围 = 全局 screen + 该 app_id 的局部 screen + 仅当存在匹配的局部 screen 时才启用 scope + """ + + def exit_scope(self) -> None: + """退出应用 scope,恢复全量匹配""" + + @property + def active_screen_names(self) -> set[str] | None: + """当前活跃的 screen 名称集合。None 表示全部活跃""" + + @property + def active_screen_info_list(self) -> list[ScreenInfo]: + """当前活跃的 ScreenInfo 列表""" + + def is_screen_active(self, screen_name: str) -> bool: + """判断某个 screen 是否在活跃范围内""" +``` + +### Application 生命周期集成 + +```python +class Application(Operation): + def handle_init(self): + # 自动进入 scope(基于 self.app_id) + self.ctx.screen_loader.enter_scope(self.app_id) + ... + + def after_operation_done(self, result): + # 自动退出 scope + self.ctx.screen_loader.exit_scope() + ... +``` + +应用无需额外代码,scope 通过 `app_id` + YAML `app_id` 字段自动匹配。 + +## 5. 匹配优化 + +### BFS 搜索优化 + +`get_match_screen_name_from_last` 中: +- 非活跃 screen **跳过匹配**(避免 OCR/模板匹配开销) +- 非活跃 screen **仍展开邻居**(保持图连通性,确保能找到活跃 screen) +- fallback 遍历仅搜索活跃 screen + +### 效果估算 + +| 场景 | 改动前 | 改动后 | +|------|--------|--------| +| 迷失之地运行时 BFS 匹配范围 | ~70 screen | ~30 screen(全局 ~16 + 局部 ~14) | +| 每轮 OCR/模板匹配次数(最坏) | ~70 次 | ~30 次 | +| Floyd 路由计算 | O(70³) ≈ 34万次 | 不变(全局路由表共用) | + +### 路由不变 + +全局路由表(Floyd)仅在 `reload()` 时计算一次,scope 不影响。 +`round_by_goto_screen` 使用全局路由表查找路径,scope 仅影响 screen 识别的搜索范围。 + +## 6. 向后兼容性 + +| 场景 | 行为 | +|------|------| +| 所有 YAML 未设 `app_id`(当前现状) | 全部为全局 → `enter_scope` 无匹配 → 不启用 scope → 行为不变 | +| 部分 YAML 设了 `app_id` | 该应用启用 scope,其他应用不受影响 | +| 应用本身无匹配 screen | `enter_scope` 跳过 → 行为不变 | + +**零风险渐进迁移**:全部不改 YAML 时与现在一模一样,改一个应用的 YAML 只影响该应用。 + +## 7. 迁移指南 + +### 步骤 1:给局部 screen 的 YAML 添加 `app_id` + +以迷失之地为例,给 14 个 screen 各加一行: + +```yaml +- screen_id: lost_void_entry + screen_name: 迷失之地-入口 + app_id: lost_void # ← 新增 + pc_alt: false + area_list: ... +``` + +### 步骤 2:完成 + +无需修改任何 Python 代码。`Application.handle_init` 自动调用 `enter_scope(self.app_id)`, +匹配到 YAML 中 `app_id: lost_void` 的 screen,scope 自动生效。 + +### 建议迁移顺序 + +1. 迷失之地(14 screen,收益最大) +2. 随便观(7 screen) +3. 零号空洞(4 screen) +4. 仓库系统(3 screen) + +## 8. 插件支持 + +第三方插件在自己的 `screen_info/` 目录中定义 YAML screen,设置 `app_id` 为自己的应用 ID。 +插件的 screen 加载到 `ScreenContext` 后,通过 `app_id` 自动归入局部命名空间,不污染其他应用。 + +## 9. 文件变更清单 + +| 文件 | 变更 | +|------|------| +| `src/one_dragon/base/screen/screen_info.py` | `ScreenInfo` 新增 `app_id` 字段 | +| `src/one_dragon/base/screen/screen_loader.py` | `ScreenContext` 新增 scope 管理 API | +| `src/one_dragon/base/screen/screen_utils.py` | BFS 匹配适配活跃范围 | +| `src/one_dragon/base/operation/application_base.py` | `Application` 生命周期自动 enter/exit scope | diff --git a/src/one_dragon/base/operation/application_base.py b/src/one_dragon/base/operation/application_base.py index f27f781db6..f97a6c5b94 100644 --- a/src/one_dragon/base/operation/application_base.py +++ b/src/one_dragon/base/operation/application_base.py @@ -64,6 +64,10 @@ def handle_init(self) -> None: 运行前初始化 """ Operation.handle_init(self) + + # 进入 screen scope(基于 app_id 自动推导) + self.ctx.screen_loader.enter_scope(self.app_id) + if self.run_record is not None: self.run_record.check_and_update_status() # 先判断是否重置记录 self.run_record.update_status(AppRunRecord.STATUS_RUNNING) @@ -84,6 +88,10 @@ def after_operation_done(self, result: OperationResult): :return: """ Operation.after_operation_done(self, result) + + # 退出 screen scope + self.ctx.screen_loader.exit_scope() + self._update_record_after_stop(result) if self.ctx.run_context.is_app_need_notify(self.app_id): diff --git a/src/one_dragon/base/screen/screen_info.py b/src/one_dragon/base/screen/screen_info.py index d30875c545..b4bbb8d23f 100644 --- a/src/one_dragon/base/screen/screen_info.py +++ b/src/one_dragon/base/screen/screen_info.py @@ -13,6 +13,7 @@ def __init__(self, data: dict[str, Any]): self.old_screen_id: str = data.get('screen_id', '') # 旧的画面ID 用于保存时删掉旧文件 self.screen_id: str = data.get('screen_id', '') # 画面ID 用于加载文件 self.screen_name: str = data.get('screen_name', '') # 画面名称 用于显示 + self.app_id: str = data.get('app_id', '') # 所属应用ID 空字符串表示全局 screen self.screen_image: MatLike | None = None @@ -96,6 +97,8 @@ def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = {} data['screen_id'] = self.screen_id data['screen_name'] = self.screen_name + if self.app_id: + data['app_id'] = self.app_id data['pc_alt'] = self.pc_alt data['area_list'] = [area.to_dict() for area in self.area_list] diff --git a/src/one_dragon/base/screen/screen_loader.py b/src/one_dragon/base/screen/screen_loader.py index 085cc1baa2..fda05e8f94 100644 --- a/src/one_dragon/base/screen/screen_loader.py +++ b/src/one_dragon/base/screen/screen_loader.py @@ -1,5 +1,4 @@ -import os -from typing import Optional +from pathlib import Path import yaml @@ -52,19 +51,24 @@ def __init__(self): self._id_2_screen: dict[str, ScreenInfo] = {} self.screen_route_map: dict[str, dict[str, ScreenRoute]] = {} - self.last_screen_name: Optional[str] = None # 上一个画面名字 - self.current_screen_name: Optional[str] = None # 当前的画面名字 + self.last_screen_name: str | None = None # 上一个画面名字 + self.current_screen_name: str | None = None # 当前的画面名字 + + # Screen scope management + self._global_screen_names: set[str] = set() + self._local_screen_names: set[str] = set() + self._scoped: bool = False @property - def yml_file_dir(self) -> str: - return os_utils.get_path_under_work_dir('assets', 'game_data', 'screen_info') + def yml_file_dir(self) -> Path: + return Path(os_utils.get_path_under_work_dir('assets', 'game_data', 'screen_info')) @property - def merge_yml_file_path(self) -> str: - return os.path.join(self.yml_file_dir, '_od_merged.yml') + def merge_yml_file_path(self) -> Path: + return self.yml_file_dir / '_od_merged.yml' - def get_yml_file_path(self, screen_id: str) -> str: - return os.path.join(self.yml_file_dir, f'{screen_id}.yml') + def get_yml_file_path(self, screen_id: str) -> Path: + return self.yml_file_dir / f'{screen_id}.yml' def reload(self, from_memory: bool = False, from_separated_files: bool = False) -> None: """ @@ -87,13 +91,12 @@ def reload(self, from_memory: bool = False, from_separated_files: bool = False) self._screen_area_map[f'{screen_info.screen_name}.{screen_area.area_name}'] = screen_area elif from_separated_files: self._id_2_screen.clear() - for file_name in os.listdir(self.yml_file_dir): - if not file_name.endswith('.yml'): + for file_path in self.yml_file_dir.iterdir(): + if file_path.suffix != '.yml': continue - if file_name == '_od_merged.yml': + if file_path.name == '_od_merged.yml': continue - file_path = os.path.join(self.yml_file_dir, file_name) - with open(file_path, 'r', encoding='utf-8') as file: + with file_path.open(encoding='utf-8') as file: log.debug(f"加载yaml: {file_path}") data = yaml_utils.safe_load(file) if not isinstance(data, dict): @@ -110,7 +113,7 @@ def reload(self, from_memory: bool = False, from_separated_files: bool = False) else: self._id_2_screen.clear() file_path = self.merge_yml_file_path - with open(file_path, 'r', encoding='utf-8') as file: + with file_path.open(encoding='utf-8') as file: log.debug(f"加载yaml: {file_path}") yaml_data = yaml_utils.safe_load(file) if not isinstance(yaml_data, list): @@ -131,6 +134,10 @@ def reload(self, from_memory: bool = False, from_separated_files: bool = False) self.init_screen_route() + # 自动计算全局 screen:没有 app_id 的 screen 为全局 + self._global_screen_names = { + s.screen_name for s in self.screen_info_list if not s.app_id + } def get_screen(self, screen_name: str, copy: bool = False) -> ScreenInfo: """ 获取某个画面 @@ -182,8 +189,8 @@ def delete_screen(self, screen_id: str, save: bool = True) -> None: del self._id_2_screen[screen_id] file_path = self.get_yml_file_path(screen_id) - if os.path.exists(file_path): - os.remove(file_path) + if file_path.exists(): + file_path.unlink() if save: self.save(screen_id=screen_id) @@ -206,11 +213,11 @@ def save(self, screen_id: str | None = None, reload_after_save: bool = True) -> all_data.append(data) if screen_id is not None and screen_id == screen_info.screen_id: - with open(self.get_yml_file_path(screen_id), 'w', encoding='utf-8') as file: + with self.get_yml_file_path(screen_id).open('w', encoding='utf-8') as file: yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False, sort_keys=False) # 保存到合并文件 - with open(self.merge_yml_file_path, 'w', encoding='utf-8') as file: + with self.merge_yml_file_path.open('w', encoding='utf-8') as file: yaml.safe_dump(all_data, file, allow_unicode=True, default_flow_style=False, sort_keys=False) if reload_after_save: @@ -284,9 +291,9 @@ def init_screen_route(self) -> None: for node_kj in route_kj.node_list: route_ij.node_list.append(node_kj) - def get_screen_route(self, from_screen: str, to_screen: str) -> Optional[ScreenRoute]: + def get_screen_route(self, from_screen: str, to_screen: str) -> ScreenRoute | None: """ - 获取两个画面之间的 + 获取两个画面之间的路径 :param from_screen: :param to_screen: :return: @@ -302,3 +309,54 @@ def update_current_screen_name(self, screen_name: str) -> None: """ self.last_screen_name = self.current_screen_name self.current_screen_name = screen_name + + # ---- Screen Scope 管理 ---- + # 通过 ScreenInfo.app_id 自动区分全局/局部 screen: + # - app_id 为空 → 全局 screen,始终参与匹配 + # - app_id 非空 → 局部 screen,仅在对应应用 scope 内参与匹配 + # reload() 后自动计算全局集合,Application 启动/停止时自动 enter/exit scope + + def enter_scope(self, app_id: str) -> None: + """进入应用 scope,活跃范围 = 全局 screen + 该 app_id 的局部 screen + + 仅当 YAML 中存在 app_id 匹配的 screen 时才真正启用 scope,否则保持全量匹配。 + + Args: + app_id: 当前应用的唯一标识符(与 ScreenInfo.app_id 对应) + """ + if not self._global_screen_names: + return # 所有 screen 都没有 app_id,不启用 scope + + local_names = {s.screen_name for s in self.screen_info_list if s.app_id == app_id} + if not local_names: + return # 该应用没有专属 screen,不启用 scope + + self._local_screen_names = local_names + self._scoped = True + + def exit_scope(self) -> None: + """退出应用 scope,恢复全量 screen 匹配""" + self._local_screen_names.clear() + self._scoped = False + + @property + def active_screen_names(self) -> set[str] | None: + """当前活跃的 screen 名称集合。None 表示全部活跃(未启用 scope)。""" + if not self._scoped: + return None + return self._global_screen_names | self._local_screen_names + + @property + def active_screen_info_list(self) -> list[ScreenInfo]: + """当前活跃的 ScreenInfo 列表""" + names = self.active_screen_names + if names is None: + return self.screen_info_list + return [s for s in self.screen_info_list if s.screen_name in names] + + def is_screen_active(self, screen_name: str) -> bool: + """判断某个 screen 是否在当前活跃范围内""" + names = self.active_screen_names + if names is None: + return True + return screen_name in names diff --git a/src/one_dragon/base/screen/screen_utils.py b/src/one_dragon/base/screen/screen_utils.py index 1c1fa2c258..e25f24008e 100644 --- a/src/one_dragon/base/screen/screen_utils.py +++ b/src/one_dragon/base/screen/screen_utils.py @@ -363,7 +363,7 @@ def get_match_screen_name( elif ctx.screen_loader.current_screen_name is not None or ctx.screen_loader.last_screen_name is not None: return get_match_screen_name_from_last(ctx, screen, crop_first=crop_first) else: - for screen_info in ctx.screen_loader.screen_info_list: + for screen_info in ctx.screen_loader.active_screen_info_list: if is_target_screen(ctx, screen, screen_info=screen_info, crop_first=crop_first): return screen_info.screen_name @@ -385,6 +385,8 @@ def get_match_screen_name_from_last( Returns: str | None: 画面名称 """ + active_names = ctx.screen_loader.active_screen_names # set or None + bfs_list = [] if ctx.screen_loader.current_screen_name is not None: # 如果有记录上次所在画面 则从这个画面开始搜索 @@ -400,6 +402,18 @@ def get_match_screen_name_from_last( current_screen_name = bfs_list[bfs_idx] bfs_idx += 1 + # 在 scope 模式下 跳过非活跃 screen 的匹配(但仍展开其邻居以保持图连通性) + if active_names is not None and current_screen_name not in active_names: + screen_info = ctx.screen_loader.screen_info_map.get(current_screen_name) + if screen_info is not None: + for area in screen_info.area_list: + if area.goto_list is None or len(area.goto_list) == 0: + continue + for goto_screen in area.goto_list: + if goto_screen not in bfs_list: + bfs_list.append(goto_screen) + continue + if is_target_screen(ctx, screen, screen_name=current_screen_name, crop_first=crop_first): return current_screen_name @@ -414,7 +428,7 @@ def get_match_screen_name_from_last( bfs_list.append(goto_screen) # 最后 尝试搜索中没有出现的画面 - for screen_info in ctx.screen_loader.screen_info_list: + for screen_info in ctx.screen_loader.active_screen_info_list: if screen_info.screen_name in bfs_list: continue if is_target_screen(ctx, screen, screen_info=screen_info, crop_first=crop_first): From 999548e2a93547bbf51ed5407a7c2bc2b4815667 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:36:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(screen):=20=E6=94=AF=E6=8C=81=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=8F=92=E4=BB=B6=E7=9A=84screen=5Finfo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/develop/screen_scope_design.md | 59 ++++++++++++++++++- .../base/operation/one_dragon_context.py | 20 +++++++ src/one_dragon/base/screen/screen_loader.py | 47 +++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/docs/develop/screen_scope_design.md b/docs/develop/screen_scope_design.md index 9b6226a62c..ea56a2a9ff 100644 --- a/docs/develop/screen_scope_design.md +++ b/docs/develop/screen_scope_design.md @@ -175,9 +175,61 @@ class Application(Operation): 3. 零号空洞(4 screen) 4. 仓库系统(3 screen) -## 8. 插件支持 +## 8. 插件 Screen 加载 -第三方插件在自己的 `screen_info/` 目录中定义 YAML screen,设置 `app_id` 为自己的应用 ID。 +### 加载机制 + +插件的 screen 通过 `OneDragonContext._load_plugin_screens()` 自动加载: + +``` +OneDragonContext.init() +├── register_application_factory() ← 扫描插件,填充 plugin_infos +├── screen_loader.reload() ← 加载主 screen YAML +└── _load_plugin_screens() ← 遍历 plugin_infos → load_extra_screen_dir() + └── 对每个插件的 screen_info/ 目录加载 YAML + └── 未设 app_id 的 screen 自动使用插件的 app_id + +refresh_application_registration() ← 运行时刷新插件 +├── clear_applications + discover_factories ← 重新扫描 +├── screen_loader.reload() ← 重新加载主 YAML +└── _load_plugin_screens() ← 重新加载插件 screen +``` + +### 插件目录结构 + +``` +plugins/ + my_plugin/ + __init__.py + my_plugin_const.py # APP_ID = 'my_plugin' + my_plugin_factory.py + my_plugin.py + screen_info/ # ← 新增,放 screen YAML + my_screen.yml +``` + +### 插件 Screen YAML 示例 + +```yaml +# plugins/my_plugin/screen_info/my_screen.yml +screen_id: my_plugin_main +screen_name: 我的插件-主界面 +# app_id 可省略,自动使用插件的 APP_ID +pc_alt: false +area_list: + - area_name: 返回按钮 + id_mark: true + pc_rect: [82, 13, 150, 90] + template_id: back + template_sub_dir: menu + goto_list: + - 大世界-普通 +``` + +### 冲突处理 + +- 插件 screen_name 与主 YAML 或其他插件冲突时,跳过并输出警告日志 +- `default_app_id` 仅在 YAML 未显式设置 `app_id` 时使用 插件的 screen 加载到 `ScreenContext` 后,通过 `app_id` 自动归入局部命名空间,不污染其他应用。 ## 9. 文件变更清单 @@ -185,6 +237,7 @@ class Application(Operation): | 文件 | 变更 | |------|------| | `src/one_dragon/base/screen/screen_info.py` | `ScreenInfo` 新增 `app_id` 字段 | -| `src/one_dragon/base/screen/screen_loader.py` | `ScreenContext` 新增 scope 管理 API | +| `src/one_dragon/base/screen/screen_loader.py` | `ScreenContext` 新增 scope 管理 API 和 `load_extra_screen_dir` | | `src/one_dragon/base/screen/screen_utils.py` | BFS 匹配适配活跃范围 | | `src/one_dragon/base/operation/application_base.py` | `Application` 生命周期自动 enter/exit scope | +| `src/one_dragon/base/operation/one_dragon_context.py` | 新增 `_load_plugin_screens()`,init 和 refresh 时加载插件 screen | diff --git a/src/one_dragon/base/operation/one_dragon_context.py b/src/one_dragon/base/operation/one_dragon_context.py index 4571bbc3eb..33431c3989 100644 --- a/src/one_dragon/base/operation/one_dragon_context.py +++ b/src/one_dragon/base/operation/one_dragon_context.py @@ -196,6 +196,21 @@ def register_application_factory(self) -> None: if default_factories: self.run_context.registry_application(default_factories, default_group=True) + def _load_plugin_screens(self) -> None: + """加载插件的 screen_info + + 遍历所有已注册插件,从其 screen_info/ 子目录加载 screen YAML。 + 未设 app_id 的 screen 自动使用插件的 app_id。 + 应在 screen_loader.reload() 之后调用。 + """ + for plugin_info in self.factory_manager.plugin_infos: + if plugin_info.plugin_dir is None: + continue + screen_dir = plugin_info.plugin_dir / 'screen_info' + self.screen_loader.load_extra_screen_dir( + str(screen_dir), default_app_id=plugin_info.app_id + ) + def refresh_application_registration(self) -> None: """刷新应用注册 @@ -216,6 +231,10 @@ def refresh_application_registration(self) -> None: if default_factories: self.run_context.registry_application(default_factories, default_group=True) + # 重新加载插件 screen + self.screen_loader.reload() + self._load_plugin_screens() + # 更新默认应用组 self.app_group_manager.set_default_apps(self.run_context.default_group_apps) @@ -252,6 +271,7 @@ def init(self) -> None: self.init_ocr() self.screen_loader.reload() + self._load_plugin_screens() # 账号实例层级的配置 不是应用特有的配置 self.reload_instance_config() diff --git a/src/one_dragon/base/screen/screen_loader.py b/src/one_dragon/base/screen/screen_loader.py index fda05e8f94..ee7808f11f 100644 --- a/src/one_dragon/base/screen/screen_loader.py +++ b/src/one_dragon/base/screen/screen_loader.py @@ -138,6 +138,53 @@ def reload(self, from_memory: bool = False, from_separated_files: bool = False) self._global_screen_names = { s.screen_name for s in self.screen_info_list if not s.app_id } + + def load_extra_screen_dir(self, dir_path: str, default_app_id: str = '') -> None: + """从额外目录加载 screen YAML 并注册(用于插件 screen 注入) + + 加载后会重新计算路由和全局 screen 集合。 + + Args: + dir_path: 包含 screen YAML 文件的目录路径 + default_app_id: 如果 YAML 中未设置 app_id,使用此默认值 + """ + screen_dir = Path(dir_path) + if not screen_dir.is_dir(): + return + + added = False + for file_path in screen_dir.iterdir(): + if file_path.suffix != '.yml': + continue + with file_path.open(encoding='utf-8') as file: + log.debug(f"加载插件画面: {file_path}") + data = yaml_utils.safe_load(file) + if not isinstance(data, dict): + log.warning(f"插件画面配置格式错误,已跳过: {file_path}") + continue + + if default_app_id and not data.get('app_id'): + data['app_id'] = default_app_id + + screen_info = ScreenInfo(data) + if screen_info.screen_name in self.screen_info_map: + log.warning(f"插件画面名称冲突,已跳过: {screen_info.screen_name}") + continue + + self.screen_info_list.append(screen_info) + self.screen_info_map[screen_info.screen_name] = screen_info + self._id_2_screen[screen_info.screen_id] = screen_info + + for screen_area in screen_info.area_list: + self._screen_area_map[f'{screen_info.screen_name}.{screen_area.area_name}'] = screen_area + added = True + + if added: + self.init_screen_route() + self._global_screen_names = { + s.screen_name for s in self.screen_info_list if not s.app_id + } + def get_screen(self, screen_name: str, copy: bool = False) -> ScreenInfo: """ 获取某个画面