一、三层阻挡
地图上的“不能走”通常不是一种东西。静态地形来自地图文件,动态建筑来自玩家建造或场景事件,单位占位来自角色、宠物和召唤物。三者的生命周期、更新频率和权威来源都不同,混在一张表里会让撤销和同步变得非常困难。
| 阻挡层 | 来源 | 生命周期 | 更新频率 | 典型错误 |
|---|---|---|---|---|
| 静态地形 | 地图资源、场景配置 | 与地图实例相同 | 加载时一次写入 | 把可破坏墙写成永久阻挡 |
| 动态建筑 | 建造、机关、临时法术 | 由实体拥有 | 秒级到分钟级 | 实体销毁后忘记撤销 |
| 单位占位 | 角色、宠物、NPC | 跟随实体 | 每帧或每个 tick | 把队友当墙导致寻路失败 |
| 越界 | 地图边界 | 永久 | 不变 | 坐标换算后落到边界外 |
静态层和动态层可以合并成“地形是否可走”的查询,但它们的存储必须分开。单位占位一般不应进入全局阻挡层,否则两个玩家互相挡路会让 A* 直接判定不可达;这类问题应交给局部避障、速度障碍或允许穿越友军的规则处理。
二、通行数据的三种格式
| 格式 | 内存 | 单格查询 | 区域更新 | 适用 |
|---|---|---|---|---|
| 位图 | 1 bit/格 | O(1) | O(区域格数) | 规则网格、大地图 |
| 稀疏格子集合 | 与阻挡格数成正比 | 哈希 O(1) | O(变更数) | 阻挡稀疏、动态多 |
| 多边形 | 与顶点数成正比 | 需点定位 | 改顶点 | 连续空间、导航网格 |
| 分块位图 | 1 bit/格 + 块索引 | O(1) | O(受影响块) | 开放世界常用折中 |
位图最直观:宽度乘以高度,y*width+x 定位,查询是一条与运算。它的缺点是区域更新要遍历整块;分块位图把地图切成 32×32 或 64×64 的块,每块独立分配,既能 O(1) 查询,也能只重建受影响的块。
位图的行宽不必与地图宽度完全相等。把每行补齐到 64 bit 的整数倍,查询时可以用字掩码一次检查 64 个格子,范围查询和批量复制都会更快;代价是每行末尾有少量 padding,坐标换算必须按 stride 而不是按地图宽度计算。
块大小是内存与更新粒度的折中。块太小,块索引本身占用可观内存;块太大,一次局部变化就要重建几千个格子。32×32 的块在常见地图上比较平衡:一块 1024 bit,索引开销小,重建也足够快。
稀疏集合适合“绝大部分格子可走、阻挡很少”的场景,但每次查询要走哈希,且不容易做范围查询。多边形适合连续空间,点定位和线段相交的几何边界更容易出错,通常配合导航网格使用。
选择格式时还要看更新频率。阻挡几乎不变、查询极频繁的地图,位图或分块位图更稳;阻挡每帧都在变化、且只影响少量格子的场景,稀疏集合的更新成本更低。不要只按“理论查询复杂度”选型,先把实际的读写比例列出来。
加载成本也要算进去。一张 4096×4096 的位图约 2 MB,看起来不大,但如果每个地图实例、每个副本都复制一份,内存会迅速膨胀。静态层应尽量共享只读数据,动态层才按实例分配。
开放世界还可以按需加载:只常驻玩家附近与传送点附近的块,远处块在进入视野前异步加载。此时“未加载”必须与“可通行”区分开,否则寻路会把尚未加载的区域当成空地,生成一条穿过未知区域的路径。
静态数据热更后要递增版本号并重建受影响的块,不能原地逐格改写。原地改写没有原子性,寻路可能读到一半旧、一半新的数据;版本号还能让客户端明确知道该丢弃哪些缓存。
三、静态层与动态层的叠加
动态层用引用计数而不是布尔值。同一个格子可能被建筑和临时法术同时占用:建筑先注册,法术后注册,法术结束时只减一,格子仍然阻挡;建筑拆除后再减一,格子才恢复可通行。
每个动态来源需要一个稳定的 ID,并记录它占用的格子列表。撤销时按来源整体移除,而不是让调用方逐个格子传回来;后者一旦漏传一个格子,就会留下永久阻挡。
来源 ID 不能简单复用自增整数。实体销毁后 ID 被新建筑复用,一个迟到的旧清理请求会把新建筑的阻挡一起删掉。常见做法是使用“槽位 + 代际”的句柄,代际不匹配的撤销请求直接忽略,并记录一条告警。
撤销操作应当是幂等的:重复撤销同一个来源返回“未知来源”,而不是把计数继续往下减。计数器一旦被减成负数,后续查询会把被占用的格子误判为可通行,这类错误往往要等到玩家穿墙才发现。
批量添加必须是原子的:先校验所有格子都在边界内,再统一增加计数。如果边校验边写入,遇到越界时前面的格子已经生效,调用方又拿到错误,回滚逻辑很难写对。
四、动态更新与缓存失效
寻路结果通常会被缓存:同一张地图、同一对起终点、同一组阻挡版本,可以直接复用。给阻挡数据维护一个单调递增的 Revision,缓存键带上它;任何静态或动态变化都让旧缓存自然失效。
| 缓存键组成 | 作用 | 失效条件 |
|---|---|---|
| 地图 ID + 静态版本 | 区分地图与热更 | 地图切换或热更 |
| 动态层 Revision | 捕捉建筑与法术变化 | 任意动态写入 |
| 起点、终点格子 | 区分不同请求 | 请求不同 |
| 单位半径与移动模式 | 区分体型与是否能斜走 | 单位属性变化 |
| 路径长度上限 | 防止缓存超长路径 | 配置变化 |
全图缓存全部清空很浪费。更细的做法是按块记录版本:某个块变化时只递增该块版本,缓存键包含路径经过块的版本集合。实现复杂度更高,适合大地图;小地图直接用全局版本就够。
更新频率也要控制。一个副本里几十个建筑同时变化,如果每次变化都触发全量寻路重算,会把 CPU 打满。可以按 tick 合并变更,在一个 tick 结束时统一递增版本,再让需要移动的单位重新寻路。
具体可以按下面的顺序收敛:
- 变更先写入一个待处理队列,不在业务回调里直接重算。
- tick 结束时合并同一格的多次变更,只保留最终状态。
- 递增一次版本号,让旧缓存整体失效。
- 只对当前有移动意图的单位发起重算,静止单位延迟到下次移动。
缓存还要设上限。路径条数与地图尺寸、玩家数量相关,不设上限的 map 会在长时间运行后耗尽内存;可以用 LRU,或者在版本变化时直接丢弃整代缓存。
五、坐标与边界
世界坐标到格子的换算必须统一使用向下取整,尤其是负数坐标。int(x / cellSize) 在 Go 里对负数向零截断,-0.5 会变成 0 而不是 -1;应使用 math.Floor 后再转整数。客户端和服务端各写一套换算,是“看起来站在空地却被判定在墙里”的常见来源。
| 输入 | 常见错误换算 | 正确结果 | 后果 |
|---|---|---|---|
| 世界 -0.5,格宽 1 | int(-0.5)=0 | floor(-0.5)=-1 | 左右各偏一格 |
| 世界 5.999,格宽 1 | 四舍五入得 6 | floor 得 5 | 跨格误判 |
| 像素坐标未除格宽 | 直接当格子用 | 先除再取整 | 整张图偏移 |
| 地图原点非零 | 忽略 origin | 先减 origin | 边缘区域错位 |
越界查询应返回“阻挡”,而不是返回“可走”。这样即使调用方漏了边界检查,角色也不会走出地图;错误会以“找不到路径”暴露,而不是以越界坐标进入后续逻辑。
大体型单位还要考虑半径。A* 搜索的是格心,但一个占两格的 Boss 需要“膨胀阻挡”或 clearance 地图:只要半径覆盖到阻挡格,该格就不可进入。用格心路径直接驱动大体型单位,视觉上会出现半个身子插进墙里。
地图版本也要握手。客户端加载的是旧地图,服务端已经热更了阻挡数据,客户端预测的路径会持续被服务端拒绝。登录或切图时交换地图 ID 与版本号,不匹配就要求重新下载,而不是让玩家在两张地图之间反复拉扯。
六、失败场景分析
动态阻挡不复位。 某个持续 30 秒的冰墙法术注册了动态阻挡,施法者中途死亡,清理逻辑只删除了法术实体,没有调用 RemoveBlocker。格子从此永久不可走,玩家报告“这里有空气墙”,重启地图实例才恢复。修复是让阻挡来源跟随实体生命周期,实体销毁时统一撤销;地图重置时再调用一次 ClearDynamic 兜底。
批量添加只成功一半。 建筑占地 6 个格子,其中 1 个越界。实现顺序写入,前 5 个已经加计数,第 6 个返回错误;调用方认为建筑没建成,没有执行撤销,留下 5 个隐形阻挡。修复是先整体校验、再整体写入。
客户端与服务端阻挡版本不一致。 客户端用旧地图预测出一条穿过新建筑的路径,服务端按新地图拒绝,客户端又重算,形成反复修正。修复是版本握手加明确的重新同步消息,而不是静默重试。
坐标与格子错位。 地图原点在负坐标区域,客户端用向零截断把 -0.5 算成第 0 格,服务端用向下取整算成第 -1 格;同一堵墙在两边落在不同格子,玩家在客户端能走、到服务端被拉回。修复是两端共用同一份换算函数和同一组边界测试,地图原点与格宽随地图版本一起下发。
七、Go 实现
BlockGrid 把静态位图与动态引用计数分开,动态来源按 ID 注册和撤销,批量添加先校验后写入,并用 Revision 支持缓存失效。
// file: ch07/path/block.go
// tier: L1
// verify: go test ./ch07/path/
package path
import "errors"
var (
ErrInvalidSize = errors.New("block: invalid grid size")
ErrInvalidSource = errors.New("block: source id required")
ErrDuplicateBlocker = errors.New("block: source already registered")
ErrUnknownBlocker = errors.New("block: source not registered")
)
// BlockGrid 把阻挡拆成静态地形层和动态占位层。
// 静态层由地图数据一次性写入,动态层按来源(建筑、单位、临时法术)增删。
// 同一个格子可以被多个来源同时占用,只有全部撤销后才恢复可通行。
type BlockGrid struct {
width int
height int
static []bool
dynamic []int32
sources map[uint64][]Point
revision uint64
}
func NewBlockGrid(width, height int) (*BlockGrid, error) {
if width <= 0 || height <= 0 {
return nil, ErrInvalidSize
}
n := width * height
return &BlockGrid{
width: width,
height: height,
static: make([]bool, n),
dynamic: make([]int32, n),
sources: make(map[uint64][]Point),
}, nil
}
func (b *BlockGrid) Width() int { return b.width }
func (b *BlockGrid) Height() int { return b.height }
// Revision 在静态层或动态层每次变化后递增,供寻路缓存判断是否失效。
func (b *BlockGrid) Revision() uint64 { return b.revision }
func (b *BlockGrid) InBounds(p Point) bool {
return p.X >= 0 && p.Y >= 0 && p.X < b.width && p.Y < b.height
}
func (b *BlockGrid) index(p Point) int { return p.Y*b.width + p.X }
// Static 报告静态地形是否阻挡;越界按阻挡处理。
func (b *BlockGrid) Static(p Point) bool {
if !b.InBounds(p) {
return true
}
return b.static[b.index(p)]
}
// Dynamic 返回该格当前的动态阻挡计数;越界返回 0。
func (b *BlockGrid) Dynamic(p Point) int32 {
if !b.InBounds(p) {
return 0
}
return b.dynamic[b.index(p)]
}
// Blocked 是寻路使用的最终判定:静态层或任意动态来源阻挡,越界也算阻挡。
func (b *BlockGrid) Blocked(p Point) bool {
if !b.InBounds(p) {
return true
}
i := b.index(p)
return b.static[i] || b.dynamic[i] > 0
}
// Cost 让 BlockGrid 直接满足 Grid 接口,统一成本为 1。
func (b *BlockGrid) Cost(p Point) (float64, bool) {
if b.Blocked(p) {
return 0, false
}
return 1, true
}
func (b *BlockGrid) MinCost() float64 { return 1 }
func (b *BlockGrid) SetStatic(p Point, blocked bool) error {
if !b.InBounds(p) {
return ErrOutOfBounds
}
i := b.index(p)
if b.static[i] == blocked {
return nil
}
b.static[i] = blocked
b.revision++
return nil
}
// AddBlocker 注册一个动态来源占用的格子集合。整批校验通过后才生效,
// 不会出现一半格子已写入、一半报错的状态。
func (b *BlockGrid) AddBlocker(source uint64, cells []Point) error {
if source == 0 {
return ErrInvalidSource
}
if _, exists := b.sources[source]; exists {
return ErrDuplicateBlocker
}
unique := make([]Point, 0, len(cells))
seen := make(map[Point]struct{}, len(cells))
for _, p := range cells {
if !b.InBounds(p) {
return ErrOutOfBounds
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
unique = append(unique, p)
}
for _, p := range unique {
b.dynamic[b.index(p)]++
}
b.sources[source] = unique
b.revision++
return nil
}
// RemoveBlocker 撤销一个来源的全部占位。其他来源仍占用同一格时保持阻挡。
func (b *BlockGrid) RemoveBlocker(source uint64) error {
cells, ok := b.sources[source]
if !ok {
return ErrUnknownBlocker
}
for _, p := range cells {
i := b.index(p)
if b.dynamic[i] > 0 {
b.dynamic[i]--
}
}
delete(b.sources, source)
b.revision++
return nil
}
// ClearDynamic 在副本回滚、赛季重置或地图重载时清空所有动态层。
func (b *BlockGrid) ClearDynamic() {
if len(b.sources) == 0 {
return
}
clear(b.dynamic)
clear(b.sources)
b.revision++
}
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch07/path && go vet ./ch07/path/ && go test ./ch07/path/ -count=1
ok gspractice/ch07/path 0.262s
测试覆盖静态与动态叠加、两个来源共用同一格时的引用计数、逐个撤销后的恢复、越界查询、批量添加的原子性、重复来源与零来源拒绝、ClearDynamic 重置,以及把 BlockGrid 直接交给 A* 使用。
小结
阻挡数据的关键不是“存一张布尔表”,而是分清层级与所有权:静态地形只读共享,动态建筑和法术按来源引用计数,单位占位交给局部避障。批量更新保持原子性,变化通过版本号驱动缓存失效,坐标换算与地图版本在客户端和服务端之间保持一致,才能避免空气墙、隐形建筑和反复重算。
常见错误与边界
- 动态阻挡用布尔值覆盖,两个来源重叠时撤销一个就把另一个放通。
- 实体销毁只删业务对象,不撤销阻挡来源,留下永久空气墙。
- 批量添加边校验边写入,失败后留下部分生效的格子。
- 世界坐标转格子对负数向零截断,边界格归属错误。
- 越界查询返回可走,角色被寻路到地图外。
- 客户端与服务端地图版本不一致,路径被拒绝后无限重试。
- 把单位占位放进全局阻挡层,友军互相挡路导致整队无法寻路。
- 每次阻挡变化都清空全部寻路缓存,动态场景下 CPU 被重算打满。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。