《游戏服务端编程实践》7.3.1 地图数据结构设计

从地图定义、实体表、AOI 索引、tick 状态与事件队列拆解一个地图实例,给出单 goroutine 拥有状态的 Map 实现,讨论实体句柄的代际校验、删除泄漏、范围查询与快照可见性,并给出加入、移除、快照一致性以及负坐标格子的测试要点,帮助读者把地图状态收敛到清晰的 ownership 边界。

一、地图实例是一组状态,不是一张坐标表

一个地图实例至少要回答五个问题:这张图是什么、现在有谁、谁在谁的视野里、这一帧发生了什么、事件什么时候发。只存一个 map[EntityID]*Entity 只能回答第二个问题;AOI、tick 和事件队列缺一个,快照与广播就会各自维护一份坐标,最后谁都不知道哪份是权威值。

组成职责本实现的落点
地图定义地图 ID、格子大小、静态数据版本Map.ID、GridAOI.cellSize
实体表当前实体及其权威状态entities map[EntityID]*entityState
AOI 索引实体到格子的反向索引GridAOI.cells 与 where
tick 状态当前帧号、上次广播帧Map.tick、lastSentTick
事件队列本帧待广播的 leave/enter/updateTick 的返回值

静态地图数据(阻挡、出生点、区域)属于地图定义,不应塞进实体表;它由加载器按地图 ID 准备,地图实例只持有版本和引用。动态实体才进入 entities。把两者混在一起,热更地图时会误删玩家,或者把玩家状态序列化进静态文件。

二、实体 ID 与句柄

EntityID 是业务标识,会出现在协议、日志和数据库里;EntityHandle 是代码内的引用凭证,由 ID + Gen 组成。删除实体时递增 gens[ID],旧句柄的代际对不上,就不会误命中同 ID 的新实体。

只存 ID 的引用在“删除后复用 ID”时最危险:旧 AI 目标、旧技能锁定、旧聊天对象都还拿着这个 ID,新实体一创建,这些引用全部指向错误目标。代际校验把这类悬空引用变成明确的 ErrInvalidHandle,比静默命中容易排查。

ID 0 保留为无效值,自动分配从 1 开始。代际表在实体删除后保留一个 uint32,这是有意为之:没有墓碑就无从判断旧句柄。单地图内实体数量有界时,这点内存远小于一次错误广播的代价。

三、并发模型:单 goroutine 拥有状态

Map 的所有方法都假定调用方持有地图所有权,内部不加锁。外部请求先投递到地图的命令队列,由地图 goroutine 串行执行;查询通过副本返回,调用方不能拿到 *Entity。

失败场景很具体:两个 goroutine 同时移动同一个实体,都读到旧格子 0,一个把实体写进格子 1,另一个把它写回格子 2。where 最终记录格子 2,但 cells[1] 里可能还残留这个 ID,cells[2] 也可能缺项。之后范围查询看不到玩家,广播发给空格子,删除时只清掉一个格子,另一个格子永久泄漏。

“每个方法加一把 mutex”并不能自动修复这个问题,因为两次读改写之间仍会交错;锁必须覆盖“校验句柄、改实体、改 AOI、生成事件”这一整段状态机。与其在每个方法里铺锁,不如把所有权收敛到一个 goroutine,锁的粒度问题直接消失。

四、快照与增量:新进入者先看什么

新玩家进入地图时,先发一份可见实体快照,之后只发增量事件。快照不是全图导出:它必须走与广播相同的 AOI 和可见性规则,只包含观察者半径内、未被隐藏的实体,并带上观察者自己。

快照和增量之间要有统一的 tick 基准。常见做法是:在 tick N 结束时生成快照并标记 baseTick=N,从 tick N+1 起只发事件;客户端先应用快照,再按 tick 顺序应用增量。若快照在事件生成之后才组装,同一实体可能在快照和增量里各出现一次,表现为“刚进图就看到残影”。

快照按实体 ID 排序不是为了美观,而是为了让测试、回放和问题复现有一致的字节序;顺序不稳定时,抓包对比会制造大量假差异。

五、Go 实现

Map 持有实体表、AOI 索引和代际表;AddEntity、RemoveEntity、QueryRange、SnapshotFor 都不加锁,由调用方保证单 goroutine 访问。SnapshotFor 先按格子粗筛,再按圆形半径精筛,隐藏实体不会进入别人的快照。

目录名是 ch07/map,但 map 是 Go 关键字,不能作为包名;文件统一使用 package worldmap,导入路径仍是 gspractice/ch07/map。目录名与包名不一致不会影响 go test,但阅读代码时要先看清包声明。

// file: ch07/map/map.go
// tier: L1
// verify: go test ./ch07/map/

package worldmap

import (
	"errors"
	"math"
	"sort"
)

var (
	ErrEntityExists   = errors.New("map: entity already exists")
	ErrEntityNotFound = errors.New("map: entity not found")
	ErrInvalidHandle  = errors.New("map: stale entity handle")
)

// EntityID 在单张地图内唯一,跨地图需要再加 mapID。
type EntityID uint64

type Vec2 struct {
	X float64
	Y float64
}

func (v Vec2) Distance(o Vec2) float64 {
	return math.Hypot(v.X-o.X, v.Y-o.Y)
}

// Entity 是地图对外可见的实体状态。
type Entity struct {
	ID        EntityID
	Pos       Vec2
	Dir       float64 // 弧度
	Speed     float64 // 单位/秒
	ViewRange float64
	Hidden    bool
}

// EntityHandle 用“ID + 代”避免删除后旧引用误命中新实体。
type EntityHandle struct {
	ID  EntityID
	Gen uint32
}

type Cell struct {
	X int
	Y int
}

// GridAOI 只负责格子归属,不解释业务语义。
type GridAOI struct {
	cellSize float64
	cells    map[Cell]map[EntityID]struct{}
	where    map[EntityID]Cell
}

func NewGridAOI(cellSize float64) (*GridAOI, error) {
	if cellSize <= 0 {
		return nil, errors.New("aoi: cell size must be positive")
	}
	return &GridAOI{
		cellSize: cellSize,
		cells:    make(map[Cell]map[EntityID]struct{}),
		where:    make(map[EntityID]Cell),
	}, nil
}

func (g *GridAOI) CellSize() float64 {
	return g.cellSize
}

// CellOf 使用 floor,负坐标才会落到正确的左侧/下方格子。
func (g *GridAOI) CellOf(pos Vec2) Cell {
	return Cell{
		X: int(math.Floor(pos.X / g.cellSize)),
		Y: int(math.Floor(pos.Y / g.cellSize)),
	}
}

func (g *GridAOI) Add(id EntityID, pos Vec2) Cell {
	c := g.CellOf(pos)
	g.setCell(id, c)
	g.where[id] = c
	return c
}

func (g *GridAOI) Remove(id EntityID) {
	c, ok := g.where[id]
	if !ok {
		return
	}
	delete(g.cells[c], id)
	if len(g.cells[c]) == 0 {
		delete(g.cells, c)
	}
	delete(g.where, id)
}

// Move 返回移动前后的格子;只有跨格才算 changed。
func (g *GridAOI) Move(id EntityID, pos Vec2) (from, to Cell, changed bool) {
	to = g.CellOf(pos)
	from, ok := g.where[id]
	if !ok {
		g.Add(id, pos)
		return Cell{}, to, true
	}
	if from == to {
		return from, to, false
	}
	delete(g.cells[from], id)
	if len(g.cells[from]) == 0 {
		delete(g.cells, from)
	}
	g.setCell(id, to)
	g.where[id] = to
	return from, to, true
}

func (g *GridAOI) EntitiesIn(c Cell) []EntityID {
	out := make([]EntityID, 0, len(g.cells[c]))
	for id := range g.cells[c] {
		out = append(out, id)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

func (g *GridAOI) Count() int {
	return len(g.where)
}

func (g *GridAOI) setCell(id EntityID, c Cell) {
	set := g.cells[c]
	if set == nil {
		set = make(map[EntityID]struct{})
		g.cells[c] = set
	}
	set[id] = struct{}{}
}

type entityState struct {
	Entity
	hasSent      bool
	lastSentTick uint64
	lastSentPos  Vec2
	lastSentDir  float64
}

// Map 的状态只允许地图 goroutine 读写;对外的查询返回副本。
type Map struct {
	ID       string
	aoi      *GridAOI
	entities map[EntityID]*entityState
	gens     map[EntityID]uint32
	nextID   EntityID
	tick     uint64
}

func NewMap(id string, cellSize float64) (*Map, error) {
	aoi, err := NewGridAOI(cellSize)
	if err != nil {
		return nil, err
	}
	return &Map{
		ID:       id,
		aoi:      aoi,
		entities: make(map[EntityID]*entityState),
		gens:     make(map[EntityID]uint32),
	}, nil
}

func (m *Map) AddEntity(pos Vec2) (EntityHandle, error) {
	m.nextID++
	return m.AddEntityAt(m.nextID, pos)
}

func (m *Map) AddEntityAt(id EntityID, pos Vec2) (EntityHandle, error) {
	if id == 0 {
		return EntityHandle{}, errors.New("map: entity id 0 is reserved")
	}
	if _, ok := m.entities[id]; ok {
		return EntityHandle{}, ErrEntityExists
	}
	h := EntityHandle{ID: id, Gen: m.gens[id]}
	m.entities[id] = &entityState{Entity: Entity{ID: id, Pos: pos}}
	m.aoi.Add(id, pos)
	return h, nil
}

func (m *Map) RemoveEntity(h EntityHandle) error {
	if _, err := m.entity(h); err != nil {
		return err
	}
	delete(m.entities, h.ID)
	m.gens[h.ID]++
	m.aoi.Remove(h.ID)
	return nil
}

func (m *Map) Get(h EntityHandle) (Entity, bool) {
	st, err := m.entity(h)
	if err != nil {
		return Entity{}, false
	}
	return st.Entity, true
}

func (m *Map) Len() int {
	return len(m.entities)
}

func (m *Map) TickCount() uint64 {
	return m.tick
}

func (m *Map) entity(h EntityHandle) (*entityState, error) {
	if m.gens[h.ID] != h.Gen {
		return nil, ErrInvalidHandle
	}
	st, ok := m.entities[h.ID]
	if !ok {
		return nil, ErrInvalidHandle
	}
	return st, nil
}

// QueryRange 先按格子粗筛,再按圆形半径精筛;隐藏实体不返回。
func (m *Map) QueryRange(center Vec2, radius float64) []EntityID {
	if radius < 0 {
		radius = 0
	}
	c := m.aoi.CellOf(center)
	span := int(math.Ceil(radius / m.aoi.CellSize()))
	out := make([]EntityID, 0, 8)
	for y := c.Y - span; y <= c.Y+span; y++ {
		for x := c.X - span; x <= c.X+span; x++ {
			for id := range m.aoi.cells[Cell{X: x, Y: y}] {
				st := m.entities[id]
				if st == nil || st.Hidden {
					continue
				}
				if st.Pos.Distance(center) <= radius {
					out = append(out, id)
				}
			}
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

// SnapshotFor 返回观察者可见的实体副本;观察者自己始终包含在内。
func (m *Map) SnapshotFor(h EntityHandle, radius float64) ([]Entity, error) {
	observer, err := m.entity(h)
	if err != nil {
		return nil, err
	}
	if radius <= 0 {
		radius = observer.ViewRange
	}
	seen := make(map[EntityID]Entity)
	seen[observer.ID] = observer.Entity
	for _, id := range m.QueryRange(observer.Pos, radius) {
		if st := m.entities[id]; st != nil {
			seen[id] = st.Entity
		}
	}
	out := make([]Entity, 0, len(seen))
	for _, ent := range seen {
		out = append(out, ent)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out, nil
}

测试在 map_test.go 中:加入后能按句柄读取;删除后实体表和 AOI 计数同时归零;旧句柄返回 ErrInvalidHandle;同 ID 重建后代际递增;范围查询过滤超距和隐藏实体;快照按 ID 排序且包含观察者自己;格子移动在负坐标下也落到正确格子。

六、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch07/map && go vet ./ch07/map/ && go test ./ch07/map/ -count=1
ok  	gspractice/ch07/map	0.424s

小结

地图实例的核心不是实体容器,而是“谁拥有状态”的边界。实体表、AOI 索引、tick 和事件队列必须由同一个 goroutine 串行维护;句柄用代际挡住悬空引用;快照与增量共享同一套可见性规则和 tick 基准。把这些约束写进类型和测试,后续广播与位置同步才有稳定的地基。

常见错误与边界

  • 多个 goroutine 直接调用 Move 或修改 entities,导致 AOI 索引与实体表不一致。
  • 删除实体只删实体表,不删 AOI 格子,实体数量下降但内存和查询结果仍残留。
  • 复用实体 ID 却不递增代际,旧目标句柄静默命中新实体。
  • 快照直接遍历全图,或者忘记过滤 Hidden 与超出视距的实体。
  • 快照与增量没有统一 tick,客户端收到重复 enter 或漏掉 leave。
  • 把静态地图数据塞进实体表,热更时误删玩家状态。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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