《游戏服务端编程实践》20.1 世界地图模块

从大世界坐标、地块网格和区域划分出发,说明格、块、区域三层换算、分块加载、视野内九宫格查询、地块归属、快照持久化与跨服边界,并给出可测试的 Go 稀疏网格实现,覆盖越界拒绝、边界邻块、邻块去重、占领冲突与快照恢复等边界,讨论全图加载和重复计算的代价。

一、坐标系统先于地图玩法

SLG 的世界地图不是一张贴图,而是一份所有玩家共享、必须由服务端裁决的数据。谁占了哪块地、哪块地还有多少资源、某个坐标能不能行军,这些判断都不能交给客户端。地图模块的第一件事不是画地形,而是把坐标层定清楚:同一个位置在不同层里可能有完全不同的编号,混用一次就会写出“玩家看到的是这块地,服务端扣的是另一块地”的事故。

大世界通常有三层坐标。最细的是格坐标,表示一块可占领的地块;往上是块坐标,作为缓存和批量加载的单位;再往上是区域坐标,用于跨服归属、赛季分区和运营划分。三层之间必须是纯函数换算,不能依赖数据库查询,否则每次视野刷新都要多一次远程调用。

坐标层单位典型用途是否直接落库
格坐标单块地占领、行军目标、资源点是,作为主键的一部分
块坐标32×32 格缓存预热、批量加载、快照分片否,按需换算
区域坐标256×256 格跨服归属、赛季分区、运营活动是,归属表按区域记录

表里的“是否直接落库”比“单位”更重要。格坐标是业务主键,必须稳定;块坐标和区域坐标是派生值,改了粒度就应该能重新算出来。如果块坐标也被写进业务表,后面调整缓存粒度就要做数据迁移。

二、格、块、区域的三层换算

换算公式本身很简单:块坐标等于格坐标除以块大小并向下取整,区域坐标同理。真正容易出错的是边界和负数。以块大小 32 为例,格 (31,31) 属于块 (0,0),格 (32,32) 属于块 (1,1);如果写成四舍五入,边界地块会被分到错误的块,缓存命中和跨服归属都会跟着错。本文把世界坐标限定为 0 基,负数一律视为越界;若业务确实需要负坐标,应该在入口处加固定偏移,而不是让每个换算函数各自判断符号。

格坐标块坐标(32)区域坐标(256)说明
(0,0)(0,0)(0,0)左上角
(32,32)(1,1)(0,0)跨到下一块,仍在同一区域
(255,255)(7,7)(0,0)区域边界前一格
(256,256)(8,8)(1,1)跨到下一区域

一个 4096×4096 的世界,按 32 分块是 128×128 个块,按 256 分区域是 16×16 个区域。这些数字只是示例规模,实际大小由玩法决定。关键是换算函数只依赖常量和坐标,不依赖当前加载了哪些块,这样同一坐标在任何进程、任何时刻都得到同一个块号。

三、分块加载:只把活跃区域放进内存

把整张世界地图展开成对象,是最常见也最昂贵的做法。4096×4096 就是一千六百多万格,即使每格只占几十字节,也会把内存吃满,更不用说初始化、序列化和 GC 的成本。SLG 的活跃区域通常只占世界的一小部分,地图模块应该按块加载,未加载的格子按默认空地处理。

本文的 Grid 用稀疏 map 保存“已加载或已修改”的地块。LoadChunk 只创建块内落在世界范围内的坐标,重复调用是幂等的;越界块直接返回空,不抛异常。长期未访问的块可以淘汰,但被修改过的块必须先写回或标记为脏,否则玩家占领的地块会在下次加载时变回空地。

加载粒度内存占用加载成本适合场景主要风险
全图展开最高启动时集中小世界、单机启动慢、GC 压力大
按区域高中等跨服整区迁移区域边缘仍需细粒度查询
按块低低常规大世界需要淘汰和脏数据回写

四、视野查询:九宫格与去重

玩家打开地图时,服务端要返回视野内的地块状态。最常见的视野是九宫格:以玩家所在地块为中心,取上下左右和四个斜角,共九块。边界上的中心点不足九块,例如左上角只有四块,上边缘中间只有六块。这个裁剪必须由服务端完成,客户端传来的坐标不能直接拿去拼查询。

九宫格查询有两个细节。第一,结果是否包含中心块。包含中心块可以减少调用方一次额外查询,但要求文档写清楚;本文的实现包含中心。第二,多中心查询要去重。联盟成员相邻时,两个人的九宫格会大面积重叠,如果每个中心各查一次再拼接,数据库和序列化都会做重复工作。Neighborhood 把邻块放进同一个集合,再按 Y、X 排序,让同一批输入在多次调用、多个进程中得到相同顺序。

查询方式返回数量适用场景去重责任
单中心九宫格4、6 或 9单人视野无需去重
多中心合并取决于重叠联盟视野、协同侦查服务端
矩形范围(2r+1)² 裁剪后大地图拖拽、战报范围服务端

五、地块状态与归属

一块地至少要回答三个问题:有没有归属、有没有资源、是什么类型。本文用 Owner 表示归属,0 表示无主;Level 表示资源等级,0 表示无资源;Kind 表示空地、资源点或城市。字段少不代表规则少,占领时必须先读当前状态,再判断是否可占,最后写入,整个过程要在同一个事务或同一把锁里完成。

当前状态允许操作结果拒绝原因
无主空地占领归属改为玩家,类型改为城市坐标越界、玩家不存在
已有归属放弃、转让、攻击由业务规则裁决直接覆盖必须拒绝
越界坐标无返回越界错误任何写入都不应发生

“直接覆盖必须拒绝”是地图模块的底线。很多线上事故不是算错数值,而是两个请求同时读到空地,然后先后写入,后写的把先写的归属覆盖掉,玩家看到自己的城突然变成别人的。解决方式可以是数据库条件更新、行锁,或者给地块加版本号做乐观锁;本文的 Claim 在单进程内用“读后判断”演示规则,跨进程时要把同样的判断下沉到存储层。

六、失败场景:全图展开与越界写

某次版本把世界从 1024×1024 扩到 4096×4096,地图服务启动时仍然按老逻辑把全图展开成对象。单机测试只加载了几百格,没有暴露问题;上线后每个地图进程启动都要分配大量对象,GC 周期从几十毫秒涨到数秒,玩家移动开始周期性卡顿。更糟的是快照任务会把整张图序列化,磁盘写入和网络传输同时打满。

第二个问题来自越界写。运营工具导入一批坐标时没有校验,负数坐标被直接写进地块表。读取时这些地块永远查不到,因为查询入口先做了边界检查;写入时却绕过了检查,导致快照里出现无法访问的脏数据,恢复后又占用内存。修复分三步:把加载改成按块稀疏存储,所有写入统一走带边界检查的 Set,快照恢复时再次校验坐标。越界数据宁可拒绝并报警,也不要静默修成 (0,0)。

七、持久化、缓存与跨服边界

地图持久化不需要保存整张世界,只需要保存与默认值不同的地块。本文的 Snapshot 只导出已加载或已修改的地块,并按坐标排序,保证同一份状态每次生成的快照字节稳定。Restore 会重新校验每个坐标,任何越界地块都会让恢复失败,而不是把坏数据带进内存。

缓存层可以用 map:{worldID}:{chunkX}:{chunkY} 作为键,值为该块的压缩快照。缓存失效时回源数据库,回源后仍然只加载请求的块。跨服场景要把区域归属单独存一张表,记录每个区域由哪个服负责;行军跨过区域边界时,目标地块的写入必须回到归属服,不能由出发服直接改。

数据存储位置更新时机一致性要求
地块归属数据库主表占领、放弃、转让强一致,条件更新
块缓存Redis 或本地缓存加载、修改后回写可短暂陈旧,需版本号
区域归属跨服归属表开服、合服、赛季切换强一致,变更需停写

跨服边界最容易出的问题是“两个服都认为自己拥有同一块地”。解决办法不是加锁重试,而是把归属做成显式配置,并在写入前校验当前服是否为目标区域的 owner。边界地块的争夺可以做成独立玩法,但数据归属必须唯一。

八、Go 实现:稀疏网格与九宫格

下面的 worldmap.go 把坐标换算、分块加载、越界校验、占领、九宫格查询、多中心去重和快照恢复放在同一个包里。代码不依赖数据库和缓存,方便在单元测试里覆盖边界;实际项目只需要把 Snapshot 的读写替换成存储层调用。

// file: ch20/worldmap/worldmap.go
// tier: L1
// verify: go test ./ch20/worldmap/

package worldmap

import (
	"errors"
	"fmt"
	"sort"
)

const (
	// ChunkSize 是地块缓存的加载粒度。世界坐标先换算成块坐标,再按块载入。
	ChunkSize = 32
	// RegionSize 是跨服归属的划分粒度,必须能整除世界尺寸才便于对账。
	RegionSize = 256
)

var (
	ErrOutOfBounds = errors.New("worldmap: out of bounds")
	ErrTileBusy    = errors.New("worldmap: tile already owned")
	ErrNoOwner     = errors.New("worldmap: owner id must be positive")
)

// Coord 是大世界里的格坐标,原点在左上角,X 向右、Y 向下。
type Coord struct {
	X int
	Y int
}

func (c Coord) String() string { return fmt.Sprintf("(%d,%d)", c.X, c.Y) }

// Chunk 是缓存加载单位;Region 是跨服归属单位。
type Chunk struct {
	X int
	Y int
}

type Region struct {
	X int
	Y int
}

// Tile 只保存非默认状态。Kind 为空时按 "empty" 处理,避免为每块地存一份空结构。
type Tile struct {
	Coord Coord
	Owner uint64
	Level uint8
	Kind  string
}

// Free 表示地块没有归属,可以被占领。
func (t Tile) Free() bool { return t.Owner == 0 }

// Grid 用稀疏 map 保存已加载或已修改的地块。未加载的地块按空地处理,
// 这样大世界不需要一次性展开成 width*height 个对象。
type Grid struct {
	width  int
	height int
	tiles  map[Coord]Tile
}

func NewGrid(width, height int) (*Grid, error) {
	if width <= 0 || height <= 0 {
		return nil, fmt.Errorf("worldmap: invalid size %dx%d", width, height)
	}
	return &Grid{width: width, height: height, tiles: make(map[Coord]Tile)}, nil
}

func (g *Grid) InBounds(c Coord) bool {
	return c.X >= 0 && c.X < g.width && c.Y >= 0 && c.Y < g.height
}

// ChunkOf 与 RegionOf 只做整除换算,不校验边界:越界坐标也要能定位到块,
// 由加载函数决定裁剪还是拒绝。世界坐标按 0 基设计,负数一律拒绝。
func ChunkOf(c Coord) Chunk {
	return Chunk{X: c.X / ChunkSize, Y: c.Y / ChunkSize}
}

func RegionOf(c Coord) Region {
	return Region{X: c.X / RegionSize, Y: c.Y / RegionSize}
}

// LoadChunk 按需载入一个块,返回块内落在世界范围内的坐标。
// 重复调用是幂等的,已经加载过的块不会重复创建地块。
func (g *Grid) LoadChunk(ch Chunk) []Coord {
	if ch.X < 0 || ch.Y < 0 {
		return nil
	}
	x0, y0 := ch.X*ChunkSize, ch.Y*ChunkSize
	if x0 >= g.width || y0 >= g.height {
		return nil
	}
	x1, y1 := min(x0+ChunkSize, g.width), min(y0+ChunkSize, g.height)
	coords := make([]Coord, 0, (x1-x0)*(y1-y0))
	for y := y0; y < y1; y++ {
		for x := x0; x < x1; x++ {
			c := Coord{X: x, Y: y}
			if _, ok := g.tiles[c]; !ok {
				g.tiles[c] = Tile{Coord: c, Kind: "empty"}
			}
			coords = append(coords, c)
		}
	}
	return coords
}

// At 返回地块状态。越界返回 ErrOutOfBounds;未加载的合法坐标返回默认空地。
func (g *Grid) At(c Coord) (Tile, error) {
	if !g.InBounds(c) {
		return Tile{}, fmt.Errorf("%w: %s", ErrOutOfBounds, c)
	}
	if t, ok := g.tiles[c]; ok {
		return t, nil
	}
	return Tile{Coord: c, Kind: "empty"}, nil
}

// Set 写入地块状态;写入前必须校验坐标,防止坏数据污染快照。
func (g *Grid) Set(t Tile) error {
	if !g.InBounds(t.Coord) {
		return fmt.Errorf("%w: %s", ErrOutOfBounds, t.Coord)
	}
	if t.Kind == "" {
		t.Kind = "empty"
	}
	g.tiles[t.Coord] = t
	return nil
}

// Claim 占领空地。已归属的地块返回 ErrTileBusy,不会静默覆盖。
func (g *Grid) Claim(c Coord, owner uint64) error {
	if owner == 0 {
		return ErrNoOwner
	}
	t, err := g.At(c)
	if err != nil {
		return err
	}
	if !t.Free() {
		return fmt.Errorf("%w: %s owned by %d", ErrTileBusy, c, t.Owner)
	}
	t.Owner = owner
	t.Kind = "city"
	return g.Set(t)
}

// Neighbors 返回以 center 为中心的九宫格内、落在世界范围内的坐标,
// 包含 center 本身。边界上的中心点自然少于九个,这就是视野裁剪。
func (g *Grid) Neighbors(center Coord) ([]Coord, error) {
	if !g.InBounds(center) {
		return nil, fmt.Errorf("%w: center %s", ErrOutOfBounds, center)
	}
	out := make([]Coord, 0, 9)
	for dy := -1; dy <= 1; dy++ {
		for dx := -1; dx <= 1; dx++ {
			c := Coord{X: center.X + dx, Y: center.Y + dy}
			if g.InBounds(c) {
				out = append(out, c)
			}
		}
	}
	return out, nil
}

// Neighborhood 合并多个中心的九宫格并去重。联盟视野、多人协同侦查
// 都会出现相邻玩家看到同一块地的情况,去重必须在服务端完成。
func (g *Grid) Neighborhood(centers []Coord) ([]Coord, error) {
	if len(centers) == 0 {
		return nil, nil
	}
	seen := make(map[Coord]struct{}, len(centers)*9)
	for _, center := range centers {
		nb, err := g.Neighbors(center)
		if err != nil {
			return nil, err
		}
		for _, c := range nb {
			seen[c] = struct{}{}
		}
	}
	out := make([]Coord, 0, len(seen))
	for c := range seen {
		out = append(out, c)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Y != out[j].Y {
			return out[i].Y < out[j].Y
		}
		return out[i].X < out[j].X
	})
	return out, nil
}

// Snapshot 只导出已加载或已修改的地块,保证快照大小与活跃区域成正比。
type Snapshot struct {
	Width  int
	Height int
	Tiles  []Tile
}

func (g *Grid) Snapshot() Snapshot {
	tiles := make([]Tile, 0, len(g.tiles))
	for _, t := range g.tiles {
		tiles = append(tiles, t)
	}
	sort.Slice(tiles, func(i, j int) bool {
		if tiles[i].Coord.Y != tiles[j].Coord.Y {
			return tiles[i].Coord.Y < tiles[j].Coord.Y
		}
		return tiles[i].Coord.X < tiles[j].Coord.X
	})
	return Snapshot{Width: g.width, Height: g.height, Tiles: tiles}
}

// Restore 从快照恢复网格,并拒绝任何越界地块。
func Restore(s Snapshot) (*Grid, error) {
	g, err := NewGrid(s.Width, s.Height)
	if err != nil {
		return nil, err
	}
	for _, t := range s.Tiles {
		if err := g.Set(t); err != nil {
			return nil, fmt.Errorf("worldmap: restore: %w", err)
		}
	}
	return g, nil
}

LoadChunk 不记录“已加载块”的集合,而是依靠稀疏 map 判断地块是否已经存在。这样做的好处是状态只有一个来源,不会出现“块标记为已加载但地块被淘汰”的不一致。代价是重复加载时要再遍历一次块内坐标,但块大小固定,成本可控。Neighborhood 用集合去重后再排序,Snapshot 和 Restore 则把持久化边界收在一处,恢复时的校验和写入时的校验共用同一套逻辑。

九、测试覆盖哪些边界

测试集中在六类输入,都是地图模块上线前必须验证的:

  • 坐标换算覆盖块边界和区域边界,确认 31 与 32 分属不同块。
  • 边界坐标 (0,0)、(width-1,height-1) 可访问,负数、等于宽高的坐标全部拒绝。
  • 块加载覆盖完整块、被世界边界裁剪的块、越界块和重复加载。
  • 九宫格覆盖角上四块、边上六块、内部九块,并检查结果不含越界坐标和重复坐标。
  • 多中心合并后数量正确、无重复、按 Y/X 排序。
  • 占领覆盖首次成功、重复占领拒绝、零归属拒绝、越界拒绝,以及快照恢复时的越界拒绝。

运行命令与输出如下:

cd .local/checks/gs-practice-lab && gofmt -l ch20/worldmap && go vet ./ch20/worldmap/ && go test ./ch20/worldmap/ -count=1
ok  	gspractice/ch20/worldmap	0.249s

gofmt -l 没有输出说明格式一致,go vet 没有告警。测试不依赖真实数据库,可以在 CI 里快速执行;它验证的是地图模块的内部规则,不验证缓存命中率、网络延迟和跨服写入冲突,那些需要集成测试和压测。

小结

世界地图模块先定坐标层,再定加载粒度和归属规则。格、块、区域三层换算必须是纯函数,边界和负数要在入口处处理;地图数据按块稀疏加载,未加载区域按默认值处理;视野查询由服务端裁剪和去重;地块归属写入必须拒绝覆盖和越界。把这些规则写进可测试的 Go 包,能让地图问题在单元测试里暴露,而不是等到线上玩家发现城池易主。

常见错误与边界

  • 把整张世界地图一次性展开成对象,世界扩大后启动慢、GC 抖动、快照巨大。
  • 块坐标用四舍五入而不是向下取整,边界地块被分到错误的块,缓存和归属一起错。
  • 只在读取时校验坐标,写入和快照恢复绕过校验,脏数据进入持久化层。
  • 九宫格查询不包含中心块却没有文档说明,调用方重复查询中心,或者漏掉中心状态。
  • 占领采用“先查后写”却没有事务或版本号,并发请求互相覆盖归属。
  • 缓存淘汰时不回写脏块,玩家刚占领的地块在下次加载时变回空地。
  • 跨服区域归属没有唯一 owner,两个服同时写同一块地,最后只能靠人工对账。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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