BetterGenshinImpact/GameTask/Common/StateMachine/README.md
StateMachineBase<TState, TContext> 是给游戏自动化任务使用的有限状态机基类。它把 UI 场景识别、场景处理、状态转移、重试和超时兜底放到统一框架里,避免任务里堆一长串 switch、while 和零散的超时判断。
推荐写法是:
[StateDetector] 标记状态检测方法。[StateHandler] 标记状态处理方法,并在节点上声明重试策略。RunStateMachineUntil 运行到目标状态。| 概念 | 说明 |
|---|---|
State | 一个明确的 UI 场景,比如 MainWorld、EventMenu、DomainEntrance |
Detector | 状态检测器,签名为 bool Method(ImageRegion ra),只判断当前截图是否匹配这个状态 |
Handler | 状态处理器,签名为 Task<StateHandlerResult> Method(TContext context),负责在当前状态执行动作 |
Transition | 状态图的有向边,声明某个状态执行成功后允许到达哪些状态 |
RetryPolicy | 节点级兜底策略,可按次数或按时间限制当前状态的重试 |
TState 必须是枚举。建议第一个枚举值是 Unknown,因为 default(TState) 会被框架当作“未识别到状态”使用。
public enum MyState
{
Unknown,
MainWorld,
EventMenu,
TargetPage,
}
public class MyTask : StateMachineBase<MyState, BvPage>, ISoloTask
{
protected override ILogger Logger => TaskControl.Logger;
public MyTask()
{
RegisterAllStateHandlers();
}
private void RegisterAllStateHandlers()
{
RegisterStateMethodsByAttribute();
RegisterStateTransitions(
(MyState.MainWorld, [MyState.EventMenu]),
(MyState.EventMenu, [MyState.TargetPage])
);
}
public async Task Start(CancellationToken ct)
{
Initialize(ct, MyState.Unknown);
var page = new BvPage(ct);
await RunStateMachineUntil(page, MyState.TargetPage);
}
}
Detector 只负责“当前截图是不是这个状态”,不要在里面点击、等待或修改状态。
[StateDetector(MyState.MainWorld, Order = 10)]
private bool DetectMainWorld(ImageRegion ra)
{
return ra.Find(ElementAssets.Instance.PaimonMenuRo).IsExist();
}
[StateDetector(MyState.EventMenu, Order = 20)]
private bool DetectEventMenu(ImageRegion ra)
{
return ra.FindMulti(RecognitionObject.Ocr(125, 142, 113, 28))
.Any(o => o.Text.Contains("活动一览"));
}
Order 表示全量检测时的检测顺序,数值越小越先检测。建议把快且稳定的模板匹配放前面,把大范围 OCR 放后面。
状态机每轮只截图一次并复用给多个检测器。若当前状态注册了邻接状态,则只检测邻接状态;若当前状态没有注册转移关系,则回退到全量检测。
Handler 只负责“在当前状态做一步动作”,动作结果用 StateHandlerResult 表达。
[StateHandler(MyState.MainWorld)]
private async Task<StateHandlerResult> HandleMainWorld(BvPage page)
{
Simulation.SendInput.SimulateAction(GIActions.OpenTheEventsMenu);
await Delay(500, _ct);
return StateHandlerResult.Success;
}
[StateHandler(MyState.EventMenu, RetryTimeout = 30000)]
private async Task<StateHandlerResult> HandleEventMenu(BvPage page)
{
var target = page.GetByText("目标活动").FindAll().FirstOrDefault();
if (target == null)
{
return StateHandlerResult.Retry;
}
target.Click();
await Delay(300, _ct);
return StateHandlerResult.Success;
}
StateHandlerResult 语义如下:
| 返回值 | 框架行为 | 典型场景 |
|---|---|---|
Success | 动作完成,框架等待当前状态的邻接状态出现;若转场超时,会占用当前状态的 retry 预算 | 点击按钮、发送交互键后等待页面切换 |
Wait | 不计 retry,直接进入下一轮检测 | 加载中、动画中、当前状态无需动作 |
Retry | 当前状态 retry 次数加一,超限后抛异常 | 按钮没找到、OCR 暂时失败、动作没法确认 |
Fail | 立即抛异常 | 配置错误、关键条件不满足、无法恢复 |
目标状态通常不会执行 Handler。RunStateMachineUntil 每轮会先检测当前状态,发现已到达目标状态就直接返回。
未知状态处理器用于兜底恢复,例如回主界面后重新识别。
[UnknownStateHandler]
private async Task<StateHandlerResult> HandleUnknownState(BvPage page)
{
await new ReturnMainUiTask().Start(_ct);
return StateHandlerResult.Wait;
}
未知状态处理器最多只能注册一个。它适合做“恢复到已知入口”的动作,不适合吞掉真正的业务错误。
状态转移关系必须集中注册,保持流程可读。
RegisterStateTransitions(
(MyState.MainWorld, [MyState.EventMenu]),
(MyState.EventMenu, [MyState.TargetPage])
);
状态图是严格的:
Retry 或 Success 后转场超时进入 retry。Success 状态必须有合理的下一跳,否则等待邻接状态会失败并消耗 retry。候选状态顺序会影响检测优先级。更具体、更不容易误判的状态应放在前面。
结束节点用 RunStateMachineUntil 的目标状态表达,不需要也不应该注册空邻接。
RegisterStateTransitions(
(MyState.MainWorld, [MyState.EventMenu]),
(MyState.EventMenu, [MyState.TargetPage])
);
await RunStateMachineUntil(page, MyState.TargetPage);
上面这个例子里,TargetPage 没有出边,所以不注册 (MyState.TargetPage, [])。状态机每轮会先检测是否到达目标状态,命中后直接退出,不会执行目标状态的 Handler。
如果一个状态在某个阶段是结束节点,在另一个阶段还要继续流转,也可以注册它的出边。是否结束由本次 RunStateMachineUntil 的目标状态决定,不由状态本身永久决定。
节点可以在 [StateHandler] 上声明 retry 策略:
[StateHandler(MyState.EventMenu, RetryTimeout = 30000, RetryInterval = 500)]
private async Task<StateHandlerResult> HandleEventMenu(BvPage page)
{
...
}
[StateHandler(MyState.TargetPage, RetryTimes = 10)]
private async Task<StateHandlerResult> HandleTargetPage(BvPage page)
{
...
}
| 属性 | 说明 |
|---|---|
RetryTimes | 当前状态最多允许多少次 retry |
RetryTimeout | 当前状态最多允许在 retry 窗口内持续多久,单位毫秒 |
RetryInterval | 发生 retry 后下一轮状态机循环的等待时间,单位毫秒 |
TransitionTimeout | Handler 返回 Success 后等待邻接状态出现的超时时间,单位毫秒 |
RetryTimes 和 RetryTimeout 互斥,不能同时设置。
retry 预算会被两类事件消耗:
Retry。Success,但等待邻接状态转换超时。状态变化后 retry 计数和 retry 计时都会重置。
用 RetryTimes:
用 RetryTimeout:
例如“找不到活动入口”和“交互秘境入口后没法确认是否已经选中”属于同一类问题:动作可重复,但最终只能靠后续状态检测确认。这类节点更适合设置 RetryTimeout。
DefaultTransitionTimeout 表示:Handler 返回 Success 后,如果仍然停留在原状态,最多等多久。默认是 3000ms。
DefaultIntermediateTransitionTimeout 表示:如果界面已经离开原状态,但还没到任何邻接目标,最多允许中间态持续多久。默认是 120000ms。这个设计是为了避免游戏加载、传送、黑屏等中间态被短转场超时误判。
可以在节点上覆盖转场超时:
[StateHandler(MyState.TeleportMap, TransitionTimeout = 10000)]
private async Task<StateHandlerResult> HandleTeleportMap(BvPage page)
{
...
}
Handler 内可以读取当前 retry 信息,用于调整动作强度或打印日志:
if (CurrentStateRetryCount > 3)
{
Logger.LogWarning("当前状态已重试 {Count} 次", CurrentStateRetryCount);
}
可读属性:
| 属性 | 说明 |
|---|---|
CurrentState | 当前状态 |
CurrentStateRetryCount | 当前状态已累计 retry 次数 |
CurrentStateRetryLimit | 当前状态的次数上限,使用时间策略时为 0 |
CurrentStateRetryTimeout | 当前状态的时间上限,使用次数策略时为 null |
CurrentStateRetryUsesTimeout | 当前状态是否使用时间策略 |
CurrentStateRetryInterval | 当前状态 retry 后的循环间隔 |
CurrentStateTransitionTimeout | 当前状态等待邻接状态的转场超时 |
这些属性都是只读保护属性,节点不能直接改框架内部计数。
自动幽境危战任务是当前状态机的完整示例:
GameTask/AutoStygianOnslaught/AutoStygianOnslaughtTask.cs
简化状态图:
flowchart LR
Unknown --> MainWorld
MainWorld --> EventMenu
MainWorld --> StygianOnslaughtPage
EventMenu --> StygianOnslaughtPage
StygianOnslaughtPage --> TeleportMap
StygianOnslaughtPage --> DomainEntrance
TeleportMap --> DomainEntrance
DomainEntrance --> DifficultySelect
DifficultySelect --> DomainLobby
DomainLobby --> BossSelect
DomainLobby --> LeylineFlowerPrompt
BossSelect --> BattleArena
BattleArena --> BattleResultWin
BattleArena --> BattleResultLose
BattleResultWin --> DomainLobby
BattleResultLose --> BossSelect
LeylineFlowerPrompt --> ResinSelect
ResinSelect --> ContinueOrExit
ResinSelect --> DomainLobby
ContinueOrExit --> BattleArena
ContinueOrExit --> MainWorld
幽境危战里的两个典型兜底:
[StateHandler(StygianState.EventMenu, RetryTimeout = 30000)]
private async Task<StateHandlerResult> HandleEventMenuState(BvPage page)
{
...
}
[StateHandler(StygianState.StygianOnslaughtPage, RetryTimes = 10)]
private async Task<StateHandlerResult> HandleStygianOnslaughtPageState(BvPage page)
{
...
}
EventMenu 使用时间预算,因为找不到活动入口时无法确认是否已经进入右侧详情页,只能交给后续状态检测兜底。StygianOnslaughtPage 使用次数预算,因为找不到“前往挑战”按钮是当前页内的明确失败。
Retry 或节点 retry 策略。Wait 不消耗 retry。只在确实可预期等待时使用,否则可能靠 maxIterations 兜底才退出。Success 表示“动作已发出”,不是“已经到达下一页”。下一页确认由状态机的邻接状态检测完成。RetryTimeout。RetryTimes。旧代码如果手动调用 RegisterStateHandlers 和 RegisterStateDetectors,可以按下面步骤迁移:
[StateDetector(State, Order = n)]。[StateHandler(State)]。[UnknownStateHandler]。RegisterStateMethodsByAttribute()。RegisterStateTransitions(...)。[StateHandler] 上。迁移后,状态的检测逻辑、处理逻辑和节点策略都贴在方法声明处;状态图仍集中维护。