简介:这份2012年RoboCup 3D仿真足球赛冠军队伍的参赛可执行代码出自南京邮电大学代表队,面向机器人足球仿真、多智能体协同与强化学习方向的学习者和参赛队伍,可用于复现冠军球队的决策与协作策略、对照研究其场上行为逻辑。压缩包共175个文件、约29.13MB,主体是76个rsg行为脚本与69个svn-base版本备份文件,另有format、entries、all-wcprops等Subversion元数据各5份,5个rb与4个sh脚本承担构建与运行辅助工作,并附带apollo3d仿真环境相关文件与libruby动态库,保留了原始工程目录结构。目前已有899人学习下载。借助这些脚本可拆解球员移动、传球、射门与防守的策略组织方式,理解多智能体协同与实时通信的实现思路,为组建RoboCup 3D队伍或开展自动驾驶、无人机编队等多智能体课题提供可参考的工程样本。
1. 一份 2012 年的 3D 仿真足球冠军 agent,拿到手先别急着点开
如果你手里正好有这个包——2012 年 3D 仿真足球竞赛(SimSpark 平台)冠军队伍的 agent 可执行代码,基于 apollo3d 框架——那么第一件值得提醒的事是:它并不是一个“双击运行”的东西。它只是一个客户端,只有服务端先启动并推进物理世界,它才能感知、决策和行动;服务端不启动,它什么也做不了。很多人第一次打开就是./xxx &,然后盯着空终端等日志,最后把包删了。
这份代码真正的价值,在于它保留了从 UDP 感知解析、世界模型、定位、行为决策到关节控制的完整链路,而且很多队伍当年的实现都只到了“能跑”这一步,并没有完全收尾。你拿到的不是一个教学 Demo,而是一份面向比赛的工程实现。适合两类人:想搞清楚 3D 仿真足球 agent 到底怎么搭骨架的,以及想基于一个能跑通的底座,做多智能体协作、定位滤波、行为树实验的。后面几章我按“先能连上、再看懂数据、最后改得动”的顺序拆。
2. SimSpark 通信骨架:先让 agent 说上第一句话
这套系统的通信模式比很多人想象的简单,也比很多人想象的脆弱。简单在于它只是一个 UDP 端口加一种 s-expression 文本协议;脆弱在于,一旦周期错位或端口没对应上,你的 agent 就会安静地站着,既不会报错,也不会提示。
2.1 运行时拓扑:服务端推进世界,客户端只是无状态应答器
SimSpark 的服务端(通常叫 spark 或 rcssserver3d)是一个单进程程序,它维护整个球场的物理状态,按固定步长推进。每个 agent 客户端通过 UDP 连上去之后,服务端每个周期会给它推一条感知串,客户端回一条命令串。注意这两个动作是配对的:这一周期收到的(sense ...)描述的是上一周期结束时的世界,你回的命令影响的是下一周期。
关键结论是:agent 侧是有状态的,协议侧近乎无状态。服务端不会记住你上一周期发了什么,它只按当前关节角度做物理积分。所以像“我上一周期已经抬腿了,这一周期不用再发”这种省事的想法会直接导致动作丢失。踩过这个坑的人都懂:机器人会一抖一抖地走,因为你只发了一半的指令。
端口的那点事也值得说明清楚。默认服务端监听在 3100 附近,一个服务端可以挂几十个 agent,每个 agent 用(unum N)区分号码。监控端(monitor)是另一个独立客户端,它只收不发,用来肉眼看比赛。如果你只起了 agent 没起监控,那看日志就成了唯一的排查手段,这也是后面第 5 章要重点讲的。
2.2 目录结构与编译:先把二进制弄出来
这类 apollo3d 系的工程,目录通常长成下面这个样子(不同队伍的裁剪程度不同,但骨架高度相似):
| 目录 | 职责 | 是否可直接替换 |
|---|---|---|
framework/ | 主循环、网络收发、消息分发 | 不建议动 |
worldmodel/ | 感知对象、球场坐标系、队友模型 | 可扩展 |
localization/ | 自身位置估计、滤波 | 重点改造区 |
action/ | 关节控制、技能封装 | 按需改 |
behavior/ | 行为层、决策层 | 重点改造区 |
formation/ | 阵型定义、角色站位 | 配置文件 |
utility/ | 数学、日志、参数解析 | 一般不动 |
编译这一步,血泪经验是:先看 Makefile 里写死的编译器和 C++ 标准。2012 年前后的代码大多是 C++03 风格,用-std=c++17编会在一堆register关键字、auto_ptr、隐式char*转换上翻车。我一般会先做一次不优化的编译,把错误一次性看全:
# 1) 先只编译不优化,错误信息更可读 make clean make CXXFLAGS="-g -O0 -Wall" 2>&1 | tee build.log # 2) 只统计错误类型,避免被几百行输出淹没 grep -E "error:" build.log | sed 's/.*error: //' | sort | uniq -c | sort -rn | head -20 # 3) 常见依赖缺失时,先确认链接器到底缺哪个库 ldd ./bin/agent 2>/dev/null | grep "not found"第一段命令的意图是把-O2关掉,因为优化后的模板报错会牵扯出十几层实例化,根本没法读。第二段是把错误按类型聚合,如果前几条全是‘auto_ptr’ is not a member of ‘std’,那结论很明确——加-std=c++03或者逐个替换,而不是去改业务代码。第三段是运行期检查,ldd报not found说明动态库路径没配好,这时候设置LD_LIBRARY_PATH比重新编译快得多。
参数上还有一个容易忽略的点:编译产物的输出路径。很多老工程把bin/写死在 Makefile 里,make成功但你在当前目录找不到可执行文件,误以为失败。先grep -n "BIN\|TARGET" Makefile确认一下,比瞎猜省时间。如果工程里带了libb64、boost之类的第三方依赖,注意它们通常是以源码内嵌方式放在utility/或lib/下,不需要系统级安装,但需要确认子目录也执行了make。
2.3 三十行 Python 验证连通性:看清楚到底在收发什么
在动 C++ 之前,我强烈建议先用一个最小客户端确认“服务端起没起、协议通不通”。这一步能把问题范围从“代码有 bug”缩小到“环境有问题”,价值极高。
import socket HOST, PORT = "127.0.0.1", 3100 TEAM, UNUM = "Probe", 1 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(3.0) # 第一步:报名。服务端据此分配号码,返回的 unum 才是真正生效的 sock.sendto(f"(init (unum {UNUM})(teamname {TEAM}))".encode(), (HOST, PORT)) reply, _ = sock.recvfrom(65535) print("INIT:", reply.decode(errors="ignore")[:200]) # 第二步:主循环。每收一条 sense 就必须回一条命令,否则关节保持不动 for tick in range(30): try: data, _ = sock.recvfrom(65535) except socket.timeout: print("timeout at tick", tick) break msg = data.decode(errors="ignore") if tick < 2: print(f"--- tick {tick} len={len(msg)} ---") print(msg[:500]) # 只是保持一个合法命令串,证明链路是双向的 sock.sendto(b"(HJ (n laj) (ax 0.0))", (HOST, PORT))逻辑说明:(init ...)必须最先发,且服务端会回一条确认,里面的unum才是你真正拿到的号码——如果你请求的号码已被占用,服务端会另分配一个,很多“我的 agent 不动”就是号码被顶掉了。主循环里每周期必须调用一次recvfrom,因为服务端是持续推送的,你不收,缓冲区会堆满旧数据,之后收到的全是过期的世界状态,看起来就像“延迟越来越大”。
参数说明:HOST填服务端所在地址,本机对局就是127.0.0.1;PORT必须和服务端启动参数完全一致,差一位都连不上,而且 UDP 不会给你“连接被拒绝”这种友好提示,只会超时。TEAM建议用纯 ASCII、不带空格,很多老版本解析器对空格和大小写敏感。UNUM取 1 到 11 之间的整数,超过队伍上限会被拒。
跑通这一步你会看到两类输出:如果第一行就有(init (unum 1)(teamname Probe))之类的回包,说明握手成功;如果直接 timeout,那就是服务端没起或者端口不对。第二种情况下不要去改 C++ 代码,先去确认服务端进程和端口。
2.4 命令串的组装顺序:为什么你的 agent 收到了数据也不动
握手通了之后,下一个坑是命令格式。s-expression 的容错性很差但不是零,少一个右括号往往不会报错,只会被静默丢弃。一个周期内的命令大致分三类:关节控制(HJ)、通信(say)、重置(beam)。它们拼在一个字符串里发出去。
顺序上有个实践约定:先发关节,再发say,最后如果需要重置再发beam。原因是部分服务端实现会按顺序处理,beam会把位置直接改掉,如果放在关节前面,那一周期的关节指令作用在新位置上,看起来就像瞬移加抽搐。beam只在开球重置或者调试时用,正常比赛周期不该出现。
还有一个细节是角度单位。这套协议里关节角度基本用度,而绝大多数 C++ 数学库默认弧度。我见过不止一个工程在action/里混用,表现是“走路姿势诡异但不报错”。定位这类问题的办法很土但有效:把目标角度和实际角度都打进日志,跑十个周期对着看,量级差 57 倍就是单位问题。
3. 世界模型与定位:把一帧感知变成能用的坐标
能收发只是及格线。真正决定 agent 水平的是第二阶段:把每周期那几百字节的感知串,变成“球在哪、我在哪、队友在哪”这三件事。这一层做不好,后面的行为决策全是空中楼阁。
3.1 从 See 数据到球场坐标:极坐标解算的三个易错点
感知串里描述物体的部分通常是极坐标三元组:距离、水平角、垂直角。转成球场笛卡尔坐标的公式本身不复杂,但工程上有三个点年年有人踩。
第一个是角度单位。协议里给的是度,转三角函数前必须乘π/180。第二个是垂直角符号。多数实现里垂直角向下为正,而球场坐标系 z 轴向上,所以 z 分量要取负。第三个是水平角的参考方向。它不是世界坐标系的 x 轴,而是 agent 自身的朝向,所以解算出来的只是“相对自身”的坐标,要再叠加自身位姿才能进世界坐标系。
struct Polar { double dist, theta, phi; }; // theta/phi 单位:度 // 极坐标 -> 自身坐标系下的笛卡尔坐标 Vec3 polarToLocal(const Polar& p) { const double k = M_PI / 180.0; double th = p.theta * k; double ph = p.phi * k; double r = p.dist * std::cos(ph); // 水平投影距离 return Vec3(r * std::cos(th), // 前向 r * std::sin(th), // 左向 -p.dist * std::sin(ph)); // 垂直角向下为正,取负 } // 自身坐标 -> 世界坐标,pose 为定位模块的输出 Vec3 localToWorld(const Vec3& local, const Pose2D& pose) { double c = std::cos(pose.yaw), s = std::sin(pose.yaw); return Vec3(pose.x + local.x * c - local.y * s, pose.y + local.x * s + local.y * c, local.z); }这段代码的意图是把“解算”和“坐标变换”拆成两个纯函数,好处是任何一个环节出问题都能单独写单测验证。参数说明:dist单位是米,theta、phi单位是度;Pose2D只需要平面位姿(x、y、yaw),因为球场是平的,z 主要影响球的判断。yaw的零位定义必须和polarToLocal里的前向定义一致,否则会出现“球在正前方但算出来在左边”这种经典错位。
还有一个工程上的取舍:要不要做对象关联。每周期看到的球只有一个,好办;但看到的球员有十几个,你得判断哪个是队友、哪个是对手、哪个是上一周期的同一个。常见做法是用号码(如果对手不匿名)+ 距离最近匹配,简单粗暴但够用。apollo3d 系里通常会有一层WorldModel::update,把这一堆匹配逻辑收在一起,改的时候集中改一个地方,别到处散。
3.2 定位:为什么有的队伍不靠复杂滤波器也能稳
定位模块是整个工程里最“玄学”的部分。简单说,你要在只有带噪声的相对观测、没有 GPS 的条件下,估计自己在球场上的绝对位置和朝向。
可用的观测有三类:地标线(球场上的白线,能给出到某条线的距离和角度)、角旗和球门柱(已知世界坐标的点)、球和球员(坐标未知,只能做相对参考)。最朴素的做法是三角测量:同时看到两个已知地标,用两条距离画圆求交点,取合理的那一个。这个方法的精度在近距离足够,但在中场附近地标稀疏时会突然跳变。
成熟一点的实现会用扩展卡尔曼滤波做融合:状态量取(x, y, yaw),观测量用地标的距离和方位角,预测步用上一周期的关节指令积分出来的位移。这里有个容易忽略的细节——运动学积分比你想的准,但也比你想的更容易漂。走路时的实际位移和指令位移之间有几十毫秒的延迟,如果你不补偿,滤波会一直在“追”真实位置,表现为位置估计持续滞后。
提示:如果定位出现周期性抖动,先在日志里把“纯运动学积分”和“纯视觉观测”两条曲线都打出来,看抖动来自哪一路。只调滤波参数而不看分解曲线,基本等于盲调。
调试定位的实用办法是开一个“上帝视角”对照:用监控端看 agent 的真实位置,同时把 agent 自己估计的位置打到日志,跑一场十分钟,把两列数据导进表格算误差。误差在 0.2 米以内可以接受,超过 0.5 米说明某一类观测的权重给错了。
3.3 时间对齐:感知是过去时,动作是未来时
第三个容易忽视的点是时间。你这一周期收到的感知,描述的是上一周期结束时的世界;你这一周期发出的关节指令,生效在下一周期。中间隔着至少一个周期,加上视觉更新频率可能低于控制频率,实际延迟可能到两三个周期。
这意味着你在做“球在正前方,向前踢”这种决策时,用的其实是几十毫秒前的球位置。低速时无所谓,高速奔跑和球快速滚动时误差就出来了。修正方式有两种:一是用球的速度外推一个延迟量,二是把决策层设计成“预测-执行”两段,预测未来 N 个周期的球位置再选动作。
apollo3d 系里通常有一个getTime()之类的接口读当前仿真时间,每个周期递增一个固定步长。我习惯在日志里同时记录“仿真时间”和“墙钟时间”,一旦两者比例明显偏离,说明服务端在丢周期或者你的 agent 处理太慢拖累了节奏,这时候要做的不是调策略,是砍掉每周期里的耗时计算。
4. 行为决策:分层拆开,别写成一个大 switch
世界模型稳了之后,才轮到策略。这份代码里最值得学的部分,是它的分层方式——不是因为它多先进,而是因为它把一个本质上很乱的问题切成了几块可独立调试的模块。
4.1 三层结构:决策层、行为层、技能层各管什么
从工程实现看,这套结构大致是:
- 决策层(Decision):每周期判断“我该干什么”,输出一个高层意图,比如“追球”“回防”“站位”“庆祝”。判断依据是角色、球的位置、比分、剩余时间。
- 行为层(Behavior):把高层意图翻译成具体动作序列,比如“追球”下面有“绕到球后方”“接近”“起脚”几个子行为,靠条件切换。
- 技能层(Skill/Action):真正算关节角度的地方,比如“走到某个点”“转身到某个朝向”“踢球”。
分层的价值在于调试时能定位到层。表现是“机器人原地转圈”,如果日志显示决策层一直输出“追球”、行为层一直在“绕后”和“接近”之间跳,那就是行为层的切换条件有抖动;如果决策层本身就每周期换一个意图,那是决策层的判据太敏感。
行为层最容易翻车的地方是切换抖动。条件写成“球距离 < 2 米就接近,否则绕后”,当球正好在 2 米附近晃动时,行为会高频切换,机器人看起来像在抽筋。标准解法是加滞回区间:进入阈值 2 米,退出阈值 2.4 米,中间地带保持当前行为。
// 带滞回的行为切换:避免阈值附近高频抖动 enum class ChaseState { Approach, Circle }; ChaseState nextState(ChaseState cur, double ballDist) { const double kEnter = 2.0; // 进入接近的距离 const double kExit = 2.4; // 退出接近的距离,必须大于 kEnter if (cur == ChaseState::Circle && ballDist < kEnter) return ChaseState::Approach; if (cur == ChaseState::Approach && ballDist > kExit) return ChaseState::Circle; return cur; // 中间地带保持不切换 }这段代码的关键不是逻辑,是kExit > kEnter这个约束。参数怎么定?一般取进入阈值的 1.2 倍左右。太小起不到滞回作用,太大机器人会长时间赖在错误行为里。这个值我给不出 universal 的答案,需要在场上跑几场根据表现微调,但所有行为切换点都应该有滞回这条原则是通用的。
4.2 角色与阵型:把站位写成数据,别写成代码
多智能体配合里,最容易写成硬编码的是站位。新手常写if (unum == 2) target = {2.0, 3.0};,写到第十一个 agent 的时候代码已经没法看了,改一个阵型要改十几处。
成熟实现会把阵型外置成配置文件,按“角色名 + 基准位置 + 跟随对象”描述。比如前锋跟随球的位置横移,后卫跟随球的 x 坐标前后压上。运行时按当前球位置对基准点做平移和旋转,得到一个动态目标点,再交给技能层去走。
| 配置项 | 含义 | 典型取值 |
|---|---|---|
role | 角色名 | 前锋 / 中场 / 后卫 / 门将 |
base_x,base_y | 基准站位(米) | 随阵型不同 |
follow_ball_x | 对球 x 的跟随系数 | 0.2 ~ 0.6 |
min_x,max_x | 前后活动边界 | 防止压过中线或退太深 |
unum | 绑定号码 | 1 ~ 11 |
这张表里的“跟随系数”是调阵型时最常改的参数。调大,全队跟着球跑,阵型被拉扁;调小,球员各自站桩,球从身边过也不管。经验值在 0.3 附近,但要看你的队伍整体移动速度——走得慢的队伍系数要给大一点,否则根本跟不上球。
号码分配这里有个坑:同一个号码被两个 agent 抢占。如果你用脚本批量拉起 agent,没做串行等待,两个进程几乎同时发(init (unum 5)...),服务端会给其中一个改号,而那个 agent 自己不知道,仍然按 5 号的行为逻辑跑,结果就是“场上出现两个 5 号在做不同的事”。解决办法是拉进程时加间隔,或者让 agent 启动后校验一次服务端返回的号码,不一致就退出报错。这种问题排查起来非常费劲,因为日志看起来一切正常。
4.3 参数外置:把能调的都从代码里挪出来
最后一点是工程习惯。这份代码里凡是能在场上调的数值——跑动速度、转身阈值、传球距离、射门角度——都应该在配置文件里,而不是散在behavior/的各个.cpp里。
理由很实在:调参的时候你要反复重启 agent,如果每次都要重新编译,一场实验的迭代周期从 30 秒变成 3 分钟,一天下来能试的方案数量差一个数量级。改成配置文件之后,改完重启进程即可,还能写脚本做参数扫描。
常见做法是启动时读一个agent.ini之类的文本文件,用key=value的格式,解析器自己写三十行就够。注意两点:一是给每个参数一个合理默认值,配置文件缺项时不至于除零;二是启动时把最终生效值打印出来,避免“我明明改了配置但没生效”这种因为路径写错导致的浪费时间。
5. 排错避坑:那些让 agent 站着不动的真实原因
下面五条是我在这类工程上反复遇到的,按“现象 → 原因 → 解决”写清楚,遇到问题时可以直接对号入座。
5.1 编译过了但运行秒退:动态库路径与 ABI
现象:make全程无错,执行二进制立刻退出,终端只留一行Segmentation fault或者什么都没有。
原因:九成是动态库没找到,或者找到的是系统里另一个版本的库。老工程编译时链接的是utility/下的本地库,运行时ldd却指向/usr/lib里的同名库,ABI 不匹配,进main之前就崩了。
解决:先ldd ./bin/agent | grep "not found",有输出就补LD_LIBRARY_PATH;没输出但还崩,用gdb ./bin/agent跑一次,看崩在哪个符号上。如果是 C++ 标准库符号不匹配,那就是编译和运行环境不一致,重新在目标机器上完整编译一遍。
5.2 连上了但不说话:UDP 端口与防火墙
现象:Python 探针能收到(init ...)回包,但正式 agent 启动后服务端日志里看不到它的任何命令。
原因:agent 配置里的端口和服务端不一致,或者 agent 绑定了错误的本地地址。UDP 没有连接概念,发错了地址不会报错,只会石沉大海。
解决:在 agent 的网络收发处加一行日志,把每一帧的目标地址和字节数打出来。如果字节数是 0 或者地址不对,问题就在配置。另外注意有些环境对 UDP 有限制,本机回环一般没事,跨机器要确认端口放行。
5.3 收到感知但解析出空对象:字段顺序与容错
现象:日志显示每周期都收到了几 KB 的数据,但世界模型里的球位置一直是 NaN 或者零。
原因:解析器对字段顺序做了硬假设,而服务端的输出顺序在不同版本、不同视角下会变;或者某个对象类型没被识别,解析指针错位,后面全乱。
解决:别写“跳过固定字节数”的解析,写一个真正的 s-expression 递归下降解析器,把整条消息解析成树,再按名字取字段。这样即使服务端加了新字段也不影响。调试时把原始感知串的前 500 字节打到文件里,对着解析结果逐字段核对,比在代码里打断点快。
5.4 能走但走不直:单位混用与关节零位
现象:机器人能动,但走路明显跑偏,或者每周期都在小幅修正,像喝醉了一样。
原因:角度单位在度和弧度之间混用,或者关节零位和目标角度的偏移量没标定。前者是量级错误,后者是常数偏差,两者表象相似但修法完全不同。
解决:先做单位检查——把某个关节的目标角度设成 90 度,看实际姿态是不是转了四分之一圈。如果明显不是,就是单位问题。如果方向对但总量差一个固定值,那是零位偏移,需要逐个关节标定,把偏移量单独存一张表。
5.5 多开就崩、单开正常:资源与端口竞争
现象:起一个 agent 一切正常,起三个就开始随机崩,或者有 agent 收不到数据。
原因:多个进程共用了同一个日志文件、同一个临时端口或者同一块共享内存。老工程里这种全局资源写死的情况很常见。
解决:所有跟进程相关的路径都带上网号或进程号,日志文件名形如agent_5.log。端口方面,如果每个 agent 需要独立监听端口(有些实现是这样),要按号码偏移分配,不能全用同一个。
5.6 一条固定的排查顺序
上面五条之外,我给一个通用的排查链路,从底层往上走,不要跳步:先确认服务端进程在不在、端口通不通;再确认 agent 能不能收到(init ...)回包;再确认每周期是否收到感知且长度稳定;再确认解析出来的球和自身位置是否合理;最后才看行为层输出。跳过中间任何一步,你都会在错误的地方花时间。
6. 二次开发与验证:让改动有据可依,而不是凭感觉
改这份代码的人,八成会掉进同一个坑:改一点参数,跑一场,觉得“好像好一点”,然后继续改。这种迭代方式效率极低,因为 3D 仿真足球的随机性很强,一场比赛说明不了任何问题。我后来固定了一套验证流程,跑起来不复杂但很有用。
第一步是批量对局。写个脚本,用固定随机种子启动 N 场,每场自动跑完并把日志落到独立目录:
#!/usr/bin/env bash # 批量跑 N 场,每场独立目录,便于事后对比 N=${1:-20} for i in $(seq 1 "$N"); do dir="runs/exp_${i}" mkdir -p "$dir" # 服务端:固定端口 + 记录所有感知 ./bin/server --port=$((3100 + i)) --log="$dir/server.log" & srv=$! sleep 1 # 拉起多个 agent,注意加间隔避免号码抢占 for u in 1 2 3 4 5; do ./bin/agent --host=127.0.0.1 --port=$((3100 + i)) \ --unum="$u" --team=A --log="$dir/agent_${u}.log" & sleep 0.2 done wait $srv done逻辑说明:每场用独立端口,避免上一场的进程没退干净导致端口占用;每个 agent 的日志单独一个文件,事后可以按号码追溯单个球员的行为。sleep 0.2那一行看着不起眼,但它解决了第 4 章说的号码抢占问题,去掉之后偶尔就会有两个 agent 抢同一个号。
第二步是从日志里抽出可比指标。至少要统计:进球数、控球时间占比、平均每场摔倒次数、射门次数。这几个指标不需要多精密,用 grep 加简单脚本就能算。关键是同一套指标在不同参数下横向比,而不是拿感觉比。
第三步,也是我觉得最有用的一步:单变量扫描。挑一个参数(比如跑动速度上限),从 0.6 倍到 1.2 倍,每档跑 20 场,画出指标曲线。多数参数的最优值是个平台区,不是尖峰,找到平台区的中部比追求极限值更稳。这个过程很枯燥,但它是把“玄学调参”变成工程问题的唯一办法。
最后说个习惯。我早年改这类代码,最喜欢一次改三四个地方然后一起跑,结果好了不知道哪个起作用,坏了不知道哪个背锅。后来强制自己一次只动一个变量,改完先跑十场小样本看有没有明显退化,再上二十场确认。看着慢,实际上总时间省下来了——因为省掉了无数次“回滚到哪个版本”的纠结。希望帮到你。
本文还有配套的精品资源,点击获取