一、AOI 不是全图广播
区域广播要回答一个问题:一个实体移动后,谁需要知道。全图广播的复杂度是 O(n),百人场景还能撑,千人战场会把带宽和序列化 CPU 同时打满。AOI 的本质是用空间索引把候选集合压到常数级。
| 做法 | 查询成本 | 更新成本 | 边界精度 | 适用 |
|---|---|---|---|---|
| 格子/九宫格 | O(1) 定位 | O(1) 跨格 | 格子级,可加抑制带 | 大多数 MMO、SLG |
| 十字链表 | O(k) 邻接 | O(k) 插入删除 | 精确矩形 | 超大规模、均匀密度 |
| 全图广播 | O(n) | O(n) | 精确 | 房间数很少的小游戏 |
本系列只讲格子和九宫格。它把世界切成固定大小的格子,实体只和所在格及周围八格互相可见;跨格时比较前后两个可见集合,只发差量。十字链表的精度更高,但实现和调试成本也更高,没有明确容量压力时不必先上。
二、进入与离开怎么判定
九宫格的可见关系定义为“格子切比雪夫距离不超过 1”。这个定义有一个重要性质:A 能看见 B 时,B 也一定能看见 A。因此一次移动只需要计算移动者视角的 enter 和 leave,同一组实体就是双向广播的名单,不需要再为观察者单独算一遍。
进入场景时,Add 返回新实体当前能看见的所有人;离开场景时,Remove 返回它离开前能看见的所有人,服务端据此给这些人发离开包。离开事件必须和进入事件一样可靠,玩家下线、切图、死亡消失都要走同一条移除路径。
同一帧内移动多次,不要每移动一次就广播一次。正确做法是先合并到帧末的最终坐标,再计算一次差量;如果必须逐包处理,也应该把中间结果攒到 tick 边界统一发送。否则玩家在格线附近连续走三步,周围人会收到“进入、离开、进入”的抖动包,客户端模型反复创建销毁。
三、边界抖动与抑制带
即使只在跨格时广播,站在格线两侧来回走仍会产生大量进出事件。抑制带的思路是:越过格线但不足 hysteresis 距离时,AOI 格子先不切换;只有真正离开旧格足够远,才承认跨格。回程同样有抑制,所以不会在另一侧立刻切回来。
| 方案 | 格线附近行为 | 广播量 | 风险 |
|---|---|---|---|
| 无抑制 | 每次越线都切格 | 高,随抖动放大 | 客户端频繁创建/销毁 |
| 固定抑制带 | 越线一小段仍留原格 | 低 | AOI 格子与真实坐标短暂不一致 |
抑制带只影响 AOI 归属,不影响真实坐标。碰撞、拾取、技能判定必须用真实位置,不能拿 AOI 格子反推坐标。抑制带也不能太大,否则玩家已经站在隔壁格,视野却还停留在旧格,会出现“贴脸看不见”。
四、广播裁剪与隐身
广播内容只发变化量:进入时发实体快照,离开时发移除,移动时发位置和朝向,不要每次重发完整属性。同一 tick 内多个实体的变化可以合并成一个批次,减少包头和系统调用。
隐身是可见性裁剪的一种特例:隐身实体不出现在别人的可见集合里,但它自己仍然能看见别人。因此隐身切换要单独计算“谁看得见我”,开启时给这些观察者发离开,关闭时发进入。不能简单地把隐身实体从格子成员表删除,否则它自己也收不到周围广播。
五、Go 实现
GridAOI 用九宫格维护成员表,Move 返回排序后的 enter/leave,cellForLocked 负责抑制带,SetStealth 返回需要更新视野的相邻实体。
// file: ch07/world/aoi.go
// tier: L1
// verify: go test ./ch07/world/
package world
import (
"errors"
"sort"
"sync"
)
var (
ErrEntityExists = errors.New("world: entity already exists")
ErrEntityNotFound = errors.New("world: entity not found")
)
// CellSize 是 AOI 格子的边长,单位与逻辑坐标一致。格子过大会让广播量
// 膨胀,过小会让跨格事件过多,通常取玩家视野半径的一半到一倍。
const CellSize int64 = 50 * 100
// Cell 是 AOI 格子编号,与分区 ZoneID 是两套独立网格。
type Cell struct {
X int64
Y int64
}
func CellOf(p WorldCoord) Cell {
return Cell{X: FloorDiv(p.X, CellSize), Y: FloorDiv(p.Y, CellSize)}
}
// GridAOI 用九宫格维护可见性。可见关系定义为格子切比雪夫距离不超过 1,
// 因此 A 能看见 B 时 B 也一定能看见 A,进入/离开集合可以双向复用。
type GridAOI struct {
mu sync.Mutex
hysteresis int64
pos map[uint64]WorldCoord
cell map[uint64]Cell
members map[Cell]map[uint64]struct{}
stealth map[uint64]bool
}
// NewGridAOI 创建 AOI。hysteresis 是跨格抑制带宽度:实体越过格线但
// 不足该距离时仍留在原格,避免在边界来回走动导致反复进出。
func NewGridAOI(hysteresis int64) *GridAOI {
if hysteresis < 0 {
hysteresis = 0
}
return &GridAOI{
hysteresis: hysteresis,
pos: make(map[uint64]WorldCoord),
cell: make(map[uint64]Cell),
members: make(map[Cell]map[uint64]struct{}),
stealth: make(map[uint64]bool),
}
}
// Add 登记实体并返回它刚进入视野的实体集合(不含自己)。
func (g *GridAOI) Add(id uint64, p WorldCoord) ([]uint64, error) {
g.mu.Lock()
defer g.mu.Unlock()
if _, ok := g.pos[id]; ok {
return nil, ErrEntityExists
}
c := CellOf(p)
g.pos[id] = p
g.cell[id] = c
g.addToCellLocked(id, c)
return sortedIDs(g.visibleLocked(id)), nil
}
// Move 更新坐标并返回 enter/leave 两个集合。
// enter 是移动后新看见的实体,leave 是移动后看不见的实体。
// 返回集合从移动者视角计算;由于可见关系对称,未被隐身时它同时也是
// 其他玩家视角下的变化,可直接用于双向广播。
func (g *GridAOI) Move(id uint64, to WorldCoord) (enter, leave []uint64, err error) {
g.mu.Lock()
defer g.mu.Unlock()
oldCell, ok := g.cell[id]
if !ok {
return nil, nil, ErrEntityNotFound
}
before := g.visibleLocked(id)
g.pos[id] = to
if next := g.cellForLocked(oldCell, to); next != oldCell {
g.removeFromCellLocked(id, oldCell)
g.cell[id] = next
g.addToCellLocked(id, next)
}
after := g.visibleLocked(id)
return diffIDs(after, before), diffIDs(before, after), nil
}
// Remove 移除实体并返回它离开前能看见的实体集合,调用方据此通知对方。
func (g *GridAOI) Remove(id uint64) ([]uint64, error) {
g.mu.Lock()
defer g.mu.Unlock()
c, ok := g.cell[id]
if !ok {
return nil, ErrEntityNotFound
}
left := g.visibleLocked(id)
g.removeFromCellLocked(id, c)
delete(g.pos, id)
delete(g.cell, id)
delete(g.stealth, id)
return sortedIDs(left), nil
}
// SetStealth 切换隐身。隐身只影响别人能否看见该实体,不影响该实体
// 观察别人。返回需要收到变更的相邻实体:开启时是 leave 名单,
// 关闭时是 enter 名单。
func (g *GridAOI) SetStealth(id uint64, on bool) ([]uint64, error) {
g.mu.Lock()
defer g.mu.Unlock()
if _, ok := g.pos[id]; !ok {
return nil, ErrEntityNotFound
}
if g.stealth[id] == on {
return nil, nil
}
g.stealth[id] = on
c := g.cell[id]
seen := make(map[uint64]struct{})
for dx := int64(-1); dx <= 1; dx++ {
for dy := int64(-1); dy <= 1; dy++ {
for other := range g.members[Cell{X: c.X + dx, Y: c.Y + dy}] {
if other != id {
seen[other] = struct{}{}
}
}
}
}
return sortedIDs(seen), nil
}
// Visible 返回 id 当前能看见的实体,保持与 Move 的 enter/leave 同视角。
// 隐身只影响别人看不看得见 id,不影响 id 观察别人。
func (g *GridAOI) Visible(id uint64) []uint64 {
g.mu.Lock()
defer g.mu.Unlock()
return sortedIDs(g.visibleLocked(id))
}
// Observers 返回能看见 id 的实体;id 隐身时为空。
func (g *GridAOI) Observers(id uint64) []uint64 {
g.mu.Lock()
defer g.mu.Unlock()
c, ok := g.cell[id]
if !ok || g.stealth[id] {
return nil
}
out := make(map[uint64]struct{})
for dx := int64(-1); dx <= 1; dx++ {
for dy := int64(-1); dy <= 1; dy++ {
for other := range g.members[Cell{X: c.X + dx, Y: c.Y + dy}] {
if other != id {
out[other] = struct{}{}
}
}
}
}
return sortedIDs(out)
}
// Position 返回实体真实坐标;AOI 格子可能因抑制带而暂时滞后。
func (g *GridAOI) Position(id uint64) (WorldCoord, bool) {
g.mu.Lock()
defer g.mu.Unlock()
p, ok := g.pos[id]
return p, ok
}
// Cell 返回实体当前所在的 AOI 格子。
func (g *GridAOI) Cell(id uint64) (Cell, bool) {
g.mu.Lock()
defer g.mu.Unlock()
c, ok := g.cell[id]
return c, ok
}
func (g *GridAOI) visibleLocked(id uint64) map[uint64]struct{} {
out := make(map[uint64]struct{})
c, ok := g.cell[id]
if !ok {
return out
}
for dx := int64(-1); dx <= 1; dx++ {
for dy := int64(-1); dy <= 1; dy++ {
for other := range g.members[Cell{X: c.X + dx, Y: c.Y + dy}] {
if other == id || g.stealth[other] {
continue
}
out[other] = struct{}{}
}
}
}
return out
}
func (g *GridAOI) cellForLocked(cur Cell, p WorldCoord) Cell {
next := CellOf(p)
if next == cur {
return cur
}
m := g.hysteresis
if m > 0 {
left := cur.X*CellSize - m
right := (cur.X+1)*CellSize + m
bottom := cur.Y*CellSize - m
top := (cur.Y+1)*CellSize + m
if p.X >= left && p.X < right && p.Y >= bottom && p.Y < top {
return cur
}
}
return next
}
func (g *GridAOI) addToCellLocked(id uint64, c Cell) {
set, ok := g.members[c]
if !ok {
set = make(map[uint64]struct{})
g.members[c] = set
}
set[id] = struct{}{}
}
func (g *GridAOI) removeFromCellLocked(id uint64, c Cell) {
set, ok := g.members[c]
if !ok {
return
}
delete(set, id)
if len(set) == 0 {
delete(g.members, c)
}
}
func sortedIDs(set map[uint64]struct{}) []uint64 {
out := make([]uint64, 0, len(set))
for id := range set {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func diffIDs(a, b map[uint64]struct{}) []uint64 {
out := make(map[uint64]struct{})
for id := range a {
if _, ok := b[id]; !ok {
out[id] = struct{}{}
}
}
return sortedIDs(out)
}
实现里有两个容易忽略的点。第一,Move 先记录移动前的可见集合,再更新坐标和格子,最后计算移动后的集合,差量天然包含跨格和隐身变化。第二,返回的 ID 排过序,网络层可以直接按序打包,测试也能做精确断言。
六、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch07/world && go vet ./ch07/world/ && go test ./ch07/world/ -count=1
ok gspractice/ch07/world 0.266s
本次运行全部测试通过;gofmt -l 无输出,go vet 无告警。耗时随机器负载变化。
七、失败场景:离开事件丢失
玩家在跨格瞬间断线,断线处理只从场景实体表删除了对象,没有调用 AOI 的 Remove。周围玩家的客户端里,这个角色永远停在原地:名字还在、模型还在,攻击它没有反应,重新登录后出现两个自己。更常见的是移动差量算错:只发了 enter 没发 leave,或者先删格子再算差量,导致离开集合永远为空。
修复要保证移除是单一入口:任何删除实体、切图、下线的路径都先调用 Remove 拿到离开名单,再删业务数据;Remove 内部先算可见集合再改成员表。测试要显式断言“移除返回的集合”和“移除后对方视野为空”,而不是只测进入。
小结
九宫格 AOI 用格子距离定义可见性,进入和离开都只发差量;同帧多次移动先合并再算差量;边界用抑制带减少抖动,但真实坐标不能被 AOI 格子替代;隐身只改变别人是否看得见自己。实现上把成员表、可见集合和差量计算收口到一个结构里,移除走单一入口,离开事件才不会丢。
常见错误与边界
- 每次移动都全量广播周围实体,带宽随人数平方增长。
- 只广播进入不广播离开,客户端留下幽灵角色。
- 先删格子成员再算差量,离开集合永远为空。
- 用 AOI 格子坐标代替真实坐标做碰撞和技能判定。
- 抑制带设得过大,玩家贴脸却看不见对方。
- 隐身时把实体从成员表删除,隐身者自己也失去视野。
- 同一帧多次移动逐次广播,格线附近产生大量抖动包。
- 下线、切图、死亡各写一套删除逻辑,总有一条路径漏掉 AOI。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。