《MMO 服务器开发》17.2 登录、移动与广播端到端

三个真实客户端连上网关、登录进世界、各自移动,靠一套 AOI 均匀网格做到彼此可见。本节讲清「世界是一个 actor」的事件循环、权威移动的速度校验、以及 AOI 广播只发「集合差」的做法,再用一个 L2 测试与真起三进程的日志证明这条链路确实跑通、瞬移确实被拒。

17.2 登录、移动与广播端到端

上一节把零件装配成了可执行文件,但「能启动」离「能用」还差一整条业务链路。这一节要回答一个具体问题:三个客户端连上来,登录进世界、各自走两步,它们能不能互相看见? 这正是 MMO 最核心的那件事——不是「我动了」,而是「别人看见我动了」。

这件事难在三处。第一是状态归属:谁改位置、谁判合法、谁决定谁能看见谁,如果分散在多个 goroutine 里,就必须到处加锁。第二是权威边界:客户端说「我在 (9999, 0)」,服务端凭什么信?第三是广播成本:把每个位置变化告诉所有人是 O(N²),几百人就是灾难。

本节的做法:把整个世界做成一个 actor,用单 goroutine 串行所有状态变更,从而一把锁都不用;位置由服务端裁决,速度超限直接拒绝;广播只发集合差,用一张均匀网格把「谁能看见谁」压到常数时间。代码仍在 /tmp/mmobook/ch/ch17/。

世界是一个 actor:单 goroutine 串行所有状态变更

第 10 章讲过消息驱动的实体,这里把它放大到整个「世界」:World 内部有 sessions、byName、grid 三张表,它们只被一个 goroutine 读写。每条连接有一个读循环,读到的帧不直接处理,而是投进世界的邮箱 inbox;世界 goroutine 从邮箱里一条条取出来,串行处理。下面这段是 L2 的核心,go test ./internal/gateway/ 会真起监听并跑通:

// snippet: internal/gateway/world.go(世界 goroutine 与事件分派)

// event 是投进世界邮箱的一条消息。所有世界状态变更都由世界 goroutine
// 串行消费这些事件完成,因此 World 内部无需任何锁。
type event struct {
	kind  eventKind
	sess  *Session
	frame proto.Frame
}

// World 是 aegis 的权威世界:一个 actor,所有消息进它的邮箱,单线程处理。
type World struct {
	cfg    Config
	ln     net.Listener
	router *Router
	sched  *Scheduler
	grid   *Grid

	inbox   chan event
	stopped chan struct{}
	wg      sync.WaitGroup

	// 以下状态只由世界 goroutine 读写。
	sessions map[uint64]*Session
	byName   map[string]*Session
	nextID   uint64
}

// run 是世界 goroutine:串行消费邮箱事件与定时任务,直到收到关停事件。
func (w *World) run() {
	defer w.wg.Done()
	defer close(w.stopped)
	ticker := time.NewTicker(w.cfg.Tick)
	defer ticker.Stop()
	for {
		select {
		case e := <-w.inbox:
			if e.kind == evShutdown {
				w.closeAll()
				return
			}
			w.handle(e)
		case now := <-ticker.C:
			w.sched.Run(now) // 心跳清扫等周期任务,也在同一个 goroutine 里跑
		}
	}
}

// handle 分派一条事件。它在世界 goroutine 里运行,可安全改状态。
func (w *World) handle(e event) {
	switch e.kind {
	case evConnect:
		w.onConnect(e.sess)
	case evFrame:
		w.router.Dispatch(e.sess, e.frame)
	case evDisconnect:
		w.onDisconnect(e.sess)
	}
}

读循环 serveConn 里没有任何业务:它 proto.NewDecoder(conn) 逐帧读出,更新一次活跃时间,然后 w.post(event{kind: evFrame, ...})。帧的解析在连接 goroutine,帧的处理在世界 goroutine——这条边界是「一把锁都不用」的全部秘密。

代价也必须说清:世界 goroutine 是单点,所有帧的处理能力加总就是它的吞吐上限。好处是逻辑永远串行,sessions 的并发 bug 从根上消失;坏处是它一旦被某个 handler 卡住,全世界都停。所以 send 到客户端时用的是非阻塞投递 + 丢弃计数,绝不在世界 goroutine 里等一个慢客户端。

权威移动:先校验,再广播

移动请求 CmdMoveReq 的载荷是目标坐标 {x, y}。服务端不会无条件采纳——它先算「这次位移除以时间」得到速度,超过 MaxSpeed 就拒绝并回 CmdErrorNotify。通过之后才改状态,并把变化广播给邻居。

// snippet: internal/gateway/world.go(权威移动与 AOI 广播)

// onMove 是权威移动的入口:先做速度校验,通过后再改状态并向 AOI 邻居广播。
func (w *World) onMove(s *Session, f proto.Frame) {
	if !s.inWorld {
		s.send(errorFrame(f.Seq, "move before enter world"))
		return
	}
	var req moveReq
	if err := json.Unmarshal(f.Payload, &req); err != nil {
		s.send(errorFrame(f.Seq, "bad move payload"))
		return
	}
	to := Vec3{X: req.X, Y: req.Y}
	now := time.Now()
	dt := now.Sub(s.lastMoveAt).Seconds()
	if s.lastMoveAt.IsZero() || dt < 0.02 {
		dt = 0.02 // 首个移动或同 tick 内连发,给 dt 一个下限,避免误判
	}
	if d := distance(s.Pos, to); d/dt > w.cfg.MaxSpeed {
		log.Printf("[world] #%d 移动被拒: %.2f 单位 / %.0f ms 超过 %.0f 单位/s",
			s.ID, d, dt*1000, w.cfg.MaxSpeed)
		s.send(errorFrame(f.Seq, "move rejected: speed"))
		return
	}
	from := s.Pos
	w.grid.Move(s, from, to)
	s.Pos = to
	s.lastMoveAt = now

	body, _ := json.Marshal(moveResp{OK: true, X: to.X, Y: to.Y})
	s.send(proto.Frame{Cmd: proto.CmdMoveResp, Seq: f.Seq, Payload: body})
	w.fanoutMove(s, from, to)
}

// fanoutMove 比较移动前后的可见集,只对「新进入」和「已离开」的实体发通知。
func (w *World) fanoutMove(s *Session, from, to Vec3) {
	oldN := w.neighborSet(from, s.ID)
	newN := w.neighborSet(to, s.ID)
	for id := range newN {
		if oldN[id] {
			continue
		}
		if other := w.sessions[id]; other != nil {
			other.send(aoiAddFrame(s)) // 对方开始能看见我
			s.send(aoiAddFrame(other)) // 我开始能看见对方
			w.metrics.AoiAdds.Add(2)
		}
	}
	for id := range oldN {
		if newN[id] {
			continue
		}
		if other := w.sessions[id]; other != nil {
			other.send(aoiRemoveFrame(s.ID))
			s.send(aoiRemoveFrame(id))
			w.metrics.AoiRemoves.Add(2)
		}
	}
}

广播的窍门全在「只发集合差」:移动前可见的集合是 oldN,移动后是 newN,只对 newN - oldN(新进入视野)发 CmdAoiAdd,只对 oldN - newN(离开视野)发 CmdAoiRemove。如果每次都把附近所有人重发一遍,带宽会随移动频率线性膨胀。

neighborSet 背后是一张均匀网格(第 12 章):单元边长等于 AOIRadius,查邻居只扫当前单元加周围 8 格(3×3)。单元边长必须 ≥ 兴趣半径,否则第 2 圈的邻居会被漏掉。网格本身的可见判定由 TestGridNeighbors 钉住,go test ./internal/gateway/ -run TestGridNeighbors 可直接跑。

端到端 L2 断言:三个客户端互相看见

自动化的验收不看日志,只看断言。下面这个测试真起一个监听端口、真连三个客户端,断言「每个都看到另外两个」,再让其中一个走远、断言其余收到它的移除:

// snippet: internal/gateway/world_test.go(核心 L2 断言节选)

// TestLoginMoveBroadcast 是本章的核心 L2 断言:三个客户端登录进世界后,
// 每个都能看到另外两个;其中一个走远后,其余收到它的 AOI 移除。
func TestLoginMoveBroadcast(t *testing.T) {
	w := startWorld(t, Config{MaxSpeed: 1e6, AOIRadius: 30})
	addr := w.Addr().String()

	clients := map[string]*tclient{}
	ids := map[string]uint64{}
	for _, name := range []string{"alpha", "beta", "gamma"} {
		c := dialT(t, addr)
		ids[name] = loginT(t, c, name)
		clients[name] = c
	}
	for _, name := range []string{"alpha", "beta", "gamma"} {
		enterT(t, clients[name])
	}

	// 每个客户端都应看到另外两个(去重后)。
	for _, name := range []string{"alpha", "beta", "gamma"} {
		seen := map[uint64]bool{}
		for _, f := range clients[name].collect(300 * time.Millisecond) {
			if f.Cmd != proto.CmdAoiAdd {
				continue
			}
			var e struct {
				ID uint64 `json:"id"`
			}
			_ = json.Unmarshal(f.Payload, &e)
			seen[e.ID] = true
		}
		if len(seen) != 2 {
			t.Fatalf("%s 可见 %d 个邻居,期望 2(ids=%v)", name, len(seen), ids)
		}
	}

	// gamma 跳到远处(超过 AOIRadius),alpha 与 beta 应收到它的 AOI 移除。
	clients["gamma"].call(proto.CmdMoveReq, mustJSONT(map[string]float64{"x": 500, "y": 0}), proto.CmdMoveResp)
	for _, name := range []string{"alpha", "beta"} {
		got := false
		for _, f := range clients[name].collect(300 * time.Millisecond) {
			if f.Cmd != proto.CmdAoiRemove {
				continue
			}
			var e struct {
				ID uint64 `json:"id"`
			}
			_ = json.Unmarshal(f.Payload, &e)
			if e.ID == ids["gamma"] {
				got = true
			}
		}
		if !got {
			t.Fatalf("%s 未收到 gamma(#%d) 的 AOI 移除", name, ids["gamma"])
		}
	}
}

这里有个容易踩的细节:客户端收到的帧是混在一起的——CmdAoiAdd、CmdEnterWorldResp、CmdMoveResp 可能先后到达。测试客户端因此按 (cmd, seq) 匹配应答,把不匹配的帧缓存起来(pending),断言时再一次性收走,而不是假设「请求下一帧一定是应答」。

真起三个进程跑一遍

单测在进程内起监听,已经算 L2;但书稿要的是真二进制。把 world 与 gateway 各起一个进程,world 监听 127.0.0.1:19002、gateway 监听 19001 并转发到 world,再让机器人跑冒烟:

$ ./bin/aegis -role=world   -listen 127.0.0.1:19002 -aoi-radius 30 &
$ ./bin/aegis -role=gateway -listen 127.0.0.1:19001 -upstream 127.0.0.1:19002 &
$ ./bin/bot -mode=smoke -addr 127.0.0.1:19001

[alpha] 登录应答 {"ok":true,"id":1,"name":"alpha"}
[beta] 登录应答 {"ok":true,"id":2,"name":"beta"}
[gamma] 登录应答 {"ok":true,"id":3,"name":"gamma"}
[alpha] 进入世界 (x=8.0)
[beta] 进入世界 (x=16.0)
[gamma] 进入世界 (x=24.0)
[alpha] 可见 2 个邻居: [beta gamma]
[beta] 可见 2 个邻居: [alpha gamma]
[gamma] 可见 2 个邻居: [alpha beta]
[alpha] 合法移动 -> MoveResp
[alpha] 瞬移到 x=9999 -> 被拒(ErrorNotify)
smoke OK

world 侧的日志把每一步都记了下来,包括那次被拒的瞬移:

[world] #1 "alpha" 进入世界 (8.0, 0.0)
[world] #2 "beta" 进入世界 (16.0, 0.0)
[world] #3 "gamma" 进入世界 (24.0, 0.0)
[world] #1 移动被拒: 9990.90 单位 / 20 ms 超过 12 单位/s

三个客户端在 X 轴上以 8 个单位间隔出生(spawnSpacing = 8),彼此都在半径 30 内,所以每个人进世界时就和先到的人互相 AoiAdd。alpha 从 x=8 合法挪到 8.1 被接受;接着想瞬移到 x=9999,位移 9990 个单位、用时 20 毫秒,折算速度远超 12 单位/秒,直接被拒。

实测结论

本机(Go 1.26,macOS arm64)跑出来的断言结果,同机相对量级,绝对数值不可移植:

断言结果
三个客户端互相可见各收到 2 条 CmdAoiAdd,世界累计 adds=6
走远后互相不可见各收到 1 条 CmdAoiRemove,世界累计 removes=3
瞬移被拒回 CmdErrorNotify:move rejected: speed
合法小位移被接受回 CmdMoveResp{ok:true}
静默连接回收200ms 无帧后心跳清扫关闭,live 回到 0

一句话:「登录 → 进世界 → 移动 → 彼此可见 → 越界移除」这条链路是真跑通的,不是靠 mock 拼出来的。下一节把它换成压测机器人,看看这套单 goroutine 的世界能扛多少连接。

阅读导航:上一节:17.1 把前 16 章拼成 aegis · 下一节:17.3 机器人客户端与第一组承载数字 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标