《游戏服务端编程实践》7.3.2 区域广播算法实现

地图帧内往往有几十上百个移动请求。本篇先把移动收集合并,再统一应用并生成 leave、enter、update 三类事件,给出可预测的排序、同一实体多事件合并、update 频率限制与陈旧句柄过滤的 Tick 实现,并附事件顺序、去重和阈值抑制的测试。

一、逐条广播为什么在帧内失控

最直观的写法是每收到一个移动请求就立刻算 enter/leave 并广播。单玩家时看不出问题;一帧有 200 个玩家移动时,每次移动都遍历周边实体,消息量按“移动数 × 周边实体数”增长,帧时间被广播吃光。更糟的是广播回调里如果又触发一次移动,递归会直接绕过帧预算。

逐条广播还会制造中间态。实体从格子 0 走到格子 1 再走到格子 2,格子 1 的观察者收到 enter,却没收到对应的 leave;客户端上就是一个人影闪一下就消失。事件不是越多越实时,超出 tick 节奏的“实时”只是把不一致提前暴露。

用一组示例值感受差距:一帧 200 次移动、每次周边 50 个实体,逐条广播约产生 1 万次事件;合并后若只有 120 个实体真正跨格,事件量降到几百条。实际数字取决于格子大小和玩家密度,但数量级差异来自“是否按实体合并”,而不是微调。

二、帧内先收集,再统一应用

一次 Tick 分成四段:收集本帧移动、按实体合并、统一改状态和 AOI、生成并排序事件。四段之间不再插入业务逻辑,避免“边遍历边修改”。

阶段输入输出关键约束
收集本帧所有 MoveCommand按实体去重的移动表陈旧句柄直接丢弃
合并同一实体的多次移动最后一次位置不做逐条 enter/leave
应用合并后的位置新实体状态与新 AOI先改状态,再算事件
分发事件列表排序后的 leave/enter/update排序稳定、可重放

合并只保留最后一次位置,是因为同一 tick 内的中间位置对观察者没有意义。若业务确实需要轨迹(比如冲刺判定),应把轨迹放进独立的移动日志,而不是让广播层承担动画帧。

tick(moves):
    merged = merge_by_entity(moves)      # 同一实体只保留最后位置
    for entity in merged:
        oldCell = grid.cell(entity.pos)
        newCell = grid.cell(command.pos)
        apply(entity, command)
        if oldCell != newCell:
            emit leave(oldCell, entity)
            emit enter(newCell, entity)
        else if changed(entity):
            emit update(newCell, entity)
    stable_sort(events, type, entityID, cell)

三、enter、leave、update 的顺序

本实现的事件先按类型排序:leave 在最前,enter 其次,update 最后;同类型内按实体 ID 排序。这样客户端可以先移除旧格子成员,再加入新格子成员,最后用 update 修正朝向和速度。

事件语义携带的格子何时产生
leave实体离开旧格子Cell = From跨格移动
enter实体进入新格子Cell = To跨格移动
update实体仍在原格子Cell = To同格内位置、朝向或速度变化

跨格时只发 leave + enter,不再补一条 update:enter 已经携带新位置、朝向和速度。若再发 update,客户端会多一次无意义的状态覆盖,抓包时也很难判断哪条才是最终值。事件里的 Tick 用于客户端排序和丢弃过期包,不能省略。

排序使用 sort.SliceStable,并在比较器里补上实体 ID 和格子坐标。只按类型排序时,Go 的排序不保证同类型元素的相对顺序,测试和回放会时好时坏。

一个具体的帧内序列:A 从格子 0 到 1,B 从格子 2 到 0,C 在格子 1 内微调。排序后是 leave(A)、leave(B)、enter(A)、enter(B)、update(C);客户端先清旧格子再建新格子,不会出现同名实体的两份副本。

客户端应用顺序也要固定:先处理 leave,把实体从旧格子移除;再处理 enter,创建或激活实体;最后处理 update,覆盖位置与朝向。若客户端按到达顺序直接应用,跨格与新进入同帧时,enter 可能先于 leave,旧格子里的同名实体就删不掉。

四、同一实体多事件合并

合并的收益不只是省消息,更是消除“幽灵 enter”。假设实体在同一帧内从格子 0 到格子 1 再回格子 0:如果逐条处理,格子 1 的观察者会收到 enter,却永远等不到 leave;合并后最后一次位置仍在格子 0,from == to,整帧不产生 enter/leave。

失败场景:某次技能位移先把玩家传出 AOI,再被拉回原地。逐条广播会产生两条 enter 和一条 leave,客户端顺序稍有偏差就会留下一个不可见的残留实体。合并后这一帧没有跨格事件,只有位置微调时才会产生 update,语义干净得多。

同一实体在同一帧被删除又重建时,代际校验会让旧句柄的移动命令直接失效,不会把新实体的位置写错。事件层不需要知道“删除”细节,它只处理仍然有效的句柄。

合并阶段还要保留事件的原因:跨格、同格位移、转向、速度变化。原因决定事件类型,也决定后续能否按类型裁剪;只保留最终坐标,会把“原地转向”误判成“无变化”。

如果实体在本帧被删除,删除本身应生成 leave,而不是静默从实体表消失;否则观察者只能等下一次快照才发现目标不存在。删除事件和移动事件一样,需要进入同一套排序与去重流程。

五、广播裁剪与频率限制

广播裁剪分三层:只发变化量、只发 update、按阈值抑制微小变化。enter/leave 是拓扑变化,不能省;update 是连续量,可以按 tick 间隔和位移阈值降频。

策略作用对象收益风险
仅变化实体的 update所有实体避免每帧全量需要可靠的上次发送状态
UpdateInterval同格 update降低高频小移动的包量间隔过大时客户端插值变粗糙
MoveEpsilon / DirEpsilon位置、朝向过滤抖动和原地转向阈值过大时脚步漂移
隐藏与视距过滤AOI 查询不向不可见观察者发包规则必须与快照一致

阈值只应影响 update,不应影响 enter/leave。曾见过把“位移小于 0.05 不广播”套到所有事件上的实现,结果玩家跨格时因为步长小,离开事件被吞掉,旧格子的客户端一直显示残留模型。

阈值参数应按地图类型配置,而不是全服一套:大地图载具的速度高,0.05 的位移阈值可能每帧都触发;室内小场景的步行速度低,同样的阈值会显得迟钝。示例配置只用于说明机制,上线前要用真实移动分布校准。

六、按格子扇出,还是按观察者裁剪

事件是格子级还是观察者级,决定了广播层的复杂度。

模型做法优点代价
格子扇出事件投递到格子,订阅该格子的连接接收AOI 简单,事件可缓存复用同格玩家都收到,客户端或网关还要过滤
观察者裁剪对每个观察者按距离、朝向、隐身单独过滤带宽最省,能做视野锥复杂度 O(观察者 × 周边实体)
混合enter/leave 按格子扇出,update 按观察者裁剪拓扑简单,连续量省带宽两套规则必须共享同一份可见性定义

本实现选择格子扇出:Event 只带格子,不绑定连接。小地图和同格人数可控时,这层最简单;大规模团战再在网关按观察者做二次裁剪。关键不是选哪种,而是快照、enter/leave、update 三处使用同一套可见性规则,否则客户端会看到“快照里有、增量里没有”的实体。事件只描述“哪个格子发生了什么”,具体发给哪些连接由分发层决定,这样同一批事件可以复用于广播、录像和调试回放。

七、帧预算与背压

事件生成后还要过帧预算。超过预算时不能无差别丢弃:update 可以丢,下一次有变化再补;enter/leave 是拓扑事实,丢了客户端就会缺实体或留残影。

超限类型处理理由
update 超限丢弃低优先级或最远实体下一帧可补,客户端可插值
enter 超限保留并拆分到后续 tick漏 enter 客户端看不到实体
leave 超限优先于 enter 发送漏 leave 会留下幽灵实体
持续超限降 tick 或扩容地图分片不能靠丢拓扑事件硬扛

拆帧时要保持同一实体的 leave 先于 enter,并且同一实体的 enter 不能晚于它的第一条 update。做不到这一点,客户端状态机就要额外处理乱序,复杂度会反向吞掉省下的带宽。监控上至少记录每帧三类事件数量、被裁剪的 update 数和拆帧次数;只看平均帧时间,会漏掉少数格子突然爆量的尖峰。

八、Go 实现

Tick 先按实体合并移动,再统一应用;跨格生成 leave + enter,同格生成至多一条 update;shouldSendUpdate 只在 UpdateInterval 未到且位移、转向都低于阈值时抑制。
BroadcastConfig 的默认值只是可用起点,不是性能承诺;UpdateInterval 调大能省带宽,但客户端插值要相应变强,二者必须一起评估。

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

package worldmap

import (
	"math"
	"sort"
)

type MoveCommand struct {
	Handle EntityHandle
	Pos    Vec2
	Dir    float64
	Speed  float64
}

type EventType uint8

const (
	EventLeave EventType = iota
	EventEnter
	EventUpdate
)

func (t EventType) String() string {
	switch t {
	case EventLeave:
		return "leave"
	case EventEnter:
		return "enter"
	case EventUpdate:
		return "update"
	default:
		return "unknown"
	}
}

// Event 是发给一个格子的区域事件,不是发给单个玩家的完整包。
type Event struct {
	Type   EventType
	Entity EntityID
	Cell   Cell
	From   Cell
	To     Cell
	Pos    Vec2
	Dir    float64
	Speed  float64
	Tick   uint64
}

type BroadcastConfig struct {
	// UpdateInterval 内,只有位移或转向超过阈值才补发 update。
	UpdateInterval uint64
	MoveEpsilon    float64
	DirEpsilon     float64
}

func DefaultBroadcastConfig() BroadcastConfig {
	return BroadcastConfig{UpdateInterval: 2, MoveEpsilon: 0.05, DirEpsilon: math.Pi / 180}
}

// Tick 先合并本帧所有移动,再统一应用并生成事件。
func (m *Map) Tick(moves []MoveCommand, cfg BroadcastConfig) []Event {
	m.tick++
	merged := make(map[EntityID]MoveCommand, len(moves))
	order := make([]EntityID, 0, len(moves))
	for _, mv := range moves {
		if _, err := m.entity(mv.Handle); err != nil {
			continue
		}
		if _, ok := merged[mv.Handle.ID]; !ok {
			order = append(order, mv.Handle.ID)
		}
		merged[mv.Handle.ID] = mv // 同一实体只保留最后一次位置
	}

	events := make([]Event, 0, len(merged)*2)
	for _, id := range order {
		mv := merged[id]
		st, err := m.entity(mv.Handle)
		if err != nil {
			continue
		}
		oldPos, oldDir, oldSpeed := st.Pos, st.Dir, st.Speed
		from := m.aoi.CellOf(oldPos)
		to := m.aoi.CellOf(mv.Pos)

		st.Pos, st.Dir, st.Speed = mv.Pos, mv.Dir, mv.Speed
		if from != to {
			m.aoi.Move(id, mv.Pos)
			events = append(events,
				Event{Type: EventLeave, Entity: id, Cell: from, From: from, To: to, Pos: oldPos, Dir: oldDir, Speed: oldSpeed, Tick: m.tick},
				Event{Type: EventEnter, Entity: id, Cell: to, From: from, To: to, Pos: mv.Pos, Dir: mv.Dir, Speed: mv.Speed, Tick: m.tick},
			)
			st.hasSent, st.lastSentTick, st.lastSentPos, st.lastSentDir = true, m.tick, mv.Pos, mv.Dir
			continue
		}
		if oldPos == mv.Pos && oldDir == mv.Dir && oldSpeed == mv.Speed {
			continue
		}
		if !shouldSendUpdate(st, cfg, m.tick) {
			continue
		}
		events = append(events, Event{Type: EventUpdate, Entity: id, Cell: to, From: from, To: to, Pos: mv.Pos, Dir: mv.Dir, Speed: mv.Speed, Tick: m.tick})
		st.hasSent, st.lastSentTick, st.lastSentPos, st.lastSentDir = true, m.tick, mv.Pos, mv.Dir
	}

	sort.SliceStable(events, func(i, j int) bool {
		if events[i].Type != events[j].Type {
			return events[i].Type < events[j].Type
		}
		if events[i].Entity != events[j].Entity {
			return events[i].Entity < events[j].Entity
		}
		return events[i].To.X < events[j].To.X
	})
	return events
}

func shouldSendUpdate(st *entityState, cfg BroadcastConfig, tick uint64) bool {
	if !st.hasSent {
		return true
	}
	if tick-st.lastSentTick >= cfg.UpdateInterval {
		return true
	}
	if st.Pos.Distance(st.lastSentPos) >= cfg.MoveEpsilon {
		return true
	}
	return math.Abs(st.Dir-st.lastSentDir) >= cfg.DirEpsilon
}

// CountByType 便于监控一帧事件构成。
func CountByType(events []Event) map[EventType]int {
	out := make(map[EventType]int, 3)
	for _, ev := range events {
		out[ev.Type]++
	}
	return out
}

对应测试覆盖:三类事件同时出现时的排序(两条 leave、两条 enter、一条 update);同一实体两次移动只产生一次跨格;UpdateInterval=2 时小位移被抑制、间隔到达后补发;删除后的旧句柄不产生事件;
测试使用固定格子大小 2、固定实体 ID,所有断言只依赖事件类型、实体 ID 和格子坐标,不依赖时间或 goroutine 调度;因此可以重复运行,用来抓排序和去重回归。

九、运行测试

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

小结

帧内广播的正确顺序是“先收集合并、再应用、后排序分发”,而不是收到一条就发一条。跨格只发 leave + enter,同格只发 update;enter/leave 不能做阈值裁剪,update 才适合按间隔和位移降频。
合并与代际校验一起,消除了中间态、幽灵 enter 和陈旧句柄三类常见问题。如果只能记住一句话:本帧的广播结果应当只由“本帧开始时的状态”和“本帧合并后的最终命令”决定,中间过程不对外可见。

常见错误与边界

  • 边遍历实体表边修改 AOI,导致漏发或重复处理。
  • 同一实体在同一帧内多次跨格,逐条广播产生重复 enter。
  • 只发 enter 不发 leave,旧格子客户端残留模型。
  • 把位移阈值套到 enter/leave 上,跨格事件被静默吞掉。
  • update 不做频率限制,原地转向或微小抖动打满带宽。
  • 使用不稳定的排序,事件顺序在测试和线上不一致。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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