
简介RoboCup 2D仿真足球比赛的官方Agent2D框架agent2d-3.1.1源码结构庞杂初学者常因行为、角色和世界模型交织而难以入手。这份docx笔记以中文逐模块解释代码核心从bhv_系列行为动作基础跑位、进攻踢球、铲球、各类定位球等到多类球员角色后卫、前锋、中场、守门员的执行逻辑再到WorldModel的获取与体力值读取同时梳理了进攻半场函数中踢球与移动的切换流程补充球场区域划分、服务端六种动作和Vector2D等基础概念。整份资源仅包含1个docx文档压缩包约17KB轻量但信息密度较高每处解释均对应具体类名和函数名便于对照源码自主学习。目前已有3000人学习下载不少读者借此快速定位到源码位置减少翻查文档和调试代码的时间。适合作为备赛笔记、代码导读或课程辅助材料对想要二次开发或调整比赛策略的RoboCup 2D开发者尤其有用。1. Robocup2D 的 agent2d 到底在调度什么Robocup2D 仿真赛里你提交的不是一个球员而是一个可执行程序它每 100ms 和 soccer server 做一次通信拿回场上所有信息再决定 11 个球员各自下一步干什么。如果只盯着一堆 if else 看很容易迷失——实际上 agent2d-3.1.1 的核心是两层结构底层是 bhv 行为动作上层是 role 球员角色。你可以把 role 想成岗位职责把 bhv 想成具体动作技能一个 role 在其 execute 里按场上态势挑选并调用若干个 bhv这就是整个决策链的雏形。这篇文章按 agent2d-3.1.1 的源码拆开讲重点放在行为动作的调度顺序、角色的执行链、WorldModel 数据获取以及用 Vector2D 做几何计算的实战写法。适合两类人一是刚接触 Robocup2D、被代码结构劝退的新手二是想改策略但不知道从哪个函数下手的老手。读完你能画出一张从 server 数据到最终动作的完整调用图并且能动手改一个角色让它按你要的区域逻辑跑位。2. 行为动作bhv不是函数是决策的最小单元2.1 bhv 的命名规律和继承关系agent2d-3.1.1 中所有行为类都继承自SoccerBehavior这个基类只暴露一个纯虚函数execute(PlayerAgent*)。你看到的bhv_basic_move、bhv_basic_offensive_kick、bhv_set_play_kick_in等其实是类名的缩写映射源码里对应的是Bhv_BasicMove、Bhv_BasicOffensiveKick、Bhv_SetPlayKickIn。命名上有个规律bhv_前缀表示它是可执行的行为中间段表示行为所属类别比如basic是最基础的动作set_play是死球/重新开球场景goalie是守门员专用最后一段才是动词如move、kick、tackle、chase_ball。当你想新增一个行为时照这个规则命名继承SoccerBehavior并实现execute然后在角色的doMove或doKick中调用即可。2.2 开球到死球set_play 系列的执行链bhv_set_play是一个总入口它内部通过wm.gameMode()判断当前比赛模式然后分发到具体的bhv_set_play_kick_off、bhv_set_play_goal_kick、bhv_set_play_kick_in等。看源码时你会发现一个细节bhv_set_play_kick_off并不直接执行踢球动作它先调用Bhv_CustomBeforeKickOff让球员跑位然后才根据谁是开球者决定调用踢球逻辑还是Bhv_BasicMove。bool Bhv_SetPlayKickOff::execute(PlayerAgent * agent) { const WorldModel wm agent-world(); if ( Bhv_CustomBeforeKickOff().execute( agent ) ) { return true; } if ( wm.self().isKickable() ) { Bhv_BasicOffensiveKick().execute( agent ); } else { Bhv_BasicMove().execute( agent ); } return true; }这段代码的逻辑是开球前先执行站位习惯如果设置成功就直接返回否则判断当前球员能否踢到球能踢就进攻踢球不能踢就基本跑位。注意return true的作用是告诉上层的决策循环这个行为已经被处理了避免继续掉到其他分支。2.3 守门员行为为何单独成类守门员的代码看起来复杂其实是因为它复用了非守门员行为后又补充了扑球逻辑。bhv_goalie_basic_move内部调用Bhv_GoalieMove这个专用移动类它会计算球门中心和球的连线再根据球速预测落点。而bhv_goalie_chase_ball只有球进入守门员活动半径时才被调用这里的半径通常设置为Min( wm.self().pos().dist( wm.ball().pos() ), 2.5 )之类的值。行为类名适用场景关键调用Bhv_BasicMove无球跑位直接输出 dash 或 turn 命令Bhv_BasicOffensiveKick持球进攻内部计算踢球方向和力度Bhv_BasicTackle对方持球且距离近输出 tackle 命令Bhv_GoalieBasicMove守门员无球球门中心定位跑位Bhv_GoalieChaseBall球进入守门员范围结合球速预测追球Bhv_SetPlayGoalKick球门球按队友站位分发传球从这张表可以看出bhv 的粒度控制在一个行为只解决一个意图比如Bhv_BasicTackle只判断需要铲球的方向不会管铲完球之后怎么处理——那是后续决策帧的事。这个设计让策略调试变得非常简单球员表现不对时你只需要在对应的 bhv 里加日志看它是否被调用就能定位是决策问题还是执行问题。3. 球员角色Role怎么把 11 个人变成一支队伍3.1 角色与号码的映射关系agent2d-3.1.1 的sample_player里每个角色对应一个或多个 uniform number这种映射在PlayerAgent::createRole中通过 switch 完成。默认配置下1 号是守门员2、3 号是中后卫4、5 号是边后卫6 号是防守中场7、8 号是进攻中场9、10 号是边前卫11 号是中锋。bool createRole( PlayerAgent * agent ) { const int number agent-world().self().unum(); switch ( number ) { case 1: agent-setRole( new RoleGoalie( agent ) ); break; case 2: case 3: agent-setRole( new RoleCenterBack( agent ) ); break; case 4: case 5: agent-setRole( new RoleSideBack( agent ) ); break; case 6: agent-setRole( new RoleDefensiveHalf( agent ) ); break; case 7: case 8: agent-setRole( new RoleOffensiveHalf( agent ) ); break; case 9: case 10: agent-setRole( new RoleSideForward( agent ) ); break; case 11: agent-setRole( new RoleCenterForward( agent ) ); break; default: agent-setRole( new RoleCenterForward( agent ) ); break; } return true; }这段代码的核心思想是角色和球衣号码绑定但绑定逻辑是运行时决定的你完全可以把 4 号改成中后卫。注意RoleGoalie只给 1 号如果队伍阵型调整让守门员穿其他号码这个函数需要同步修改否则会出现两个场上球员都没有守门员捕球权限的问题。3.2 execute 的三层决策自己踢、队友踢、还是去跑位以RoleOffensiveHalf的execute为例它是整个决策系统的中间层。第一层判断自己能否踢球第二层检查队友是否有更优踢球条件第三层决定是执行踢球还是移动。这里用了getMaxBallDist()作为共享踢球半径所有球员用同一个判断标准避免两个人同时去抢球。bool RoleOffensiveHalf::execute( PlayerAgent * agent ) { const WorldModel wm agent-world(); // 1. 自己能否踢到球 if ( wm.self().isKickable() ) { // 2. 检查队友是否更适合处理球 if ( wm.existsKickableTeammate() wm.kickableTeammate().distFromSelf() wm.ball().distFromSelf() ) { doMove( agent ); // 队友更近自己去跑位接应 return true; } doKick( agent ); // 自己处理球 return true; } // 3. 没球权就去跑位 doMove( agent ); return true; }逻辑很短但里面藏着一个容易踩坑的点wm.existsKickableTeammate()返回的是全队中最近可踢球队友如果你在 1v1 防守场景下直接使用它可能因为队友在远处而返回 false导致后卫在持球时不知道传球给谁。常见做法是在这之前加一个区域过滤只统计同区域或更靠前的队友。3.3 doKick 和 doMove 的内部分工doKick先检查是否正在执行Bhv_ChainAction这是 agent2d-3.1.1 中的联合进攻机制特点是会向队友发送意图消息让多个球员按既定路线连续传递。如果链式进攻正在进行这个球员只需要发送消息并返回避免打断整体节奏如果不在链式动作中就调用Bhv_BasicOffensiveKick执行直接进攻。doMove是重头戏它先把场地分成几个语义区域再用 switch 分流到不同的移动函数。agent2d 默认实现里没有直接给传球区运球区这种中文命名但代码注释中的危险区DangerArea、半场防守区DefensiveArea、半场进攻区OffensiveArea和doDribbleBlockMove、doCrossBlockAreaMove这些函数说明的就是同一套区域化跑位思想。switch 的依据是wm.ball().pos()落在哪个矩形区域内。4. WorldModel 与动作模型你拿到什么数据就能做什么决策4.1 WorldModel 的数据来源与更新机制WorldModel 不是实时更新的它在每个周期开始时由agent-world()返回引用内部包含 self、ball、teammates、opponents 四个子模型。每个子模型都有独立的置信度因为 server 只会把视线范围内的信息发给你你看不到的就是unknown。这也是新手最容易踩的坑以为wm.ball().pos()永远可用实际上当球被其他球员挡住时它是基于上一次感知做的预测。const WorldModel wm agent-world(); // 获取体力值范围 0~8000低于 2500 时 dash 功率会下降 const double stamina wm.self().stamina(); // 获取球的位置 const Vector2D ballPos wm.ball().pos(); // 获取队友数量 const int teammateCount wm.teammatesFromBall().size();wm.self().stamina()返回的是当前体力server 的模型是体力越低dash 的实际加速度越小。如果你在代码里把 dash 的功率固定设为 100到比赛后半场会发现球员跑不动了。常见做法是让功率dash_power min( 100.0, stamina / 80.0 )这样能延长球员的高效跑动时间。4.2 六种动作的参数语义server 给 client 提供了 6 种动作源码里对应的是agent-doDash(power)、agent-doKick(power, direction)、agent-doTurn(angle)、agent-doTackle(power_or_dir)、agent-doCatch(direction)、agent-doMove(x, y)。统计上每周期只能真正执行一个动作但同一个动作可以携带多个参数比如踢球时方向是相对身体朝向的不是绝对角度。动作命令函数关键参数调用限制dashdoDashpower 0~100每周期 1 次体力消耗kickdoKickpower 0~100direction 相对角度必须可踢到球turndoTurnangle每周期 1 次tackledoTackle方向或功率有概率犯规catchdoCatchdirection仅守门员且球在禁区movedoMovex, y仅开球前可用move是一个特殊动作它只在 kick_off、goal_kick 等死球状态下可用作用是让球员瞬移到指定坐标。如果你在正常比赛中调用它server 会直接忽略这也是为什么Bhv_SetPlay系列会在特定 gameMode 下才调用 doMove而不是在Bhv_BasicMove里用。4.3 一个实际决策函数里的数据组合使用把 WorldModel 和动作模型组合起来就能写出一个简单的追球行为先判断自己离球的距离再决定用 dash 还是 turn。如果球在身后应该先 turn 改变朝向再 dash否则 dash 方向会偏离球。bool Bhv_SimpleChase::execute( PlayerAgent * agent ) { const WorldModel wm agent-world(); const Vector2D myPos wm.self().pos(); const Vector2D ballPos wm.ball().pos(); const double dist myPos.dist( ballPos ); if ( dist 0.5 ) { // 够近了直接踢 agent-doKick( 30.0, ( ballPos - myPos ).th().degree() ); return true; } if ( dist 3.0 ) { // 先转向再冲刺 agent-doTurn( ( ballPos - myPos ).th() - wm.self().body() ); agent-doDash( 80.0 ); } return true; }这个函数看起来合理但实际跑起来会发现两个问题一是(ballPos - myPos).th()得到的是绝对角度必须减去wm.self().body()才是相对身体的转向角二是 dash 只能向身体朝向方向跑所以转向和冲刺最好分开两个周期或者用doTurn后立即 dash靠 server 的 inertia 模型让转弯和加速同时生效。这也解释了为什么 agent2d 里的行为大多要跨多个决策周期才能完成单周期决策只能改变一个自由度。5. 区域划分与跑位决策doMove 的实战拆解5.1 五种区域的划分标准doMove的第一步是确定球所在的区域这一步通常用矩形判断。agent2d-3.1.1 的球场是 105x68中线在 x0己方球门在 x-52.5。下面是常见区域划分的坐标和策略含义区域名x 范围y 范围球员行为倾向危险区x -35全 y回撤防守封堵射门角度半场防守区-35 x 0全 y保持阵型盯人半场进攻区0 x 30全 y前压跑位接应传球运球区x 30y 在禁区附近尝试带球突破传球区x 30y 扩散到边路拉边传中交叉封堵区x 40全 y阻止对方边路推进这个表只是逻辑划分实际开发中需要根据己方阵型调整 y 边界。比如打 4-4-2 时边后卫的活动范围在|y| 20打 3-5-2 时翼卫要覆盖整个边路这时 y 的边界会缩小到|y| 15x 的推进距离也要相应提前。5.2 从区域到行为的映射实现在RoleOffensiveHalf::doMove中区域判断的结果直接决定调用哪个移动函数。下面的代码展示了完整的映射逻辑注意switch用的不是整型而是字符串枚举这样可读性更好也不容易在添加新区块时搞乱索引。void RoleOffensiveHalf::doMove( PlayerAgent * agent ) { const WorldModel wm agent-world(); const Vector2D ballPos wm.ball().pos(); // 用球的位置判断当前区域 AreaType area getArea( ballPos ); switch ( area ) { case DANGER_AREA: doDangerAreaMove( agent ); break; case DEFENSIVE_AREA: doDefensiveMove( agent ); break; case OFFENSIVE_AREA: doOffensiveMove( agent ); break; case DRIBBLE_AREA: doDribbleBlockMove( agent ); break; case CROSS_AREA: doCrossBlockAreaMove( agent ); break; default: Bhv_BasicMove().execute( agent ); break; } }getArea内部就是一组if或switch的比较比如if ( ballPos.x -35.0 ) return DANGER_AREA;。当调用doDangerAreaMove时球员的回撤目标是球门和球之间的一个中间点这个点不是固定的而是随球移动的防守锚点doDribbleBlockMove则是让自己站在球与球门连线的延长线上阻断对方从边路切入的角度。5.3 区域跑位的参数调节与可视化验证调区域跑位最痛苦的事情是不知道球员到底在想什么。我的做法是在角色里临时加一段调试输出把当前区域和跑位目标点打印到标准错误流然后配合 log player 回放比赛。std::cerr unum wm.self().unum() ball_pos ballPos area area target targetPos body wm.self().body() std::endl;跑完一场比赛后重点看三类异常一是一整场都不进入某个区域这说明区域边界写得太窄二是目标点频繁在两个区域间跳变说明边界与球的位置过于敏感需要加一个滞回区间也就是进入区域 A 的边界比离开区域 A 的边界更宽三是球员在无球状态下仍然调用doKick这种问题通常出在isKickable判断用了self().pos()而不是self().rpos()导致把队友脚下的球误判为己方控制。6. 用 Vector2D 重写一个行为验证你对整套机制的理解6.1 Vector2D 的极坐标和直角坐标互转Vector2D是 librcsc 中最常用的几何类它同时表示点的坐标和方向向量。源码里提供了两种构造方式直接传 x、y或者用Vector2D::POLAR标志传半径和角度。极坐标构造特别适合处理踢球方向问题比如你要往球门方向踢 25 米可以直接用极坐标生成目标向量。#include rcsc/geom/vector_2d.h int main() { // 直角坐标构造 rcsc::Vector2D p1( 0.0, 0.0 ); // 极坐标构造半径 10.0方向 90 度 rcsc::Vector2D p2( rcsc::Vector2D::POLAR, 10.0, 90.0 ); // 向量加法与标量倍乘 p1 p2; p1 * 2.0; // 获取长度与方向 double r p1.r(); // 长度 rcsc::AngleDeg th p1.th(); // 方向角 // 内积与外积 double dot p1.innerProduct( p2 ); double cross p1.outerProduct( p2 ); // 长度归一化和方向旋转 p1.normalize(); p1.setLength( 10.0 ); p1.rotate( 20.0 ); return 0; }innerProduct的典型用途是判断两个球员是否朝向同一方向比如协防时检查自己和队友的站位连线是否与对方带球方向平行outerProduct更常用在判断球员是在球的左侧还是右侧。AngleDeg类型重载了减法运算符两个角度相减会自动处理大于 180 度的回绕直接用( a - b ).degree()就能得到带符号的相对角度。6.2 写一个会评估传球窗口的行为理解了 Vector2D 的接口后可以自己写一个相当于bhv_basic_offensive_kick的简化版传球行为。核心逻辑是把自己与每个队友的连线作为传球方向检查这条线上是否有对方球员干扰用点到直线的距离判断干扰程度。bool Bhv_SimplePass::execute( PlayerAgent * agent ) { const WorldModel wm agent-world(); const Vector2D myPos wm.self().pos(); const Vector2D ballPos wm.ball().pos(); const PlayerPtrCont teammates wm.teammatesFromSelf(); for ( const PlayerObject * t : teammates ) { const Vector2D target t-pos(); const Vector2D passDir target - myPos; const double passDist passDir.r(); if ( passDist 30.0 ) continue; // 检查对方球员到传球线段的距离 bool blocked false; const PlayerPtrCont opps wm.opponentsFromSelf(); for ( const PlayerObject * o : opps ) { Vector2D oppToStart o-pos() - myPos; double distLine oppToStart.outerProduct( passDir ) / passDist; if ( fabs( distLine ) 1.0 ) { blocked true; break; } } if ( ! blocked ) { agent-doKick( 30.0, passDir.th().degree() ); return true; } } return false; }这里用了两个容易混淆的 Vector2D 操作outerProduct的绝对值除以passDist得到点到直线的垂直距离过滤掉拦截者passDir.th().degree()将方向向量转成角度传给 doKick。有几个细节需要注意一是teammatesFromSelf返回的容器已经按距离排序所以先找到的队友一定是最佳的二是oppToStart用的是相对自己的坐标不是绝对坐标因为outerProduct要求两个向量有同一个起点三是doKick的方向是相对身体朝向的如果传球方向和身体朝向偏差超过 180 度要先 doTurn否则这一脚会踢到奇怪的地方。你可以在赛前一次 kick_off 里多加一个动作调用doTurn校准身体也可以把 doKick 改成先 turn 再等下一周期踢球后者更符合 server 的动作执行模型。这段代码能跑通说明你已经掌握了 WorldModel 数据遍历、Vector2D 几何判断、以及动作输出这三件套。接下来可以试着把这个行为替换到RoleOffensiveHalf::doKick里看它和默认的Bhv_BasicOffensiveKick在传球成功率上有多少区别——对比结果会让你对 agent2d 的默认策略为什么那么写有直观的体会。本文还有配套的精品资源点击获取