状态机与角色匹配¶
前面的“状态机”页主要是在帮你建立概念,这一页则更偏向代码视角:Python 这一层到底是怎么把一整队的任务组织起来,再把角色号分下去的。
真正建议你反复读的核心文件只有四个:
RoleMatch_LuaStyle/Task.pyRoleMatch_LuaStyle/State.pyRoleMatch_LuaStyle/StateMachine.pyAlgorithm/munkre.py
绝大多数 NormalPlay、RefPlay、Test_*,最终都在用这套框架。
Task:一帧里单个角色的任务包装¶
在 Python 层里,Task 不是“已经算好的底层机器人命令”,而是一个更上层的包装。它至少包含三类信息:
- 这个角色本帧想执行什么 Skill;
- 角色匹配时该参考哪个
matchPos; - 是否要固定给某个真实车号。
真正值得你注意的是:Task 同时参与 角色匹配 和 任务执行。也就是说,它不是匹配完就没用了,而是先帮系统决定“谁更适合接这件事”,再在匹配完成后真正把这件事执行下去。
为什么 getTasks() 返回的是字典,不是列表¶
很多旧代码读者会下意识想把任务看成“按顺序的一串动作”,但当前框架里更自然的组织方式是:
也就是说,这是一张“角色槽位 -> 任务”的映射表。角色名本身就是语义,不只是一个列表下标。这样做的好处是,当你读 Messi_11vs11_new.py 或 GameStop.py 时,看到 L、A、G,可以马上知道它们在整套战术里分别扮演什么位置。
延迟求值为什么是刚需,不是花活¶
Task.py 里最容易让人皱眉的一段逻辑,就是它为什么要支持 lambda: 或 lambda runner: 这种延迟写法。原因非常现实:很多任务在创建那一刻,还不知道最终会分给哪台真实车辆。
例如某个任务可能要引用:
- “receiver 这帧匹配到的真实车号”;
- “另一个角色当前实际位置”;
- “匹配完成后 executor 朝向球门的方向”。
这些信息如果在创建 Task(...) 的当下就算死,往往是错的。所以框架选择先保留一个延迟计算的壳,等 Munkres 把车号分好,再真正展开 skill_cpp 和 matchPos。这也是 State.py 里要维护 achedMatchPos 的原因。
State.run() 是这套框架真正的主线¶
从代码层面看,State.run() 基本可以理解成 Python 顶层决策的一帧骨架。它做的事情比表面上丰富得多:
- 先调用
getTasks()产出当前状态的任务字典; - 给每个
Task填上角色名,并初始化需要缓存的matchPos; - 调
updateRole(),根据matchStr和 Munkres 分配真实车号; - 对延迟任务做解包;
- 逐个执行
task.run(task.getNum()),把底层 Skill 注册到 C++; - 更新
Global.rolePositions与调试显示; - 最后调用
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 更像“这些状态之间的调度壳”。
GameStop 和 Messi_11vs11_new 为什么值得一起读¶
这两个脚本分别代表了两种非常典型的使用方式。
GameStop.py 很适合你看懂“状态少但切换明确”的 RefPlay 写法。它的 Stop 和 Run 状态都很清楚,generateStopTasks()、stopMatch()、play_switch() 这些函数边界也分得很干净。
NormalPlayMessi_11vs11_new.py 则更适合你理解“状态不多,但每个状态内部逻辑很重”的主战术写法。它会在 GetBall 和 Pass 两个状态里不停地重新生成 attack / defend 任务表,再通过 nextTempRoleNumber、lastTempRoleNumber 和 matchStr 控制身份稳定性。
一个偏入门、一个偏主战术,把这两个读透之后,你再回来看其他脚本,会轻松很多。
写这类脚本时,最该保持的两个习惯¶
第一个习惯,是让角色意图尽量显式。宁可 getTasks() 写得长一点,也尽量把 “谁在主攻、谁在补防、谁在盯人、谁是守门员” 直接写明白。因为这套系统长期维护最大的敌人,不是代码行数多,而是角色语义隐在一堆临时变量和副作用里。
第二个习惯,是把“当前状态下要做什么”和“这个状态该不该切”分开思考。前者属于 getTasks(),后者属于 transFunction()。很多脚本变复杂之后,问题往往不是 Munkres 算错,而是这两件事被缠在一起了。
这一页的落点¶
读完这页,你最好能形成这样一个非常具体的脑内流程:
State.getTasks()先产出角色任务字典;State.updateRole()再根据matchStr和 Munkres 把角色分给真实车号;之后每个Task被真正执行,把 Skill 下发给 C++;最后StateMachine根据返回值决定下一帧是否切换状态。
只要这个流程在脑子里稳住,后面不管你是新增一个 Test 脚本,还是去读 Messi_11vs11_new 里的复杂角色漂移逻辑,都会有抓手。