5.1 服务器权威移动
前四章解决了「谁在哪、谁能看见谁」:实体有句柄、场景有实例、AOI 决定广播范围。但这些都建立在一个前提上——位置是可信的。这一节就来解决位置本身从哪来。
如果让客户端上报自己的坐标,服务端只负责转发,那么一张改内存的客户端就能瞬移、加速、穿墙。更糟的是,这类作弊在服务端看来和正常玩家没有区别,等你想封号时已经找不到判据。所以 MMO 的通行做法只有一条:位置由服务器独占计算,客户端只能提交「我想去哪」,不能提交「我在哪」。
难点在于,服务器算位置不能随心所欲。一帧可能收到同一玩家十条请求,也可能一条都收不到;如果按「收到就推进」,不同玩家的时间轴就会漂移,两个人的相对位置在各自的机器上对不上。本节的做法是把网络收发与逻辑推进彻底解耦:位置只在固定步长里推进,无论这段时间收到了多少请求。全部代码是 L1,go test 可以直接跑。
本章与站内既有内容的分工
站内已有一篇文章讲过权威移动与校验的架构取舍,它负责「为什么这么做、系统怎么切分」。本节不复述结论,只做一件事:把权威移动的状态机从零写出来并跑通。分工如下。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/authoritative-movement-validation-in-game-servers/ | 权威移动与校验的整体架构、服务端与客户端的职责边界 | 只写服务端这一侧:逻辑坐标、固定步长状态机与只广播动了的实体 |
逻辑坐标:先把「位置」这个类型定死
权威移动的第一块地基是坐标类型。服务端用的是逻辑坐标,与客户端的像素坐标彻底分开:客户端一屏多少像素、缩放多少倍、镜头偏到哪,全是渲染层的事,服务端一概不关心。服务端只认一个连续量,双方约定好它和像素的换算比例即可。
格边长 GridSize = 8.0 是与上一章 AOI 九宫格共用的口径——AOI 一格八米、寻路一格八米、移动校验一格八米,三个子系统说的「一格」必须是同一个物理尺度,否则会出现「AOI 判定同格、寻路却认为隔了一堵墙」的错位。下面这段是 L1,go test ./internal/move/ 可以直接跑:
// file: internal/move/vec2.go
// tier: L1
// verify: go test ./internal/move/
package move
import "math"
// GridSize 是逻辑坐标的格边长。AOI 九宫格、寻路网格与移动校验共用这一口径,
// 保证「一格」在三个子系统里是同一个物理尺度,不会出现 AOI 一格八米、
// 寻路一格十米的错位。
const GridSize = 8.0
// Vec2 是服务器的浮点逻辑坐标,与客户端像素坐标分离。
// 服务端只认逻辑坐标,渲染缩放、镜头偏移全部由客户端自己做。
type Vec2 struct{ X, Y float64 }
func (a Vec2) Add(b Vec2) Vec2 { return Vec2{a.X + b.X, a.Y + b.Y} }
func (a Vec2) Sub(b Vec2) Vec2 { return Vec2{a.X - b.X, a.Y - b.Y} }
func (a Vec2) Scale(s float64) Vec2 { return Vec2{a.X * s, a.Y * s} }
func (a Vec2) Len() float64 { return math.Hypot(a.X, a.Y) }
func (a Vec2) LenSq() float64 { return a.X*a.X + a.Y*a.Y }
// Normalize 返回同方向的单位向量;零向量返回零向量而不是 NaN。
// 浮点坐标里 0/0 会变成 NaN,一旦写进权威位置就再也拉不回来。
func (a Vec2) Normalize() Vec2 {
if l := a.Len(); l > 0 {
return Vec2{a.X / l, a.Y / l}
}
return Vec2{}
}
// Distance 是两点欧氏距离。
func Distance(a, b Vec2) float64 { return a.Sub(b).Len() }
// CellOf 把逻辑坐标映射到格坐标,供 AOI 与寻路网格共用。
func CellOf(p Vec2) (int, int) {
return int(math.Floor(p.X / GridSize)), int(math.Floor(p.Y / GridSize))
}
// CellCenter 是格坐标的中心点,寻路结果转成逻辑坐标时用。
func CellCenter(x, y int) Vec2 {
return Vec2{X: (float64(x) + 0.5) * GridSize, Y: (float64(y) + 0.5) * GridSize}
}
两个刻意的选择值得说明。Normalize 对零向量返回零向量而不是 NaN——Vec2{0,0}.Normalize() 若按 0/0 算就是 NaN,而 NaN 参与任何比较都是 false,一旦它混进权威位置,后面所有 dist == 0 的判断都会失效,位置再也拉不回来。CellOf 用 math.Floor 而不是 int() 截断:负坐标下 int(-0.5) 是 0,会让 -8 到 0 的整段被错划进第 0 格,跨格判定整体偏移。
固定步长:位置只在 tick 里推进
有了坐标类型,权威移动的核心就是 Mover 状态机。它只有四个字段:权威位置 Pos、速度 Speed、目标点 Goal、是否在动 Moving。关键约束是——Pos 只能被 Step 改写,外部没有任何接口能直接写它;客户端发来的请求只能改 Goal。
Step(dt) 是唯一的推进入口,它按「剩余距离」和「本 tick 能走的距离」取小,到达目标时吸附到 Goal 并停下,绝不越过。这一点常被写错:如果实现成「无条件 pos += dir*speed*dt」,角色会以半格为单位在目标点左右反复横跳,看起来一直在抖。下面这段同样是 L1,go test ./internal/move/ 会一并跑到:
// file: internal/move/mover.go
// tier: L1
// verify: go test ./internal/move/
package move
import (
"errors"
"sort"
)
// CmdMoveReq 0x0201:客户端发来的「我要去某点」请求。
// 载荷里是目标点,不是坐标本身——坐标永远由服务器算。
const CmdMoveReq uint16 = 0x0201
// CmdMoveResp 0x0202:服务器对 MoveReq 的应答,拒绝时带原因码。
const CmdMoveResp uint16 = 0x0202
// TickRate 是服务器移动模拟的固定频率(Hz)。逻辑推进与网络收发解耦:
// 无论一帧收到多少条 MoveReq,位置都只按固定步长推进。
const TickRate = 20
// TickSeconds 是每个 tick 的逻辑时长(20Hz → 50ms)。
const TickSeconds = 1.0 / TickRate
// MaxRequestDistance 是单次 MoveReq 允许的目标点最远距离。
// 客户端只能「点一下附近」,不能一次传一个跨半张地图的坐标。
const MaxRequestDistance = 3 * GridSize
var (
ErrGoalTooFar = errors.New("move: 目标点超出单次请求上限")
ErrBadSpeed = errors.New("move: 速度非正")
)
// Mover 是单个实体的权威移动状态机。
// 关键:Pos 只能被 Step 改写,客户端无法直接写它。
type Mover struct {
Pos Vec2 // 权威位置(服务器独占)
Speed float64 // 逻辑单位 / 秒
Goal Vec2 // 当前移动目标
Moving bool
}
// NewMover 创建位于 pos、以 speed 逻辑单位/秒移动的实体。
func NewMover(pos Vec2, speed float64) *Mover {
return &Mover{Pos: pos, Speed: speed}
}
// Request 处理一条 MoveReq:把目标点写进 Goal。
// 目标点过远直接拒绝,而不是「截断到边界」——截断会让客户端与服务器
// 对终点的理解不一致,反而制造持续的位置漂移。
func (m *Mover) Request(goal Vec2) error {
if m.Speed <= 0 {
return ErrBadSpeed
}
if Distance(m.Pos, goal) > MaxRequestDistance {
return ErrGoalTooFar
}
m.Goal = goal
m.Moving = Distance(m.Pos, goal) > 1e-9
return nil
}
// Step 推进 dt 秒,返回本 tick 的位移(供 AOI 与广播判断是否需要通知)。
// 到达目标时吸附到 Goal 并停下,不做过冲。
func (m *Mover) Step(dt float64) Vec2 {
if !m.Moving || dt <= 0 {
return Vec2{}
}
to := m.Goal.Sub(m.Pos)
dist := to.Len()
if dist == 0 {
m.Moving = false
return Vec2{}
}
step := m.Speed * dt
var next Vec2
if step >= dist {
next = m.Goal
m.Moving = false
} else {
next = m.Pos.Add(to.Scale(step / dist))
}
delta := next.Sub(m.Pos)
m.Pos = next
return delta
}
// World 用固定步长统一推进所有实体。
type World struct {
Movers map[uint64]*Mover
moved []uint64 // 复用切片,避免每 tick 分配
}
// NewWorld 创建空世界。
func NewWorld() *World {
return &World{Movers: make(map[uint64]*Mover)}
}
// StepAll 用同一个 dt 推进所有实体,返回本 tick 真正产生位移的实体 ID。
// 只广播「动了的」是移动同步省带宽的第一原则。返回结果排过序,
// 保证同一份输入在任何机器上产生同一份广播顺序,便于回放与对账。
func (w *World) StepAll(dt float64) []uint64 {
w.moved = w.moved[:0]
for id, m := range w.Movers {
if d := m.Step(dt); d != (Vec2{}) {
w.moved = append(w.moved, id)
}
}
sort.Slice(w.moved, func(i, j int) bool { return w.moved[i] < w.moved[j] })
return w.moved
}
Request 里「过远就拒绝」而不是「截断到最远点」是刻意的:如果截断,客户端以为自己在 A 点、服务器把角色放在 B 点,两者从第一帧起就分叉,之后每一次校验都在放大这个差值。直接拒绝、让客户端重新发一个近一点的目标,反而收敛得更快。
World.StepAll 承担两件事。一是用同一个 dt 推进所有人——同一帧内所有实体共享一个时间轴,这是「两人的相对位置在任何机器上一致」的前提。二是返回「本 tick 真正动了」的实体列表,让上层只对这部分广播:一万人在线时,绝大多数玩家每帧其实没动,省下的广播量非常可观。返回前排序,是为了让广播顺序可复现——同样的输入在任何机器上产生同样的输出,回放和事后对账才有意义。
请求怎么上线路:载荷里没有「我在哪」
请求本身怎么走网络?按卷一定稿的固定头,CmdMoveReq 0x0201 落在帧载荷里。载荷只装「目标点」与客户端时间戳,不装当前位置——这是权威移动在协议层最直接的体现:协议里根本没有让客户端上报坐标的字段,作弊者想改也无从改起。下面这段是同一个 internal/move 包里的载荷编解码,L1,go test 会一并跑到:
// file: internal/move/wire.go
// tier: L1
// verify: go test ./internal/move/
package move
import (
"encoding/binary"
"errors"
"math"
)
// ErrShortPayload 表示 MoveReq 载荷长度不足。
var ErrShortPayload = errors.New("move: 载荷长度不足")
// EncodeMoveReq 把「我要去哪」编成 CmdMoveReq 0x0201 的载荷。
// 载荷只装目标点与客户端时间戳,不装「我在哪」——这是权威移动在协议层的体现。
// 帧头 11 字节(length|cmd|seq|flags)见卷一,这里只负责载荷。
func EncodeMoveReq(goal Vec2, clientTimeMs uint32) []byte {
buf := make([]byte, 0, 20)
buf = binary.BigEndian.AppendUint64(buf, math.Float64bits(goal.X))
buf = binary.BigEndian.AppendUint64(buf, math.Float64bits(goal.Y))
buf = binary.BigEndian.AppendUint32(buf, clientTimeMs)
return buf
}
// DecodeMoveReq 解析载荷。任何越界都返回错误,绝不 panic——
// 越界输入来自网络,是攻击者可控的。
func DecodeMoveReq(b []byte) (Vec2, uint32, error) {
if len(b) < 20 {
return Vec2{}, 0, ErrShortPayload
}
x := math.Float64frombits(binary.BigEndian.Uint64(b[0:8]))
y := math.Float64frombits(binary.BigEndian.Uint64(b[8:16]))
return Vec2{x, y}, binary.BigEndian.Uint32(b[16:20]), nil
}
坐标用 float64 直传而不是压成 float32:逻辑坐标要参与权威位置的累加,float32 只有约 7 位十进制有效数字,走几万步后误差会累积到肉眼可见;20 字节的载荷在移动报文里完全可以接受。DecodeMoveReq 先查长度再切片,和卷一编解码器是同一条纪律——越界输入来自网络,必须返回错误而不是 panic。注意载荷里那个 clientTimeMs 只是给客户端做预测回滚用的,服务器不拿它当权威时间,否则客户端改一个时间戳就能让速度校验失效(这正是 5.3 要防的)。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go test ./internal/move/
ok aegis/internal/move 0.293s (14 个 test 全绿)
$ go test ./internal/move/ -bench BenchmarkStepAll -benchtime=200x
BenchmarkStepAll-10 200 83204 ns/op 0 B/op 0 allocs/op
StepAll 推进 10000 个实体一次只要约 83 微秒,且零分配——因为复用了 moved 切片,每 tick 不产生垃圾,20Hz 下一小时也压不出 GC 压力。以上数字是同机相对量级,绝对数值不可移植。状态机只解决了「怎么走」,下一节解决「往哪走」:A* 与导航网格。
阅读导航:上一节:目录 · 下一节:5.2 A* 与导航网格 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。