一、地图实例是一组状态,不是一张坐标表
一个地图实例至少要回答五个问题:这张图是什么、现在有谁、谁在谁的视野里、这一帧发生了什么、事件什么时候发。只存一个 map[EntityID]*Entity 只能回答第二个问题;AOI、tick 和事件队列缺一个,快照与广播就会各自维护一份坐标,最后谁都不知道哪份是权威值。
| 组成 | 职责 | 本实现的落点 |
|---|---|---|
| 地图定义 | 地图 ID、格子大小、静态数据版本 | Map.ID、GridAOI.cellSize |
| 实体表 | 当前实体及其权威状态 | entities map[EntityID]*entityState |
| AOI 索引 | 实体到格子的反向索引 | GridAOI.cells 与 where |
| tick 状态 | 当前帧号、上次广播帧 | Map.tick、lastSentTick |
| 事件队列 | 本帧待广播的 leave/enter/update | Tick 的返回值 |
静态地图数据(阻挡、出生点、区域)属于地图定义,不应塞进实体表;它由加载器按地图 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。
- 把静态地图数据塞进实体表,热更时误删玩家状态。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。