《MMO 服务器开发》3.1 地图数据结构与逻辑坐标

地图是场景逻辑的地基。本节从零写 internal/scene 的地图数据结构:一张 Width×Height 格网加一张阻挡位图描述静态地图,float64 逻辑坐标 Vec2 与客户端像素解耦,格边长固定 GridSize=8.0,并实现世界坐标与格号互转、可走判定与边界夹取。全部是 L1,go test 可直接跑。

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 实例创建与回收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标