《游戏服务端编程实践》7.1.1 世界分区与逻辑坐标

把世界坐标与服务器逻辑坐标分开,用整数定点描述位置,比较矩形网格、兴趣点与副本三种分区方式,给出含负数向下取整的坐标换算、八邻接分区和单跳跨区校验,并附可运行的 Go 坐标测试,覆盖边界归属、负数取整、跨区跳跃拒绝与迁移失败回滚的判定口径。

一、世界坐标不是服务器坐标

场景编辑器里拖出来的是世界坐标,单位通常是米,类型是浮点;客户端渲染用的是屏幕坐标;服务器真正应该保存的是逻辑坐标,单位可以自定义,类型必须是整数。三套坐标混用,是地图边界、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 号分区;一个点只属于一个分区,边界不会重复归属。

坐标 xGo 的 x/10000向下取整正确分区
9999000
10000111
-10-1-1
-10000-1-1-1
-10001-1-2-2

Go 的整数除法向零截断,-1 / 10000 得到 0,会把原点左侧一格错算进 0 号分区。正确做法是先做向下取整除法再转分区号。这个坑在只有正坐标的测试地图上永远暴露不出来,上线后玩家往地图左下角一走就出现“人在 0 区、数据在 -1 区”。

分区内的局部偏移同样要用 floor 后的原点计算,范围必须落在 [0, size)。如果用截断除法算原点,负坐标的局部偏移会变成负数,碰撞和 AOI 会直接错格。

五、跨区是迁移,不是复制

实体从 A 区走到 B 区,正确动作是一次所有权移交:

  1. 源分区停止接受该实体的移动输入,冻结当前状态。
  2. 目标分区预分配实体槽位,校验目标格可站立。
  3. 源分区把状态快照和版本号交给目标分区。
  4. 目标分区接管并回写新位置,源分区删除实体并广播离开。
  5. 任一步失败,实体留在源分区,清除目标分区的预分配。

只在两边都 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 后造成巨大格子号。
  • 在多个模块各自把世界坐标转逻辑坐标,同一帧出现多个权威位置。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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