跑 TorchCraftAI 的强化学习任务,最让人抓狂的不是奖励函数怎么设计,而是眼看着一队机枪兵绕着一块石头转了四十多帧,最后卡在一个只有三格宽的缺口里来回摩擦。这类问题追到根上,往往不是策略网络的锅,而是喂给它的地图特征本身就是残缺的——模型知道"哪里能走",却不知道"哪里算一个区域""哪里是咽喉""哪个角落值得扩张"。BWEM 这个库在 TorchCraftAI 里扮演的正是把这层语义补齐的角色,而围绕它做的地图特征提取,基本决定了上层决策的质量上限。这篇就聊透一件事:怎么把一张星际争霸的地图,从一堆 0/1 栅格,变成策略网络能真正读懂的结构化输入。适合已经能跑通 TorchCraftAI demo、正在被寻路卡顿和建造乱放折磨的中阶玩家,也适合任何想把"图结构先验"塞进强化学习状态表示的人参考。我不打算只给结论,每一步为什么这么设计、参数怎么定、哪里最容易翻车,都会掰开讲。
1. 地图分析为什么必须排在策略学习前面
1.1 星际争霸的"物理规则"有一半写在地图里
很多人第一次接触 TorchCraftAI,会觉得这是个纯粹的策略学习问题:造兵、进攻、扩张。真跑起来才发现,星际争霸这类 RTS 的"物理引擎"其实分散在两处——一处是单位属性和碰撞规则,另一处就是地图本身。地形的可通行性、高地视野差、矿区的分布、基地之间的连接关系,这些在人类玩家眼里是"看一眼就懂"的常识,对模型来说却是需要从数据里重新归纳的隐知识。
问题在于,如果只给模型一张 tile 级的 walkable 二值图(能走 / 不能走),它要花巨量的训练样本才能自己发现"这两片连通区域之间那三格宽的缝是个瓶颈"。而实际上这个结论用图论几行代码就能算出来。把能算出来的东西交给算法算,把算不出来的东西交给模型学,这是我在做游戏 AI 特征工程时最核心的一条分界线。
TorchCraftAI 的架构本身是模块化的,C++ 侧做状态解析和底层操作,Python 侧做学习和高层决策。地图分析天然属于 C++ 侧该干的活:一次算完,缓存下来,Python 侧直接消费。如果把这部分逻辑放进 Python 每帧重算,帧率会立刻崩给你看。
1.2 原始栅格到底缺了什么信息
我把一张典型对战地图的 tile 栅格和 BWEM 解析出的结构做了个对照,差别非常直观:
| 信息维度 | 原始栅格能给出 | BWEM 补充后 |
|---|---|---|
| 某格能不能走 | 能 | 能,且区分静态地形与动态阻挡 |
| 两片区域是否连通 | 需要自己跑 flood fill | 直接给 AreaId |
| 最窄通道在哪、多宽 | 无 | ChokePoint 及其宽度 |
| 资源点归属哪个区域 | 无 | Base 与 Area 的绑定 |
| 区域邻居关系 | 无 | 邻接表可直接遍历 |
| 区域内部路径 | 需要全图 A* | 区域级路径 + 局部细化 |
看这张表就能明白,假如模型只能拿到第一列,那它相当于被蒙住眼睛在一间黑屋子里摸路;而一旦把后四列补上,状态空间的维度其实没有爆炸性增长,可解释性却提升了不止一个档次。我实测过同一个策略网络在两种输入下的收敛速度差异,加了区域特征之后,前期"乱走撞墙"的无效动作比例明显下降。
1.3 BWEM 在整条数据链路里的落点
BWEM 全称是 Brood War Easy Map,本质是一个建立在底层地图查询接口之上的地图分析中间层。它的价值不在于"提供了新数据",而在于"把原始数据算成了结构"。它主要做三件事:把连通的可通行瓦片聚合成 Area,识别 Area 之间的 ChokePoint,把资源点聚合成 Base。这三件事恰好对应了 RTS 里最常用的三种空间推理:我在哪个区、我怎么过去、我该往哪扩。
在 TorchCraftAI 的工程结构里,BWEM 挂在 C++ 侧,通过地图初始化回调拿到地形数据,之后在整局游戏生命周期内维护。Python 侧拿不到 BWEM 的原生对象,只能拿到序列化后的结果。这个不对称是后面很多对接问题的根源,也是我在工程实现上要重点处理的地方。
2. 拆开 BWEM 的数据结构:粒度、连通与咽喉
2.1 三层粒度:像素、走位、瓦片
搞懂 BWEM 的第一件事是搞懂单位。星际争霸里常见的三种坐标粒度经常被混用,混用一次就错位一片:
- 像素坐标:最精细,单位位置用它。
- 走位坐标 WalkPosition:4×4 像素一格,用于精细的碰撞与可通行判断。
- 瓦片坐标 TilePosition:8×8 像素一格,建造、寻路、地图分析大多用它。
换算关系很简单:一个 Tile 等于 2×2 个 WalkPosition,一个 WalkPosition 等于 4×4 个像素。听起来是小学算术,但我敢说至少有一半的地图特征 bug 出在这个换算上。BWEM 内部用 WalkPosition 精度做通行性判断(它称之为 MiniTile),对外聚合到 Tile 级提供 Area 查询。也就是说,一个 Tile 内部四个小格可能有的能走有的不能走,BWEM 会据此判断它到底算不算属于某个 Area。
提示:如果你的代码里同一段逻辑同时出现了
x * 8和x * 4,先停下来检查一遍坐标域,别急着往下调。
2.2 Area 是怎么生成的,为什么它比连通域多一层语义
Area 的生成过程大致是:先标记所有静态可通行的 MiniTile,然后做连通性聚合成若干连通块,再处理边缘情况(比如被矿挡住的半连通区域)。这个过程听起来和普通的 flood fill 没区别,但 BWEM 多做了一步——它会区分"地形上可通行"和"实际可达"。
这个区别在实战里非常关键。地图上经常有一些看起来能走、实际上被资源矿或者地图边界封死的"口袋区域"。用普通连通域算法,这些口袋会被算成一个独立 Area,你的 AI 就会以为那里可以扩张、可以藏兵,然后派一队兵长途跋涉过去发现根本进不去。BWEM 至少能让你在区域层面就识别出"这个 Area 没有有效邻居",提前排除。
C++ 侧取 Area 的典型写法是这样的:
#include <bwem.h> const BWEM::Map& bwem = BWEM::Map::Instance(); BWEM::Area const* area = bwem.GetArea(BWAPI::TilePosition(tx, ty)); if (area != nullptr) { int areaId = area->Id(); // 该区域的邻居数量直接反映了它的战略价值 size_t neighborCount = area->AccessibleNeighbors().size(); }注意GetArea返回的是指针,可能是空指针——比如那个 tile 落在不可通行区域。我在第一版代码里就没判空,结果在几张边界特别复杂的地图上直接段错误,排查了半天才发现是地图边缘的一格。
2.3 ChokePoint 是二值图永远给不出来的东西
ChokePoint 是整个 BWEM 里我觉得最值钱的部分。它表示两个 Area 之间的狭窄连接,并且带有宽度属性。有了它,很多原本需要模型自己悟的战术常识立刻变成可计算量:
- 宽度小于某个阈值的咽喉,是天然的防守点,只需要少量兵力就能封锁。
- 通向己方主基地的所有咽喉宽度之和,可以量化"防守压力"。
- 敌方部队穿越咽喉的频率,是判断进攻意图的强信号。
获取咽喉及其连通关系大致是这样:
for (const BWEM::ChokePoint* cp : area->ChokePoints()) { int width = cp->Width(); auto areas = cp->GetAreas(); int from = areas.first->Id(); int to = areas.second->Id(); // 用于构建区域邻接图 }这里有个容易忽略的点:GetAreas()返回的是pair,两个 Area 的顺序是不确定的,你不能假设first就是"当前区域"。我第一次写邻接表的时候就栽在这,把邻接关系搞成了有向图,导致寻路时从 A 区到 B 区能找到路,反过来找不到。正确做法是先判断哪一个是当前 Area 的 Id,再取另一个。
2.4 Base 与矿区的绑定省掉一堆几何计算
Base 表示一个资源点集群,包含矿区和气矿。BWEM 把它和所属 Area 绑定好了,所以"这个基地属于哪个区域""这个区域里有几个基地"这类查询是一行代码的事。这在做扩张决策时极其便利——你可以直接在一个 Base 图上做排序,而不用自己算矿区中心的几何距离。
有个细节值得说:Base 的Center()返回的并不一定是几何中心,而是 BWEM 认为最合适的建造参考点。如果你要做"在基地附近找位置造兵营"这种事,用Center()作为起点做螺旋搜索,比用矿区坐标平均值靠谱得多。
3. 地图特征怎么设计,才不至于让网络学个寂寞
3.1 通道清单:哪些该编码,哪些是噪音
特征设计的第一步是列清单。我把常用通道分成三类:
地形类:可通行性、可建造性、地形高度、资源分布。这几层是必备的,直接从底层地图数据拿。
结构类:AreaId、ChokePoint 标记、Base 标记。这是 BWEM 的增量价值所在。
动态类:己方建筑占位、敌方建筑占位、视野覆盖、单位密度热力图。这几层随帧变化,需要增量更新。
我的建议是别一上来就堆三十个通道。通道数直接决定输入张量大小和显存占用,而且很多通道之间高度相关。我踩过的坑是同时给了"可通行"和"可建造"两层,结果两者在高地上高度重合,网络基本没利用上第二层。后来精简到 8 个通道,性能反而更好。
一个实测可用的精简清单:
| 通道 | 内容 | 更新频率 |
|---|---|---|
| 0 | 静态可通行 | 开局一次 |
| 1 | 可建造(扣除动态阻挡) | 建筑变化时 |
| 2 | 地形高度归一化 | 开局一次 |
| 3 | AreaId 归一化 | 开局一次 |
| 4 | ChokePoint 掩码 | 开局一次 |
| 5 | 资源点掩码 | 开局一次 |
| 6 | 己方单位密度 | 每帧衰减更新 |
| 7 | 敌方可见单位密度 | 每帧衰减更新 |
3.2 分辨率:别拿像素级精度喂一个瓦片级决策
这一步很多人会想当然地按像素来。地图通常是 256×256 瓦片量级,如果按像素展开就是 2048×2048,一个通道就 400 多万个格子,八个通道直接上天。而实际上你的决策粒度根本到不了像素级:建造是瓦片级的,寻路可以瓦片级,只有微操才需要像素级。
我的做法是分层处理:全局决策用瓦片级栅格(256×256 左右),局部微操才下沉到走位级甚至像素级。这样全局张量大小是可控的,而局部精度通过裁剪窗口(crop)在需要的时候临时取。
import numpy as np def crop_around(chan, cx, cy, radius): h, w = chan.shape x0 = max(0, cx - radius) x1 = min(w, cx + radius) y0 = max(0, cy - radius) y1 = min(h, cy + radius) patch = chan[y0:y1, x0:x1] # 边界补齐,保证输出尺寸固定,方便批量堆叠 out = np.zeros((2 * radius, 2 * radius), dtype=chan.dtype) out[:patch.shape[0], :patch.shape[1]] = patch return out裁剪时一定要注意边界补齐,否则不同位置的 patch 尺寸不一致,batch 直接组装不起来。这个坑我在做单位级微操输入时踩过,报错信息完全不指向根源,查了很久。
3.3 区域级的图表示:one-hot 不行,得上图结构
AreaId 直接当数值喂给网络是个坏主意,因为 Id 本身没有序关系,网络会把"Id 3"和"Id 4"当成"很接近",这完全是误导。比较朴素的做法是 one-hot,但地图上动辄二三十个 Area,one-hot 会撑爆通道数。
更合理的做法是把地图当成图来处理:节点是 Area,边是 ChokePoint,边权是咽喉宽度。区域级的决策(往哪扩张、在哪集结)直接在图上做,可以用图神经网络,也可以用很朴素的规则打分。我在实际项目里用的是一套加权评分,简单但好用:
def score_area(area, my_base_area, enemy_area_ids): s = 0.0 s += 2.0 * len(area.bases) # 资源越多越值得去 s -= 1.5 * area.dist_to(my_base_area) # 越远支援越难 s -= 3.0 * area.adjacent_enemy_pressure # 贴着敌方区域要谨慎 s -= 4.0 * area.choke_min_width_penalty # 入口太窄容易被堵 return s这套打分加上阈值筛选,在很多场景下比训练出来的网络还稳,因为它的先验是明确的。先用规则把明显错误的选项砍掉,再让模型在剩下的候选里挑,这是我在 RTS 决策里反复验证有效的一招。
3.4 归一化:别让数值范围差异毁掉特征
通道之间的数值范围差异巨大:0/1 掩码是二值,高度是几十到几百,密度热力图可能是任意浮点。如果直接拼在一起,网络在前几层主要在处理量纲差异,学习效率极低。
统一做法是每个通道都归一化到 [0, 1]。高度可以用(h - h_min) / (h_max - h_min),密度用指数衰减后再除以观测到的最大值。归一化参数一定要用训练集统计出来并固化,别每帧动态算,否则训练和推理时的分布会不一致。
注意:动态归一化是隐形 bug 的重灾区。训练时每帧重算 min/max,推理时忘了同步,模型表现会莫名其妙地掉一截,而且极难定位。
4. C++ 侧提取与 Python 侧消费的对接细节
4.1 静态部分一次算完,动态部分增量维护
地图分析要分清静态和动态。静态部分是地形、Area、ChokePoint、Base,这些在整局游戏里不会变(除非是可变地形,但标准对战里没有)。动态部分是建筑占位和视野,这些一直在变。
我的实现是把静态部分在游戏开始的第一帧算完,序列化成一块内存缓冲区;动态部分维护一个增量更新队列,每当有建筑创建或销毁事件时,只更新受影响的那几个 tile 及其邻域。
// 静态分析结果缓存,开局算一次 struct StaticMapCache { std::vector<int> areaId; // 每 tile 的 AreaId,-1 表示无 std::vector<uint8_t> walkable; // 静态可通行 std::vector<uint8_t> buildable; // 静态可建造 std::vector<std::pair<int,int>> chokeTiles; std::vector<std::array<int,4>> baseInfo; // x, y, areaId, mineralCount }; void buildStaticCache(const BWEM::Map& bwem, int mapW, int mapH, StaticMapCache& out) { out.areaId.assign(mapW * mapH, -1); out.walkable.assign(mapW * mapH, 0); for (int ty = 0; ty < mapH; ++ty) { for (int tx = 0; tx < mapW; ++tx) { BWAPI::TilePosition tp(tx, ty); out.walkable[ty * mapW + tx] = bwem.GetTile(tp).Walkable() ? 1 : 0; if (auto* a = bwem.GetArea(tp)) { out.areaId[ty * mapW + tx] = a->Id(); } } } }这段代码看着平庸,但有两个地方我第一次写错了:一是bwem.GetTile(tp)在越界时会返回无效 tile,得先做边界判断;二是Area::Id()从 1 开始编号(0 是无效值),所以用 -1 表示"不属于任何区域"更安全,用 0 会和无效值撞上。
4.2 缓存键要包含地图名和版本,别偷懒
训练脚本经常跑不同地图。如果你只按"第几次对局"做缓存键,切换到新地图时就会读到旧地图的结构,症状是 AI 在新地图上表现诡异——比如跑到一个实际不存在的区域去。
我的缓存键是地图名 + 地图哈希 + 特征版本号。特征版本号很重要,你改了通道定义之后,旧缓存必须失效。我吃过一次亏:调整了通道顺序之后忘了升版本号,训练跑了两天才发现模型用的还是旧特征。
cache/ (2)Killing Fields/ v3/ static.bin meta.json4.3 序列化格式:能用二进制就别用 JSON
地图特征的数据量不小,256×256×8 个通道,浮点的话是 2MB 量级。JSON 序列化会膨胀好几倍,解析还慢。我的做法是静态部分直接写裸二进制 buffer,配一个小的 meta.json 描述尺寸和通道含义。
Python 侧读取非常简单:
import json import numpy as np def load_static_map(root): with open(f"{root}/meta.json") as f: meta = json.load(f) raw = np.fromfile(f"{root}/static.bin", dtype=np.uint8) chans = raw.reshape(meta["C"], meta["H"], meta["W"]).astype(np.float32) return chans注意fromfile读出来是一维的,reshape 顺序必须和 C++ 侧写入顺序(一般是 NCHW 或 CHW)严格一致,否则整张图会错位成一团乱码。这个错误的表现很有迷惑性——网络还是能训,但效果就是差,你很难意识到是 reshape 顺序反了。
5. 地图特征真正影响决策的三个落点
5.1 寻路:区域图跑粗路径,局部再细化
最直接的收益在寻路。全图 A* 的代价随地图尺寸平方增长,一帧几十个单位各跑一次,性能立刻就崩。用区域图先把问题降维:先算从源 Area 到目标 Area 的区域级路径(这步在图上是几十个节点的事),再对每段路径的起止点做局部瓦片级寻路。
BWEM::CPPath cpPath = bwem.GetPath(fromTile, toTile); // cpPath 是 ChokePoint 序列,按顺序穿过即可实测下来,这个改动让寻路耗时下降了一个数量级,而且路径质量反而更好——因为区域级路径天然会走那些"宽敞"的通道,而不是像纯 A* 那样经常贴着墙走最短路。
有一点要记得:GetPath只返回咽喉序列,不返回具体瓦片。中间的具体走法还得自己补。我的做法是以相邻两个 ChokePoint 的中心为起止点,做局部 A*,把这些小段拼起来。
5.2 建造位置评分:可建造性只是及格线
建造是最容易暴露地图特征不足的环节。只给可建造性,AI 会把兵营造在地图角落里、造在矿区中间、造在唯一一条出兵通道上把自己堵死。
我在建造评分里加了几项 BWEM 相关约束:
| 约束项 | 作用 | 权重方向 |
|---|---|---|
| 所属 Area == 主基地 Area | 避免造到隔壁去 | 强正向 |
| 到最近 ChokePoint 的距离 | 别堵住出兵口 | 中负向 |
| 到矿区中心距离 | 别压矿 | 强负向 |
| 区域内已用建造面积占比 | 避免过度拥挤 | 中负向 |
| 到己方单位平均位置距离 | 别造在前线 | 中负向 |
这套评分加上随机的候选点采样(比如在基地周围螺旋采样 200 个点),实际效果比纯学习出来的建造策略还稳,尤其是不容易犯"把自己堵死"这种低级但致命的错误。
5.3 扩张决策:在 Base 图上排序
扩张是典型的图论优化问题。把每个 Base 当成候选节点,边权综合考虑距离、咽喉宽度、敌方压力,然后做一个简单的优先级排序。BWEM 已经把 Base 和 Area 绑好了,所以"这个 Base 属于哪个区域""这个区域有几个入口"都是现成的。
我常用的一层过滤是:先排除那些入口咽喉数超过阈值、且咽喉总宽度超过某个值的 Expansion。原因很直白——这种位置的防守成本太高,前期根本守不住。这层规则排除掉的候选,往往也是人类玩家不会选的,说明它抓住了真实的战术常识。
6. 踩过的坑,以及我是怎么一步步定位的
6.1 坐标错位:整张特征图偏移了半格
症状:AI 建造建筑时总是差一格,或者寻路目标点总是落在矿区里。表现很规律但不明显,因为只偏了半格到一格。
排查过程是这样的:先打印几个已知地标的坐标(比如主基地的矿),看 Python 侧读到的坐标和 C++ 侧算出来的坐标是否一致。很快发现差了 1 个瓦片。原因是 C++ 侧写 buffer 时用的是ty * mapW + tx,而 Python 侧 reshape 成了(H, W)之后,索引是[ty, tx],理论上一致。但问题出在缓冲区每行的宽度是按mapW + 1对齐的(为了性能做了 padding),Python 侧没考虑这个 padding。
修法很简单,在 meta.json 里显式写出stride,Python 侧按 stride 切片,而不是直接按 mapW reshape。这个坑的教训是:只要涉及手工内存布局,就把 stride 写进元数据,别让两边各自猜。
6.2 动态建筑导致缓存失效不及时
症状:AI 造完一个建筑之后,后续的建造规划会在同一个位置继续规划,导致重复建造失败。
根因很直白:我做了静态缓存,但动态建筑占位更新是异步的,有时候规划模块读到的还是上一帧的缓存。修法是在每次规划前强制同步一次动态层,或者给缓存加一个版本号,规划时检查版本是否最新。
if (dynamicCacheVersion != lastPlannedVersion) { refreshDynamicLayer(); lastPlannedVersion = dynamicCacheVersion; }这个改动增加的耗时可忽略,但消除了一整类偶发性的规划失败。凡是"缓存 + 异步更新"的组合,版本号基本都是必需品,别指望靠时序保证。
6.3 训练集与评测集地图分布不一致
症状:离线评测表现很好,换一批地图在线跑就崩。
排查时我把每张地图的 Area 数量、Base 数量、最大咽喉宽度都统计出来,画了个分布图,立刻看出问题——训练用的地图平均 Area 数在 12 左右,评测集里有几张平均 25 以上。区域数量一多,one-hot 或者归一化的 AreaId 就完全失效了。
这个问题本质上不是代码 bug,是数据设计问题。解决方案是放弃 AreaId 的直接编码,改用区域拓扑特征(邻居数、平均咽喉宽度、到主基地的图距离),这些特征对区域数量不敏感,泛化能力好得多。
6.4 每帧重算的性能黑洞
早期版本我在 Python 侧每帧都对整张地图做一次bwem.GetArea的等价查询。跑小地图还行,换大地图之后帧率直接从 40 掉到个位数。
定位方法是用分段计时把每帧的耗时拆开,一眼就看出地图查询占了 60% 以上。修法就是把查询结果全部预计算成查表(一张mapW × mapH的 int 数组),查询变成 O(1) 的数组索引。
提示:地图相关的任何操作,只要输入不随帧变化,就一定要预计算成表。这个优化几乎没有副作用,收益却是线性的。
7. 几个可以直接抄的实现细节
7.1 区域邻接表的构建顺序
std::vector<std::vector<std::pair<int,int>>> adj(areaCount); // (neighborId, width) for (int aid = 1; aid <= areaCount; ++aid) { BWEM::Area const* a = bwem.GetArea(aid); if (!a) continue; for (const BWEM::ChokePoint* cp : a->ChokePoints()) { auto pr = cp->GetAreas(); int other = (pr.first->Id() == aid) ? pr.second->Id() : pr.first->Id(); if (other != aid) { adj[aid].push_back({other, cp->Width()}); } } }注意去重。同一个 Area 对之间可能有多个 ChokePoint,我保留全部而不是合并,因为"两条各宽三格的通道"和"一条宽六格的通道"在战术上完全不同。
7.2 特征张量的组装顺序
我固定的通道顺序是:静态可通行、动态可建造、高度、区域掩码、咽喉掩码、资源掩码、己方密度、敌方密度。顺序定下来之后就写进文档,任何改动都要升版本号。别小看这一点,团队协作时通道顺序错乱是最难查的 bug 之一。
7.3 一个简单的可视化自检
调试地图特征最有效的手段是可视化。把每个通道导成灰度图存 PNG,人眼扫一眼就能看出问题:
from PIL import Image def dump_channels(chans, prefix): for i, ch in enumerate(chans): img = (np.clip(ch, 0, 1) * 255).astype(np.uint8) Image.fromarray(img).save(f"{prefix}_ch{i}.png")我现在的习惯是每次改完特征提取逻辑,先跑一张已知地图,肉眼确认每个通道的图都对,再开始跑训练。这十秒钟的检查,省掉的可能是两天的白跑。
7.4 关于 BWEM 与动态阻挡的边界
最后说一个需要心里有数的地方:BWEM 主要关注静态地形,动态建筑造成的阻挡需要你自己在可建造层上叠加。我见过有人以为 BWEM 会自动处理建筑阻挡,结果 AI 一直往自己刚造好的建筑上规划路径。正确做法是维护一个独立的动态阻挡层,在最终判断可通行性时和静态层做逻辑与。
另外,资源矿本身在底层接口里就是不可通行的,这一点 BWEM 已经算进去了,你不用额外处理。但气矿的采集点和矿区中心位置是两回事,做"别压矿"的评分时用的是矿区中心,用采集点会偏。
我在实际操作中的体会是,地图特征这块最忌讳"想一步做一步"。先用最朴素的可通行张量跑通全流程,再把 BWEM 的结构化信息一层一层加进去,每加一层都做一次可视化核对和指标对比,最后你会发现真正有用的通道其实很少,但没有经过这个逐层添加的过程,你永远不会知道是哪一层在起作用。