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
19 changes: 19 additions & 0 deletions docs/game/gameplay/迷失之地.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ last_updated: 2026-07-26

大世界 → 快捷手册(作战)→ 零号空洞 → 迷失之地 → 入口画面(周期/常规)→ 选副本 + 调查战略 + 周期增益 + 编队 → 出战 → 层间移动。**【代码】** `LostVoidApp` 节点链:`tp_to_lost_void` → `wait_lost_void_entry` → `check_bounty_commission_before` → (按 mission 分支:矩阵行动编队 / 战略+增益+编队) → `deploy` → `run_level`(循环)→ `after_complete` → 全部领奖 → `back_at_last`。

**入口层(第一层)交互机制**(2026-08-20 实拍确认 + 2026-08-26 冻结入口层逐步实测,`【单源】`+**【代码】**):
- 开局站台有多个相邻 NPC:蕾(通用武备/初始武备选择)、奥菲莉亚(代理人武备;特遣调查下为战术棱镜)、神出鬼没的研究员(2.0 特遣调查,给卡牌礼物);各自头顶感叹号。入口层地图有多种布局(码头斜坡版 / 警戒区版等),NPC 与下层入口的相对位置随布局变化。
- **选完武备携带后,NPC 头顶感叹号消失,但 NPC 本体仍可重复交互**(可反悔换选)→ 再次交互会重新打开同一个武备选择画面。
- **交互判定:角色朝向 180° 扇面内,优先级 角度>距离**;朝向 = 最后一次移动方向。站在两个相邻 NPC 之间朝向不对时,按交互会命中刚选完的 NPC 而不是目标 NPC。
- **当前会被交互键命中的对象,头顶感叹号变大且下方显示「> 名字 <」标签(只有它显示)**——交互文本 OCR 读到的就是按下交互会命中谁,可作按键前的可靠信号。
- **朝目标方向迈一小步(0.1~0.3 秒)瞄准即切换**(标签跟着换);鼠标滚轮只是相机缩放,切换不了交互目标。
- 自动化若不处理会死循环:选完蕾 → 走向旁边感叹号 → 交互又命中蕾 → 重开同一画面(整套识别约 40 秒)→ 循环到 10 分钟超时。修复见文末附录「层间移动」。

---

## 附录:自动化实现(代码编排,非玩法)
Expand All @@ -125,6 +133,17 @@ last_updated: 2026-07-26

**层间移动 `LostVoidRunLevel`**(`lost_void_run_level.py`):核心循环 = `非战斗画面识别`(YOLO `detect_to_go` 找 感叹号/白点/下层入口,按优先级移动)→ `try_interact`(interact F)→ `handle_interact`(**按画面路由**:武备选择→`LostVoidChooseGear` / 通用选择→`LostVoidChooseCommon` / 邦布商店→`LostVoidBangbooStore` / 路径迭换→`LostVoidRouteChange` / 抽奖机→`LostVoidLottery` / 挑战结果→结算 / 否则对话/黑屏兜底)→ `after_interact`。战斗走 `init_auto_op` → `in_battle`(自动战斗 + 识别脱战)。失败兜底:`handle_find_target_fail` → `push_error` → `fail_exit_lost_void`。

**入口层重复交互防护**(2026-08-20 首版,2026-08-26 按实测机制重写走位,对应上文「入口层交互机制」):
- 入口层构造 `LostVoidChooseGear` 时传共享名单 `entry_gear_name_list`;携带成功后把画面**首个**武备名记入名单;再次进入武备选择画面时先只点首个武备读名字,difflib(cutoff=0.8,只容忍同名 OCR 错字)命中名单 → 直接点返回退出(约 4 秒,状态 `重复武备选择`),不再重复整套识别(约 40 秒)。判断信号与存储信号对称(都是画面最左侧槽位的武备名,最左格 OCR 解析失败则本画面不记录)。**名单已有 2 个武备名时再进武备画面必为重复,直接退出**(入口层只有两个武备 NPC,连点首格读名都省掉)。
- **按键前闭环校验**(完成过首次交互后,每次按交互键前):OCR `区域-交互文本` 读「> 名字 <」标签确认按下会命中谁——文本是入口或**没交互过的目标**才按;是已交互 NPC 或读不出时不按,改为走位换目标后重试(上限 8 次,确认到目标后清零)。
- **走位换目标**(`_steer_towards_other_interact_icon`):用当前标签的 x 坐标排除已瞄准的感叹号图标,朝剩余图标里最大(=最近)的一个迈步(先前进 0.18 秒收距离,再按水平偏移横移,偏得越多步子越大,`min(0.3, 0.1+|dx|/800)` 秒),让朝向指向目标,下一轮标签即切换。找门阶段(感叹号已忽略/放弃)则直接朝**下层入口图标**迈步;什么图标都检测不到才前进+左右交替兜底。
- 完成一次武备选择后后退 0.8 秒,让相邻感叹号进入画面再接近(首版的「贴近 NPC+横移」会因取最右侧图标而走向刚交互完的 NPC,已废弃)。
- **三个 NPC(两武备+研究员)都交互完成后,主动把 `感叹号` 加入 `had_been_list` 直接找下层入口**——剩余感叹号已无价值,不再走过去,避免去门途中被 NPC 反复抢交互(实测最坏一轮从 981 秒收敛到 248 秒)。
- 走位重试达上限时:标签明确是已交互 NPC 就**不硬按**(硬按必进重复画面白费 40 秒+),后退 1.2 秒换角度重新接近;只有标签读不出才硬按兜底(可能是门没读出标签),错按由判重退出自愈。
- 重复退出累计 3 次 → 放弃感叹号直接找下层入口(最后兜底,宁可丢研究员礼物也不卡死)。
- 入口层完成过武备选择后,走向剩余感叹号的移动超时(感叹号被桌子/栅栏遮挡走不到)同样转为放弃感叹号直取下层入口,不再直接重开整层(重开后大概率同样走不到);一个武备都没选到时保持重开(重摇布局)。
- 超时重开:`handle_find_target_fail` 重开挑战成功后置 `attempt_start_time` 重新计时(此前重试沿用 `operation_start_time`,超时触发的重试会立刻再次超时,3 次全废),并清空本层交互状态与脱困计数(`stuck_state`,重开后位置已重置,旧计数会让新一轮直接放弃寻路)。

**通用选择决策(`LostVoidChooseCommon`)**:OCR 标题定子态与需选数 → 四层选择策略(NEW → 同流派优先级 → 答题兜底 → 盲点兜底),选满 N → 点确定(解析按钮 `(x/y)` 计数判断选满)。

## 信息来源与置信度
Expand Down
6 changes: 6 additions & 0 deletions docs/game/screens/迷失之地.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ source_image: `screens/迷失之地-通用选择/选1张卡牌.webp`
**状态流转**:
- 入口 = 第一层(ENTRY)开局选初始武备;或层中交互武备图标后。
- 出口 = 选 1 个武备 → 点「按钮-携带」→ (开局)弹「选择初始战术棱镜方案」确认框 → 点确认 → 返回大世界。
- ⚠️ 入口层选完携带后 **NPC 仍可重复交互再次打开本画面**(可反悔换选);游戏交互判定按 角度>距离,
站在两个相邻 NPC 之间容易再次命中刚选完的 NPC(见 [gameplay 入口层交互机制](../gameplay/迷失之地.md#入口))。
自动化对策:`LostVoidChooseGear` 传入 `completed_name_list` 时先只点首个武备读名字,命中已完成名单(各画面首个武备名)
→ 直接点返回退出(状态 `重复武备选择`;名单已有 2 个武备名时必为重复,连读名都省掉直接退出);
`LostVoidRunLevel` 按键前先 OCR 交互文本确认目标,命中已交互 NPC 时朝排除其图标后的剩余感叹号迈步换目标,
重复 3 次则本层放弃感叹号直接找下层入口。

**识别特征(稳定锚点)**:
- `按钮-携带` — `id_mark=true`,`text=携带`,`pc_rect=[1732,846,1912,896]`(右下)。
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,46 @@
import time
import re
import time
from typing import Any, ClassVar

import cv2
from cv2.typing import MatLike
from typing import Any

from one_dragon.base.geometry.point import Point
from one_dragon.base.geometry.rectangle import Rect
from one_dragon.base.matcher.match_result import MatchResult, MatchResultList
from one_dragon.base.matcher.match_result import MatchResultList
from one_dragon.base.operation.operation_edge import node_from
from one_dragon.base.operation.operation_node import operation_node
from one_dragon.base.operation.operation_round_result import OperationRoundResult
from one_dragon.utils import cv2_utils, cal_utils
from one_dragon.utils import cv2_utils, str_utils
from one_dragon.utils.log_utils import log
from zzz_od.application.hollow_zero.lost_void.context.lost_void_artifact import LostVoidArtifact
from zzz_od.application.hollow_zero.lost_void.operation.interact.lost_void_artifact_pos import LostVoidArtifactPos
from zzz_od.application.hollow_zero.lost_void.context.lost_void_artifact import (
LostVoidArtifact,
)
from zzz_od.application.hollow_zero.lost_void.operation.interact.lost_void_artifact_pos import (
LostVoidArtifactPos,
)
from zzz_od.context.zzz_context import ZContext
from zzz_od.operation.zzz_operation import ZOperation


class LostVoidChooseGear(ZOperation):

def __init__(self, ctx: ZContext):
STATUS_REPEATED: ClassVar[str] = '重复武备选择'

def __init__(self, ctx: ZContext, completed_name_list: list[str] | None = None):
"""
入口处 人物武备和通用武备的选择
:param ctx:
:param completed_name_list: 入口层已完成选择画面的首个武备名列表 跨交互共享
传入时 若当前画面首个武备命中列表 说明重复交互了同一个NPC 直接返回退出 不再重复整套选择
完成一次选择后 本画面的首个武备名会补充进列表
判断信号与存储信号对称(都是画面最左侧槽位的武备名) 避免与其他画面的非首个武备误匹配
"""
ZOperation.__init__(self, ctx, op_name='迷失之地-武备选择')
self.completed_name_list: list[str] | None = completed_name_list
# 本次画面最左侧槽位的武备名 携带成功后写入 completed_name_list
# 最左侧槽位OCR解析失败时为None 本画面不记录(宁可下次重复识别 也不存错位的名字)
self.first_slot_gear_name: str | None = None

@operation_node(name='选择武备', is_start_node=True)
def choose_gear(self) -> OperationRoundResult:
Expand All @@ -39,11 +53,27 @@ def choose_gear(self) -> OperationRoundResult:
# 进入本指令之前 有可能识别错画面
return self.round_retry(status=f'当前画面 {screen_name}', wait=1)

# 入口层只有两个武备NPC 两个都完成过还进武备画面 必为重复 直接退出
# 省掉判重的点击首格+OCR(约10秒) 去门途中被NPC抢交互时的自愈更快
if self.completed_name_list is not None and len(self.completed_name_list) >= 2:
log.info('入口层两个武备均已完成 当前武备画面必为重复 直接返回')
return self.round_success(LostVoidChooseGear.STATUS_REPEATED)

choose_new: bool = False
gear_contours, gear_context = self._find_gears_with_status()
if not gear_contours:
return self.round_retry(status='无法识别武备槽位')

# 入口层可能因为交互判定(角度>距离)命中了刚选完武备的NPC 重复打开同一个画面
# 此时只看首个武备名称 命中已完成列表(各画面首个武备名)则直接退出 避免重复整套点击识别
# cutoff=0.8 只容忍同名的OCR错字 不同画面即使名称相近也不会误判为重复
if self.completed_name_list is not None and len(self.completed_name_list) > 0:
first_name = self._get_first_gear_name(gear_context)
if (first_name is not None
and str_utils.find_best_match_by_difflib(first_name, self.completed_name_list, cutoff=0.8) is not None):
log.info(f'重复进入已完成选择的武备画面 直接返回 首个武备={first_name}')
return self.round_success(LostVoidChooseGear.STATUS_REPEATED)

gear_list, has_level_list = self.get_gear_pos_by_click_ocr(gear_contours, gear_context)
if len(gear_list) == 0:
return self.round_retry(status='无法识别武备名称')
Expand Down Expand Up @@ -122,7 +152,7 @@ def _find_gears_with_status(self) -> tuple[list[tuple[Any, bool]], Any]:
for i, (gear_contour, (gear_x1, gear_y1, gear_x2, gear_y2)) in enumerate(gear_rects):
has_level = False

for j, (level_contour, (level_x1, level_y1, level_x2, level_y2)) in enumerate(remaining_levels):
for j, (_level_contour, (level_x1, level_y1, level_x2, level_y2)) in enumerate(remaining_levels):
is_overlapping = not (gear_x2 < level_x1 or level_x2 < gear_x1 or
gear_y2 < level_y1 or level_y2 < gear_y1)

Expand All @@ -139,6 +169,43 @@ def _find_gears_with_status(self) -> tuple[list[tuple[Any, bool]], Any]:
# cv2_utils.show_image(self.last_screenshot, debug_rects, wait=0)
return gear_with_status, gear_context

def _get_first_gear_name(self, gear_context: Any) -> str | None:
"""
点击最左侧的武备 读取其名称 用于入口重复画面的快速判断
:param gear_context: 武备列表检测的CV流水线结果
:return: 武备展示名 无法识别时返回None
"""
gear_abs_pairs = gear_context.get_absolute_rect_pairs()
if len(gear_abs_pairs) == 0:
return None
gear_abs_pairs.sort(key=lambda item: item[1][0])
x1, y1, x2, y2 = gear_abs_pairs[0][1]
self.ctx.controller.click(Point((x1 + x2) // 2, (y1 + y2) // 2))
time.sleep(1)
_, current_screen = self.ctx.controller.screenshot()

name_area = self.ctx.screen_loader.get_area('迷失之地-武备选择', '武备名称')
name_part = cv2_utils.crop_image_only(current_screen, name_area.rect)
ocr_map = self.ctx.ocr_service.get_ocr_result_map(
image=name_part,
crop_first=False,
)

token_list: list[tuple[int, str]] = []
for text, mrl in ocr_map.items():
token = text.strip()
if len(token) == 0:
continue
for mr in mrl:
token_list.append((mr.center.x, token))
token_list.sort(key=lambda item: item[0])
ocr_text = ''.join([i[1] for i in token_list]).strip()

art, _ = self._build_artifact_from_ocr_name(ocr_text)
if art is None:
return None
return art.display_name

def get_gear_pos_by_click_ocr(
self,
gear_with_status: list[tuple[Any, bool]],
Expand Down Expand Up @@ -182,6 +249,7 @@ def get_gear_pos_by_click_ocr(
crop_first=False,
)
name_list = self._extract_names_from_stitched_ocr(ocr_map, len(slice_list), slice_list[0].shape[0])
self._update_first_slot_gear_name(name_list)

result_list: list[LostVoidArtifactPos] = []
total_cnt = min(len(click_rect_list), len(name_list))
Expand All @@ -207,6 +275,20 @@ def get_gear_pos_by_click_ocr(
log.info(f'当前识别武备 {display_text}')
return result_list, has_level_list

def _update_first_slot_gear_name(self, name_list: list[str]) -> None:
"""
从各槽位的OCR文本更新最左侧槽位的武备名 解析失败时为None

与入口重复判断(_get_first_gear_name 点击最左格)读取的槽位保持一致
不能用识别结果列表的首元素代替: 结果列表会跳过解析失败的槽位 首元素不一定是最左格
:param name_list: 按槽位从左到右的OCR文本列表
"""
self.first_slot_gear_name = None
if len(name_list) > 0 and len(name_list[0]) > 0:
first_art, _ = self._build_artifact_from_ocr_name(name_list[0])
if first_art is not None:
self.first_slot_gear_name = first_art.display_name

def _extract_names_from_stitched_ocr(
self,
ocr_map: dict[str, MatchResultList],
Expand Down Expand Up @@ -260,6 +342,25 @@ def click_equip(self) -> OperationRoundResult:
if result.is_success:
self.ctx.lost_void.priority_updated = False
log.info("武备选择成功,已设置优先级更新标志")
# 只记录本画面最左侧槽位的武备名 与重复判断读取的信号一致
# 记录其他槽位名称会让别的画面的首个武备有机会与本画面的非首个武备误匹配
if (self.completed_name_list is not None
and self.first_slot_gear_name is not None
and self.first_slot_gear_name not in self.completed_name_list):
self.completed_name_list.append(self.first_slot_gear_name)
return result

@node_from(from_name='选择武备', status=STATUS_REPEATED)
@operation_node(name='重复退出')
def exit_repeated(self) -> OperationRoundResult:
"""
重复进入已完成选择的画面时 点击返回退出 并透传重复状态给调用方
"""
result = self.round_by_find_and_click_area(screen_name='迷失之地-武备选择', area_name='按钮-返回',
until_not_find_all=[('迷失之地-武备选择', '按钮-返回')],
success_wait=1, retry_wait=1)
if result.is_success:
return self.round_success(LostVoidChooseGear.STATUS_REPEATED)
return result

@node_from(from_name='选择武备', success=False)
Expand Down
Loading