一、世界坐标不是服务器坐标
场景编辑器里拖出来的是世界坐标,单位通常是米,类型是浮点;客户端渲染用的是屏幕坐标;服务器真正应该保存的是逻辑坐标,单位可以自定义,类型必须是整数。三套坐标混用,是地图边界、AOI 和存档错位的第一大来源。
| 坐标 | 谁产生 | 典型单位 | 用途 | 主要风险 |
|---|---|---|---|---|
| 世界坐标 | 场景编辑器、美术工具 | 米,浮点 | 摆放物件、烘焙导航 | 精度漂移、负零、NaN |
| 逻辑坐标 | 服务器 | 厘米,整数 | 碰撞、AOI、分区、存档 | 溢出、取整口径不一致 |
| 屏幕坐标 | 客户端 | 像素 | 渲染、点击 | 分辨率与缩放,不可信 |
转换点要收口:客户端上报的位置在网关或场景入口转换成逻辑坐标,之后所有服务只认整数。不要在战斗、AI、掉落各个模块里各转一次,否则同一帧里同一实体会有多个“权威位置”。
逻辑坐标的精度用整数单位表达。1 单位等于 1 厘米时,一格 100 米就是 10000 单位;int64 的表示范围远大于任何实际地图,但线上仍要设一个世界边界,例如 ±2000 公里,越界直接判为异常坐标并踢回。没有边界的整数同样会被外挂拿来构造溢出和哈希碰撞。
二、定点整数还是浮点
浮点不是不能用,而是不适合做“键”和“权威判定”。float32 在十公里量级只剩毫米级分辨率,float64 精度足够,但仍然带来三个麻烦:序列化文本的舍入、不同平台数学库的微小差异、以及 NaN/-0.0 这类无法比较的值。分区键一旦是浮点,1.0 和 1.0000000001 就是两个格子。
| 方案 | 精度 | 可复现 | 适合做分区键 | 建议 |
|---|---|---|---|---|
float32 | 约 7 位有效数字 | 差 | 否 | 只用于渲染插值 |
float64 | 约 15 位有效数字 | 较好 | 否 | 可用于物理中间计算 |
定点 int64 | 精确到单位 | 是 | 是 | 服务器权威坐标 |
定点数的代价是每次乘除都要明确取整规则。位置换算用“四舍五入到最近单位”,分区编号用“向下取整”。两种取整不能混:位置可以先 round,分区必须 floor,否则边界附近会出现一个实体同时满足两个分区的条件。
三、分区有三种切法
分区不是只有“把大地图切成方块”。选哪种,取决于地图形状、玩家密度和跨区成本。
| 方式 | 形状 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| 矩形网格 | 规则方格 | 换算 O(1),邻居固定 8 个 | 空旷区域浪费,边界笔直 | 开放世界、大世界 |
| 兴趣点/区域图 | 不规则多边形 | 贴合城镇、副本、战场 | 边界判定复杂,需要区域邻接表 | 主城、任务区 |
| 副本实例 | 与空间无关 | 状态隔离,互不影响 | 需要实例生命周期管理 | 战斗、剧情、活动 |
矩形网格最适合做基础层:分区 ID 可以直接由坐标算出,跨区只影响相邻的至多 8 个分区,广播和持久化都能按格切分。兴趣点分区适合在上层叠加,例如“主城”“拍卖行”这种逻辑区域,不要求严格覆盖整个世界。副本则是另一种维度:它不切空间,而是复制一份地图状态,因此同一坐标在不同实例里是完全不同的世界。
不要把“副本”当成“分区”的替代品。副本解决的是隔离,分区解决的是单进程承载量。一个副本内部仍然要分区,否则一个万人战场还是会把单核打满。
四、坐标到分区:半开区间与负数
约定分区覆盖左闭右开区间 [origin, origin+size)。坐标 9999 属于 0 号分区,10000 属于 1 号分区;一个点只属于一个分区,边界不会重复归属。
| 坐标 x | Go 的 x/10000 | 向下取整 | 正确分区 |
|---|---|---|---|
| 9999 | 0 | 0 | 0 |
| 10000 | 1 | 1 | 1 |
| -1 | 0 | -1 | -1 |
| -10000 | -1 | -1 | -1 |
| -10001 | -1 | -2 | -2 |
Go 的整数除法向零截断,-1 / 10000 得到 0,会把原点左侧一格错算进 0 号分区。正确做法是先做向下取整除法再转分区号。这个坑在只有正坐标的测试地图上永远暴露不出来,上线后玩家往地图左下角一走就出现“人在 0 区、数据在 -1 区”。
分区内的局部偏移同样要用 floor 后的原点计算,范围必须落在 [0, size)。如果用截断除法算原点,负坐标的局部偏移会变成负数,碰撞和 AOI 会直接错格。
五、跨区是迁移,不是复制
实体从 A 区走到 B 区,正确动作是一次所有权移交:
- 源分区停止接受该实体的移动输入,冻结当前状态。
- 目标分区预分配实体槽位,校验目标格可站立。
- 源分区把状态快照和版本号交给目标分区。
- 目标分区接管并回写新位置,源分区删除实体并广播离开。
- 任一步失败,实体留在源分区,清除目标分区的预分配。
只在两边都 Add 而不 Remove,会出现“影子实体”:两个分区同时 tick 同一个玩家,掉落翻倍、AOI 重复、下线时互相覆盖存档。反过来只删不加,玩家会从世界里消失。跨区消息必须带实体 ID、源分区、目标分区和版本号,接收方按版本拒绝重复投递。
单次移动最多跨一个分区。坐标从 0 区直接跳到 3 区,要么是客户端瞬移作弊,要么是网络包乱序导致的位置跳变,应该走显式的传送/拉回流程,而不是普通跨区。相邻分区集合固定为八邻接,不含自身,顺序固定,便于广播去重。
六、Go 实现
WorldCoord 这一层只做三件事:分区换算、局部偏移、跨区判定。坐标用 int64,分区号也用 int64,不依赖浮点。
// file: ch07/world/coord.go
// tier: L1
// verify: go test ./ch07/world/
package world
import "fmt"
// ZoneSize 是正方形分区的边长,单位与逻辑坐标一致:1 单位 = 1 厘米。
const ZoneSize int64 = 100 * 100
// WorldCoord 是服务器内部的逻辑坐标,原点是世界的左下角,允许为负。
type WorldCoord struct {
X int64
Y int64
}
func (p WorldCoord) Add(dx, dy int64) WorldCoord { return WorldCoord{X: p.X + dx, Y: p.Y + dy} }
// ZoneID 是矩形网格的分区编号。分区键必须是整数对,绝不能用浮点数。
type ZoneID struct {
X int64
Y int64
}
func (z ZoneID) String() string { return fmt.Sprintf("zone(%d,%d)", z.X, z.Y) }
// Key 是分区在哈希表中的稳定键,格式与日志一致,便于排查。
func (z ZoneID) Key() string { return fmt.Sprintf("%d:%d", z.X, z.Y) }
// FloorDiv 做向下取整除法。Go 的 / 向零截断,负数坐标会因此落错分区。
func FloorDiv(v, size int64) int64 {
if size <= 0 {
panic("world: non-positive divisor")
}
q := v / size
if v%size != 0 && v < 0 {
q--
}
return q
}
// ZoneOf 把逻辑坐标换算成分区编号。区间按左闭右开处理:
// 坐标 10000 属于 X=1 的分区,9999 仍属于 X=0。
func ZoneOf(p WorldCoord) ZoneID {
return ZoneID{X: FloorDiv(p.X, ZoneSize), Y: FloorDiv(p.Y, ZoneSize)}
}
// ZoneOrigin 返回分区左下角的逻辑坐标。
func ZoneOrigin(z ZoneID) WorldCoord {
return WorldCoord{X: z.X * ZoneSize, Y: z.Y * ZoneSize}
}
// Local 返回点相对所属分区原点的偏移,范围是 [0, ZoneSize)。
func Local(p WorldCoord) WorldCoord {
z := ZoneOf(p)
return WorldCoord{X: p.X - z.X*ZoneSize, Y: p.Y - z.Y*ZoneSize}
}
// Neighbors 返回八邻接分区(不含自身),顺序固定,便于测试与广播去重。
func (z ZoneID) Neighbors() [8]ZoneID {
return [8]ZoneID{
{X: z.X - 1, Y: z.Y - 1},
{X: z.X, Y: z.Y - 1},
{X: z.X + 1, Y: z.Y - 1},
{X: z.X - 1, Y: z.Y},
{X: z.X + 1, Y: z.Y},
{X: z.X - 1, Y: z.Y + 1},
{X: z.X, Y: z.Y + 1},
{X: z.X + 1, Y: z.Y + 1},
}
}
// CrossZone 描述一次跨区迁移:实体被从 From 移交给 To。
type CrossZone struct {
EntityID uint64
From ZoneID
To ZoneID
}
// PlanCross 在坐标变化后判断是否需要跨区;仍在同一分区时返回 nil。
func PlanCross(entityID uint64, from, to WorldCoord) *CrossZone {
a, b := ZoneOf(from), ZoneOf(to)
if a == b {
return nil
}
return &CrossZone{EntityID: entityID, From: a, To: b}
}
// ValidCross 限制一次迁移最多跨一个分区。跳跃式坐标应拆成多步,
// 或按瞬移/作弊处理,不能直接在新分区里凭空出现。
func (c CrossZone) ValidCross() bool {
dx := c.To.X - c.From.X
dy := c.To.Y - c.From.Y
if dx < -1 || dx > 1 || dy < -1 || dy > 1 {
return false
}
return dx != 0 || dy != 0
}
FloorDiv 是全篇最容易写错的一行。先判断余数是否为零,再判断被除数是否为负;size 固定为正数时条件可以简化,但保留通用写法能防止以后有人传入负的分区尺寸。
Local 先用 ZoneOf 得到 floor 后的原点,再相减,负坐标的偏移自然落回 [0, ZoneSize)。如果这里复用截断除法的原点,-1 会算出 -1,AOI 格子号跟着错一位。
PlanCross 只回答“要不要跨”,不执行迁移。真正的迁移由分区管理器负责,这样才能在失败时回滚。ValidCross 把单跳限制显式化,超距移动必须走传送流程。
七、测试
测试覆盖边界点归属、负数向下取整、分区原点与局部偏移的往返、八邻接集合,以及跨区判定。重点不是覆盖率,而是把“负数”和“边界”这两个会在线上放大的口径钉死。
// file: ch07/world/coord_test.go
// tier: L1
// verify: go test ./ch07/world/
package world
import "testing"
func TestZoneOfBoundaries(t *testing.T) {
cases := []struct {
p WorldCoord
want ZoneID
}{
{WorldCoord{X: 0, Y: 0}, ZoneID{X: 0, Y: 0}},
{WorldCoord{X: ZoneSize - 1, Y: ZoneSize - 1}, ZoneID{X: 0, Y: 0}},
{WorldCoord{X: ZoneSize, Y: ZoneSize}, ZoneID{X: 1, Y: 1}},
{WorldCoord{X: -1, Y: -1}, ZoneID{X: -1, Y: -1}},
{WorldCoord{X: -ZoneSize, Y: -ZoneSize}, ZoneID{X: -1, Y: -1}},
{WorldCoord{X: -ZoneSize - 1, Y: 0}, ZoneID{X: -2, Y: 0}},
{WorldCoord{X: ZoneSize * 3, Y: -ZoneSize * 2}, ZoneID{X: 3, Y: -2}},
}
for _, c := range cases {
if got := ZoneOf(c.p); got != c.want {
t.Fatalf("ZoneOf(%+v) = %v, want %v", c.p, got, c.want)
}
}
}
func TestZoneBoundaryIsHalfOpen(t *testing.T) {
if got := ZoneOf(WorldCoord{X: ZoneSize - 1, Y: 0}).X; got != 0 {
t.Fatalf("left of boundary = %d, want 0", got)
}
if got := ZoneOf(WorldCoord{X: ZoneSize, Y: 0}).X; got != 1 {
t.Fatalf("on boundary = %d, want 1", got)
}
if got := Local(WorldCoord{X: -1, Y: -1}); got != (WorldCoord{X: ZoneSize - 1, Y: ZoneSize - 1}) {
t.Fatalf("Local(-1,-1) = %+v", got)
}
}
func TestZoneOriginRoundTrip(t *testing.T) {
points := []WorldCoord{
{X: 0, Y: 0},
{X: 1, Y: 1},
{X: ZoneSize - 1, Y: -1},
{X: -ZoneSize - 1, Y: ZoneSize*2 + 7},
{X: 123456789, Y: -987654321},
}
for _, p := range points {
z := ZoneOf(p)
back := ZoneOrigin(z).Add(Local(p).X, Local(p).Y)
if back != p {
t.Fatalf("round trip %+v -> %v -> %+v", p, z, back)
}
}
}
func TestNeighborsAreEightAndExcludeSelf(t *testing.T) {
z := ZoneID{X: 2, Y: -3}
got := z.Neighbors()
if len(got) != 8 {
t.Fatalf("len(Neighbors) = %d, want 8", len(got))
}
seen := make(map[ZoneID]bool, len(got))
for _, n := range got {
if n == z {
t.Fatalf("Neighbors contains self: %v", z)
}
if seen[n] {
t.Fatalf("duplicate neighbor: %v", n)
}
seen[n] = true
}
for dx := int64(-1); dx <= 1; dx++ {
for dy := int64(-1); dy <= 1; dy++ {
if dx == 0 && dy == 0 {
continue
}
want := ZoneID{X: z.X + dx, Y: z.Y + dy}
if !seen[want] {
t.Fatalf("missing neighbor %v", want)
}
}
}
}
func TestPlanCross(t *testing.T) {
if c := PlanCross(7, WorldCoord{X: 100, Y: 100}, WorldCoord{X: 200, Y: 200}); c != nil {
t.Fatalf("same zone returned %+v", c)
}
c := PlanCross(7, WorldCoord{X: ZoneSize - 1, Y: 100}, WorldCoord{X: ZoneSize + 1, Y: 100})
if c == nil || c.From != (ZoneID{X: 0, Y: 0}) || c.To != (ZoneID{X: 1, Y: 0}) {
t.Fatalf("cross-zone = %+v", c)
}
if !c.ValidCross() {
t.Fatal("adjacent zone should be valid")
}
jump := CrossZone{EntityID: 7, From: ZoneID{X: 0, Y: 0}, To: ZoneID{X: 3, Y: 0}}
if jump.ValidCross() {
t.Fatal("three-zone jump should be rejected")
}
same := CrossZone{EntityID: 7, From: ZoneID{X: 0, Y: 0}, To: ZoneID{X: 0, Y: 0}}
if same.ValidCross() {
t.Fatal("same zone should not be a crossing")
}
}
测试里的往返断言把“原点 + 局部偏移 = 原坐标”写成不变量。以后如果有人把 Local 改成截断除法,(-1,-1) 和 (-10001, ...) 两个用例会先失败,而不是等到线上跨区。
八、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch07/world && go vet ./ch07/world/ && go test ./ch07/world/ -count=1
ok gspractice/ch07/world 0.266s
本次运行五项测试全部通过;gofmt -l 没有输出,go vet 没有告警。计时随机器负载变化,只用于说明命令可执行。
九、失败场景:两个服务用了不同的取整
假设网关用浮点世界坐标乘 100 转成厘米,A 区服务用 int32(x/10000),B 区服务用 floor。玩家站在 x = -1:A 算 0 区,B 算 -1 区。两个分区都认为自己拥有该玩家,各自加载了一份角色数据,各自 tick。玩家看到两个自己,掉落被领两次;下线时后写的一方覆盖先写的一方,仓库里少了一件装备。
根因不是网络分区,而是“坐标到分区”的函数不是同一个。修复只有一条:把换算收口到一个共享函数,所有服务引用同一份实现和同一组边界测试;跨区迁移时以源分区记录的 ZoneID 为准,不在目标分区用坐标重新推断。只要还有第二条换算路径,事故就会以“偶尔串号”的形式回来。
小结
世界坐标负责表达,逻辑坐标负责判定。服务器内部统一用整数定点,位置取整和分区取整分开定义;分区优先用矩形网格,副本只解决隔离不解决容量。坐标换算必须向下取整,分区按左闭右开归属,跨区是带版本的所有权移交,单次最多跨一格。
常见错误与边界
- 用浮点做分区键或哈希键,
1.0与1.0000000001被当成两个区。 - 用 Go 的
/直接算分区,负数坐标被截断到 0 区。 - 边界两侧都用闭区间,实体同时属于两个分区。
- 跨区只
Add不Remove,出现影子实体和双倍掉落。 - 允许一次跨多个分区,客户端瞬移被当成正常移动。
- 逻辑坐标没有世界边界,异常值进入 AOI 后造成巨大格子号。
- 在多个模块各自把世界坐标转逻辑坐标,同一帧出现多个权威位置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。