Unity SLG大地图核心框架:网格管理与AOI视野同步实战

📅 2026/7/8 17:53:23 👁️ 阅读次数 📝 编程学习
Unity SLG大地图核心框架:网格管理与AOI视野同步实战

1. 项目概述:为什么SLG大地图是块“硬骨头”?

做SLG(Simulation Game,策略游戏)的同行都知道,大地图系统是整个项目的“心脏”,也是最容易让团队“心肌梗塞”的地方。它不像一个简单的跑酷或者卡牌界面,逻辑清晰、边界明确。SLG大地图是一个庞大、动态、高并发的复杂系统,它需要同时处理成千上万个玩家单位(城池、部队、资源点)的实时状态、交互与表现。今天,我们就以Unity 2019.4 LTS这个在工业界依然保有巨大存量的稳定版本为基石,抛开那些华而不实的理论,直接上手,手把手搭建一套能扛能打、思路清晰的SLG大地图核心框架。

这个框架的核心目标是什么?简单说就两点:管得清看得见。“管得清”指的是底层的数据与逻辑管理,比如成百上千个地图对象(我们称之为Entity)的位置、状态、归属如何高效组织与更新;“看得见”指的是客户端的表现与交互,如何让玩家流畅地浏览巨大地图,并只渲染和交互玩家“应该看到”的部分。后者就是AOI(Area Of Interest,兴趣区域)技术要解决的核心问题。网上很多教程要么只讲抽象的服务器AOI算法,要么只展示一个花哨的Unity地形,中间那层最关键的、连接逻辑与表现的客户端框架却语焉不详。这次,我们就把它彻底打通,从网格管理到AOI同步,附上可直接运行的源码解析,让你不仅知其然,更知其所以然。

2. 核心框架设计:网格化与AOI的“黄金搭档”

在动手写代码之前,我们必须把设计思路理清楚。SLG大地图框架的设计,本质上是在空间管理效率逻辑计算精度之间寻找最佳平衡点。

2.1 为什么是网格(Grid)管理?

首先,我们选择网格作为基础的空间管理数据结构。你可能听说过四叉树(Quadtree)、动态网格(Dynamic Grid)等更“高级”的方案,但对于大多数SLG游戏,尤其是偏中重度的、地图规则统一的类型,静态均匀网格往往是性价比最高的选择。

核心优势在于:

  1. 计算复杂度稳定且极低:定位一个坐标(x, z)属于哪个网格,只需要gridX = Mathf.FloorToInt(x / cellSize); gridZ = Mathf.FloorToInt(z / cellSize);。这是O(1)的操作,无论地图上有10个还是10000个Entity,查找速度都一样快。
  2. 遍历与查询高效:当我们需要获取某个点周围N格内的所有Entity时,只需要计算中心网格及其周边网格的索引,然后直接访问这些网格存储的Entity列表即可。这比遍历所有Entity计算距离(O(N))要高效得多。
  3. 与游戏逻辑天然契合:SLG游戏的移动、战斗、建造等规则,通常本身就基于格子(如六边形格或正方形格)。用网格管理底层Entity,能非常方便地与这些游戏规则对接。

当然,它的缺点是内存占用固定(网格总数=地图长/格宽 * 地图宽/格宽),且对于Entity分布极度不均匀的场景可能有空间浪费。但在SLG中,Entity通常是相对均匀分布在地图上的,这个缺点影响很小。

网格参数如何定?这是第一个实操要点。网格大小(CellSize)直接决定了查询的精度和性能。设得太小(如1x1),网格数量爆炸,遍历周边网格时开销增大;设得太大(如100x100),每个网格内Entity过多,失去了空间划分的意义。根据经验,网格大小应略大于你的游戏内一个典型单位的交互半径。例如,如果你的城池的视野或攻击范围是10个单位,那么将CellSize设为15-20是一个不错的起点。这样,查询一个单位周围一格的Entity,基本就能覆盖其所有潜在交互目标。

2.2 AOI(兴趣区域)的核心思想

AOI解决的是“谁需要知道谁”的问题。在服务器端,它用于决定向哪个客户端同步哪些Entity的状态(如移动、战斗);在客户端,它用于决定渲染哪些Entity、处理哪些Entity的交互。

我们的框架主要实现客户端的AOI,其核心思想是:每个客户端(玩家)都有一个“视野范围”,我们只关心和处理视野范围内的Entity。这能极大减轻客户端的CPU(遍历、渲染)和GPU(绘制调用)压力。

如何实现客户端AOI?最直接的方法就是利用我们刚刚建立的网格系统。我们将玩家的视野也抽象为一个矩形或圆形区域。每一帧(或每几帧),我们:

  1. 根据玩家摄像机或主城位置,计算出当前视野覆盖了哪些网格。
  2. 从网格管理器中获取这些网格内的所有Entity。
  3. 将这些Entity标记为“在视野内”,进行加载、渲染和逻辑更新。
  4. 对于上一帧在视野内而这一帧不在的Entity,标记为“离开视野”,进行卸载或休眠。

这个过程听起来简单,但实现时有几个魔鬼细节:

  • 频繁计算的优化:我们不需要每帧都重新计算所有网格。可以缓存上一次的视野网格列表,只计算新增和移除的网格,进行增量更新。
  • Entity的加载与卸载:直接Instantiate和Destroy是性能杀手。必须使用对象池(Object Pool)。当Entity进入视野时,从对象池中取出并初始化;离开视野时,回收到对象池,并重置状态。
  • LOD(多层次细节):对于大地图,距离玩家很远的Entity,可以用更简单的模型(甚至只是一个图标)来渲染。这需要在Entity身上挂载LOD Group组件,并根据与摄像机的距离动态切换。

注意:这里容易混淆服务器AOI和客户端AOI。我们框架实现的是后者。服务器AOI更复杂,涉及网络同步和反作弊,常用九宫格、十字链表等算法。客户端AOI可以看作是服务器AOI结果的一个子集和表现层。

3. 源码实现:网格管理器的构建

理论说再多不如一行代码。我们开始构建核心的GridManager。这个管理器将是一个单例,负责整个地图的空间划分和Entity的增删改查。

using System.Collections.Generic; using UnityEngine; public class GridManager : MonoBehaviour { public static GridManager Instance; // 地图和网格参数 public float mapWidth = 1000f; public float mapHeight = 1000f; public float cellSize = 20f; // 关键参数,根据游戏设定调整 private int gridCountX; private int gridCountZ; private Dictionary<Vector2Int, List<MapEntity>> gridDictionary; void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); InitializeGrid(); } void InitializeGrid() { gridCountX = Mathf.CeilToInt(mapWidth / cellSize); gridCountZ = Mathf.CeilToInt(mapHeight / cellSize); gridDictionary = new Dictionary<Vector2Int, List<MapEntity>>(); Debug.Log($"网格系统初始化: {gridCountX} x {gridCountZ} 个网格"); } // 关键方法1:将世界坐标转换为网格坐标 public Vector2Int WorldToGridPosition(Vector3 worldPos) { int x = Mathf.FloorToInt((worldPos.x + mapWidth * 0.5f) / cellSize); int z = Mathf.FloorToInt((worldPos.z + mapHeight * 0.5f) / cellSize); // 假设地图原点(0,0,0)在地图中心,进行偏移计算 x = Mathf.Clamp(x, 0, gridCountX - 1); z = Mathf.Clamp(z, 0, gridCountZ - 1); return new Vector2Int(x, z); } // 关键方法2:注册Entity到网格 public void RegisterEntity(MapEntity entity) { Vector2Int gridPos = WorldToGridPosition(entity.transform.position); if (!gridDictionary.ContainsKey(gridPos)) { gridDictionary[gridPos] = new List<MapEntity>(); } if (!gridDictionary[gridPos].Contains(entity)) { gridDictionary[gridPos].Add(entity); entity.CurrentGrid = gridPos; // Entity自己记录所在网格,方便后续更新 } } // 关键方法3:更新Entity的网格位置(移动时调用) public void UpdateEntityGrid(MapEntity entity, Vector3 newWorldPos) { Vector2Int oldGrid = entity.CurrentGrid; Vector2Int newGrid = WorldToGridPosition(newWorldPos); if (oldGrid != newGrid) { // 从旧网格移除 if (gridDictionary.ContainsKey(oldGrid) && gridDictionary[oldGrid].Contains(entity)) { gridDictionary[oldGrid].Remove(entity); } // 添加到新网格 RegisterEntity(entity); // 注意:这里RegisterEntity会更新entity.CurrentGrid } } // 关键方法4:获取某一网格内的所有Entity public List<MapEntity> GetEntitiesInGrid(Vector2Int gridPos) { if (gridDictionary.TryGetValue(gridPos, out List<MapEntity> entities)) { return new List<MapEntity>(entities); // 返回副本,避免外部修改内部列表 } return new List<MapEntity>(); } // 关键方法5:获取某世界点周围指定半径(网格数)内的所有Entity public List<MapEntity> GetEntitiesInRadius(Vector3 center, int radiusInGrids) { List<MapEntity> result = new List<MapEntity>(); Vector2Int centerGrid = WorldToGridPosition(center); for (int xOffset = -radiusInGrids; xOffset <= radiusInGrids; xOffset++) { for (int zOffset = -radiusInGrids; zOffset <= radiusInGrids; zOffset++) { Vector2Int checkGrid = new Vector2Int(centerGrid.x + xOffset, centerGrid.y + zOffset); // 检查网格是否在地图范围内 if (checkGrid.x >= 0 && checkGrid.x < gridCountX && checkGrid.y >= 0 && checkGrid.y < gridCountZ) { result.AddRange(GetEntitiesInGrid(checkGrid)); } } } return result; } }

代码解析与实操要点:

  1. WorldToGridPosition方法:这里假设地图中心是(0,0,0),地图向X和Z正负方向各延伸一半。如果你的地图原点在角落,公式需要调整。Mathf.Clamp确保了坐标不会超出网格数组边界,这是重要的健壮性处理。
  2. RegisterEntityUpdateEntityGrid:每个MapEntity(我们自定义的地图实体基类)在创建或位置发生重大变化时,必须调用这些方法。UpdateEntityGrid应在Entity移动后调用,但不要每帧调用!对于连续移动的单位,可以在每次寻路目标点改变或每隔一定时间(如0.5秒)更新一次网格位置。
  3. GetEntitiesInRadius方法:这是实现“视野查询”或“技能范围查询”的基础。参数radiusInGrids表示搜索的网格半径。例如,radiusInGrids = 1会搜索中心网格及其周围8个网格(九宫格)。这个方法返回的列表可能包含重复Entity(如果Entity跨网格存储,但我们的设计不会),且没有进行精确距离筛选。通常,我们会在这个方法返回结果后,再遍历一次,用Vector3.Distance进行精确距离过滤,以得到真正在圆形范围内的Entity。

4. 源码实现:AOI视野管理器的构建

有了网格管理器,AOI视野管理器(AOIManager)的工作就清晰了。它监听玩家视野的变化,并向网格管理器查询Entity,控制它们的显隐。

using System.Collections.Generic; using UnityEngine; public class AOIManager : MonoBehaviour { public Camera playerCamera; // 或跟随玩家的一个视野中心点 public float viewRadius = 50f; // 视野半径(世界单位) private float gridViewRadius; // 换算成网格半径 private HashSet<Vector2Int> currentViewGrids = new HashSet<Vector2Int>(); private HashSet<MapEntity> entitiesInView = new HashSet<MapEntity>(); // 对象池引用(假设已实现) public ObjectPool<MapEntity> entityPool; void Start() { if (playerCamera == null) playerCamera = Camera.main; gridViewRadius = Mathf.CeilToInt(viewRadius / GridManager.Instance.cellSize); UpdateViewArea(); // 初始更新 } void Update() { // 优化:可以每N帧或当摄像机移动超过一定距离时再更新 if (IsCameraMovedSignificantly()) { UpdateViewArea(); } } bool IsCameraMovedSignificantly() { // 简单的实现:记录上一帧位置,判断距离差 // 更复杂的可以判断视野覆盖的网格是否发生变化 return true; // 示例中简化为每帧更新,实际项目需要优化 } void UpdateViewArea() { Vector3 viewCenter = playerCamera.transform.position; viewCenter.y = 0; // 假设是2D平面或俯视角,忽略Y轴 // 1. 计算当前视野覆盖的网格 Vector2Int centerGrid = GridManager.Instance.WorldToGridPosition(viewCenter); HashSet<Vector2Int> newViewGrids = new HashSet<Vector2Int>(); for (int x = -gridViewRadius; x <= gridViewRadius; x++) { for (int z = -gridViewRadius; z <= gridViewRadius; z++) { Vector2Int grid = new Vector2Int(centerGrid.x + x, centerGrid.y + z); // 检查网格是否有效(可调用GridManager的方法) if (IsGridValid(grid)) { newViewGrids.Add(grid); } } } // 2. 找出新增和移除的网格(增量更新) HashSet<Vector2Int> gridsToAdd = new HashSet<Vector2Int>(newViewGrids); gridsToAdd.ExceptWith(currentViewGrids); HashSet<Vector2Int> gridsToRemove = new HashSet<Vector2Int>(currentViewGrids); gridsToRemove.ExceptWith(newViewGrids); // 3. 处理新增网格:加载其中的Entity foreach (var gridPos in gridsToAdd) { LoadEntitiesInGrid(gridPos); } // 4. 处理移除网格:卸载其中的Entity(但需判断Entity是否还在其他视野网格内) foreach (var gridPos in gridsToRemove) { UnloadEntitiesInGrid(gridPos); } // 5. 更新当前视野网格记录 currentViewGrids = newViewGrids; } void LoadEntitiesInGrid(Vector2Int gridPos) { List<MapEntity> entities = GridManager.Instance.GetEntitiesInGrid(gridPos); foreach (var entity in entities) { if (!entitiesInView.Contains(entity)) { entitiesInView.Add(entity); ActivateEntity(entity); // 从对象池取出并激活/初始化 } } } void UnloadEntitiesInGrid(Vector2Int gridPos) { List<MapEntity> entities = GridManager.Instance.GetEntitiesInGrid(gridPos); foreach (var entity in entities) { // 关键:只有当这个Entity所在的所有网格都不在视野内时,才卸载它 bool isInAnyViewGrid = false; foreach (var viewGrid in currentViewGrids) { if (entity.CurrentGrid == viewGrid) // 这里简化判断,精确判断需要Entity记录所有覆盖的网格(对于大体积单位) { isInAnyViewGrid = true; break; } } if (!isInAnyViewGrid && entitiesInView.Contains(entity)) { entitiesInView.Remove(entity); DeactivateEntity(entity); // 回收到对象池 } } } void ActivateEntity(MapEntity entity) { // 1. 从对象池获取实体GameObject GameObject go = entityPool.GetPooledObject(); if (go != null) { go.transform.position = entity.Data.WorldPosition; // 假设entity有数据类 go.SetActive(true); // 2. 初始化实体表现(模型、UI等) EntityView view = go.GetComponent<EntityView>(); if (view != null) view.Initialize(entity.Data); } else { Debug.LogWarning("对象池为空,无法激活实体!"); } } void DeactivateEntity(MapEntity entity) { GameObject go = entity.ViewGameObject; // 假设Entity持有其GameObject引用 if (go != null) { // 清理状态,回收 EntityView view = go.GetComponent<EntityView>(); if (view != null) view.Reset(); go.SetActive(false); entityPool.ReturnToPool(go); } } bool IsGridValid(Vector2Int grid) { // 委托给GridManager或自己检查边界 var gm = GridManager.Instance; return grid.x >= 0 && grid.x < gm.gridCountX && grid.y >= 0 && grid.y < gm.gridCountZ; } }

代码解析与避坑指南:

  1. 视野更新频率UpdateViewArea()Update中调用是最简单的,但可能过于频繁。优化策略包括:
    • 距离阈值:记录上次更新时的摄像机位置,仅当移动超过一定距离(如半个网格大小)时才更新。
    • 时间间隔:使用InvokeRepeating或协程,每0.1-0.2秒更新一次。
    • 网格变化检测:计算当前视野覆盖的网格哈希值,只有哈希值变化时才触发完整更新。
  2. 增量更新:代码中使用了HashSetExceptWith来高效计算新增和移除的网格,这是性能关键。避免每帧清空列表再全部重新添加。
  3. Entity卸载判断UnloadEntitiesInGrid中的判断逻辑至关重要。一个Entity可能同时位于多个网格(如果它的体积大于一个网格)。简单的解决方案是:每个Entity只记录它“主要”所在的网格(如中心点所在网格)。这样,只有当它记录的这个网格不在任何视野网格内时,才卸载它。对于超大型单位,你需要更复杂的“占据网格”记录。
  4. 对象池集成ActivateEntityDeactivateEntity展示了与对象池的交互。对象池必须预先实例化好一定数量的Entity预制体。EntityView组件负责将逻辑数据(entity.Data)同步到GameObject的Transform、MeshRenderer、UI Text等组件上。

5. 地图实体(MapEntity)与数据驱动设计

框架的另一个核心是MapEntity类。它代表了地图上的一个逻辑实体,如城池、部队、资源点。它不直接处理表现,只负责逻辑数据。

using UnityEngine; public class MapEntity : MonoBehaviour { // 核心数据(通常来自服务器或配置表) public EntityData Data { get; private set; } // 关联的表现层对象 public GameObject ViewGameObject { get; set; } // 当前所在的网格坐标(由GridManager更新) public Vector2Int CurrentGrid { get; set; } // 初始化,注入数据 public void Initialize(EntityData data) { this.Data = data; this.transform.position = data.WorldPosition; // 向GridManager注册自己 GridManager.Instance.RegisterEntity(this); } // 逻辑更新,如移动、状态改变 public void LogicUpdate(float deltaTime) { if (Data.IsMoving) { // 模拟移动 Vector3 newPos = Vector3.MoveTowards(transform.position, Data.TargetPosition, Data.Speed * deltaTime); transform.position = newPos; Data.WorldPosition = newPos; // 检查网格是否更新 Vector2Int newGrid = GridManager.Instance.WorldToGridPosition(newPos); if (newGrid != CurrentGrid) { GridManager.Instance.UpdateEntityGrid(this, newPos); } // 到达目标点 if (Vector3.Distance(newPos, Data.TargetPosition) < 0.1f) { Data.IsMoving = false; } } } void OnDestroy() { // 从GridManager注销 // 注意:需要根据GridManager的实现提供注销方法 // GridManager.Instance.UnregisterEntity(this); } } // 实体数据类(可序列化,用于存储和网络传输) [System.Serializable] public class EntityData { public long EntityId; public EntityType Type; // 枚举:City, Army, Resource public Vector3 WorldPosition; public Vector3 TargetPosition; public float Speed; public bool IsMoving; // ... 其他业务数据,如等级、血量、所有者等 }

设计要点:

  1. 数据与表现分离MapEntity持有EntityData纯数据类。表现层(ViewGameObject)通过EntityView组件监听数据变化并更新显示。这符合MVC/MVCS模式,便于网络同步和状态回放。
  2. 逻辑更新与渲染更新分离MapEntity.LogicUpdate处理游戏逻辑(如移动计算),它可能在固定的时间步长(如每秒10次)中调用,而不是每帧。渲染更新(位置插值、动画)则在EntityView.Update中每帧进行,这样可以实现更平滑的视觉表现和更稳定的逻辑模拟。
  3. 网格同步:在LogicUpdate中,当实体移动并跨越网格边界时,调用GridManager.Instance.UpdateEntityGrid。这是保持网格数据正确的关键。

6. 性能优化与高级技巧

基础框架搭建完毕后,我们面临的是性能挑战。一个活跃的SLG大地图可能有数千个实体。以下是几个关键的优化方向:

6.1 网格查询的进一步优化

我们的GetEntitiesInRadius方法返回了所有在相关网格内的Entity,但其中可能包含大量距离中心点实际很远的Entity(位于搜索网格的角落)。对于需要精确距离判断的场景(如技能释放),我们可以在获取粗略列表后,进行二次精确过滤。

// 优化后的精确范围查询 public List<MapEntity> GetEntitiesInRadiusPrecise(Vector3 center, float radius) { // 1. 先用网格进行粗略筛选 int gridRadius = Mathf.CeilToInt(radius / cellSize); List<MapEntity> coarseList = GetEntitiesInRadius(center, gridRadius); // 2. 精确距离过滤 List<MapEntity> preciseList = new List<MapEntity>(); float radiusSqr = radius * radius; // 使用平方比较,避免开方运算 for (int i = 0; i < coarseList.Count; i++) { if ((coarseList[i].transform.position - center).sqrMagnitude <= radiusSqr) { preciseList.Add(coarseList[i]); } } return preciseList; }

6.2 使用Job System与Burst Compiler处理密集计算

对于超大规模的地图(数万实体),即使有网格过滤,遍历和距离计算也可能成为瓶颈。Unity的Job System和Burst Compiler可以将这些计算转移到多线程,并编译成高性能的本地代码。

概念:你可以将GetEntitiesInRadiusPrecise中的过滤逻辑,以及MapEntityLogicUpdate(尤其是移动计算),改造成并行Job。例如,创建一个NativeArray存储所有实体的位置,然后使用IJobParallelFor来并行计算距离或移动。

注意:这属于高级优化,会显著增加代码复杂度。建议在性能剖析(Profiler)明确显示这里是瓶颈后再引入。同时,需要小心处理与主线程的同步(如网格字典的修改)。

6.3 渲染优化:GPU Instancing与LOD

GPU Instancing:对于大量相同或相似的地图元素(如树木、石头、同种类型的部队图标),使用GPU Instancing可以极大减少Draw Call。你需要确保这些实体使用相同的材质球,并在材质上启用GPU Instancing。

LOD(多层次细节):为地图实体配置LOD Group。当实体距离摄像机很远时,自动切换到一个面数很少的模型,甚至是一个Billboard(始终面向摄像机的面片)。这能大幅降低GPU负载。Unity的LOD Group组件可以很方便地实现这一点,你需要为每个LOD级别分配不同的GameObject。

6.4 数据与配置的热更新

SLG游戏经常需要调整平衡性,如移动速度、视野范围、网格大小等。硬编码在脚本里每次都要重新打包。更好的做法是:

  1. 使用ScriptableObject:将网格大小、视野半径、实体属性等配置数据存储在ScriptableObject资产文件中。策划可以在Unity编辑器内修改,甚至可以通过资源热更进行更新。
  2. 使用JSON或二进制配置表:从服务器或本地文件加载配置。这为动态调整提供了最大灵活性。

7. 常见问题与调试技巧

在实际开发中,你肯定会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。

7.1 Entity“闪烁”或突然消失

现象:Entity在视野边缘时,时隐时现。排查:

  1. 检查视野更新频率:如果更新太慢,Entity进入/离开视野的判断会延迟。尝试增加更新频率或改用基于距离阈值的更新。
  2. 检查网格坐标计算:确保WorldToGridPosition公式与你的地图坐标系(原点、轴向)完全匹配。在Scene视图用Debug.DrawLine画出网格线,看Entity是否被正确归入网格。
  3. 检查卸载逻辑:在UnloadEntitiesInGrid方法中打日志,确认一个Entity被卸载时,它是否真的不在任何当前视野网格内。可能是“Entity所在网格”的判断逻辑有误。

7.2 移动卡顿或不流畅

现象:当大量Entity同时移动时,游戏帧率下降。排查:

  1. Profiler是王道:打开Unity Profiler (Window -> Analysis -> Profiler),观察CPU占用。是GridManager.UpdateEntityGrid耗时高,还是AOIManager.UpdateViewArea耗时高,或者是MapEntity.LogicUpdate本身?
  2. 优化更新频率
    • 对于UpdateEntityGrid,不要每帧调用。为移动中的Entity设置一个定时器或计数器,每移动一定距离(如超过0.5个网格大小)或每隔几帧更新一次网格位置。
    • 对于AOIManager,采用“基于距离变化”或“固定时间间隔”的更新策略。
  3. 逻辑帧与渲染帧分离:将MapEntity.LogicUpdate放在FixedUpdate或一个独立的、频率较低(如15Hz)的MonoBehaviour更新循环中。在EntityView.Update中,根据逻辑位置和当前渲染位置进行插值(Lerp),实现平滑移动。这能保证逻辑模拟的稳定性,同时提供流畅的视觉表现。

7.3 内存占用过高

现象:游戏运行一段时间后,内存持续增长。排查:

  1. 对象池泄漏:确保所有离开视野的Entity都通过DeactivateEntity正确回收到对象池。检查是否有直接Destroy而不是ReturnToPool的情况。
  2. 未清理的引用:在DeactivateEntityReset()方法中,确保清除了EntityView对旧数据的所有引用,避免意外持有导致GC无法回收。
  3. 网格字典膨胀:如果地图上的Entity被频繁创建和销毁(非池化管理),gridDictionary中某些网格对应的List<MapEntity>可能会变成空列表但未被移除。可以定期(如每60秒)清理这些空列表,但要注意性能权衡。

7.4 编辑器下的可视化调试

在开发阶段,可视化调试至关重要。为你的框架添加一些调试绘制功能。

// 在GridManager中添加 void OnDrawGizmos() { if (!Application.isPlaying) return; Gizmos.color = Color.white; // 绘制所有网格线 for (int x = 0; x <= gridCountX; x++) { float xPos = x * cellSize - mapWidth * 0.5f; Gizmos.DrawLine(new Vector3(xPos, 0, -mapHeight * 0.5f), new Vector3(xPos, 0, mapHeight * 0.5f)); } for (int z = 0; z <= gridCountZ; z++) { float zPos = z * cellSize - mapHeight * 0.5f; Gizmos.DrawLine(new Vector3(-mapWidth * 0.5f, 0, zPos), new Vector3(mapWidth * 0.5f, 0, zPos)); } // 高亮显示当前AOI视野覆盖的网格(需要AOIManager提供数据) // if (AOIManager.Instance != null) ... } // 在AOIManager中添加 void OnDrawGizmosSelected() { if (playerCamera == null) return; Gizmos.color = Color.green; Gizmos.DrawWireSphere(playerCamera.transform.position, viewRadius); }

在Scene视图中,你将看到白色的网格线和绿色的视野圈,这能帮你直观地理解Entity是如何被管理和筛选的。

搭建SLG大地图核心框架是一个系统工程,它没有唯一的“正确答案”,只有最适合你项目需求的“权衡之选”。本文提供的网格+AOI方案,在Unity 2019.4上经过验证,具备良好的性能基础和清晰的扩展路径。从这里的核心框架出发,你可以继续深化网络同步、战斗结算、地图事件等模块。记住,性能优化永无止境,但前提是架构清晰可测。希望这套手把手的源码和解析,能成为你征服SLG大地图的第一块坚实基石。