3D仿真机器人足球冠军智能体代码解析与多智能体协作实战

简介:这份2012年RoboCup 3D仿真足球赛南京邮电大学冠军队伍的可执行代码包,面向机器人仿真、多智能体协作与竞赛复盘的学习者,可用于研究Apollo3D平台下的自主决策、团队配合和实时策略执行。压缩包为gz格式,共175个文件、约29.13MB,以76个rsg文件与69个svn-base版本库元数据为主,辅以Ruby脚本、Shell启动脚本、动态库及apollo3d相关组件,保留了较完整的工程组织、依赖关系与版本控制痕迹。已有899人学习,适合作为竞赛代码阅读与实验环境搭建的参考。读者可从中观察冠军队伍如何划分策略模块、组织球员角色文件、通过脚本启动仿真并调用底层库,进而理解多智能体协同、强化学习策略和实时通信在真实赛题代码中的落地方式,也可为自动驾驶、无人机编队等群体智能研究提供借鉴,对理解当年参赛工程的环境配置与调试流程亦有帮助。

1. 一支仿真球队是怎么跑起来的:11 个进程、一个仿真服务器和一堆 UDP 小包

2012 年那届 3D 仿真机器人足球赛的冠军智能体,解压出来既不是一个大工程,也不是一个能双击运行的 exe,而是一堆源码、一份 CMake 配置和几张阵型表。比赛的实际情况是:仿真服务器里跑一块 20ms 步进的物理世界,场上 11 个独立进程各自接收自己视角的视觉、听觉和本体感知,各自建一份世界模型,各自算决策,再用 UDP 报文互相喊话。这份可执行代码包就是那支队伍的完整智能体实现:从底层步态、起身、踢球技能,到上层阵型、角色分配和多人协作。

它适合两类人:一类是课程设计或毕设要做多智能体对抗、想找一个能编译能跑的完整参考实现;另一类是已经上手过单智能体控制,想看多人协同里定位、通信、角色切换怎么写。下面按“能干什么—怎么跑—怎么改”的顺序拆开。

2. 代码包结构与技术栈:先看清 cmake 与三套依赖再动手

拿到压缩包别急着make。这套代码的依赖关系和普通 C++ 项目不太一样,它要和外部仿真服务器做二进制级的协议对接,服务器版本、agent 端的解析逻辑、监视器的渲染库三者必须对得上,否则编译能过、跑起来就是连不上或者连上就掉线。

2.1 目录长什么样:从 main.cpp 到 formations

我把包解开后按职责重排了一下,结构大致是这样,各目录的职责边界很清楚,看一遍就能知道改哪个文件:

apollo3d/ ├── CMakeLists.txt # 顶层构建,负责找 simspark 三件套 ├── src/ │ ├── main.cpp # 解析命令行参数、建连接、进主循环 │ ├── agent/ │ │ ├── AgentController.* # 与服务器收发报文的封装 │ │ ├── WorldModel.* # 全局世界模型:球、我、队友、对手 │ │ └── Behavior.* # 决策层,按角色选当前行为 │ ├── perception/ │ │ ├── Vision.* # 视觉对象解析(极坐标 + 斜视角) │ │ ├── Localization.* # 粒子滤波定位 │ │ └── Communication.* # say/listen 报文编解码 │ ├── skills/ │ │ ├── Walk.* Turn.* # 步态与转向 │ │ ├── Kick.* GetUp.* # 踢球与倒地起身 │ │ └── SkillBase.* # 技能基类,统一 execute 接口 │ └── utils/ # 数学、日志、参数解析 ├── conf/ │ ├── formations/ # 4-3-3、4-4-2 等阵型表 │ ├── playerTypes.conf # 球员号到身高/速度/臂长的映射 │ └── agent.conf # 阈值、开关、调试输出等级 ├── scripts/ │ ├── start_server.sh # 起仿真服务器 │ ├── start_monitor.sh # 起三维监视器 │ ├── start_team.sh # 一次拉起 11 个 agent │ └── kill_all.sh # 清场,调试时一天要跑几十次 └── build/ # 外部构建目录,别往仓库里提交

src下的分层是典型的“感知—建模—决策—执行”四段式,skills和behavior拆开是有原因的:技能层只关心关节怎么动,决策层只关心现在该不该踢,两层之间用世界坐标传参,后面改行为树的时候不用碰底层步态。

2.2 技术栈定死在三样东西上:仿真服务器、三维渲染、C++ 智能体

这套代码的技术栈没有什么花哨的东西,但每一样都不能随便换版本:

层次组件作用备注
仿真层3D 仿真服务器物理世界、裁判、比分必须先装,agent 依赖它的开发库
渲染层三维监视器可视化看球纯观察用,不影响比赛结果
基础库oxygen / kerosin / zeitgeist数学、渲染、场景图服务器和监视器的公共依赖
智能体C++(C++98/03 风格)感知解析、决策、技能单进程单球员
构建CMake找库、生成二进制需要手动指定依赖前缀
协作UDP say 报文队友间共享球和位置有长度上限,后面细说

语言用 C++ 而不是脚本,是因为 20ms 一个周期里要做粒子滤波更新、十几条腿关节的角度计算,还要留出通信和日志的时间,脚本语言在那个年代的机器上撑不住。代码里大量裸指针和定长数组,看着不现代,但延迟稳定,这一点在对抗类仿真里比可读性重要。

2.3 编译链路与依赖关系:CMake 怎么找到那几个库

编译顺序不能反:先仿真服务器及其依赖,再 agent。CMake 找库靠的是前缀路径,我第一次编的时候就是库装在/usr/local下但没告诉 CMake,报了一屏Could NOT find OXYGEN。

# 1. 先确认仿真服务器的开发库装好了,头文件和 so 都在 ls /usr/local/include/oxygen /usr/local/lib/liboxygen* # 2. 外部构建,别在源码目录里 make,避免污染 mkdir -p build && cd build # 3. 把依赖前缀显式喂给 CMake,这一步是新手最容易漏的 cmake .. \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH=/usr/local \ -DCMAKE_CXX_FLAGS="-O2 -march=native" # 4. 并行编,机器弱的话 -j2,不然内存容易打满 make -j4 # 5. 产物检查:应该出现一个 agentspark 之类的可执行文件 ls -lh bin/

CMAKE_PREFIX_PATH指的是仿真服务器安装的根目录,include和lib都在它下面;CMAKE_BUILD_TYPE=Release必须开,Debug 版本在 20ms 周期里跑粒子滤波会掉帧,掉帧就意味着这个周期你的球员什么都没做,场上看起来就是“发呆”。-march=native可加可不加,同一台机器上编译和运行才有意义,换机器部署要删掉。

编完之后先别急着联机,用--help确认参数解析正常,很多连接问题其实是参数名拼错,程序静默用了默认值:

./bin/agentspark --help

提示:如果make报链接错误但头文件都找到了,八成是服务器装的是静态库、agent 按动态库找。看ldd ./bin/agentspark里有没有not found,有就补LD_LIBRARY_PATH。

3. 启动全链路:从仿真服务器到 11 个智能体接入

这套东西的运行方式和普通程序最大的差别是:它没有“主程序”。仿真服务器是主,agent 是客户端,服务器不点头,agent 就一直等在那儿。所以启动顺序、端口占用、队名和球衣号的唯一性,任何一项出错都会表现为“程序起来了但场上没人”。

3.1 先把仿真服务器和监视器跑起来

服务器负责物理和裁判,监视器负责让你看见发生了什么。调试期我建议始终开着监视器,很多逻辑错误(比如球员一直原地转圈)看两眼就明白了,光看日志能查一晚上。

# 启动仿真服务器,默认监听 agent 端口 3100、监视器端口 3200 # 前台运行,日志直接打屏,方便第一时间看到连接和判罚信息 rcssserver3d --port 3100 --monitorPort 3200 # 另开一个终端,起三维监视器连上去 # 连不上就先确认 3200 没被占:ss -lntp | grep 3200 ./scripts/start_monitor.sh --host 127.0.0.1 --port 3200

服务器日志里有几个关键信息要盯:accepted connection说明 agent 连上了;player type说明球衣号绑定到了哪种异构球员;kick off说明比赛真的开始了。如果日志里一直停在等待状态,通常是场上人数不够或者两队没有都到齐,仿真比赛要求两边都有人才会开球。

监视器连上后是黑屏,先别怀疑代码,八成是 OpenGL 环境问题。服务器上跑没有显卡的机器,监视器起不来很正常,这时候用无头模式跑对抗,靠日志和回放分析结果,效率反而更高。

3.2 智能体接入参数:host、port、teamname、unum

单个 agent 的启动参数是整套系统里最需要记牢的东西,四个参数错一个就连不上或者连上去被踢:

参数含义常见取值出错表现
--host服务器地址127.0.0.1一直重连,日志刷屏
--portagent 端口3100连上后立刻断开
--teamname队名Apollo3D两队同名,服务器拒绝
--unum球衣号1到11同队重号,后到的被踢
--syncret同步模式开关0/1不匹配时步进错位

启动一个前锋:

# 参数用等号形式,某些解析实现不认空格分隔 ./bin/agentspark \ --host=127.0.0.1 \ --port=3100 \ --teamname=Apollo3D \ --unum=11

unum不只是编号,它同时决定了这个球员的异构类型:1 号一般是守门员,身高和臂长参数不同,抓球判定也不一样;2 到 11 号是场上球员,速度、加速度、关节范围都在conf/playerTypes.conf里绑定。改球衣号等于换了一个身体,步态参数要跟着调,这一点后面二次开发会再说。

3.3 一队人怎么一次性拉起来:脚本与进程管理

手动开 11 个终端不现实,队里一般都有一份启动脚本。脚本的核心就是循环加后台运行,再加一个清场逻辑,因为调试期最烦的就是上一场的进程没死干净,端口占着,新的一队人全连不上。

#!/usr/bin/env bash # start_team.sh:拉起一整队,参数 1 是队名,参数 2 是球员数量 set -e TEAM=${1:-Apollo3D} COUNT=${2:-11} # 先清场,把上一轮残留的 agent 全干掉 pkill -f "agentspark --teamname=${TEAM}" || true sleep 1 for i in $(seq 1 "${COUNT}"); do # 每个进程独立日志,出问题只看对应球衣号那一份 ./bin/agentspark \ --host=127.0.0.1 --port=3100 \ --teamname="${TEAM}" --unum="${i}" \ > "log/${TEAM}_${i}.log" 2>&1 & # 稍微错开启动,避免 11 个进程同一毫秒抢连接 sleep 0.2 done echo "team ${TEAM} started with ${COUNT} players"

pkill -f的匹配串要写得足够具体,只匹配队名,不要匹配agentspark,否则会把自己这条脚本之外的调试进程也杀掉。sleep 0.2这个错峰是我踩过坑之后加的:11 个进程同时发起连接,服务器那边偶发丢包,表现就是某一号球员一直连不上,重跑一次又好了,典型的环境玄学问题,加个延时最省事。

两队对抗就再跑一遍脚本,换个队名。跑起来之后先别改代码,用监视器看满一整场,观察三件事:球员会不会自己站起来、拿到球会不会往对方半场走、丢球之后会不会回追。这三件事对应技能层、决策层、角色分配三个不同层次的问题,先定位层次再改代码,比上来就翻源码高效得多。

4. 核心模块拆解:一个感知周期里到底发生了什么

20ms 一个周期,从服务器推感知过来,到 agent 把关节指令发回去,中间要过四道手:解析感知、更新世界模型、选行为、算技能。这四步任何一步超时,这个周期就等于白送。拆代码的时候建议按这个顺序读,main.cpp里就是这几行的循环。

4.1 主循环:step、update、think、execute 四段

主循环写得非常朴素,所有复杂度都在四个函数里面。这段是整个项目的骨架,读懂它,剩下的都是细节:

// main.cpp 核心循环,去掉参数解析和异常处理后的主干 while (mController->isAlive()) { // 1. 阻塞等待服务器推来一帧感知,内含视觉、听觉、本体感知 if (!mController->step()) break; // 2. 用这一帧感知更新世界模型:球、队友、对手、自己的位姿 mWorldModel->update(mController->sensor()); // 3. 决策层根据当前角色和场上态势选一个行为 mBehavior->think(mWorldModel); // 4. 技能层把行为翻译成关节指令,本周期内必须算完 mBehavior->currentSkill()->execute(mController); // 5. 心跳日志,调试期开,比赛期关,写盘会拖慢周期 if (mDebug) mLogger->tick(mWorldModel); }

step()是阻塞的,它返回就意味着这一帧的感知已经到齐,此时世界模型里的数据是自洽的,不要在多线程里异步改模型。think只管选行为,不允许在里面做耗时计算,粒子滤波这类重活放在update里。execute只负责下发指令,不负责判断该不该做,这个边界一旦破坏,行为层和技能层就会互相污染,后期改一个步态会牵动半个决策树。

这里有个隐性的时序约定:本周期算出的指令,服务器在下一个周期才生效。也就是说你的球员永远在“慢一拍”地响应世界,做预测和提前量是必需的,不是优化项,是正确性的一部分。

4.2 世界模型:把极坐标视觉拼成球场坐标

视觉输入不是笛卡尔坐标,而是以头部为中心的极坐标加斜视角,还带噪声,而且一帧只给你视野锥内的东西。世界模型要干的事就是把这些碎片的观测,结合自己的本体感知(关节角度、陀螺仪)和定位结果,拼成一份全局视图。

定位这块,当年的主流做法是粒子滤波:撒一批粒子,每个粒子代表一个可能的位姿,用观测到的地标(球门柱、场地线交点)去打分,重采样。简化版用两根球门柱做三角解算,够用但一旦被遮挡就飘。核心逻辑长这样:

// WorldModel::update:本帧观测融合 void WorldModel::update(const SensorInput& in) { // 本体感知始终可信,先更新自身关节和朝向 mSelf.updateJoints(in.jointAngles); mSelf.updateGyro(in.gyro); // 视觉对象逐个转成球场坐标,beamed 表示服务器直接给的精确值 for (const auto& obj : in.visionObjects) { Vec3f pos = mSelf.toWorldFrame(obj.polar, obj.azimuth); switch (obj.type) { case OBJ_BALL: // 球用卡尔曼滤波平滑,速度比位置更容易被噪声带偏 mBall.update(pos, obj.beamed); break; case OBJ_FLAG: // 地标喂给粒子滤波,权重在这里更新 mLocalization.observe(obj.type, pos); break; case OBJ_PLAYER: mPlayers.track(obj.unum, pos, obj.team); break; } } // 每 N 帧重采样一次,太频繁会抖动,太稀疏会跟丢 if (++mFrame % 5 == 0) mLocalization.resample(); }

toWorldFrame里的polar是距离,azimuth是水平方位角,垂直角度用来算高度,三者加上自身位姿就得到世界坐标。beamed是个开关性质的字段:服务器在某些情况下(比如球进了自家禁区附近)会给精确值,这时候直接采信,不要再过滤波器,否则反而引入延迟。粒子滤波的重采样周期 5 帧是个经验值,改小了定位抖,改大了跟丢人,不同球员类型(守门员和前锋的运动特性差别很大)可以分开配。

4.3 技能层:走路、转身、踢球的参数化实现

技能层的接口很统一,都是execute返回是否完成。走路是最基础也最难调的一块,仿真里的双足行走本质上是关节角度序列的周期函数,参数全靠手工调。以下是转向加前进的组合逻辑,实际代码里还有侧移和抗扰:

// Walk::execute:走向世界坐标下的目标点 bool Walk::execute(AgentController* agent, const Vec3f& target, double speed) { // 1. 世界坐标转机器人自身坐标系,只关心平面 Vec3f local = mWorldModel->toRobotFrame(target); double dist = local.length(); // 2. 球在侧后方时先转身再走,硬走会绕大圈,实测差半秒以上 double theta = atan2(local.y(), local.x()); if (fabs(theta) > DEG2RAD(30.0)) { return mTurn.execute(agent, theta); } // 3. 距离近就降速,避免冲过头被球绊倒(是的,会绊倒) double v = speed * clamp(dist / 1.5, 0.3, 1.0); // 4. 按步态表下发关节速度,mGait 是离线调好的周期参数 for (int j = 0; j < mJointNum; ++j) { double phase = mGaitPhase[j]; agent->setJointSpeed(j, mGait[j] * v * sin(phase)); } mGaitPhase += mGaitStep; return dist < 0.3; // 到点即视为完成 }

DEG2RAD(30.0)是转身阈值,超过就先转,这个值是调出来的:设小了走 Z 字,设大了绕远路。clamp(dist / 1.5, 0.3, 1.0)是速度缩放,1.5 米以内的减速区间,下限 0.3 保证再近也在动,不然会停在球前面“思考人生”。mGait每个关节一个系数,mGaitStep决定步频,这两个数组是整个项目里最需要耐心的地方,改一个数整个步态就废了。

踢球技能类似,但它多一个“触球窗口”:球必须在脚的可触及范围内,且相对速度方向合适才真正发力,否则就是空踢。这个窗口判定写错,场上表现就是球员对着球疯狂抬腿但球纹丝不动。

4.4 通信与协作:say 消息怎么塞进有限字节里

11 个人各看各的视角,不通信就是 11 个近视眼各踢各的。仿真里的 say 是广播,同队都能收到,但有长度上限(当年是 20 个字符左右,卡得很死),所以消息必须编码压缩,不能发自然语言。

// 广播球的位置和速度,"B" 是类型前缀,后面四个字段各保留两位小数 // 注意总长度必须控制在协议上限内,超了会被截断甚至丢弃 char buf[32]; int n = snprintf(buf, sizeof(buf), "B%.1f,%.1f,%.1f,%.1f", ball.x(), ball.y(), ball.vx(), ball.vy()); if (n > 0 && n < (int)sizeof(buf)) { mController->say(buf); } // 接收端按前缀分发,解析失败直接丢弃,不要 try/catch 到主循环 void Communication::onSay(const std::string& msg) { if (msg.empty()) return; switch (msg[0]) { case 'B': mWorldModel->fuseBallFromTeammate(msg); break; case 'S': mWorldModel->fuseSelfFromTeammate(msg); break; default: break; // 未知前缀忽略,保持向前兼容 } }

snprintf的返回值一定要判,返回负数说明编码失败,返回大于等于缓冲区说明被截断,两种情况都不能发。前缀字符的选择有讲究:用一个字母区分消息类型,比在固定位置放类型字段更省字节,也更容易做向前兼容——以后加新消息类型,老代码遇到不认识的前缀直接忽略,不会崩。

队友信息融合要谨慎,别人报的球位置是他自己视角算出来的,带有他的定位误差,简单平均会把自己的精度也拉低。常见的做法是给自己的观测更高权重,队友消息只在视野盲区时作为补充,这条在防守回追时差别非常明显。

5. 避坑与排查:五类翻车场景和它们的解

这套代码的报错信息普遍不友好,连不上就是连不上,不告诉你为什么。下面这五条是我在复现和改代码过程中反复遇到的,每条都按现象、原因、解决的顺序写清楚,照着排查能省不少时间。

5.1 编译与依赖类问题

第一条:CMake 报找不到 oxygen / kerosin。

现象是cmake ..直接失败,提示Could NOT find OXYGEN (missing: OXYGEN_LIBRARY OXYGEN_INCLUDE_DIR)。原因基本是两个:仿真服务器根本没装开发包,或者装了但装在非标准前缀,CMake 的默认搜索路径里没有。解决顺序是先ls /usr/local/lib | grep oxygen确认库存在,再用-DCMAKE_PREFIX_PATH=/usr/local显式指定。如果库确实不在,那就先回去把仿真服务器编译安装一遍,注意安装时别跳过make install,只编译不安装等于没有开发包。

第二条:编译过了,运行时报 so 找不到。

现象是./bin/agentspark提示error while loading shared libraries: liboxygen.so。原因是编译期靠CMAKE_PREFIX_PATH找到了库,运行期动态链接器不知道去哪儿找。解决要么把路径写进/etc/ld.so.conf.d/再ldconfig,要么临时export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH。前者一劳永逸,后者适合在容器里临时跑。排查时ldd ./bin/agentspark | grep "not found"一眼就能看出缺哪个。

5.2 连接与对抗类问题

第三条:agent 一连上就被断开。

现象是日志里出现连接成功后立刻 disconnect,服务器那边显示 illegal connection。原因通常是队名或球衣号冲突:同队两个 11 号,后连的那个必被踢;两队用了同一个队名,服务器认为是同一支队在超员。解决是先用ps aux | grep agentspark看有没有残留进程,再检查start_team.sh里传给--unum的循环变量是不是从 1 开始。用pkill -f "teamname=你的队名"清干净再起。

第四条:球员原地转圈,或者对着球疯狂抬腿但球不动。

现象是监视器里看得很清楚,球员围绕某个点不停旋转,或者距离球只有半米却踢不到。原因分别是:视觉解析时没有正确处理球不在视野锥内的情况,定位飘了之后目标点算错,走路技能就会不停修正朝向形成转圈;踢球技能的触球窗口用世界坐标判定而没用自身坐标系,或者球的高度算错。解决办法是先关掉决策层的复杂逻辑,只留“lookAt 球然后走过去踢”这条最小链路,能踢到再加回角色和协作。定位问题就打开--debug把粒子云打出来看,粒子全糊成一团就是观测权重写反了。

第五条:比赛跑着跑着整体卡顿,周期越拖越长。

现象是刚开始流畅,跑一两分钟后所有球员动作变慢,日志写盘时间越来越长。原因是调试日志同步写文件,每个周期都 flush,磁盘 IO 拖垮了主循环。解决是把日志改成异步写或者只在特定球衣号上开日志,比赛模式下mDebug直接关掉。另外检查一下有没有在think里做了粒子滤波这类重计算,那个必须放在update里。

注意:调试期看到一个球员行为异常,先看它那份独立日志,不要一起看 11 份。日志里每帧打印的世界模型状态,比监视器画面能说明的问题多得多。

6. 二次开发:换阵型、加技能、改决策层的稳妥路径

改动从代价最小的地方开始。第一层是纯配置:阵型文件和球员类型表都在conf/下,换打法不用重编译。阵型文件定义每个球衣号在不同球状态下的目标位置,格式大致是“名字 + 球衣号 + 坐标序列”,改坐标就能把 4-3-3 变成 4-4-2。这一层的坑在于坐标是球场绝对坐标,且服务器会按球员类型限制活动范围,守门员的位置填到对方半场,场上表现就是他站在原地不动。

第二层是加技能。新技能继承技能基类,实现execute即可,但要遵守两条约定:只下发指令不做决策,以及返回布尔值表示是否完成。技能内部不要读全局状态,所有需要的数据从世界模型传入,这样单元测试能单独跑,不用起服务器。我一般会先在无对抗场景里验证技能,用一个固定的球位置反复调用,看关节角度曲线是否收敛,收敛了再放进对抗。

第三层才是动决策层。原始的 if-else 风格行为选择在角色少的时候够用,一旦要加“回防优先级”“协防补位”,就会变成一大坨嵌套。换成行为树或者状态机都可以,关键是把“角色分配”和“行为选择”拆开:角色由阵型和球位置决定,行为由角色和当前局面决定,两层之间的接口只有几个字段。

改造目标动哪里重编译验证方式
换打法conf/formations/*否看站位是否符合预期
调步态skills/Walk.cpp参数表是单技能无对抗回放
加技能新增skills/xxx.cpp是固定球位置反复调用
换决策agent/Behavior.cpp是整场对抗 + 日志统计

验证这块我有一个固定流程:改完先跑单队无对手 3 分钟,确认没人摔倒、没人卡住;再跑两队对抗 10 场,用脚本统计进球数和控球时间。日志里把每帧的球位置和角色记下来,一个简单的 Python 脚本就能算出控球率,比肉眼看监视器靠谱得多。批量跑的时候把监视器关掉,省下来的渲染开销能让仿真跑快不少。

血泪经验是:每次只改一个变量。步态系数和决策阈值一起改,出了问题根本不知道该回滚哪个,最后只能整个目录 git checkout。从那以后我每次动技能参数前,都强制走一遍“单技能验证—无对抗整场—对抗十场”这条链路,慢是慢一点,但省下的排查时间远不止这些。这套 2012 年的冠军代码放到今天,架构上依然站得住,值得拿来当多智能体对抗的练手底子。希望帮到你。

本文还有配套的精品资源,点击获取