3.1 地图数据结构与逻辑坐标
前两章我们把「玩家」抽象成了 entity:有句柄、有生命周期、有所有权。但 entity 总得待在一个地方——待在哪张图上、站在哪个坐标、脚下能不能走。这一章开始处理「场景」,而场景的第一块地基,是地图数据与逻辑坐标。
地图看起来简单:一张图,几堵墙,玩家绕着走。麻烦在于服务端和客户端对「位置」的理解完全不同。客户端在意的是屏幕上的像素,服务端在意的是逻辑世界里一个可比较、可计算、可判定阻挡的坐标;两者必须能对得上,又不能互相绑死。如果服务端直接存客户端像素,换一次分辨率、加一次缩放,所有存档坐标就全错位了。
本节做两件事:定义一套逻辑坐标(浮点、与像素无关),和一套地图数据结构(格网 + 阻挡位图)。它是后续 AOI、寻路、移动校验全部要复用的底座。代码放在 internal/scene 包,全部是 L1,go test 可以直接跑。
本章与站内既有内容的分工
站内已有几篇文章从架构层面讲过地图实例、分区分服与开放世界流式加载,它们负责「方案与取舍」。本章不复述结论,只做一件事:把场景与地图实例从零写出来并跑通。分工如下。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/game-server-map-instance-lifecycle-management/ | 地图实例从创建到回收的架构 | 只落地地图的数据结构与坐标模型 |
/game-server-zone-and-world-architecture-design/ | 分区(zone)与世界的整体划分 | 只做单张地图内的格网与阻挡 |
/game-server-open-world-streaming-architecture/ | 开放世界如何流式加载 | 只做「一张图在内存里长什么样」 |
逻辑坐标:服务器与客户端各算各的
先说坐标。服务端用的坐标只有一个要求:它是游戏世界里的物理量,不是显示量。我们把它定义成 Vec2{X, Y float64}——两个浮点数,单位是「世界单位」,与客户端用什么分辨率、缩放比例、相机偏移统统无关。
为什么不直接用整数格号当坐标?因为连续移动。玩家从 A 走到 B,中间的位置是连续的,如果只用整数格号,移动就会一格一格地跳,插值、碰撞、朝向全都没法算。所以坐标是浮点,而格网只是坐标之上的一层索引:把连续的世界切成 GridSize 见方的格子,用来做阻挡查询和(下一章的)AOI。
GridSize 取 8.0,是本节固定的常量。它是一把标尺:世界坐标 (20, 20) 落在第 2 行第 2 列的格子里(20 / 8 = 2.5,向下取整)。客户端只要知道同一个 GridSize,就能把服务端广播的逻辑坐标换算成自己该画的像素位置;换算公式是客户端的事,服务端不关心。
这里有个容易踩的坑:坐标转换用 math.Floor,不是整数除法。-0.1 / 8 的整数除法在 Go 里会截断成 0,但正确的格号应该是 -1。地图边缘、出生点靠负坐标时,这个差别会让「明明在墙外却被判成墙内」。
坐标与格号的关系用一张图说清楚:格号是坐标除以 GridSize 后向下取整,格中心是格号加半格再乘回去。
GridSize = 8.0
世界坐标 X: 0.0 8.0 16.0 24.0
| | | |
格号 cx: [ 0 ][ 1 ][ 2 ]
格中心 X: 4.0 12.0 20.0
WorldToGrid(Vec2{20, 20}) -> cx=20/8=2.5 -> Floor -> 2
GridCenter(2, 2) -> (2+0.5)*8 = 20.0 (与输入一致)
所以 WorldToGrid 与 GridCenter 是一对互逆操作:把任意坐标吸附到格中心、再从格中心反解回格号,必须回到同一个格。测试里的 TestGridCenterRoundTrip 就是拿 5×5 的格子逐个验证这条往返一致性。
还有一点常被忽略:坐标是 float64 而不是定点整数。MMO 的地图尺度通常几十万单位,float64 有 52 位尾数,在这个量级上仍有亚毫米精度,完全够用;而定点整数要自己管小数位、管溢出,换来的那点确定性对本节的地图查询没有收益。真要确定性(比如回放、反作弊),那是移动校验(5.3)要单独处理的事。
地图数据:一张格网加一张阻挡位图
地图的静态部分只有两类信息:尺寸和哪里不能走。我们用一张 Width × Height 的格网,加一个长度 Width*Height 的 []bool 阻挡位图来表示。位图而不是二维切片,是因为前者内存连续、缓存友好,IsBlocked 只是下标运算。
下面这段是 L1,go test ./internal/scene/ 可以直接跑:
// file: internal/scene/map.go
// tier: L1
// verify: go test ./internal/scene/
package scene
import (
"errors"
"fmt"
"math"
)
// GridSize 是逻辑坐标系里一个格子的边长(世界单位)。
// 服务器的一切阻挡、AOI、寻路都以它为单位,与客户端像素无关。
const GridSize = 8.0
// Vec2 是服务器的逻辑坐标:float64 连续量,与客户端像素坐标解耦。
// 客户端按自己的分辨率渲染,服务器只认这一套坐标。
type Vec2 struct {
X, Y float64
}
func (p Vec2) Add(o Vec2) Vec2 { return Vec2{X: p.X + o.X, Y: p.Y + o.Y} }
func (p Vec2) Sub(o Vec2) Vec2 { return Vec2{X: p.X - o.X, Y: p.Y - o.Y} }
// Dist2 返回平方距离:比较远近时不必开方,省一次 sqrt。
func (p Vec2) Dist2(o Vec2) float64 {
dx, dy := p.X-o.X, p.Y-o.Y
return dx*dx + dy*dy
}
// ErrOutOfMap 表示坐标或格号落在地图之外。
var ErrOutOfMap = errors.New("scene: 坐标超出地图范围")
// MapData 是静态地图:一张 Width×Height 的格网,加每格是否阻挡。
// 它是只读模板,被所有实例共享;运行时状态不写回这里。
type MapData struct {
ID uint32
Width int // 列数(格)
Height int // 行数(格)
blocked []bool // 长度 Width*Height,true = 不可走
}
// NewMapData 分配一张空地图(全部可走)。
func NewMapData(id uint32, w, h int) (*MapData, error) {
if w <= 0 || h <= 0 {
return nil, fmt.Errorf("scene: 非法地图尺寸 %dx%d", w, h)
}
return &MapData{ID: id, Width: w, Height: h, blocked: make([]bool, w*h)}, nil
}
func (m *MapData) idx(cx, cy int) int { return cy*m.Width + cx }
func (m *MapData) InBounds(cx, cy int) bool {
return cx >= 0 && cx < m.Width && cy >= 0 && cy < m.Height
}
func (m *MapData) SetBlocked(cx, cy int, b bool) error {
if !m.InBounds(cx, cy) {
return ErrOutOfMap
}
m.blocked[m.idx(cx, cy)] = b
return nil
}
// IsBlocked:界外一律视为阻挡,这样移动校验不必再单独判越界。
func (m *MapData) IsBlocked(cx, cy int) bool {
if !m.InBounds(cx, cy) {
return true
}
return m.blocked[m.idx(cx, cy)]
}
// WorldToGrid 把逻辑坐标落到格号。用 Floor 而非整除,负坐标才不会截断到 0。
func WorldToGrid(p Vec2) (int, int) {
return int(math.Floor(p.X / GridSize)), int(math.Floor(p.Y / GridSize))
}
// GridCenter 返回某格中心的世界坐标,用于把实体吸附到格心。
func GridCenter(cx, cy int) Vec2 {
return Vec2{X: (float64(cx) + 0.5) * GridSize, Y: (float64(cy) + 0.5) * GridSize}
}
// WalkableAt 判断一个逻辑坐标能否站立。
func (m *MapData) WalkableAt(p Vec2) bool {
cx, cy := WorldToGrid(p)
return !m.IsBlocked(cx, cy)
}
// ClampToBounds 把坐标夹到地图内,防止越界坐标把实体推出世界。
func (m *MapData) ClampToBounds(p Vec2) Vec2 {
maxX := float64(m.Width) * GridSize
maxY := float64(m.Height) * GridSize
p.X = math.Max(0, math.Min(p.X, math.Nextafter(maxX, 0)))
p.Y = math.Max(0, math.Min(p.Y, math.Nextafter(maxY, 0)))
return p
}
几个刻意的设计。MapData 是只读模板:它只描述「这张图长什么样」,不记录「现在谁站在哪」。所有实例共享同一份 MapData,运行时状态放在实例里(下一节讲)。这样一张 4096×4096 的地图只有一份阻挡位图,几万个实例共享,内存不会随实例数线性涨。
IsBlocked 对界外一律返回 true,把「越界」和「撞墙」合并成同一种结果。这样上层移动校验只需要问「这一步可不可走」,不必再单独写一段边界判断——少一个分支就少一类漏判。
ClampToBounds 用 math.Nextafter(maxX, 0) 而不是直接取 maxX,是为了让夹取后的坐标严格小于上界,落在最后一格内。否则坐标恰好等于 Width*GridSize 时会落到界外格,反被 IsBlocked 判成不可走。
再算一笔内存账:阻挡位图用 []bool,每格 1 字节。一张 1024×1024 的地图是 1 MiB,4096×4096 是 16 MiB——对一张全服共享、只加载一次的模板来说完全可接受。若嫌大,可以把 []bool 换成位压缩的 []uint64(每格 1 bit,省 8 倍),代价是 IsBlocked 多一次位运算。本节先用直白的 []bool,把正确性摆在省内存前面;等地图真的撑爆内存,再谈位压缩。
坐标转换与边界:把负坐标和越界钉死
坐标转换是那种「平时都对、边界就错」的代码,所以用表驱动测试把边界全钉死。下面这段是同一包里的测试摘录,属于已跑通的 L1:
// snippet: internal/scene/map_test.go(摘录)
func TestWorldToGridRoundsDown(t *testing.T) {
cases := []struct {
p Vec2
cx int
cy int
}{
{Vec2{0, 0}, 0, 0},
{Vec2{7.999, 7.999}, 0, 0},
{Vec2{8.0, 8.0}, 1, 1},
{Vec2{15.9, 16.1}, 1, 2},
{Vec2{-0.1, -8.1}, -1, -2}, // 负坐标必须向下取整到 -1 / -2
}
for _, c := range cases {
cx, cy := WorldToGrid(c.p)
if cx != c.cx || cy != c.cy {
t.Errorf("WorldToGrid(%v) = (%d,%d),期望 (%d,%d)", c.p, cx, cy, c.cx, c.cy)
}
}
}
func TestClampToBounds(t *testing.T) {
m, _ := NewMapData(1, 4, 4)
max := float64(4) * GridSize // 32
got := m.ClampToBounds(Vec2{X: -5, Y: 999})
if got.X != 0 {
t.Errorf("X 应夹到 0,实得 %v", got.X)
}
if !(got.Y < max) {
t.Errorf("Y 应夹到 < %v,实得 %v", max, got.Y)
}
if cx, cy := WorldToGrid(got); cx != 0 || cy != 3 {
t.Errorf("夹取后应落在 (0,3),实得 (%d,%d)", cx, cy)
}
}
7.999 落在第 0 格、8.0 落在第 1 格——这是格网的「临界点」,差一点点就换格。-0.1 必须落到 -1 而不是 0,这是 math.Floor 与整数除法的分水岭。ClampToBounds 的测试则验证两头:负坐标夹到 0、超大坐标夹回最后一格 3,夹取后都还要能反解回一个合法格号。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go vet ./...
(无输出,通过)
$ go test ./internal/scene/
ok aegis/internal/scene 0.4s
本节确立的三样东西会被后面反复复用:Vec2 浮点逻辑坐标、GridSize = 8.0 这把标尺、以及「静态地图模板 + 只读阻挡位图」的模型。可以先把它们记成三条约定:
- 坐标是浮点的、连续的,格网只是它之上的一层索引;
- 格号用
math.Floor算,负坐标不能截断; - 地图模板是只读的、被所有实例共享,运行时状态一律放在实例里;
- 越界与撞墙合并处理,
IsBlocked对界外一律返回不可走,上层少一个分支。
下一节处理实例:同一张地图模板,怎么被开成一个个可以被创建、被回收、被复用的地图实例,以及实例 ID 与实体 ID 怎么拼。
阅读导航:上一节:目录 · 下一节:3.2 实例创建与回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。