
1. 项目概述网格地图的通用性思考网格地图这个看似简单的概念从《俄罗斯方块》里下落的方块到《火焰纹章》里角色移动的格子再到《文明》系列中广袤的六边形大陆几乎贯穿了整个策略与战棋游戏的发展史。很多开发者初次接触时可能会觉得这不就是画个棋盘吗但当你真正动手去实现一个能适配多种玩法、易于扩展和维护的网格系统时才会发现其中门道之深。我在Unity里折腾过不少项目从简单的塔防到复杂的回合制战棋几乎都绕不开网格地图这个核心模块。踩过不少坑之后我逐渐意识到一个设计良好的网格系统其价值远不止于“显示格子”它更应该是游戏逻辑的基石是角色移动、技能范围、寻路算法、地图编辑乃至数据驱动的枢纽。这次我们不谈某个具体游戏的实现而是聚焦于如何设计一个通用的、可复用的网格地图系统。这个系统应该足够抽象能让你轻松地从“俄罗斯方块”的规则切换到“战棋游戏”的复杂逻辑同时又足够具体提供清晰的API和可视化的调试工具。我们会从最基础的网格数据模型开始一步步构建出包含寻路、范围计算、可视化编辑等功能的完整模块并分享那些在官方文档里不会写的、只有实际踩坑才能获得的经验。无论你是想做一个简单的解谜游戏还是一个拥有复杂地形和单位交互的策略游戏这套设计思路都能为你提供一个坚实的起点。2. 网格系统的核心架构设计2.1 数据层抽象与存储一切始于数据。一个健壮的网格系统首先要将“视图”屏幕上显示的格子与“模型”内存中存储的逻辑状态分离。我通常会定义一个GridDataT的泛型类其中T是每个格子Cell存储的自定义数据。这保证了网格逻辑的纯粹性它不关心渲染只关心每个格子的坐标、状态和附加信息。// 网格坐标结构体使用整数避免浮点数精度问题 public struct GridCoord { public int x; public int y; // 可以扩展z轴用于3D或层叠地图 public int layer; public GridCoord(int x, int y, int layer 0) { this.x x; this.y y; this.layer layer; } // 重写Equals和GetHashCode以便用于字典键 } // 网格数据单元基类 public interface IGridCellData { GridCoord Coord { get; set; } bool IsWalkable { get; set; } // 是否可通行 float MovementCost { get; set; } // 移动消耗用于寻路 // 可根据游戏类型扩展地形类型、所属势力、特殊效果等 } // 核心网格数据容器 public class GridDataT where T : IGridCellData, new() { private T[,] _cells; public int Width { get; private set; } public int Height { get; private set; } public GridData(int width, int height) { Width width; Height height; _cells new T[width, height]; // 初始化每个格子 for (int x 0; x width; x) { for (int y 0; y height; y) { _cells[x, y] new T { Coord new GridCoord(x, y) }; } } } public T GetCell(GridCoord coord) { if (IsValidCoord(coord)) return _cells[coord.x, coord.y]; return default(T); } public bool IsValidCoord(GridCoord coord) { return coord.x 0 coord.x Width coord.y 0 coord.y Height; } }设计要点与避坑经验坐标系统选择坚持使用整数坐标。浮点数世界坐标Unity的Transform.position只用于渲染和交互检测逻辑计算全部基于整数网格坐标。这能彻底避免边界判断的精度问题也是A*等算法高效运行的前提。数据与表现分离GridData不持有任何GameObject或MonoBehaviour引用。它只存储逻辑状态。这样你可以轻松实现存档/读档序列化网格数据、服务器-客户端同步只同步数据层以及在不重启游戏的情况下热更新地图规则。泛型与接口的威力通过IGridCellData接口你规定了格子数据的最小契约。而GridDataT的泛型设计允许你在不同游戏模式中复用同一套网格管理逻辑只需更换T的具体实现。例如战棋游戏里T可能包含“防御加成”、“所属区域”等字段而俄罗斯方块里可能只需要“是否被方块占据”一个布尔值。2.2 逻辑层寻路与范围计算有了数据层我们就可以在其上构建游戏逻辑。最核心的两个功能是寻路和范围计算。A寻路算法的通用实现* A*算法本身是标准的但关键在于如何将其与我们的网格数据模型结合并处理游戏中的特殊规则如不同地形消耗、单位体积、动态障碍。public class AStarPathfinder { private GridDataBattleCellData _gridData; public ListGridCoord FindPath(GridCoord start, GridCoord end, Unit movingUnit) { // 开放列表和关闭列表 var openSet new PriorityQueueNode(); var closedSet new HashSetGridCoord(); var startNode new Node(start); startNode.GCost 0; startNode.HCost CalculateHeuristic(start, end); openSet.Enqueue(startNode, startNode.FCost); while (openSet.Count 0) { var currentNode openSet.Dequeue(); if (currentNode.Coord.Equals(end)) { return RetracePath(startNode, currentNode); } closedSet.Add(currentNode.Coord); foreach (var neighborCoord in GetNeighbors(currentNode.Coord)) { if (closedSet.Contains(neighborCoord)) continue; var neighborCell _gridData.GetCell(neighborCoord); // **关键点1移动可行性判断** if (!IsCellTraversable(neighborCell, movingUnit)) continue; // **关键点2动态移动成本** float moveCostToNeighbor currentNode.GCost CalculateMoveCost(currentNode.Coord, neighborCoord, movingUnit); Node neighborNode GetOrCreateNode(neighborCoord); if (moveCostToNeighbor neighborNode.GCost || !openSet.Contains(neighborNode)) { neighborNode.GCost moveCostToNeighbor; neighborNode.HCost CalculateHeuristic(neighborCoord, end); neighborNode.Parent currentNode; if (!openSet.Contains(neighborNode)) { openSet.Enqueue(neighborNode, neighborNode.FCost); } else { openSet.UpdatePriority(neighborNode, neighborNode.FCost); } } } } return null; // 未找到路径 } private bool IsCellTraversable(BattleCellData cell, Unit unit) { // 不仅仅是“是否可行走”还要考虑 // 1. 单位体积大型单位可能占据多个格子需要检查所有相关格子。 // 2. 友军阻挡战棋游戏中能否穿过友军单位通常不能。 // 3. 动态障碍如临时产生的火焰、流沙等效果。 // 4. 高度差某些游戏有跳跃或攀爬规则。 return cell.IsWalkable !IsCellOccupiedByBlockingUnit(cell.Coord, unit); } private float CalculateMoveCost(GridCoord from, GridCoord to, Unit unit) { var toCell _gridData.GetCell(to); float baseCost toCell.MovementCost; // 地形基础消耗 // 单位特性影响骑兵在平原消耗低在森林消耗高。 float unitTerrainModifier unit.GetTerrainModifier(toCell.TerrainType); return baseCost * unitTerrainModifier; } }范围计算移动范围与技能范围 战棋游戏中显示角色的可移动范围或技能的影响范围是基本需求。这本质是一个广度优先搜索BFS或Dijkstra算法的应用。public class RangeCalculator { public HashSetGridCoord CalculateMovementRange(GridCoord start, int movePoints, Unit unit) { var reachableCells new HashSetGridCoord(); var frontier new PriorityQueueGridCoord(); var costSoFar new DictionaryGridCoord, float(); frontier.Enqueue(start, 0); costSoFar[start] 0; while (frontier.Count 0) { var current frontier.Dequeue(); reachableCells.Add(current); foreach (var next in GetNeighbors(current)) { if (!_gridData.IsValidCoord(next)) continue; var cell _gridData.GetCell(next); if (!IsCellTraversable(cell, unit)) continue; float newCost costSoFar[current] CalculateMoveCost(current, next, unit); // **关键消耗不超过移动力且未被更优路径访问过** if (newCost movePoints (!costSoFar.ContainsKey(next) || newCost costSoFar[next])) { costSoFar[next] newCost; frontier.Enqueue(next, newCost); } } } return reachableCells; } // 技能范围计算如圆形、扇形、直线 public HashSetGridCoord CalculateSkillArea(GridCoord center, SkillShape shape, int radius) { var area new HashSetGridCoord(); switch (shape) { case SkillShape.Circle: // 使用曼哈顿距离或欧几里得距离取决于游戏规则。 // 战棋常用曼哈顿距离菱形范围更符合格子移动直觉。 for (int dx -radius; dx radius; dx) { for (int dy -radius; dy radius; dy) { if (Mathf.Abs(dx) Mathf.Abs(dy) radius) { // 曼哈顿距离 var coord new GridCoord(center.x dx, center.y dy); if (_gridData.IsValidCoord(coord)) area.Add(coord); } } } break; case SkillShape.Line: // 向某个方向延伸直到遇到阻挡或达到最大距离 break; } return area; } }实操心得性能优化A和BFS在大型地图上可能成为性能瓶颈。对于静态地图可以预计算每个格子到关键点的路径成本或使用跳点搜索JPS优化A。对于移动范围计算如果单位移动力不高BFS的搜索空间其实很小性能通常足够。缓存结果单位的移动范围在移动力、地形未改变时是固定的。可以缓存计算结果在需要显示时直接取出避免每帧计算。可视化调试一定要为这些算法编写可视化的调试视图。用不同颜色在Scene视图或Game视图绘制出开放列表、关闭列表、最终路径、可移动范围等。这是排查寻路逻辑错误最有效的手段没有之一。3. 表现层在Unity中的可视化与交互逻辑层完备后我们需要让玩家看到并与之交互。这部分的核心是将抽象的网格坐标映射到Unity的世界空间。3.1 网格渲染与高亮我通常创建一个GridRenderer组件来负责网格的视觉表现。它不存储逻辑数据只从GridData中读取信息进行渲染。public class GridRenderer : MonoBehaviour { public GameObject cellPrefab; // 一个简单的Quad或自定义Mesh public Material walkableMaterial; public Material unwalkableMaterial; public Material highlightMaterial; // 用于高亮移动范围、攻击范围等 private DictionaryGridCoord, GameObject _cellVisuals new DictionaryGridCoord, GameObject(); private GridDataBattleCellData _gridData; public void Initialize(GridDataBattleCellData gridData) { _gridData gridData; GenerateGridVisual(); } private void GenerateGridVisual() { for (int x 0; x _gridData.Width; x) { for (int y 0; y _gridData.Height; y) { var coord new GridCoord(x, y); var cellData _gridData.GetCell(coord); Vector3 worldPos GridToWorld(coord); // 坐标转换 var cellGo Instantiate(cellPrefab, worldPos, Quaternion.identity, this.transform); cellGo.name $Cell_{x}_{y}; var renderer cellGo.GetComponentRenderer(); renderer.material cellData.IsWalkable ? walkableMaterial : unwalkableMaterial; // 添加一个脚本用于处理鼠标事件 var interactable cellGo.AddComponentGridCellInteractable(); interactable.Initialize(coord, this); _cellVisuals[coord] cellGo; } } } // **核心网格坐标与世界坐标的相互转换** public Vector3 GridToWorld(GridCoord coord) { // 假设每个格子宽高为1原点在网格中心 float x (coord.x - _gridData.Width / 2.0f 0.5f) * CellSize; float z (coord.y - _gridData.Height / 2.0f 0.5f) * CellSize; // Unity中Z轴为前后 return new Vector3(x, 0, z); } public GridCoord WorldToGrid(Vector3 worldPos) { // 反向计算注意处理偏移和取整 int x Mathf.FloorToInt((worldPos.x / CellSize) _gridData.Width / 2.0f); int y Mathf.FloorToInt((worldPos.z / CellSize) _gridData.Height / 2.0f); return new GridCoord(x, y); } public void HighlightCells(IEnumerableGridCoord cells, Color color) { ClearHighlights(); foreach (var coord in cells) { if (_cellVisuals.TryGetValue(coord, out var go)) { // 可以采用更换材质、叠加一个半透明面片、修改Shader参数等方式 go.GetComponentRenderer().material.SetColor(_EmissionColor, color); } } } }注意事项渲染批次优化如果网格很大如100x100实例化10000个GameObject会带来巨大的Draw Call。此时应考虑使用GPU Instancing绘制相同的格子或使用一个大的Mesh来绘制所有格子通过Shader根据格子数据改变颜色。对于移动平台这点尤为重要。交互处理GridCellInteractable脚本负责触发鼠标悬停、点击事件并将事件传递给上层的游戏管理器如InputManager或BattleManager。事件应传递网格坐标而不是世界坐标或GameObject引用以保持逻辑层的纯净。坐标转换的精度WorldToGrid函数中的取整方式FloorToInt,RoundToInt,CeilToInt取决于你的游戏设计。通常FloorToInt是最常用的它确保了世界空间中的一个点唯一对应一个网格坐标。3.2 与Unity导航系统的结合对于需要AI自动移动的单位如RTS中的小兵我们可能希望它们能在网格地图上平滑移动并绕过动态障碍。Unity自带的NavMesh系统是一个强大的选择但需要与我们的逻辑网格协同工作。方案使用NavMesh作为表现层逻辑网格作为决策层逻辑决策单位的移动目标、路径点仍然由我们的A*算法在逻辑网格上计算得出。这保证了移动符合战棋规则格子、移动力消耗。平滑移动将计算出的路径点网格坐标转换为世界坐标然后让Unity的NavMeshAgent去执行从一个点到下一个点的平滑移动。你可以设置NavMeshAgent的speed、angularSpeed等参数来控制移动表现。动态障碍对于逻辑上不可通行的格子如有单位占据可以在对应位置的NavMesh上动态生成一个NavMeshObstacle组件阻止NavMeshAgent穿过。当障碍移除时再销毁它。public class HybridMovementController : MonoBehaviour { private NavMeshAgent _agent; private Unit _unit; private ListVector3 _pathWorldPositions new ListVector3(); public void MoveAlongPath(ListGridCoord path) { if (path null || path.Count 2) return; _pathWorldPositions.Clear(); foreach (var coord in path) { _pathWorldPositions.Add(_gridRenderer.GridToWorld(coord)); } StartCoroutine(FollowPathCoroutine()); } private IEnumerator FollowPathCoroutine() { int currentIndex 0; _agent.SetDestination(_pathWorldPositions[currentIndex]); while (currentIndex _pathWorldPositions.Count) { if (!_agent.pathPending _agent.remainingDistance _agent.stoppingDistance) { currentIndex; if (currentIndex _pathWorldPositions.Count) { _agent.SetDestination(_pathWorldPositions[currentIndex]); } else { // 到达终点 OnMovementFinished(); yield break; } } yield return null; } } }提示这种混合方案结合了逻辑网格的精确规则控制和NavMesh的成熟平滑移动与避障是制作带有策略元素的RPG或RTS游戏的常用模式。但要注意同步问题确保NavMeshAgent的实际位置与逻辑网格坐标在关键帧如回合结束、发动攻击时保持一致。4. 高级特性与扩展设计一个通用的网格系统必须考虑未来的扩展性。以下是几个常见的高级特性设计思路。4.1 多层地图与高度系统许多战棋游戏有高低差概念如《最终幻想战略版》。这可以通过扩展GridCoord增加layer或height字段来实现。数据层GridData可以变为三维数组T[,,]或者使用DictionaryGridCoord, T来稀疏存储有高度的格子。每个格子数据需要增加Height属性。移动规则在IsCellTraversable和CalculateMoveCost中加入高度差判断。例如单位有“跳跃力”属性只有高度差小于等于跳跃力时才能移动上去且移动消耗可能增加。战斗规则高度差可能影响攻击命中率、伤害加成或技能射程。这些规则可以在独立的CombatCalculator中处理它同时读取攻击方、防御方和所在格子的数据。4.2 网格编辑器的开发为了高效地设计关卡一个可视化的网格编辑器必不可少。你可以利用Unity的EditorWindow和HandlesAPI 来打造。地形笔刷在Scene视图中通过鼠标点击或拖拽来绘制地形。编辑器脚本读取鼠标位置转换为网格坐标然后修改GridData中对应格子的TerrainType和IsWalkable等属性并即时更新场景中的视觉表现。单位放置提供单位列表拖拽或点击放置到指定格子。编辑器需要记录单位Prefab与其初始网格坐标的关联。数据序列化将编辑好的GridData以及单位放置信息序列化为一个自定义的关卡数据文件如JSON、ScriptableObject。游戏运行时加载这个文件来重建地图。规则验证编辑器可以提供“验证”功能检查地图是否连通所有可到达区域是否相连、出生点是否合理等。开发编辑器虽然前期耗时但对于需要大量关卡的游戏来说能极大提升开发效率并保证数据的一致性。4.3 网络同步考量如果是多人游戏网格系统的状态需要在客户端之间同步。设计原则是只同步变化的数据。权威服务器服务器持有权威的GridData实例。所有改变地图状态的逻辑单位移动、释放改变地形的技能必须在服务器执行并验证。状态同步当地形状态改变如某格变为燃烧时服务器广播该格子的坐标和新状态数据如CellState.Burning。客户端收到后本地更新GridData并播放特效。输入预测与回滚对于单位移动为了响应迅速客户端可以立即预测移动并在本地显示。服务器验证后如果结果一致则确认如果不一致如服务器判定该格子已被占据则需要进行位置修正或回滚。这要求网格相关的逻辑运算必须是确定性的即相同的输入必然产生相同的输出。5. 实战问题排查与性能调优即使设计再完善实际开发中也会遇到各种问题。这里记录几个我踩过的“坑”及其解决方案。5.1 常见问题速查表问题现象可能原因排查步骤与解决方案单位移动时“抖动”或卡在格子边缘1. 逻辑坐标与视觉位置不同步。2. NavMeshAgent的停止距离Stopping Distance设置过大。1. 确保移动结束时强制将单位Transform位置对齐到GridToWorld计算出的精确位置。2. 将StoppingDistance设置为一个很小的值如0.05或使用协程精确控制每步移动。A*寻路性能突然变慢大地图1. 启发函数Heuristic计算开销大。2. 开放列表OpenSet数据结构效率低。3. 邻居节点获取函数GetNeighbors产生了不必要的内存分配。1. 使用曼哈顿距离对于4方向移动或切比雪夫距离对于8方向避免开平方根。2. 使用基于堆Heap的优先队列确保Enqueue和Dequeue为O(log n)。3. 缓存邻居坐标数组或使用静态数组并在循环中复用避免每次调用GetNeighbors都new一个ListGridCoord。鼠标点击格子不准确WorldToGrid坐标转换函数有误或射线检测Raycast的层Layer设置不对。1. 在GridRenderer的OnDrawGizmos中绘制每个格子的Gizmo框检查视觉网格与逻辑网格是否对齐。2. 调试打印鼠标点击的世界坐标和转换后的网格坐标。3. 确保射线只与格子的碰撞体交互。移动范围显示错误多出一圈或少了一圈范围计算算法BFS/Dijkstra的终止条件或消耗累计逻辑有误。1.可视化调试单步调试算法将每一步的“当前节点”和“待探索节点”在Scene中用不同颜色绘制出来。2. 检查CalculateMoveCost返回值是否可能为0或负数导致消耗计算溢出。3. 确认移动力movePoints的单位与地形消耗的单位是否一致。存档/读档后地图状态错乱网格数据序列化/反序列化时自定义类如BattleCellData的字段未正确标记[Serializable]或未实现序列化接口。1. 确保所有需要保存的类都标记[System.Serializable]。2. 如果使用自定义容器如DictionaryGridCoord, T可能需要编写自定义的序列化器。3. 推荐使用ScriptableObject存储静态地图数据用单独的SaveData类存储运行时动态变化的数据。5.2 性能优化实战心得池化Pooling一切格子的视觉对象GameObject、寻路算法中的节点Node对象都应该使用对象池。频繁的Instantiate/Destroy或new/GC是性能杀手。分帧计算对于超大地图上的AI决策如计算所有敌方单位的威胁范围不要在一帧内完成。可以将计算任务分摊到多帧进行避免造成卡顿。可以使用UnityEngine.Profiling.Profiler来定位耗时操作。使用Jobs System与Burst Compiler对于纯数据的、并行度高的计算例如同时更新上百个格子的状态效果或计算一片区域的光照/战争迷雾可以尝试使用Unity的C# Job System和Burst编译器将工作转移到多核CPU上并行执行能获得显著的性能提升。但这会增加代码复杂度需权衡使用。简化碰撞体如果每个格子都有一个碰撞体用于点击检测当网格很大时物理引擎的负担会很重。可以考虑使用一个大的碰撞盒覆盖整个地图然后通过WorldToGrid转换来判定点击了哪个格子或者使用更轻量的射线检测到特定层。从俄罗斯方块的规则网格到战棋游戏的复杂地形网格系统设计的关键在于分离关注点。数据层、逻辑层、表现层各司其职通过清晰的接口通信。泛型和接口的设计让核心模块得以复用而将游戏特定的规则移动力计算、技能范围抽象成可配置的策略或组件则能让系统灵活适应不同的游戏类型。在实现过程中最宝贵的工具不是某种高级算法而是可视化的调试手段。把你算法中的中间状态开放列表、路径、范围实时画在屏幕上很多逻辑错误会一目了然。最后性能优化永远不要过早进行先确保功能正确再用Profiler找到真正的瓶颈有的放矢。这套通用设计是我经过多个项目迭代后的总结希望能为你节省一些摸索的时间把精力更多地投入到游戏独特的玩法与乐趣创造中去。