一、坐标系统先于地图玩法
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,两个服同时写同一块地,最后只能靠人工对账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。