跳转至

状态机与角色匹配

前面的“状态机”页主要是在帮你建立概念,这一页则更偏向代码视角:Python 这一层到底是怎么把一整队的任务组织起来,再把角色号分下去的。

真正建议你反复读的核心文件只有四个:

  • RoleMatch_LuaStyle/Task.py
  • RoleMatch_LuaStyle/State.py
  • RoleMatch_LuaStyle/StateMachine.py
  • Algorithm/munkre.py

绝大多数 NormalPlayRefPlayTest_*,最终都在用这套框架。

Task:一帧里单个角色的任务包装

在 Python 层里,Task 不是“已经算好的底层机器人命令”,而是一个更上层的包装。它至少包含三类信息:

  • 这个角色本帧想执行什么 Skill;
  • 角色匹配时该参考哪个 matchPos
  • 是否要固定给某个真实车号。

真正值得你注意的是:Task 同时参与 角色匹配任务执行。也就是说,它不是匹配完就没用了,而是先帮系统决定“谁更适合接这件事”,再在匹配完成后真正把这件事执行下去。

为什么 getTasks() 返回的是字典,不是列表

很多旧代码读者会下意识想把任务看成“按顺序的一串动作”,但当前框架里更自然的组织方式是:

{
    "L": Task(...),
    "A": Task(...),
    "B": Task(...),
    "G": Task(...),
}

也就是说,这是一张“角色槽位 -> 任务”的映射表。角色名本身就是语义,不只是一个列表下标。这样做的好处是,当你读 Messi_11vs11_new.pyGameStop.py 时,看到 LAG,可以马上知道它们在整套战术里分别扮演什么位置。

延迟求值为什么是刚需,不是花活

Task.py 里最容易让人皱眉的一段逻辑,就是它为什么要支持 lambda:lambda runner: 这种延迟写法。原因非常现实:很多任务在创建那一刻,还不知道最终会分给哪台真实车辆。

例如某个任务可能要引用:

  • “receiver 这帧匹配到的真实车号”;
  • “另一个角色当前实际位置”;
  • “匹配完成后 executor 朝向球门的方向”。

这些信息如果在创建 Task(...) 的当下就算死,往往是错的。所以框架选择先保留一个延迟计算的壳,等 Munkres 把车号分好,再真正展开 skill_cppmatchPos。这也是 State.py 里要维护 achedMatchPos 的原因。

State.run() 是这套框架真正的主线

从代码层面看,State.run() 基本可以理解成 Python 顶层决策的一帧骨架。它做的事情比表面上丰富得多:

  1. 先调用 getTasks() 产出当前状态的任务字典;
  2. 给每个 Task 填上角色名,并初始化需要缓存的 matchPos
  3. updateRole(),根据 matchStr 和 Munkres 分配真实车号;
  4. 对延迟任务做解包;
  5. 逐个执行 task.run(task.getNum()),把底层 Skill 注册到 C++;
  6. 更新 Global.rolePositions 与调试显示;
  7. 最后调用 transFunction() 决定下一个状态。

看到这里,你就会明白为什么很多人会说“状态机其实是整个 Python 战术层的骨架”。因为它并不是只负责跳转,而是把匹配、执行和切换都放在了一起。

matchStr 解决的不是数学问题,而是身份稳定性问题

Munkres 本身只负责“最优匹配”,但比赛里真正麻烦的,是有些角色应该经常换车,有些角色却必须保持连续身份。所以 matchStr 才显得这么重要。

在当前约定里:

  • [] 表示实时匹配;
  • () 表示进入状态时匹配一次;
  • {} 表示只有切换 Play 时才尽量重匹配。

这套设计非常贴合比赛语义。比如主攻角色和接应角色,常常希望在一小段时间内保持身份连续,不然传跑配合会抖;而某些纯补位角色,如果场上位置变化很大,实时匹配反而更合理。

角色名、真实车号和 Global 之间的关系

当前代码里最容易混的是这三层概念:

  • L/A/B/C/.../G:角色槽位;
  • task.num:这一帧该角色最终分到的真实车号;
  • Global.roleNumberStructTable / lastRoleNumberStructTable:跨帧保存的角色号状态。

如果你把它们混在一起,就会很容易写出“看起来能跑,实际上角色全串了”的代码。尤其是当脚本既有固定守门员、又有 Never/Once/RealTime 混合匹配的时候,这三层一定要分清。

一个实用记忆法

角色名是“岗位”,车号是“上岗的人”,Global 则是“本队这一段时间的岗位安排表”。

StateMachine 这一层到底负责什么

StateMachine.py 里做的事情其实相当专注:它不负责生成任务细节,也不负责算 Munkres,而是负责管理“当前在哪个状态”和“什么时候发生状态切换”。

planTasks() 里最关键的逻辑有两个:

第一,如果 Play 发生了切换,就把 current_state 拉回 start_state;第二,根据 current_state.run() 的返回值,决定保持当前状态、切到另一个状态,还是在 RefPlay 中直接 exit。于是你会发现:State 更像“单个状态的业务逻辑”,StateMachine 更像“这些状态之间的调度壳”。

GameStopMessi_11vs11_new 为什么值得一起读

这两个脚本分别代表了两种非常典型的使用方式。

GameStop.py 很适合你看懂“状态少但切换明确”的 RefPlay 写法。它的 StopRun 状态都很清楚,generateStopTasks()stopMatch()play_switch() 这些函数边界也分得很干净。

NormalPlayMessi_11vs11_new.py 则更适合你理解“状态不多,但每个状态内部逻辑很重”的主战术写法。它会在 GetBallPass 两个状态里不停地重新生成 attack / defend 任务表,再通过 nextTempRoleNumberlastTempRoleNumbermatchStr 控制身份稳定性。

一个偏入门、一个偏主战术,把这两个读透之后,你再回来看其他脚本,会轻松很多。

写这类脚本时,最该保持的两个习惯

第一个习惯,是让角色意图尽量显式。宁可 getTasks() 写得长一点,也尽量把 “谁在主攻、谁在补防、谁在盯人、谁是守门员” 直接写明白。因为这套系统长期维护最大的敌人,不是代码行数多,而是角色语义隐在一堆临时变量和副作用里。

第二个习惯,是把“当前状态下要做什么”和“这个状态该不该切”分开思考。前者属于 getTasks(),后者属于 transFunction()。很多脚本变复杂之后,问题往往不是 Munkres 算错,而是这两件事被缠在一起了。

这一页的落点

读完这页,你最好能形成这样一个非常具体的脑内流程:

State.getTasks() 先产出角色任务字典;State.updateRole() 再根据 matchStr 和 Munkres 把角色分给真实车号;之后每个 Task 被真正执行,把 Skill 下发给 C++;最后 StateMachine 根据返回值决定下一帧是否切换状态。

只要这个流程在脑子里稳住,后面不管你是新增一个 Test 脚本,还是去读 Messi_11vs11_new 里的复杂角色漂移逻辑,都会有抓手。