《游戏服务端编程实践》7.1.3 实时区域广播

把 AOI 限定在格子与九宫格方案,说明进入离开判定、同帧多次移动的合并、边界抖动抑制与隐身可见性裁剪,并附可运行的 Go GridAOI 实现与测试,覆盖跨格、同格、抑制带、隐身切换、离开事件以及下线与切图的统一移除入口,帮助读者落地可维护的广播层。

一、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。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist