Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions docs/develop/screen_scope_design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# 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

```
Comment thread
ShadowLemoon marked this conversation as resolved.
┌─────────────────────────────────────────────┐
│ 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 加载

### 加载机制

插件的 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. 文件变更清单

| 文件 | 变更 |
|------|------|
| `src/one_dragon/base/screen/screen_info.py` | `ScreenInfo` 新增 `app_id` 字段 |
| `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 |
8 changes: 8 additions & 0 deletions src/one_dragon/base/operation/application_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand Down
20 changes: 20 additions & 0 deletions src/one_dragon/base/operation/one_dragon_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""刷新应用注册

Expand All @@ -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)

Expand Down Expand Up @@ -252,6 +271,7 @@ def init(self) -> None:
self.init_ocr()

self.screen_loader.reload()
self._load_plugin_screens()

# 账号实例层级的配置 不是应用特有的配置
self.reload_instance_config()
Expand Down
3 changes: 3 additions & 0 deletions src/one_dragon/base/screen/screen_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]

Expand Down
Loading
Loading