Action 生命週期(完整參考)

本文是 wait_action_result / wait_expectation 與識別層 src/core/detector/ 的完整使用參考。 development.md 中的「Action 生命週期」一節是本文摘要,細節以本文為準。

目錄


1. 它解決什麼問題

遊戲裡大量操作是同一個形狀:看到某個東西 → 動它 → 確認預期結果出現了。 具體拆開是五個環節:

代號 環節 語義
A 前置條件 識別函式判斷當前狀態是否滿足;未滿足則不動作,或按重試機制處理
Action 主動作 一次點選 / 按鍵 / 切頁 / 移動
C 結果驗證 動作後識別預期結果是否出現;命中即成功,未命中則等待/重試/重執行
B 持續條件 可選。B 持續命中時重複附加動作;未命中立即停止
Extra 附加動作 可選。持續階段重複執行的動作,每次之後繼續查 C

這套邏輯由兩個 API 覆蓋:wait_action_result(完整生命週期)與 wait_expectation(只等結果)。


2. 分層模型

識別源與生命週期是兩個正交維度,因此分成兩層——否則引數會爆炸,且 A/C 無法使用不同識別源。

┌─────────────────────────────────────────────────────────┐
│  编排层  runtime_mixin.py                                │
│  wait_action_result / wait_expectation                   │
│  职责:「什么时候做、做几次、算不算成功」                    │
└────────────────────────┬────────────────────────────────┘
                         │ 只依赖 Detector.detect(frame)
┌────────────────────────┴────────────────────────────────┐
│  识别层  src/core/detector/                              │
│  Hit + Detector 协议 + 5 个适配器 + 3 个组合器              │
│  职责:「这一帧有没有」                                    │
└────────────────────────┬────────────────────────────────┘
                         │ 包装
┌────────────────────────┴────────────────────────────────┐
│  既有原语  find_feature / find_one / yolo_detect /       │
│            ocr / find_button                             │
└─────────────────────────────────────────────────────────┘

統一判據協議只有一條:

Detector.detect(frame) -> Hit | None

必須接受外部傳入的 frame,不能自己調 next_frame()——否則一次判定內會多次截圖,時序錯亂。


3. 快速上手

from src.core.detector import TemplateDetector
from src.data.feature_list import FeatureList

# A ≠ C:看到宝箱图标 → 点击 → 等解锁界面出现
ok = self.wait_action_result(
    condition=TemplateDetector(FeatureList.treasure_icon),   # A
    action=lambda hit: self.click(hit.box),                  # Action
    expect=TemplateDetector(FeatureList.unlock_ui),          # C
    time_out=5,
    expect_time_out=1.5,
    max_attempts=2,
)
if not ok:
    self.log_info("解锁界面没出现")

三個要點:

  • hit 是 Hit,hit.box 可直接喂 self.click();
  • 判據不需要手動 attach,編排入口會代勞;
  • 返回 bool,True = 生命週期成功。

4. 識別層:統一判據

4.1 Hit:統一命中表示

四類識別源原始返回值各不相同(Box | None / list[Box] / …),統一歸一成 Hit:

@dataclass
class Hit:
    box: Box              # 可直接 click() 的目标框(唯一必需字段)
    confidence: float = 1.0
    source: str = ""      # 识别源标识,见 SOURCE_* 常量
    text: str = ""        # OCR 命中文本;非 OCR 源为空串
    raw: object = None    # 原始结果对象,见下
    metrics: dict = field(default_factory=dict)
欄位 說明
box 唯一必需。幀座標,可直接 click() / draw_boxes()
confidence 0~1;未知時 1.0
source SOURCE_TEMPLATE / SOURCE_YOLO / SOURCE_OCR / SOURCE_BUTTON / SOURCE_PREDICATE;取反判據為原判據名
text OCR 命中的文字,可直接用於日誌或分支判斷
raw 原始結果:OCR 是 list[Box],模板/按鈕是 Box。需要識別器特有欄位時從這裡取,避免歸一化時丟資訊
metrics 預留的指標字典

Hit.__bool__ 恆為 True —— 未命中時 detect() 返回的是 None,不是空 Hit。所以 if hit: 是可靠的。

hit = TemplateDetector(FeatureList.main_ui).detect(frame)
if hit:                       # None 才是未命中
    self.click(hit.box)

4.2 五個介面卡(含全部引數)

TemplateDetector —— 模板匹配

包裝 find_feature / find_one。

TemplateDetector(
    feature,                    # 必填。FeatureList 成员或字符串
    box=None,                   # 搜索区域。None 时沿用 coco 标注位置 ±variance
    horizontal_variance=0.0,
    vertical_variance=0.0,
    threshold=0,                # 匹配阈值;0 = 框架默认
    target_height=0,            # 目标缩放高度;0 = 不缩放
    mask_function=None,         # 掩膜函数,如 make_hsv_isolator(...)
    use_gray_scale=False,
    canny_lower=0,
    canny_higher=0,
    name=None,                  # 判据名;缺省用特征名
    use_find_one=False,         # True 走 find_one,False 走 find_feature
)

⚠️ box 的預設行為是陷阱:find_feature 不傳 box 時只搜 coco 標註位置 ± variance (DEFAULT_VARIANCE = 0.05)。會移動的圖示必須顯式傳 box,否則永遠搜不到。

⚠️ horizontal_variance / vertical_variance 只在使用 find_feature 時生效: 傳 0 會在呼叫處被替換為 DEFAULT_VARIANCE(0.05),即「用預設方差」而不是「零方差」; 走 use_find_one=True 時兩個引數原樣透傳(0 就是 0)。

use_find_one 的選擇:

find_feature(預設) find_one
特徵列表 ❌ 不支援 ✅ 任一命中即可
幀快取 ✅ 支援 Frame 物件 —

YoloDetector —— YOLO 檢測

包裝 yolo_detect。

YoloDetector(
    name,                       # 必填。目标名或名称列表
    box=None,                   # 裁剪区域;同时作为 pick="center" 的中心基准
    conf=0.7,                   # 置信度阈值
    model_key=None,             # 指定模型键;None 时由框架按首个 name 解析
    pick=PICK_MAX_CONF,         # 多候选选择策略
    task_name=None,             # 判据名;缺省由 name 推导(多个用 _ 连接)
)

pick 策略(PICK_* 常量):

常量 值 語義
PICK_MAX_CONF max_conf 預設。置信度最高
PICK_FIRST first 框架返回順序的第一個(yolo_detect 按 conf 降序,故接近 max_conf)
PICK_CENTER center 離搜尋區域中心最近;無 box 時退化到畫面中心
PICK_LEFTMOST leftmost 最左(min(b.x))
PICK_TOPMOST topmost 最上(min(b.y))

非法 pick 在構造時即拋 ValueError,不會靜默取錯候選。

OcrDetector —— OCR 文本匹配

包裝 ocr。

OcrDetector(
    match,                      # 必填。文本 / 列表 / 正则 / 正则列表
    box=None,                   # 识别区域;与 x/y/to_x/to_y 二选一
    x=0, y=0, to_x=1, to_y=1,   # 相对区域坐标
    threshold=0,                # OCR 置信度;0 = 框架默认
    lib="default",              # OCR 引擎名
    target_height=0,            # 识别前缩放高度
    pick=PICK_FIRST,            # 多候选选择策略
    name=None,                  # 判据名;缺省用 match 的可读形式
)

⚠️ OCR 的 pick 預設是 PICK_FIRST,與 YOLO 的 PICK_MAX_CONF 不同。 原因是 ocr() 返回的是按 y 排序的列表(ok/task/task.py:818), 所以 first 實際含義是「最上面那個」。這個差異是刻意的。

pick 支援的策略與 YOLO 相同(first / max_conf / center / leftmost / topmost)。

box 與相對座標等價,後者更短:

OcrDetector("确认", box=self.box_of_screen(0.55, 0.58, 0.68, 0.66))
OcrDetector("确认", x=0.55, y=0.58, to_x=0.68, to_y=0.66)     # 等价

命中時 Hit.text 帶出識別到的文字:

hit = OcrDetector("确认", box=area).detect(frame)
self.log_info(f"识别到「{hit.text}」")     # hit.raw 是整个 list[Box]

⚠️ ocr() 內部自帶除錯框(emit_draw_box("ocr"+name, ...)), 所以用 wait_action_result(draw=True) 時不要對 OCR 判據重複繪製。

ButtonDetectorAdapter —— 固定 Box 按鈕檢測

包裝 find_button(中央文本帶,無 OCR)。

ButtonDetectorAdapter(
    box,                        # 必填。按钮区域,必须贴合按钮
    thresholds=None,            # ButtonThresholds 完整阈值
    text_hsv=None,              # 文字颜色区间
    backdrop_hsv=None,          # 底色区间;传入即默认开启底色校验
    require_backdrop=None,      # 显式指定是否校验底色
    name="button",
)

⚠️ box 必須貼合按鈕——Box 遠大於按鈕時文本帶相對過薄,會被形狀判定拒絕。

用法見 development.md 的「固定 Box 按鈕檢測」一節(含 ButtonThresholds 預設)。

PredicateDetector —— 任意判據兜底

把一段自有邏輯包成判據。

PredicateDetector(
    predicate,                  # 必填。frame -> Box | Hit | bool | None
    box=None,                   # predicate 只返回布尔时,用它提供点击目标
    name="predicate",
    source=SOURCE_PREDICATE,
)

呼叫方的返回值決定行為:

predicate 返回 結果
Hit 原樣返回
Box 包成 Hit
True(且給了 box) 用 box 造 Hit
True(未給 box) 返回 None —— 沒有可點的座標
False / None 返回 None

這也是寫「條帶還在不在」這類自定義判定的入口:

PredicateDetector(
    lambda frame: not self._band_present(frame, band),
    box=band,
    name=f"band_gone_y{band.y}",
)

4.3 三個組合器

組合器 簽名 語義
MultiBoxDetector (detectors, name=None) 按序取首個命中。detectors 為空拋 ValueError
FirstHitDetector 同上 MultiBoxDetector 的語義別名(多個不同目標的優先順序選擇時更可讀)
InvertedDetector (detector, box=None, name=None) 取反:原判據未命中即為命中
BlindPointDetector (x, y, name="blind_point") 恆命中,返回指定座標

MultiBoxDetector 可混用不同識別源,按列表順序回退,第一個命中即返回:

MultiBoxDetector([
    TemplateDetector(FeatureList.treasure_icon),                # 先试模板
    YoloDetector("chest", box=roi, conf=0.7),                    # 再试 YOLO
    OcrDetector("开启", box=text_area),                          # 最后试 OCR
    BlindPointDetector(900, 540),                                # 都搜不到时盲点一次
], name="entry")

⚠️ InvertedDetector 必須顯式給 box——點選目標無法從取反判據推出。 未給 box 時永不命中(返回 None)。

⚠️ 取反命中時 Hit.source 是原判據名(如 not_band_gone), 因為 source 只在 Hit 上、detector 本身不持有它。

FirstHitDetector 與 MultiBoxDetector 行為完全相同,選哪個只看可讀性:

  • 判據是「同一目標的多個區域」→ MultiBoxDetector
  • 判據是「多個不同目標的優先順序選擇」→ FirstHitDetector

4.4 判據的 attach 與宿主繫結

介面卡需要宿主任務才能工作(detect 內部要調 self._task.find_feature(...))。 編排入口會自動繫結,你不需要手動 attach:

# 直接构造后传进去即可
self.wait_action_result(
    condition=TemplateDetector(FeatureList.main_ui),
    action=lambda hit: self.click(hit.box),
)

自動繫結由 RuntimeMixin._resolve_detector 完成,它:

  1. 若判據有 attach 且尚未繫結,呼叫 attach(self);
  2. 若是組合器(有 detectors 屬性),遞迴處理內部判據;
  3. 若物件沒有 detect(frame) 方法,拋 TypeError。

所以自定義判據只要滿足 detect(frame) -> Hit | None 就能直接用, 甚至不必繼承任何基類——這是協議(Protocol)而非基類繼承。


5. 編排層:wait_action_result

5.1 三階段流程

┌─ 阶段一:等条件 ────────────────────────────────┐
│  condition 为 None ?                             │
│    是 → 跳过等待,直接执行动作                     │
│  condition 在 time_out 内命中?                   │
│    否 → raise_if_not_found ? 抛异常 : 返回 None   │
│    是 → 继续(settle_time > 0 时要求持续成立)     │
└────────────────────┬────────────────────────────┘
                     ↓
┌─ 阶段二:动作 + 验证(最多 max_attempts 轮)──────┐
│  重取一帧确定目标(防位移)                        │
│  action_delay → action(hit) → after_sleep        │
│  expect is None ?  → 返回 True                   │
│  wait_expectation(expect) 命中 ? → 返回 expect Hit│
│  未命中 → 下一轮                                  │
└────────────────────┬────────────────────────────┘
                     ↓
┌─ 阶段三:持续阶段(需 while_condition 且 repeat_action)┐
│  B 未命中 → 立即 break                            │
│  repeat_interval → repeat_action(hit_b)          │
│  查 expect → 命中则返回 expect Hit                │
│  受 time_out / max_repeat 约束                    │
└────────────────────┬────────────────────────────┘
                     ↓
                  返回 None

5.2 完整參數列

wait_action_result(
    action,                     # 必填。action(hit: Hit) -> bool | None 或 action() -> bool | None
    condition=None,             # 前置条件判据 A,默认 None(不等条件直接执行)
    expect=None,                # 预期结果判据 C。None = 动作成功即返回
    time_out=5.0,               # 等 condition 的总超时;也约束持续阶段
    settle_time=0.0,            # condition 需持续成立的秒数
    max_attempts=1,             # 动作最多执行次数(含首次);< 1 会被抬到 1
    action_delay=0.0,           # 每次动作前的延时
    after_sleep=0.0,            # 每次动作后的延时
    expect_time_out=1.0,        # 每次验证 expect 的等待时间
    expect_settle_time=0.0,     # expect 需持续成立的秒数
    while_condition=None,       # 持续条件判据 B
    repeat_action=None,         # 持续阶段重复执行的动作
    repeat_interval=0.0,        # 持续阶段每轮动作的前置延时
    max_repeat=0,               # 持续阶段动作次数上限;0 = 只受 time_out 约束
    draw=False,                 # 是否画调试框
    raise_if_not_found=False,   # 条件未命中时是否抛 WaitFailedException
) -> Hit | bool | None

返回值:

  • 命中 expect 時返回其 Hit 物件(包含 box、confidence、text、raw 等,且 bool(hit) == True);
  • expect=None 且動作成功執行時返回 True;
  • 條件未命中、超時或驗證失敗時返回 None。
引數 預設 說明
condition None 前置條件判據 A;預設 None 表示不等條件直接執行動作
time_out 5.0 兩處生效:階段一等 A 的超時、階段三的總時限
settle_time 0.0 0 = 命中一次即可。非 0 時是「每次判定都要求連續成立夠時長」
max_attempts 1 1 = 不重試。expect 未通過時才進入下一輪
expect_time_out 1.0 每次驗證 C 的視窗,不宜太大(否則整體變慢)
max_repeat 0 0 = 不限次數,只受 time_out 約束
draw False 綠框 = 動作目標(A),藍框 = 持續目標(B)

5.3 動作回撥簽名

action 與 repeat_action 相容 0 引數與 1 引數簽名(接收當前命中物件,若 condition=None 則 hit 為 None):

action(hit: Hit | None) -> bool | None  # 或 action() -> bool | None
repeat_action(hit: Hit) -> None         # 或 repeat_action() -> None

因為返回值不用,多語句回撥寫成 lambda 時用元組技巧:

action=lambda hit: (self.log_info(f"点击「{hit.text}」"), self.click(hit.box)),

複雜邏輯建議定義獨立方法,可讀性更好:

def _do_unlock(self, hit):
    self.log_info(f"点击条带 y={hit.box.y}")
    self.click(hit.box)

self.wait_action_result(condition=..., action=self._do_unlock, ...)

6. wait_expectation

只用 C 時(只等一個結果)用它——不要用 wait_action_result 包一層空動作。

wait_expectation(
    expectation,                # 必填。判据
    time_out=1.0,               # 最长等待时间
    settle_time=0.0,            # 需持续成立的秒数
    raise_if_not_found=False,   # 未命中时是否抛 WaitFailedException
) -> Hit | None

它是所有「動作之後等結果」的唯一收口點——wait_action_result 的階段二/三內部都調它, 以保證 settle 語義一致。

# 等主界面出现
hit = self.wait_expectation(TemplateDetector(FeatureList.main_ui), time_out=2.0)
if hit:
    self.log_info("已进入主界面")

# 等条带稳定消失(要求连续 0.35 秒都不见)
gone = self.wait_expectation(
    PredicateDetector(lambda f: not self._band_present(f, band), box=band),
    time_out=3.0,
    settle_time=0.35,
)

7. 典型用法配方

7.1 等到就點(最簡)

self.wait_action_result(
    condition=TemplateDetector(FeatureList.skip_dialog),
    action=lambda hit: self.click(hit.box),
    expect=None,                 # 点完即成功
    time_out=3,
)

7.2 點完等結果出現(A ≠ C)

self.wait_action_result(
    condition=TemplateDetector(FeatureList.treasure_icon),
    action=lambda hit: self.click(hit.box),
    expect=TemplateDetector(FeatureList.unlock_ui),
    time_out=5,
    expect_time_out=1.5,
    max_attempts=2,              # 第一次没开成再点一次
)

7.3 點完等元素消失

box = self.box_of_screen(...)
self.wait_action_result(
    condition=TemplateDetector(FeatureList.confirm_button, box=box),
    action=lambda hit: self.click(hit.box),
    expect=InvertedDetector(
        TemplateDetector(FeatureList.confirm_button, box=box),
        box=box,                 # 取反必须给 box
    ),
    time_out=5,
    expect_settle_time=0.3,      # 消失要稳定一点
)

7.4 用 OCR 文本

self.wait_action_result(
    condition=OcrDetector("确认", box=self.box_of_screen(0.55, 0.58, 0.68, 0.66)),
    action=lambda hit: (self.log_info(f"点击「{hit.text}」"), self.click(hit.box)),
    expect=None,
    time_out=3,
)

7.5 B 成立期間重複操作

progress = TemplateDetector(FeatureList.progress_bar, box=bar_box)

self.wait_action_result(
    condition=progress,                                      # A:进度条出现
    action=lambda hit: self.click(hit.box, down_time=0.5),   # 开始长按
    expect=InvertedDetector(progress, box=bar_box),          # C:进度条消失 = 完成
    while_condition=progress,                                # B:进度条还在
    repeat_action=lambda hit: self.send_key("space"),        # 附加动作
    repeat_interval=0.3,
    max_repeat=20,
    time_out=60,
)

7.6 多區域 / 多識別源回退

MultiBoxDetector([
    TemplateDetector(FeatureList.entry_a),
    TemplateDetector(FeatureList.entry_b, box=region_b),
    OcrDetector("进入", box=text_area),
])

7.7 條件與預期用不同識別源

self.wait_action_result(
    condition=OcrDetector("可领取", box=button_area),      # A:OCR 判断按钮文字
    action=lambda hit: self.click(hit.box),
    expect=TemplateDetector(FeatureList.reward_icon),      # C:模板判断奖励图标
    time_out=5,
)

8. 契約與陷阱

expect=None 表示「動作成功即返回」

這是顯式宣告「該動作沒有可驗證的結果」,不是「不驗證」也不是「一定失敗」。 階段二執行完 action 立即返回 True。

⚠️ 如果你其實有結果可驗,別偷懶省掉——那正是這個 API 存在的意義。

settle_time 的語義是「每次判定都要連續成立」

不是「總共等 N 秒」。wait_until 同語義。預設 0。

⚠️ 非 0 會顯著增加耗時——歷史上給 find_button 加過 settle 導致效能問題被回滾。 只在「等 UI 穩定下來再確認」時開(如「消失確認」)。

⚠️ 本專案 src/config.py 設了 "wait_until_settle_time": 0, 所以框架側的 settle_time=-1 在本專案等價於 0。但它是使用者可配置項,改成非 0 就會分叉。

計時用 active_time()

暫停感知——指令碼暫停期間不消耗 time_out。這受 BaseGameTask 覆寫。

條件命中與動作之間會重取一幀

fresh = condition.detect(self.next_frame())
target = fresh if fresh is not None else hit

原因:settle_time > 0 時動作發生在條件命中之後若干毫秒,會動的目標那時可能已位移。 取不到則沿用舊命中值,不因單幀抖動放棄。

draw=True 的除錯框

  • 綠 = 動作目標(A),藍 = 持續目標(B)
  • 框 4 秒過期,長迴圈裡會重複繪製
  • ⚠️ OCR 判據不要重複畫——ocr() 內部自帶除錯框

返回 False 的含義

階段三也走完仍未成功。呼叫方據此決定重試、跳過或記錄失敗, 不要在裡面再寫一層 while True——重試邏輯已經由 max_attempts / time_out 表達了。

判據必須接受外部 frame

自定義判據若自己調 next_frame(),一次判定內會多次截圖導致時序錯亂。 必須寫成 detect(self, frame),用傳入的 frame。


9. 什麼時候不該用它

場景 該用什麼
只等一個結果出現/消失 wait_expectation
只需要「等到就點」 wait_click_feature / wait_click_ocr / wait_click_box
一次性查一下有沒有 TemplateDetector(...).detect(frame) 直接調
就是要固定延時 self.sleep(n)(但優先用視覺等待)
A 未命中要做恢復動作 safe_back 之類,或 wait_action_result + raise_if_not_found

⚠️ 等待只應發生在「即將點選的那一次」。給「查詢」類函式加 settle_time 會讓每幀都變慢。


10. 真實落地樣例

TreasureUnlockTask._click_band(src/tasks/trigger/treasure_unlock_task.py)

點條帶後用「穩定消失」確認生效——只用到 C,所以走 wait_expectation:

def _click_band(self, band: Box):
    """点击条带,并用「稳定消失」确认生效。"""
    self.click(band)
    gone = self.wait_expectation(
        PredicateDetector(
            lambda frame: not self._band_present(frame, band),
            box=band,
            name=f"band_gone_y{band.y}",
        ),
        time_out=float(self.config.get("_消失确认超时(秒)", 3.0)),
        settle_time=float(self.config.get("_消失确认时长(秒)", 0.35)),
    )
    if not gone:
        self._on_click_failed(band)
        return
    self._active_bands.remove(band)
    self.log_info(f"条带 y={band.y} 已消失,剩余 {len(self._active_bands)} 条")

要點:

  • PredicateDetector 承載「條帶還在不在」這段自有邏輯(用 presence() 而非 find(), 因為鑰匙壓在條帶上會把它切成兩段);
  • settle_time=0.35 要求連續 0.35 秒都不見才算消失——這正是 settle 該用的地方;
  • 超時與時長都是 _ 字首隱藏配置項。

相關文件

文件 內容
development.md 目錄職責、各檢測器實現細節、i18n、配置遷移
quickstart.md 從原始碼執行、開發環境搭建
tests/test_action_lifecycle.py 58 例行為固化,改 API 時會被它擋住
在 GitHub 查看來源 ↗ · 頁面產生時間: 2026年11月4日