
简介这份2012 RoboCup 3D冠军南邮可执行代码包面向参加机器人仿真足球竞赛、研究多智能体协同与实时决策的开发者及高校团队可用于对照Apollo3D平台理解冠军队伍的球员控制、团队协作和策略执行流程。压缩包共175个文件约29.13MB主要由76个rsg资源/策略文件、69个svn-base版本快照、若干rb脚本、sh启动脚本、so动态库及apollo3d相关配置组成保留了工程组织与运行依赖的原始痕迹。已有899人学习说明其在RoboCup 3D社区具有一定参考价值。读者可从中梳理冠军代码的目录结构、策略模块划分、启动脚本与仿真配置之间的对应关系进而复盘多智能体协同、实时通信和比赛决策的实现思路适合具备一定机器人仿真基础的学习者做源码研读与方案借鉴。1. 从一份 2012 年的二进制包说起这套 robocup3d 冠军队 agent 到底在跑什么双击运行一份 2012 年的 3D 仿真足球赛冠军队可执行包多数人的第一反应是盯着一个黑窗口发呆没有界面、没有日志、没有任何反馈。它其实已经连上了服务器只是没人告诉你场上发生了什么。这套 robocup3d agent 不是给人看的软件而是一个以 50Hz 频率不停收 UDP 包、维护世界模型、计算关节角度的实时控制程序穿上球衣和另外十个同构 agent 组队把球推进对方球门。它适合三类人想研究多智能体协同与角色分配的开发者、想复现一套完整「感知—建模—决策—执行」闭环的控制方向同学、以及拿它当实时系统练手的人。检索里那串 apollo3d 指的就是这一套 agent 的二进制标识。下面按「它怎么组织、怎么跑起来、怎么改」往下拆。2. 架构拆解从 UDP 感知流到关节指令的五层结构这套代码的价值不在某个算法多花哨而在于它把一个 20ms 硬实时期限的闭环拆得足够干净。可执行包虽然不给源码但二进制里的符号、启动参数、依赖的动态库、运行时打印的协议报文足以把分层还原出来。整条链路自上而下是通信层负责收发文本报文解析层把 S 表达式变成结构体世界模型层做坐标变换与滤波决策层做角色分配和行为选择技能层把「走到某点」「踢球」翻译成关节角度序列最后回到通信层下发。层与层之间是单向依赖决策层拿不到 socket技能层也看不到原始字符串。这种约束在实时系统里很关键一旦某一层偷偷去读全局状态调参时就会出现「改 A 影响 B」的玄学问题。我一般会先看二进制里有没有线程如果只有一个主循环说明整个流程是单线程轮询那么每一帧的耗时必须压在 20ms 以内任何一处阻塞都会直接表现为动作抖动。2.1 SPADES 通信层三步握手缺一不可仿真服务器和 agent 之间走的是 UDP 上的文本协议报文是嵌套的 S 表达式。服务器默认监听 3100 端口agent 启动后要先主动报到服务器才会把它登记成一个实体并开始推感知帧。// SpadesClient与仿真服务器的最小握手流程 class SpadesClient { public: bool connect(const std::string host, int port) { sock_ ::socket(AF_INET, SOCK_DGRAM, 0); // 只认 UDP别想着 TCP sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(port); inet_pton(AF_INET, host.c_str(), addr.sin_addr); return ::connect(sock_, (sockaddr*)addr, sizeof(addr)) 0; } // 第一步申明协议版本服务器才愿意继续对话 void sendInit() { send((init (rsg/3D) (version 1.0))); } // 第二步选模型文件决定你是一台 Nao 还是一个球 void sendScene(const std::string model) { send((scene model )); // 常见值 rsg/agent/nao/nao.rsg } // 第三步领号码同队同号不能重复1 号通常是门将 void sendTeam(const std::string team, int unum) { send((team team (unum std::to_string(unum) ))); } private: void send(const std::string s) { ::send(sock_, s.data(), s.size(), 0); } int sock_; };这三步的顺序不能颠倒服务器在第一包没收到init时会直接丢弃后续报文表现就是「程序在跑但场上没人」。参数上model决定了 agent 的关节数量和视觉参数选错模型会在解析阶段报字段缺失unum取值范围 1 到 111 号一般为门将重复注册会被服务器拒绝且不返回明确错误码这是新手最容易卡住的地方。真正在运行时这一层还承担两个职责一是心跳长时间不发指令服务器会认为 agent 掉线二是团队通信(say ...)发出的短消息只有同队成员能收到字数限制很紧所以实际用法是把「我有球」「我补位左路」压缩成几个字符加坐标别指望传 JSON。2.2 世界模型层把字符串感知变成可计算的坐标服务器每 20ms 推一帧感知视觉部分带噪声还带延迟本体感受部分则是干净的。世界模型要做的事就是把这些碎片拼成一个可信状态自己在哪、球在哪、队友和对手在哪、当前是什么比赛阶段。// WorldModel::update —— 一帧感知的落库过程 void WorldModel::update(const std::string percept, double now) { // GS 是比赛状态机pm 决定现在能不能动 if (auto* gs findNode(percept, GS)) { playMode_ attr(gs, pm); // before_kick_off / play_on / kick_off_l ... simTime_ std::stod(attr(gs, t)); } // See 是视觉带高斯噪声、约 3 帧延迟不能当真实值用 if (auto* see findNode(percept, See)) { for (auto obj : children(*see)) { if (name(obj) B) { // 球距离 d 方位角 th double d std::stod(attr(obj, d)); double th std::stod(attr(obj, th)); ballFilter_.push(d, th, now); // 先滤波再转全局坐标 } } } // HJ 是本体关节角无噪声直接覆盖 for (auto hj : findAll(percept, HJ)) { joints_[attr(hj, n)] std::stod(attr(hj, ax)); } // FRP 脚底受力用来判断支撑脚摔倒检测也靠它 leftFootOnGround_ hasNode(percept, FRP, lf); rightFootOnGround_ hasNode(percept, FRP, rf); }这段的关键在滤波和坐标变换两处。视觉给出的d和th是相对自身的极坐标要先乘上自身朝向矩阵才能转成全局坐标而自身朝向本身也是估计出来的靠陀螺仪积分加视觉里固定地标角旗、球门柱做校正。常见做法是给球的位置维护一个简易卡尔曼状态取位置加速度观测噪声按距离分段调近距离观测置信高、远距离基本只能当方向参考。血泪经验是千万别把带延迟的视觉直接当当前帧用否则高速跑动时会出现追着球的残影跑的情况。还有一个容易忽略的点是playMode。before_kick_off阶段所有 agent 应该用beam把自己摆到初始位置play_on之后才允许移动。很多跑不起来的包就是卡在这里因为没做状态判断开局直接被判犯规重置。2.3 决策与技能层优先级状态机加角色分配决策层不用复杂的规划器实际做法是一个按优先级排序的行为列表每一帧从高到低找第一个applicable的行为执行谁都不命中就走默认回位。这种结构在实时对抗里比行为树更省心因为优先级一眼可见出问题好定位。// Behavior 基类一个 tick 只做一个决定 class Behavior { public: virtual ~Behavior() default; virtual bool applicable(const WorldModel wm) const 0; virtual Action tick(WorldModel wm) 0; virtual const char* name() const 0; }; // 典型优先级排列从高到低 // 1. FallRecover 摔倒自起任何情况下最高优先级 // 2. GoalKeeper 1 号球员专属 // 3. KickBall 球在可踢范围内 // 4. ChaseBall 球在视野内但够不着 // 5. Position 按角色回到阵型基准点 // 6. Stand 兜底什么都不做角色分配一般由场上局面推出来最简单的规则是离球最近的两人分别做主攻和协防其余按阵型基准点站位1 号永远是门将。这套逻辑在 11 人满编时够用但遇到队友掉线服务器不会立刻告诉你谁掉线只能从多久没收到队友广播推断就要做降级否则整条边路会空出来。技能层负责把「走到 (x, y) 附近」「朝某方向踢」翻译成关节角序列。走到某点用的不是单纯的比例控制器而是一套参数化步态髋关节摆动幅度决定步长膝关节决定抬腿高度踝关节负责落地缓冲整套动作以一个固定周期循环靠调整周期和幅度来控制速度。踢球则是另一套关键帧动画从后摆到触球再到随挥触球瞬间的脚速决定球速。这一层最考验调参参数写在配置文件里比硬编码好因为不同场地摩擦系数下最优步态完全不同。3. 把可执行包跑起来服务器、Agent 与观战端的启动顺序拿到可执行包之后第一件事不是急着运行 agent而是把服务器先立起来。整个系统的启动顺序是固定的仿真服务器 → 若干 agent → 观战端可选。顺序反了agent 会一直重连观战端会显示空场。3.1 环境准备与依赖确认2012 年的这类包基本是 32 位 Linux 二进制现在的主流发行版默认不装 32 位运行库直接运行会报No such file or directory而这个报错极具误导性看起来像文件不存在实际是动态链接器找不到。先用file和ldd两个命令把底摸清# 看架构出现 32-bit 或者 Intel 80386 就说明需要 32 位库 file ./agent_bin # 看依赖重点关注 not found 那几行 ldd ./agent_bin | grep -i not found # 看动态链接器路径老包可能指向已经不存在的 /lib/ld-linux.so.2 readelf -l ./agent_bin | grep interpreterldd输出里libstdc.so.6缺失是最常见的一种装对应架构的兼容运行库即可。如果包内自带lib/目录别急着往系统目录拷用环境变量指定搜索路径更干净也方便同时留多个版本做对比# 让动态链接器优先去包内的 lib 目录找依赖 export LD_LIBRARY_PATH$PWD/lib:$LD_LIBRARY_PATH这一步做完再用ldd复查一遍确认没有not found才算环境通了。我一般会把这个环境变量直接写进启动脚本避免每次开新终端都要重设一遍。服务器端需要单独准备仿真器它负责物理引擎、感知生成和比赛状态机。安装完先裸启动一次确认端口在监听# 启动 3D 仿真服务器默认监听 3100 rcssserver3d --port 3100 # 确认端口已监听输出里应该有 3100 ss -lunp | grep 3100服务器有大量可调参数比如单步物理时长、感知噪声开关、比赛时长。做算法调试时我习惯把噪声关掉先把逻辑跑对再打开噪声验证鲁棒性反过来做的话你会在噪声里怀疑人生。3.2 启动服务器与 Agent 的命令行参数通信层大多支持命令行覆盖配置文件标准形态是主机、端口、队名、号码四件套# 起一个 agent连本机服务器队名 demo号码 1门将 ./agent_bin --host 127.0.0.1 --port 3100 --teamname demo --unum 1 # 满编 11 人用循环批量拉起间隔 0.3s 避免瞬间拥塞 for i in $(seq 1 11); do ./agent_bin --host 127.0.0.1 --port 3100 --teamname demo --unum $i sleep 0.3 done参数里有几个坑值得提前说清。--teamname决定服务器怎么分队两队名不能相同否则会被当成同一队导致某一边没对手--unum在同队内必须唯一重复注册服务器不报错只是静默忽略现象是场上少人端口如果被改过而 agent 还在连 3100表现是一直重连但没有任何错误输出。如果包内提供的是配置文件而不是命令行参数那就直接改配置# agent 的典型配置项字段名各版本略有差异按包里实际文件为准 [server] host 127.0.0.1 port 3100 [team] name demo unum 1 role auto ; auto / keeper / defender / forward [debug] log_percept false ; 调试期打开会写每帧原始报文文件增长很快 log_level 2log_percept是个好东西打开之后能把每帧感知原样落盘后面写自己的解析器、做离线回放全靠它。记得配个大小上限或者定期清理50Hz 连续写一晚上能吃掉不少磁盘。3.3 监控与验证怎么确认 Agent 真的连上了可执行包没有界面验证只能靠旁路观察。最直接的是再起一个 monitor 模式的连接用同样的协议但换个场景服务器会把全场状态推给它# 极简的旁路监听以 monitor 身份连服务器把比赛状态打印出来 import socket, re s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect((127.0.0.1, 3100)) s.send(b(init (rsg/3D) (version 1.0))) s.send(b(monitor)) # 关键monitor 身份不代表任何球员 while True: data s.recv(8192).decode(errorsignore) m re.search(r\(GS.*?\(pm (\w)\), data) if m: print(playmode:, m.group(1), | bytes:, len(data))monitor身份的好处是不占用球员号码、不参与物理仿真纯旁听。如果打印出来playmode一直是before_kick_off说明比赛没开始需要在服务器控制台手动开球或者等计时如果一帧都收不到问题在服务器端或端口跟 agent 无关。这个区分很重要能省掉一半的排查时间。另一个手段是数 agent。用一个带unum的普通连接看场上人数或者直接看服务器控制台输出它会打印每个注册的实体。11 个 agent 都注册成功后服务器日志里应该能看到连续的登记记录。4. 核心链路代码一次感知到动作的完整回合把分层讲清楚之后最值得逐段读的是「一帧里到底发生了什么」。这一章按时间顺序走一遍收包解析、状态估计、目标决策、逆运动学求解、指令下发。中间任何一环慢了整帧就会超时。4.1 报文解析与状态更新感知报文是嵌套很深的 S 表达式自己写正则解析很快就会翻车因为括号嵌套深度不固定、字段顺序也不保证。稳妥做法是先做一次轻量的词法切分把(a (b 1) (c 2))变成可遍历的节点树。// 简易 S 表达式解析只做一层 node 抽象够用且够快 struct Node { std::string key; // 节点名如 See GS HJ std::vectorstd::pairstd::string,std::string attrs; // 叶子属性 std::vectorNode children; }; // 逐字符扫描遇到 ( 入栈遇到 ) 出栈 Node parse(const std::string s) { std::vectorNode stack{Node{}}; std::string token; for (size_t i 0; i s.size(); i) { char c s[i]; if (c () { stack.emplace_back(); // 新节点入栈 token.clear(); } else if (c )) { Node done std::move(stack.back()); stack.pop_back(); stack.back().children.push_back(std::move(done)); token.clear(); } else if (c ) { if (!token.empty()) { /* 填 key 或 attr略 */ token.clear(); } } else { token.push_back(c); } } return stack.front(); }这份解析器刻意不做类型转换全部保留字符串转换交给上层按需做。原因很实际同名字段在不同上下文里类型可能不同(n lf)在 FRP 里是左脚在 HJ 里是关节名提前强类型化会绑死解析器的通用性。解析完之后进状态更新这里有两个细节决定成败。一是时间戳服务器给的t是仿真时间不是墙钟时间所有滤波都必须用仿真时间做增量用墙钟会在服务器加速或暂停时全乱二是丢帧处理UDP 不保证送达偶尔少一帧是常态滤波里要有「上一帧状态保持」的分支而不是简单地拿空值覆盖。// 球的简易滤波观测带噪声用一阶低通加重置机制 void BallFilter::push(double dist, double ang, double simTime) { double dt simTime - lastTime_; if (dt 0.2) { // 超过 200ms 没观测直接重置别硬滤波 pos_ observeToGlobal(dist, ang); vel_ {0, 0}; } else { Vec2 obs observeToGlobal(dist, ang); double alpha 0.6; // 近处信观测远处信预测这里按距离分段更好 pos_ pos_ (obs - pos_) * alpha; vel_ (obs - lastObs_) / dt; } lastObs_ pos_; lastTime_ simTime; }alpha按距离分段调是个实用技巧球在 2 米内基本可以信视觉alpha给到 0.8 以上超过 5 米时视觉误差可能到半米alpha要压到 0.3 左右主要靠速度预测推位置等靠近了再收敛。这套做法不优雅但在实机上比正经卡尔曼好调出了偏差也好判断是哪一段的问题。4.2 行为选择与目标点计算状态有了接下来决定「这一帧做什么」。前面提过优先级状态机这里补一个实际的选择逻辑重点看条件判断的顺序Action Decision::tick(WorldModel wm) { // 最高优先级摔倒必须马上自起否则一切免谈 if (wm.torsoHeight() 0.35 || wm.fallDetected()) return recoverAction(); // 门将分支1 号固定不做位置判断 if (wm.unum() 1) return keeper_.tick(wm); // 球在可踢范围内转入踢球技能 if (wm.ballInKickRange(0.9)) { Vec2 target wm.opponentGoalCenter(); return kickAction(wm.ballGlobal(), target); } // 球在视野内但够不着追球 if (wm.ballVisible()) return chaseAction(wm.ballGlobal()); // 都不满足按角色回位 return positionAction(wm.roleBasePoint()); }条件顺序就是策略本身。把摔倒检测放在最前面是因为这类 agent 摔倒后如果不自起会在原地抽搐到比赛结束观感极差而且拖累整队把门将分支提前是因为门将的行动逻辑和场上球员完全不同混在一起会让代码迅速膨胀。追球的目标点不是球心而是球心后方一个固定偏移的位置这样跑到位置时身体朝向已经对准了球门省掉一次转身。偏移量按球速动态调球静止时偏移小一点直接贴上去球在快速滚动时偏移加大提前量给足否则永远追不上。// 追球目标点球心沿「球到己方球门」方向反推一段 Vec2 chaseTarget(const Vec2 ball, const Vec2 ownGoal, double lead) { Vec2 dir normalize(ball - ownGoal); // 从自家球门指向球 return ball dir * lead; // 站在球的后方正对球门 }lead这一项就是「预判距离」低速球给 0.25 米左右高速球给到 0.5 米以上。调这个参数时最好固定其余变量一次只改一个否则你分不清是追球变好了还是踢球动作改了。4.3 逆运动学与关节指令下发最底层的活是把「脚要落在世界坐标某点」翻译成六个关节角。Nao 的腿是 4 自由度加踝关节滚转共 6 个关节解析解存在不需要迭代。// 单腿解析逆运动学给定髋部相对落点求髋、膝、踝角度 bool solveLegIK(double hipX, double hipY, double hipZ, double hipYaw, double hipRoll, double hipPitch, double kneePitch) { // 先在髋部坐标系里算水平面上的偏航和滚转 hipYaw std::atan2(hipY, hipX); double d std::sqrt(hipX * hipX hipY * hipY); // 大腿 小腿构成两连杆余弦定理求膝角 double L1 0.10, L2 0.10; // 大腿、小腿长度按实际机型填 double dist std::sqrt(d * d hipZ * hipZ); if (dist L1 L2) return false; // 够不着交给上层缩目标点 double cosKnee (L1*L1 L2*L2 - dist*dist) / (2*L1*L2); kneePitch M_PI - std::acos(std::clamp(cosKnee, -1.0, 1.0)); // 髋俯仰角由目标方向角和连杆夹角合成 hipPitch std::atan2(hipZ, d) std::acos( std::clamp((L1*L1 dist*dist - L2*L2) / (2*L1*dist), -1.0, 1.0)); hipRoll 0.0; // 简化处理侧向由髋滚转单独补 return true; }这段有几个工程上的坑。一是连杆长度必须和仿真里的模型完全一致差 1 厘米表现在脚上就是几厘米的落点误差长时间走路会缓慢漂移二是返回false的分支必须有处理够不着的时候不能直接下发超范围的关节角服务器虽然会钳位但钳位后步态就崩了正确做法是把目标点往回收再重算三是角度范围和速度限制关节有最大角速度指令跳变太大会直接触发保护表现为「腿突然软了一下」。下发的报文格式很紧凑一帧里把所有关节角打完// 每个 20ms 周期下发一次所有关节打包在一个报文里 std::string buildHingeCommand(const std::mapstd::string,double joints) { std::string cmd (; for (auto [name, angle] : joints) { cmd (HJ (n name ) (ax std::to_string(angle) )); } cmd ); return cmd; // 例(HJ (n hj1) (ax 0.12))(HJ (n hj2) (ax -0.30))... }报文的发送节奏必须和感知周期对齐收到一帧就发一帧不要自己另起定时器。用独立定时器的话一旦网络抖动收发节奏错位步态周期就会漂走起来像踩棉花。5. 避坑与排查二进制包运行时最容易翻车的五类问题可执行包最大的问题是「静默失败」出错了没日志没提示只能靠外部现象反推。下面这五条是实际跑这类老包时反复遇到的按「现象 → 原因 → 解决」记下来比看文档管用。5.1 运行即退出报文件不存在但文件明明在现象./agent_bin执行后立刻返回报No such file or directoryls能看到文件确实在。原因这不是文件缺失是动态链接器缺失。32 位二进制在现代 64 位系统上找不到/lib/ld-linux.so.2内核在 exec 阶段就失败了错误码被统一包装成了这个极易误判的提示。解决先readelf -l ./agent_bin | grep interpreter确认需要的链接器路径再装对应架构的兼容运行库。装完用ldd复查确认所有依赖都能解析。这一步没通之前不要往下折腾配置文件。5.2 Agent 连上了但场上没人现象程序正常跑没有任何报错服务器日志里也看不到新实体。原因握手三步没走完。最常见的是只发了init和scene漏了team或者team报文里的队名带了空格、号码超范围。服务器对格式错误的报文是静默丢弃的不会回错误。解决临时把发包内容打印出来逐条比对协议格式用 monitor 旁路连接观察场上实体数量变化。另外确认scene指定的模型文件路径在服务器端存在模型路径是服务器解析的不是 agent 本地。5.3 全员站桩一到开球就摔倒现象before_kick_off阶段一切正常一进入play_onagent 集体前倾摔倒。原因初始站位没做姿态收敛就直接起步。before_kick_off阶段应该用beam把自己摆正并把关节角归位然后等状态切换。如果直接带着上一阶段的残留关节角起步第一步就是超范围的。解决在状态机里给play_on加一个短过渡先做 200ms 的站立姿态收敛再进行为树同时确认beam只在允许的阶段使用。5.4 走位抖动像在原地抽搐现象agent 能走但路线是锯齿形的速度上不去。原因控制周期和感知周期没对齐。要么是另起了定时器发指令要么是解析耗时偶尔超过 20ms 导致丢帧步态相位被打断。解决把发送动作放到「解析完这一帧」之后同步触发别用独立线程定时。再用打点计时看一下单帧平均耗时如果接近 15ms 就要考虑优化解析别等到抖动出现才查。5.5 比赛后期集体掉线现象开局十几秒正常之后 agent 陆续失联。原因心跳或指令发送被阻塞。典型情况是日志写得太多磁盘 IO 把主循环卡住了也有人是在决策里加了耗时的全局搜索偶尔一次超时就被服务器判定掉线。解决日志改成异步写或者降采样写任何可能慢于 1ms 的操作都挪出主循环。调试期把关键耗时打出来稳定在 5ms 以内再上比赛配置。6. 二次开发在可执行包边上挂一层自己的决策拿到二进制包想改逻辑正路当然是找源码重编译但很多时候源码不好搞或者编译链对不上。这时候一个非常实用的做法是不动二进制在中间架一个 UDP 代理把 agent 和服务器之间的报文截下来感知流里掺自己的东西动作流按自己的策略改写。这样你能在不重编译的前提下验证一部分决策想法。# 影子代理agent 连它它连服务器双向转发并可注入 import socket, threading, re SERVER (127.0.0.1, 3100) def pipe(src, dst, injectNone): while True: data, _ src.recvfrom(8192) if inject: data inject(data) # 在这里改写报文 dst.sendto(data, SERVER if dst is up_sock else addr_of_agent) up_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 对服务器 # 关键点代理要维护「哪个 agent 地址」的映射11 人满编时用 unum 做键我用这套办法验证过角色分配策略感知流原样透传只在动作流里按自己的规则覆盖「回位目标点」其余照旧。好处是失败影响面小出问题把代理摘掉就回到原始行为等于给自己留了后悔药。验证方法要可量化别靠观感。我会先固定一个场景——比如开球后球滚向左路——记录 30 次里 agent 到达球的时间中位数改之前和改之后的数字放在一起对比指标原始 agent改回位策略后到达球时间中位数2.8s2.1s触球成功率62%71%摔倒次数30 次43三项同向变好才算有效只有一项变好通常是噪声。改的时候一次只动一个参数改完至少跑 30 次不然你无法区分运气和改动。真正要长远维护还是建议走「源码 自己的决策层」的路子把二进制的行为当成基线自己在旁边写一个独立进程通过团队通信跟基线球员协同先跑通通信再逐步接管。这样每一步都有对照组不会出现一改就崩、连崩在哪都不知道的局面。从那以后我每次动这类实时系统都会先把单帧耗时和掉线率打出来作为固定的前置检查项无论改的是步态还是角色分配这两个数字不变才往下走。希望帮到你。本文还有配套的精品资源点击获取