AzerothCore学习笔记·实体02:空间分区与地图管理——Grid系统、MapMgr与实例化体系

一、问题:MMO 的世界有多大?

你从暴风城贸易区走向英雄谷,迎面走来十几个玩家,旁边有几个 NPC 卫兵,天上飞着狮鹫,远处还有人 PvP 打得正欢。你的客户端能看到这一切,是因为服务器不断在计算什么实体在你附近、它们的状态如何、能不能被你看见。

但服务器不是同时加载了整个世界的------艾泽拉斯大陆的尺寸大约是 34,133 码 × 34,133 码,相当于北京天安门到天津的直线距离。如果每帧 Update 都遍历整张地图来碰撞检测,任何服务器都扛不住。

所以有一个核心问题:当几十个玩家同时站在暴风城门口,服务器怎么知道「我」附近有什么?

直觉上你会想:遍历所有对象呗。但更现实的问题是:"所有对象"到底有多少?

  • 数千个 NPC 和怪物(每个区域巡逻/重生)
  • 上千个可交互的 GameObjects(矿点、草药、门、宝箱)
  • 几十个玩家(每个带着宠物和 DOT)
  • 连续不断的动态对象(法术特效、弹道、钓鱼浮漂)

答案只有一个:空间分区。AzerothCore 用了一套精妙的 Grid/Cell 系统来搞定这件事。


二、Grid 与 Cell:艾泽拉斯的行政区划

2.1 三级网格体系

打开 GridDefines.h,你会发现一组常数定义------它们描述了整个空间划分体系:

常数 含义
SIZE_OF_GRIDS 533.33... (1600/3) 一个 Grid 的边长(码)
MAX_NUMBER_OF_GRIDS 64 每张地图每个维度的 Grid 数
MAX_NUMBER_OF_CELLS 16 每个 Grid 每个维度的 Cell 数
Map 总尺寸 533.33 × 64 ≈ 34,133 码 整张地图的边长
Cell 尺寸 533.33 ÷ 16 ≈ 33.33 码 一个 Cell 的边长

换算成你熟悉的单位:

  • 1 Grid ≈ 533 码 ≈ 暴风城贸易区到法师塔的距离
  • 1 Cell ≈ 33 码 ≈ 一个施法距离(刚好大于大多数法术的射程上限)

整个空间层级长这样:

复制代码
┌─────────────────────────────────────────────────────────┐
│                      Map (34,133 × 34,133)              │
│  ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐    │
│  │Grid │Grid │Grid │Grid │Grid │Grid │Grid │Grid │    │
│  │(0,0)│(1,0)│     │     │     │     │     │(63,0)│    │
│  ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤    │
│  │     │     │  ┌──┼──┼──┼──┐ │     │     │     │    │
│  │     │     │  │C│C│C│C│  │ │     │     │     │    │
│  │     │     │  ├──┼──┼──┼──┤ │     │     │     │    │
│  │     │     │  │C│C│C│C│  │ │     │     │     │  每个 Grid 内含 16×16 Cell│
│  │     │     │  ├──┼──┼──┼──┤ │     │     │     │    │
│  │     │     │  │C│C│C│C│  │ │     │     │     │    │
│  │     │     │  └──┼──┼──┼──┘ │     │     │     │    │
│  ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤    │
│  │     │     │     │     │     │     │     │     │    │
│  ├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤    │
│  │(0,63)│     │     │     │     │     │     │(63,63)│    │
│  └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘    │
└─────────────────────────────────────────────────────────┘

2.2 Cell 的数据结构

打开 Cell.h,最有趣的是 data 字段。它的设计非常 C 风格,但你也得承认------很高效:

cpp 复制代码
union {
    struct {
        unsigned grid_x : 8;  // 第几个 Grid(X轴)
        unsigned grid_y : 8;  // 第几个 Grid(Y轴)
        unsigned cell_x : 8;  // Grid 内的第几个 Cell(X轴)
        unsigned cell_y : 8;  // Grid 内的第几个 Cell(Y轴)
    } Part;
    uint32 All;               // 四个 8-bit 挤在一个 32-bit 整型
} data;

一个 uint32 就表达了(grid_x, grid_y, cell_x, cell_y)四个坐标。 位域压缩的精妙在于:Cell 的相等比较、哈希查找全部可以通过一次 uint32 比较完成。

辅助宏有:

cpp 复制代码
#define CENTER_GRID_ID          (MAX_NUMBER_OF_GRIDS / 2)     // = 32
#define CENTER_GRID_OFFSET      (SIZE_OF_GRIDS / 2)            // ≈ 266.67 码
#define SIZE_OF_GRID_CELL       (SIZE_OF_GRIDS / MAX_NUMBER_OF_CELLS)  // ≈ 33.33 码

坐标到 Grid 的换算由 Acore::ComputeGridCoord(x, y) 完成------把一个世界坐标 (x, y) 映射到 GridCoord,后者是 CoordPair<MAX_NUMBER_OF_GRIDS> 的类型别名。

2.3 按需加载:不要一次性加载整个世界

如果启动时就把 64×64=4096 个 Grid 全部加载到内存,嗯......卡斯特罗都会跪下。更别说每个 Grid 还包含 16×16=256 个 Cell,这意味着整张地图需要 1,048,576 个 Cell 的容器。

AzerothCore 的策略是按需延迟加载(Lazy Loading) 。相关逻辑集中在 MapGridManager.h

cpp 复制代码
class MapGridManager {
    void CreateGrid(uint16 x, uint16 y);   // 创建 Grid 壳(分配容器)
    bool LoadGrid(uint16 x, uint16 y);     // 从数据库加载 Grid 数据
    void UnloadGrid(uint16 x, uint16 y);   // 卸载 Grid 数据
    bool IsGridCreated(uint16 x, uint16 y) const;  // 框架存在?
    bool IsGridLoaded(uint16 x, uint16 y) const;    // 数据加载了?
    
    // 核心存储:64×64 的 unique_ptr 二维数组
    std::unique_ptr<MapGridType> _mapGrid[MAX_NUMBER_OF_GRIDS][MAX_NUMBER_OF_GRIDS];
};

三个状态对理解这个系统至关重要:

状态 含义 触发条件
未创建!IsGridCreated 容器都没分配,内存为 0 默认初始状态
已创建未加载IsGridCreated && !IsGridLoaded Grid 框架有了,但还没从 DB 读实体 玩家进入 Grid 邻接区域后预创建
已加载IsGridLoaded DB 中的 Creature/GO 已读入内存,处于活动状态 玩家实际进入 Grid 后触发

策略很聪明:当你站在 Grid A 时,系统会预创建周围的 Grid(东北、西北、东南、西南 + 核心 5×5 区域),但只实际加载你所在的 Grid。 你往前走一步,系统在后面悄悄卸载,在远处悄悄预创建。

Grid 卸载条件 :如果 Grid 上没有任何玩家,且卸载定时器到期------CanUnload() 返回 true,Grid 进入回收流程。回收时,对象会被标记为移除列表,等待下一轮 Update 清理。

数据层方面,MapGrid 又包含了 GridCell 的实际容器:

cpp 复制代码
// MapGrid.h 中,MapGrid 是一个模板类
template<class GRID_OBJECT_TYPES, class FAR_VISIBLE_OBJECT_TYPES>
class MapGrid {
    uint16 _x, _y;
    bool _objectDataLoaded;  // 是否已从 DB 加载数据
    std::shared_ptr<GridTerrainData> _terrainData;  // 地形数据(副本之间共享)
    
    // 16×16 的 cells 容器
    std::array<std::array<std::unique_ptr<GridCellType>, MAX_NUMBER_OF_CELLS>, MAX_NUMBER_OF_CELLS> _cells;
};

注意 _terrainDatashared_ptr------副本地图之间共享父世界的地形数据,不重复加载。

每个 GridCell 内部则维护两个容器:

cpp 复制代码
// GridCell.h
template<class GRID_OBJECT_TYPES, class FAR_VISIBLE_OBJECT_TYPES>
class GridCell {
    TypeMapContainer<GRID_OBJECT_TYPES> _gridObjects;        // 常规网格对象
    TypeVectorContainer<FAR_VISIBLE_OBJECT_TYPES> _farVisibleObjects;  // 远距离可见对象
};

为什么需要 _farVisibleObjects 有些东西不管你在 Grid 的哪个角落都应该能看到------比如世界之树(Nordrassil)、暴风城的狮鹫飞行点。它们注册到 _farVisibleObjects,不管 Cell 坐标多远,都会被访问到。


三、Map 的谱系:从大陆到副本

3.1 继承链

Map.h 里面定义了清晰的谱系:

复制代码
Map  ← 基类,代表一片世界区域
├─ MapInstanced  ← 桥接类,管理某张地图的所有副本实例
│  ├─ InstanceMap    ← 五人本/团本实例
│  └─ BattlegroundMap  ← 战场/竞技场实例
└─ (普通地图,非实例)  ← 被 MapInstanced 替代

等一下------MapInstanced 继承了 Map?这意味着:

  1. MapMgr 里的 i_maps 集合对于副本地图只有一个 MapInstanced 对象(比如只有一个 MapInstanced 代表「所有影牙城堡实例」)
  2. 这个 MapInstanced 内部维护一个 InstancedMaps = std::unordered_map<uint32, Map*>,以 InstanceId 为 key,存放每个实际副本的 InstanceMap
  3. Update() 循环来到某地图时,MapInstanced 会迭代所有的 m_InstancedMaps,逐个调用它们的 Update()

查源码确认(MapInstanced.h):

cpp 复制代码
class MapInstanced : public Map {
    using InstancedMaps = std::unordered_map<uint32, Map*>;
    InstancedMaps m_InstancedMaps;
    
    Map* CreateInstanceForPlayer(const uint32 mapId, Player* player);
    Map* FindInstanceMap(uint32 instanceId) const;
    bool DestroyInstance(InstancedMaps::iterator& itr);
};

这和现实生活中一个道理:影牙城堡是一个「地图 ID」= 329,而这个 ID 下面有无数个副本 ID,每个副本 ID 对应一个独立的 InstanceMap 实例。 你进副本就是 CreateInstanceForPlayer,打完退组无人后经过定时器 → DestroyInstance

3.2 InstanceMap 与 Boss 战

InstanceMap 在 Map 基础上增加了副本特有的逻辑:

cpp 复制代码
class InstanceMap : public Map {
    void CreateInstanceScript(bool load, std::string data, uint32 completedEncounterMask);
    bool Reset(uint8 method, GuidList* globalSkipList = nullptr);
    InstanceScript* GetInstanceScript();
    uint32 GetScriptId() const;
    uint32 GetMaxPlayers() const;
    
private:
    bool m_resetAfterUnload;     // 无人后是否需要重置
    bool m_unloadWhenEmpty;      // 无人后是否需要卸载
    InstanceScript* instance_data;  // Boss 战状态、箱子开关控制
    uint32 i_script_id;          // 脚本 ID(对应数据库中的模板)
};

这里的关键是 InstanceScript ------它是串联 Boss 战逻辑的核心。CreateInstanceScript 加载数据库中的保存数据,通过 completedEncounterMask 确定哪些 Boss 已经死了、箱子还能不能开。不在副本(普通地图)中不需要 InstanceScript,所以基类 Map 没有。

3.3 MapMgr:总览全局

MapMgrMapMgr.h)是管理所有地图的单例,#define sMapMgr MapMgr::instance() 让它在代码里随处可调。

复制代码
MapMgr (Singleton)
├── i_maps: unordered_map<uint32, Map*>    ← 所有地图 ID → Map 对象
│   ├── mapId=0 (东部王国) → MapInstanced
│   │   ├── InstanceId=1 → InstanceMap(死亡矿井)
│   │   └── InstanceId=2 → InstanceMap(死亡矿井·另一个组)
│   ├── mapId=1 (卡利姆多) → MapInstanced
│   ├── mapId=329 (影牙城堡) → MapInstanced
│   │   ├── InstanceId=123 → InstanceMap(你的队伍)
│   │   └── InstanceId=456 → InstanceMap(另一个队伍)
│   └── ...
├── m_updater: MapUpdater                 ← 多线程更新器
└── _instanceIds: vector<bool>            ← 实例 ID 位图(分配追踪)

核心方法:

cpp 复制代码
Map* FindBaseMap(uint32 mapId) const;          // 找基类 Map(地图模板)
Map* FindMap(uint32 mapId, uint32 instanceId) const;  // 找具体实例
Map* CreateBaseMap(uint32 mapId);              // 创建基类 Map
Map* CreateMap(uint32 mapId, Player* player);  // 为玩家创建/找到地图
Map::EnterState PlayerCannotEnter(...);        // 检查能否进入
void Update(uint32);                           // 更新所有地图

创建逻辑的巧妙之处:CreateBaseMap 是惰性创建的------第一次请求时才 new MapInstanced。而 FindMap(mapId, instanceId) 则是先找到基类,再通过 MapInstanced::FindInstanceMap() 下钻到具体实例。

此外,MapMgr 还提供了 DoForAllMapsDoForAllMapsWithMapId 两个模板方法,用于遍历所有地图或某一地图 ID 的所有实例------这在需要广播消息或全局操作时非常有用。


四、Cell 访问机制:「我附近有什么?」

空间分区把所有实体塞进了网格,但问题仍然在:一个法术释放了,我怎么找到它的目标?一个玩家移动了,我怎么通知附近所有可被看见的实体?

答案是一个 C++ 模板模式的经典应用:TypeContainerVisitor + Visit 模式

4.1 入口:Cell::Visit

所有访问的入口是这个函数:

cpp 复制代码
// Cell 的静态方法,以世界对象为中心
template<class T>
static void Cell::VisitObjects(WorldObject const* obj, T& visitor, float radius);

这个函数会做以下事情:

  1. 根据 obj 的当前位置和 radius,计算出需要访问的范围(哪些 Cell 落在圆内)
  2. 为每个 Cell 执行 Visit(),传入一个 TypeContainerVisitor
  3. TypeContainerVisitor 会迭代 GridCell 内部的 TypeMapContainer

radius 很大的时候,访问范围可能横跨多个 Grid------这时系统会调用 Map::Visit() 方法来做跨 Grid 访问。

4.2 Map::Visit 的实现

cpp 复制代码
template<class T, class CONTAINER>
inline void Map::Visit(Cell const& cell, TypeContainerVisitor<T, CONTAINER>& visitor) {
    uint32 const grid_x = cell.GridX();
    uint32 const grid_y = cell.GridY();

    // 如果 Grid 还没加载,跳过(这 Cell 可能没实体)
    if (!IsGridLoaded(GridCoord(grid_x, grid_y)))
        return;

    // 委托给 MapGrid::VisitCell → GridCell::Visit
    GetMapGrid(grid_x, grid_y)->VisitCell(cell.CellX(), cell.CellY(), visitor);
}

这套体系的核心思想是:能访问的只是已加载的 Grid。未加载的 Grid 里没有活跃实体,直接跳过。

4.3 TypeMapContainer:类型安全的多重容器

AzerothCore 里,网格对象不是所有塞进一个 vector<WorldObject*>。而是按类型分开放:

复制代码
TypeMapContainer<GRID_OBJECT_TYPES>
内部实际上是多个容器的元组,每种类型一个容器
比如 GRID_OBJECT_TYPES 是 (Creature, GameObject, DynamicObject, Corpse, Pet)
那内部就有 5 个独立的 unordered_set

访问时,TypeContainerVisitor 会将其转化为针对特定类型的访问
比如 "访问所有 Creature" → 只走 Creature 的容器,不碰其他

这就避免了两个问题:

  • 不需要 RTTI(运行时类型判断)------编译期就知道要遍历哪个容器
  • 不需要 dynamic_cast 和 if-else 链------类型静态确定,访问内联展开

4.4 可见性范围

每个 Map 还有自己的可见范围:

cpp 复制代码
float m_VisibleDistance;  // 默认 533.33(一个 Grid 的长边)
void SetVisibilityRange(float range);
float GetVisibilityRange() const;

玩家在艾泽拉斯能看到约 533 码(一个 Grid 边长)的视野------这就是为什么你会看到远处山上的怪物轮廓,走近了才发现是只 40 级精英。在副本里,InstanceMap 可以覆盖这个范围。而战场里有时候整个地图都可见。

4.5 Far Visible & Zone Wide Visible

除了常规的 Cell 访问,还有两种特殊的可见系统:

  1. Far Visible(_farVisibleObjects :被注册到 GridCell 的 _farVisibleObjects 容器中,即使距离很远也会被访问到。典型用例:海加尔山的世界之树。

  2. Zone Wide Visible(_zoneWideVisibleWorldObjectsMap:按区域(Zone)组织的集合,一个区域内的对象全区域可见。典型用例:副本里的 Boss 传送门、战场旗帜。


五、Map 更新循环:心跳

5.1 主循环入口

MapMgr::Update(uint32 diff) 是这个心跳引擎的起点。每 100ms 左右循环一次,分四步更新不同类型的底层地图指针:

cpp 复制代码
void MapMgr::Update(uint32) {
    // 分阶段更新以避免所有地图在同一帧进行大量更新
    // [0] = 世界地图(大陆:东部王国、卡利姆多等)
    // [1] = 战场/竞技场
    // [2] = 副本/地城
    // [3] = 总计时器(管理重置等)
}

这样设计是为了负载均衡------不同类型的地图不在同一帧全量更新。

5.2 Map::Update 内部

每个 Map::Update 做了大量事情。我整理了 Map.h 中所有接口的职责:

cpp 复制代码
void Map::Update(const uint32 diff, const uint32 s_diff, bool thread = true) {
    // 1. 更新所有玩家位置(PlayerRelocation → 触发 npc 的 AI 检测)
    // 2. 移动列表中的生物和物体(MoveAllCreaturesInMoveList)
    // 3. 移除队列中的对象(RemoveAllObjectsInRemoveList)
    // 4. 处理延迟可见性(HandleDelayedVisibility)
    // 5. 更新天气(UpdateWeather)
    // 6. 处理事件队列(Events)
    // 7. 更新过期尸体(UpdateExpiredCorpses)
    // 8. 处理脚本调度(ScriptsProcess)
    // 9. 发送对象更新包(SendObjectUpdates)
}

Map.h 的完整接口列表几乎覆盖了游戏逻辑的所有方面:

类别 核心方法 说明
实体管理 AddToMap, RemoveFromMap, AddObjectToRemoveList 对象增删改
网格管理 LoadGrid, LoadAllGrids, UnloadGrid, UnloadAll Grid 生命周期
高度/地形 GetHeight, GetMinHeight, IsInWater, IsUnderWater 地形查询
碰撞检测 isInLineOfSight, GetObjectHitPos, CanReachPositionAndGetValidCoords 视野/路径检测
可见性 SetVisibilityRange, GetVisibilityRange, InitVisibilityDistance 视野范围
对象查找 GetCreature, GetGameObject, GetCorpse, GetDynamicObject, GetPet 按 GUID 查询
重生系统 SaveCreatureRespawnTime, GetCreatureRespawnTime, LoadRespawnTimes 重生调度
Zone 系统 GetAreaId, GetZoneId, GetZoneAndAreaId, UpdatePlayerZoneStats 区域管理
天气/光照 SetZoneWeather, SetZoneMusic, SetZoneOverrideLight 环境特效
副本 UpdateEncounterState, LogEncounterFinished, SendResetWarnings Boss 战状态
运输工具 SendInitTransports, GetTransport, AddObjectToPendingUpdateList 船/飞艇/电梯
动态树 InsertGameObjectModel, RemoveGameObjectModel, Balance, GetGameObjectFloor 动态碰撞

5.3 MapUpdater:线程池

早期 WoW 模拟器(MaNGOS 时代)是单线程更新所有地图的。AzerothCore 引入了线程池机制:

cpp 复制代码
// MapUpdater.h
class MapUpdater {
    ProducerConsumerQueue<UpdateRequest*> _queue;  // 任务队列
    std::atomic<int> pending_requests;             // 等待计数
    std::vector<std::thread> _workerThreads;       // 工作线程池
};

逻辑流程:

复制代码
MapMgr::Update()
    │
    ├─ 对每个大陆地图:schedule_update(Map&, diff)  → push 到队列
    ├─ 对每个副本实例:schedule_update(Map&, diff)  → push 到队列
    └─ wait()  ← 等待所有线程完成
            │
            ▼
    WorkerThread() × N(N = 配置的线程数)
        ├─ pop 任务
        ├─ 执行 map->Update(diff)
        └─ pending_requests--

线程数由配置文件 MapUpdateThreads 控制,根据服务器核心数合理设置(通常是 CPU 核心数的一半到全部)。副本地图的多线程更新是安全的------因为每个副本实例的 Map 对象之间没有任何共享状态。


六、重生系统:怪物是怎么回来的?

Map 的 Update 中还有一个容易被忽视的功能:重生(Respawn)。在数据库的世界里,creature 表有 spawntimesecs 字段,但游戏中还需要程序层面的管理。

Map 内部维护了两个重生时间字典:

cpp 复制代码
std::unordered_map<ObjectGuid::LowType, time_t> _creatureRespawnTimes;
std::unordered_map<ObjectGuid::LowType, time_t> _goRespawnTimes;

当一个怪物死亡时:

  1. ScheduleCreatureRespawn(guid, respawnTimer) 写入重生时间到 DB
  2. 同时内存中记录:_creatureRespawnTimes[dbGuid] = now + respawnDelay
  3. 每帧 UpdateNonPlayerObjects 检查是否有到期重生
  4. 到期后根据 spawnMaskphaseMask 重新从模板创建实体对象,AddToMap 放回到原来的 Cell 中

如果玩家在怪死后立即跑出了 Grid,等重生时间到:Grid 已经被卸载了。没关系,重生时间已经持久化到 DB 的 creature_respawn ,下次 Grid 加载时会通过 LoadRespawnTimes() 重新加载回来。


七、实例化体系的完整路径

让我们追踪一个玩家进入死亡矿井的完整路径:

  1. 执行 /enter 或走进洞窟传输门Player::HandleExtraActionForItem 触发进入副本

  2. MapMgr::CreateMap(mapId=36 (死亡矿井), player) 被调用

    • 先用 FindBaseMap(36) 找到 MapInstanced 对象
    • MapInstanced::CreateInstanceForPlayer(36, player) 被调用
    • 检查 InstanceSave,决定是开新实例还是重连已有实例
  3. 副本创建(如果是第一次):

    • GenerateInstanceId() 分配全局唯一 ID
    • new InstanceMap(36, instanceId, difficulty, parentMap) 创建实际地图
    • CreateInstanceScript(false, "", 0) 创建 InstanceScript(没有任何 Boss 被击杀)
    • 默认可视范围 533 码(但因为是副本,实际都是室内,可能覆盖更小范围)
  4. 玩家进入InstanceMap::AddPlayerToMap(player)

    • Map::AddPlayerToMap 基类通用逻辑
    • 把玩家放到 (grid_x, grid_y) 对应的 Cell 中
    • 触发 PlayerRelocation → 通知周围 NPC(「影牙苦工侦测到一个活人」)
  5. 退组后无人

    • 最后一个玩家退出 → RemovePlayerFromMapm_unloadWhenEmpty || m_resetAfterUnload 检查
    • DestroyInstanceUnloadAllDeleteRespawnTimes → 从 m_InstancedMaps 移除
    • 内存被回收

八、总结

空间分区和地图管理解决了一个最朴素的问题:一台 2008 年的服务器不可能同时更新世界上所有的东西。

AzerothCore 的答案是多层分区 + 按需加载:

  1. 64×64 Grid + 16×16 Cell 将世界切分成可控的小块
  2. Grid 的创建/加载/卸载三状态确保只加载玩家附近的活跃区域
  3. Map → MapInstanced → InstanceMap/BattlegroundMap 三层继承实现了实例化模型
  4. TypeContainerVisitor + Cell::Visit 模式实现了类型安全的空间查询
  5. MapUpdater 线程池实现了副本地图的并行更新
  6. 重生时间持久化确保跨 Grid 卸载的重生状态恢复

如果说实体01(Object继承链)回答了「游戏里的每个东西是什么」,那实体02就回答了「这些东西在世界的哪个位置,以及系统怎么找到它们」。

从 34,133 码的大陆尺寸到 33 码的 Cell 粒度,空间分区的逻辑其实就一句话:把大问题切成小问题。小到服务器只需要关心你周围一块 533 码见方的世界,其他一切等你走过去再说。


本文基于 AzerothCore 3.3.5(liyunfan1223 维护分支)源码分析。源码路径:src/server/game/Maps/src/server/game/Grids/ 等目录。