☰
RoboCup救援仿真入门:从代码包到多智能体决策调试
2026/10/9 18:00:05 网站建设 项目流程

简介:RoboCup仿真救援代码是一份面向RoboCup Rescue仿真竞赛的Java项目源码,适合参赛队伍、机器人爱好者和想要入门智能体救灾编程的开发者。压缩包共43个文件,其中42个为Java源文件,另有1个编辑器备份文件,整体大小仅74KB,代码规模不大但模块划分清楚。工程内按救援中心、基础对象、行为控制、路径规划、工具类与主程序等职责组织,涵盖仿真环境交互、传感器信息处理、搜索避障、通信协作和自主决策等核心模块,能直观看到一套轻量救援Agent的基本骨架。通过学习这些源码,既可以理解RoboCup Rescue的常见程序结构与赛题思路,也能借鉴Java实现中地图感知、路径规划和行动调度之间的协作方式。目前已有1574人学习,适合用来快速入门仿真救援开发或作为比赛项目的参考底稿。

1. 仿真救援代码包:跑通demo只是拿到入场券

第一次拿到这份 Robocup 仿真救援代码时,多数人以为装好就能看到救援动画,结果启动后只有一堆控制台日志,界面灰得像没睡醒。真实的救援仿真核心不是画面,而是一个按时间片推进的多智能体决策擂台:kernel 维护城市状态,消防、警察、救护三类 agent 通过消息与它交互,在火灾蔓延、楼宇崩塌和通信丢包中做协作决策。代码包把这条链路完整串了起来,适合做多智能体课程设计、毕业课题或算法验证的新手照着改,也适合熟手快速搭出自己策略的测试台。别把它当游戏包,它是一具能让你反复调试决策逻辑的沙盘。

2. 仿真内核与智能体接入:先搞清消息链路再写决策

Robocup 救援仿真与大多数单机模拟不同,它把世界状态维护和决策执行拆成了两个部分:kernel 负责推进仿真时间片,内部挂接交通仿真器、火灾仿真器、路障仿真器、崩塌仿真器等模块,每次时间片更新建筑燃烧状态、车辆位置、平民掩埋情况。agent 则是独立进程,通过 TCP 连接 kernel,用一套长度编码的消息协议完成感知与命令交互。

这个拆分初看别扭,用顺了反而会感激它:kernel 崩了不影响 agent 的日志,agent 卡住了也不会把世界状态拖垮。我一般会在调 agent 逻辑时把 kernel 跑在另一个终端,这样既能盯着世界日志,又能随时重启 agent 而不重置整场模拟。动手之前,先把三条链路刻在脑子里:感知链路是 kernel 主动向 agent 推送消息,命令链路是 agent 向 kernel 发送动作指令,通信链路是 agent 之间通过 radio 或 voice 通道交换信息,带宽和频率受限,需要自己做消息节流。

2.1 kernel与agent分离运行:为什么“黑匣子”反而更好调

很多第一次接触这份代码包的人会问:为什么不把 kernel 和 agent 写进同一个进程里?常见做法是把它们合成一个程序,启动、运行、结束都在一起,单单省去端口配置这一步。这样看起来简单,实际跑两轮就会发现“后悔药”没了:agent 策略写崩了,整个进程一起挂,连世界日志都没有,你根本说不清是 kernel 的火灾模型出了问题,还是 agent 的决策在错误的时间片发了错误指令。

所以这份代码包一开始就把两者拆开,kernel 只做一件事:推进世界状态并广播感知。它不做任何主观判断,不替 agent 决定去哪灭火、怎么清障、救谁。所有“智能”都发生在 agent 侧。这带来的调试价值非常大,你可以把同一份世界日志拿来反复喂给不同的 agent 策略,比较它们在完全相同城市状态下的行为差异。

启动方式上,我习惯用两个终端分别操作,下面是一个稳定的启动脚本骨架。

# 终端A:启动kernel服务,读取城市地图与仿真配置 cd simulator java -cp bin:lib/* rrs.kernel.Kernel \ --kernel.config config/kernel.conf \ --kernel.randomseed 20240521 # 终端B:启动单个agent,连接同一份配置里的kernel cd agent java -cp out:lib/* com.example.rescue.SimpleRescueAgent \ --host 127.0.0.1 \ --port 7000

--kernel.config是 kernel 的配置入口,地图文件、时间片长度、通信损耗、路障生成概率都在这份.conf里控制。--kernel.randomseed 20240521是调试期最重要的参数,固定随机种子才能让火灾蔓延轨迹可复现,后面做策略对比实验时这个值必须保持不变。agent 的--host和--port要和 kernel.conf 里配置的 KernelPort 对齐,端口对不上是启动期最高频错误,后面避坑章会专门说。

2.2 最小Agent骨架:把“感知-决策-执行”闭环走通

拿到这份代码包,我建议你第一件事不是去研究比赛策略,而是先写一个什么都不做、只会原地转圈的 agent,把消息收发链路打通。这个最小骨架能跑通,后面加策略才有意义。

代码包里已经包含了消息收发的基础类,你需要继承基类并实现主循环。下面是最小实现。

public class SimpleRescueAgent extends Agent { private int entityID; @Override public void init(int entityID, List<EntityID> team) { // kernel连接成功后,把本实体的ID和同team的实体列表存下来 this.entityID = entityID; } @Override public void think(int time, int deadline) { if (time == 1) { // 第一轮必须先建立连接,否则kernel认为agent未就绪 send(new AKConnect(entityID)); return; } // 读取本轮感知结果 SenseResult ps = getSenseResult(); if (ps == null) { return; } // 简单策略:把自己能看到的着火建筑打印出来,不做任何行动 for (Building b : ps.getBuildings()) { if (b.getFireCode() > 0) { System.out.println("time=" + time + " building=" + b.getId() + " fire=" + b.getFireCode()); } } // 不发送任何动作命令,agent原地等待 } }

这段代码的关键是think方法的调用节奏:kernel 每个时间片到来时调用一次,time是当前世界时间,deadline是这次决策的截止时间戳。getSenseResult拿到的感知数据只对当前时间片有效,不能缓存太久。第一次运行后你会看到 kernel 终端里打出一系列连接确认日志,agent 终端里开始周期性打印着火建筑,说明消息链路已经通了。

在此基础上,你需要了解动作指令的消息类型。这份代码包支持以下核心命令:

消息类动作使用方
AKMove移动到目标道路所有车辆
AKExtinguish对建筑喷水灭火消防队
AKClear清除道路路障警察
AKRescue从废墟中救出平民救护队
AKLoad将平民装载到救护车救护队
AKRest本时间片原地休息所有实体

每个动作命令都必须在明确的条件下发送:灭火命令要有目标建筑和剩余水量的判断,清障命令必须确认自己与道路距离满足范围。它们之间没有全局协调机制,协调完全靠 agent 间的通信完成,这就是后面章节要解决的真实难点。

3. 救援决策模块落地:任务分配、路径规划与通信受限处理

跑通最小骨架后,代码包的价值才真正开始浮现。通常团队里会同时运行多个消防队、多个警察、多个救护队,它们看的是同一个世界,却各自维护一份感知副本,决策质量完全取决于三个模块:任务分配、路径规划、通信策略。这三个模块也是我拆这份代码包时最花时间的部分。

3.1 消防任务分配:贪心覆盖为什么比全局优化抗干扰

比赛场景里最常见的困境是:一栋高级建筑着火,旁边几栋平民建筑也在烧,消防队一共有三支,水罐容量还不同。很多新手第一版会写一个“全局最优分配”,把所有火场和所有消防队建模成线性规划,算出谁去哪。

但仿真环境里火势每时间片都在变,道路阻塞也在变,全局最优往往算完就已经过期。我在代码包里调试时发现,贪心分配配合动态重算是实践中最稳的方案:每轮都对当前燃烧建筑打一次分,再按分数从高到低为消防队分配目标,分配完成后把目标从待分配列表移除。

// 威胁度打分:火势级别 + 建筑价值 + 倒塌风险 - 路径距离 private double score(Building b, FireBrigade fb) { double fire = b.getFireCode() * 2.0; double value = b.getBuildingValue(); double collapse = b.getCollapseRisk(); double dist = 0; try { dist = getPathDistance(fb.getPosition(), b.getPosition()); } catch (NoPathException e) { dist = 1e9; // 路径不可达时给最大惩罚 } return fire + value * 0.1 + collapse * 1.5 - dist * 0.01; } // 每轮重新计算分配 private Map<FireBrigade, Building> assignTasks(List<FireBrigade> brigades, List<Building> onFire) { List<Building> pending = new ArrayList<>(onFire); pending.sort((b1, b2) -> Double.compare( score(b2, brigades.get(0)), score(b1, brigades.get(0)))); Map<FireBrigade, Building> assign = new HashMap<>(); for (FireBrigade fb : brigades) { if (pending.isEmpty()) break; Building target = pending.remove(0); assign.put(fb, target); } return assign; }

代码里最值得注意的参数是dist * 0.01这个惩罚系数。它太小会让消防队跨越大半个城市去救一栋高分建筑;它太大会让消防队只盯着脚边的低火势建筑,错过关键目标。我调试时通常先固定其它系数,把距离惩罚从 0.005 按 0.005 的步长往上加,观察从“到达时间”和“扑灭数量”两个指标选阈值。

这个模块有三个边界条件需要盯紧。第一,距离必须是路径距离而不是欧氏距离,地图里两栋建筑直线距离很近,中间却可能隔着一堵无法穿越的墙。第二,水罐容量不是无限的,sendExtinguish(entityID, buildingID, water)里的 water 参数是本次动作的水量,分配时要预留回火场补水站的返程水量,否则灭火到一半断水,火势会反弹。第三,每轮都需要重新调用 assignTasks,不能只分配一次,因为火灾蔓延会改变目标优先级。

3.2 路径规划:把道路阻塞系数塞进启发式代价

消防队决定去哪个火场之后,接下来是“怎么去”。很多新手的第一个直觉是找最短路径,但救援仿真的路网里遍布路障,还有大量车辆在跑,最短路径常常是拥堵最严重的路径。到后期警察清障会产生新的可用道路,纯粹按地图静态长度算路径会非常吃亏。

我在这份代码包里常用的方案是给 A* 的代价函数加上道路状态动态项。每次计算移动代价时,把目标道路上的路障数量和车辆数量折算进代价,让路径规划自然避开堵点。

class RoadNode { int roadId; int lane; List<RoadNode> neighbors; double baseLen; } private double movementCost(RoadNode from, RoadNode to, WorldState s) { double cost = to.baseLen; cost += s.getBlockageCount(to.roadId) * 3.5; // 路障越多,代价越高 cost += s.getVehicleCount(to.roadId) * 0.8; // 拥堵惩罚 if (from.lane != to.lane) { cost += 2.0; // 变道惩罚,避免频繁换道 } return cost; }

路障权重3.5是我调试地图时的经验值,地图大且警察多的场景可以降到2.5,因为路障会被快速清理,不必过度绕行。拥堵权重0.8在宽马路上需要调低,在窄巷子密集的地图可以调到1.2。变道惩罚 2.0 是为了抑制 agent 在相邻车道上反复横跳,这类小幅震荡会让移动轨迹看起来像喝醉了。

另一个容易忽略的问题是路径缓存。同一个队伍里多个 agent 同时做路径规划,如果各自算各的,每个时间片都会产生大量重复计算,时间片 deadline 会非常紧张。我一般会在共享内存里维护一个buildingId -> path的缓存,只有当目标建筑上的路障状态发生显著变化时才重新规划,否则沿用缓存路径。

3.3 通信受限下的信息融合:控制广播周期与消息大小

救援仿真的通信模型刻意做了带宽限制,agent 之间不能无限发送消息。很多策略在本地跑得好好的,一放进完整仿真里就“哑火”,因为重要消息把带宽占满,普通消息全部排队丢弃,队内决策所需的全局信息根本传不到。

代码包自带的通信库支持 Radio 与 Voice 两种通道,Radio 带宽更宽但有延迟,Voice 实时性更好但容量更小。我在这份代码包里落地的是三层节流方案:高优先级消息(着火建筑、平民被救出)第一时间广播;中优先级状态(车辆位置、剩余水量)合并到固定周期广播;低优先级消息只保存本地,不进入带宽消耗。

// 每个实体记录上次广播的时间片,按周期合并发送 private Map<Integer, Integer> lastBroadcast = new HashMap<>(); private final int BROADCAST_INTERVAL = 10; public void maybeBroadcast(int time, Building b) { int last = lastBroadcast.getOrDefault(b.getId(), 0); if (time - last < BROADCAST_INTERVAL) { return; } // 把多个字段拼成一条消息,减少发送次数 String payload = String.format("FIRE %d %d %d %d", b.getId(), b.getFireCode(), b.getBuildingValue(), time); sendRadioMessage(payload.getBytes()); lastBroadcast.put(b.getId(), time); }

广播周期 10 个时间片是我常用的起步值,带宽紧张的地图可以调到 20,关键灭火阶段可以临时降到 3。注意,广播周期的本质是用“信息新鲜度”换“带宽占用”,周期太大可能出现两个 agent 同时奔向同一个火场,因为它俩各自缓存的状态不一样。我建议在关键建筑上做一次短周期广播,普通建筑用长周期,而不是全城一视同仁。

4. 仿真跑起来总是翻车:五条避坑排查记录

这部分是血泪经验。拆这份代码包的过程中,我在启动、连接、移动、灭火、复现五个环节都踩过坑,每一条都是先看到现象、再查半天源码才发现原因,现在一次说清楚。

4.1 第一次启动就崩溃:端口和配置文件不对齐

现象:kernel 正常起来了,agent 一启动就抛 SocketException,连接被拒绝。

原因:kernel.conf 里配的 KernelPort 是 7000,而 agent 默认连接的是 7001;或者上一轮 kernel 进程没有退出,端口还被占用。

解决:先执行netstat -ano | grep 7000查看端口占用情况,再核对 agent 启动命令里的--port与 kernel.conf 里的 KernelPort 是否完全一致。我现在的习惯是启动脚本开头加一段端口检查,端口被占就直接报错退出,而不是等到连接超时才意识到问题。

4.2 agent 连接上了却收不到感知消息

现象:连接日志显示成功,AKConnect 已发送,但 think 里的 getSenseResult 一直返回 null。

原因:握手顺序不对。某些代码包版本要求第一轮发送 AKConnect,第二轮立即发送 AKConnectToServiceAgent,订阅感知通道。漏掉注册消息,kernel 会把该 agent 当作离线实体,不推送任何感知。

解决:在 time == 1 时依次发送连接类消息,time >= 2 后再读取感知结果。调试时在 getSenseResult 返回非空的第一时间打印时间戳,以此确认感知通道已建立。

4.3 车辆原地打转不走直线

现象:sendMove 的目标道路是对的,但车辆在同一路段上来回折返,路线图看起来像在画正弦曲线。

原因:移动命令里的 heading 是绝对方向角,很多实现只在开始移动时计算了一次角度,转弯之后没有用当前位置重新计算方向。

解决:每一步移动前,用当前位置到道路中心点的连线方向重新计算 heading,再传给它。速度也要按道路限速折算,不能总是给最大速度,否则会在拐弯处冲过目标点。

4.4 灭火指令下了但建筑火势不见小

现象:extinguish 连续发送,日志里显示命令被 kernel 接收,但建筑火势还在涨。

原因:大概率是三种情况之一。第一,消防车水罐水位已经空了,命令被 kernel 忽略;第二,火势级别超过单辆消防车的灭火能力上限,必须多辆同时喷;第三,消防车不在有效射程内,灭火动作要求与目标建筑相邻或处于同一道路。

解决:先查看实体水位日志确认水罐容量;再确认目标火势级别,超过上限就并发分配多辆消防车;最后检查当前车辆位置与目标建筑的路径距离是否在射程范围内。连续灭火时建议每轮都重新确认这三个条件,因为范围会随着移动变化。

4.5 同一个策略换了机器结果就对不上

现象:同一份策略代码,在一台机器上稳定跑出成绩,换台机器重装环境后结果完全不一样。

原因:kernel 没有固定随机种子,火灾蔓延和路障生成使用随机因子;agent 内部还用了依赖系统时钟的随机数。

解决:kernel 启动时固定randomseed;agent 里所有随机数统一用固定种子创建Random实例;同时保留完整 kernel 配置和世界日志目录,回放时用同一份配置。这样至少能保证同一策略前后两次运行在同一张地图上产生可对比的轨迹。

5. 让仿真结果可复现:数据录制、回放与一份算分脚本

代码包的价值不只在跑通策略,更在于能让你反复验证“这个改动到底有没有进步”。我最常用的进阶套路是固定随机种子,录制世界日志,再用脚本算分对比。

kernel.conf 里有几个参数值得你每次实验前先确认:

参数作用调试建议
randomseed控制随机序列固定为一个整数,禁止为0
logdir世界日志输出目录每次实验前清空
timestep每个世界时间片对应毫秒保持默认,不要改
mapfile城市地图文件对比实验全程使用同一张地图

启动 kernel 后,代码包会把每个时间片的完整世界状态写入logdir目录,包括建筑火势、车辆位置、平民状态。回放器读取这个目录,能在不启动 agent 的情况下重建整场模拟过程。我调试时经常做的一件事是:从日志里换上某个时间片,手动模拟一条命令发送,看 kernel 会不会接受、世界状态会怎么变。

最后放一份我常用的算分脚本,它不算比赛官方积分,但足够衡量策略相对进步:

import csv import json from glob import glob alive = 0 dead = 0 saved = 0 avg_fire = 0.0 building_count = 0 for f in glob("logs/world/state_*.csv"): with open(f) as fh: reader = csv.DictReader(fh) for row in reader: if row["type"] == "civilian": if row["state"] == "ALIVE": alive += 1 elif row["state"] == "DEAD": dead += 1 elif row["state"] == "SAVED": saved += 1 elif row["type"] == "building": avg_fire += int(row["fire_code"]) building_count += 1 print(json.dumps({ "alive": alive, "dead": dead, "saved": saved, "avg_fire": round(avg_fire / max(building_count, 1), 2) }, indent=2))

脚本里alive是当前还活着的平民数,saved是已经转移到避难所的平民数,dead是死亡数,avg_fire是全体建筑平均火势级别。我每次改策略前,都会先跑一局基准局记录这三个指标,再跑改动后的版本。只看官方总分会把策略进步藏进随机波动里,但这三列数据能让每次改动的好坏一目了然。

有一阵我为了让消防队更快到火场,把路径规划里的拥堵权重直接调成了 0,结果所有车全堵在主干道上,整场比赛多救出来的人数是零。从那以后我每次改策略,都强制走一遍“固定种子 → 录制世界日志 → 回放确认命令偏离 → 算分对比”的完整流程,再往下动下一种策略。这份代码包的骨架非常适合做这种对照实验,agent 进程重启多少轮都不会污染世界状态。如果你也需要在仿真环境里验证你自己的多智能体策略,直接拿这套骨架改,能少走很多弯路,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询