资讯动态

Unity六边形网格地图搭建实战:坐标系统与A*寻路全解析

发布时间:2026/9/16 21:26:08 来源:尧图企业网站定制
最近在做一个策略类游戏原型地图部分我第一反应就是用六边形网格。原因很简单六边形格子之间的邻居距离天然相等不会出现方格图那样“对角相邻和对边相邻距离不一样”的尴尬情况这对战棋走格子、范围攻击判定这些玩法来说体验要稳定得多。这篇文章就把我这次在Unity里搭建六边形网格地图的完整过程写出来。从坐标系统选型、网格生成算法到点击检测、邻居查找、寻路再到踩过的坑和性能建议全都摊开讲。所有代码我都整理成了可以直接放进Unity工程里跑起来的完整脚本不是片段没有删减你照着建工程、挂脚本、运行就能看到效果。1. 为什么选择六边形网格以及坐标系统的选型逻辑先聊选型。很多人做网格地图第一反应是用方形格子毕竟Tilemap用起来太方便了。但在战棋、策略、模拟经营这类玩法里六边形网格有三个方形网格很难替代的优势第一个优势是距离严谨。六边形的每个邻居到中心格子的距离都相等无论从哪个方向走一格消耗完全一致。方形网格里斜着走和对边走距离不等要么需要引入复杂的距离修正公式要么就得忍受移动力计算上的细微不公平。第二个优势是视觉和地形表现更自然。六边形拼起来像蜂窝边界的锯齿感比方块拼接少很多。地图上的山脉、河流用六边形来表达比方形网格更容易做出自然过渡尤其是做岛屿、部落这类有机形态的地图时。第三个优势是扩展性。六边形网格天然支持“环状范围”的计算比如“距离某格两格以内的所有格子”这类查询在六边形网格里逻辑非常直观这个特性在范围攻击、AOE范围效果技能、怪物仇恨圈的实现里特别有用。当然六边形网格也有代价——坐标系统比方形复杂这是它最劝退的点。所以选型时要把这块想清楚。六边形有两种常见的朝向一种是尖顶朝上的pointy-top一种是平顶朝上的flat-top。我这次用尖顶朝上因为竖屏策略游戏里尖顶排列的横向跨度逻辑更贴近列网格在屏幕上的轴对齐关系比较好理解。坐标系统我也做了一番对比。六边形网格有三种主流的坐标表达偏移坐标直接沿用二维数组的(row, col)索引看起来最像方形网格的思维但奇偶行的格子会错开半格导致所有邻居计算都要判断奇偶性代码很啰嗦。轴向坐标用(q, r)两个轴来表达邻居方向表固定换算公式简洁性能和可读性平衡得最好。立方体坐标用(x, y, z)三个轴满足xyz0数学上最美旋转、镜像这些操作非常好算但存储和显示时需要多做一次换算。我的建议是渲染和存储用轴向坐标需要做复杂几何变换时临时转成立方体坐标。这也是Red Blob Games那篇经典文章推荐的路线实践下来确实最顺。后面所有代码我都基于轴向坐标(q, r)来写。2. 核心逻辑一个不依赖Unity渲染的HexGrid类写代码之前先把逻辑和渲染分开。很多教程把所有东西揉在一个MonoBehaviour里格子生成、坐标计算、点击检测全在一坨看着热闹后面改起来想砸电脑。我习惯先写一个纯C#的HexGrid类它只负责数学计算和数据结构不引用UnityEngine的渲染部分。这样网格逻辑可以单独测试甚至可以在控制台工程里跑排错非常方便。2.1 轴向坐标到世界坐标的映射算法在尖顶朝上的六边形布局里假设六边形的外接圆半径是size那么单个格子的宽度和高度的关系是固定的六边形宽度 width size * sqrt(3)六边形高度 height size * 2当按行排列时同一行的格子水平间距就是width。下一行的格子整体向右偏移半格。垂直方向上的间距不是height而是height * 3 / 4。为什么是3/4因为尖顶六边形上下是尖角上下两行之间嵌合时垂直方向只重叠到四分之三的高度。所以轴向坐标(q, r)转世界坐标的公式是pos.x size * sqrt(3) * (q r * 0.5) pos.y size * 1.5 * r注意这里的r * 0.5就是“下一行偏移半格”的体现。如果不管这个偏移所有格子的左侧都会对齐在一条竖直线上整个网格看起来会变成歪歪扭扭的砖墙这是新手最容易犯的错误之一。下面我这个HexGrid类坐标转换、邻居查找、点击反查、范围搜索都放进去了using System.Collections.Generic; using UnityEngine; // 纯逻辑六边形网格类不依赖场景渲染 public class HexGrid { public int columns; public int rows; public float size; // 六边形外接圆半径 // 邻居方向表奇数行和偶数行因为错位方向不一样 private static readonly int[,] OddNeighbors new int[,] { { 1, 0 }, { 0, 1 }, { -1, 1 }, { -1, 0 }, { 0, -1 }, { 1, -1 } }; private static readonly int[,] EvenNeighbors new int[,] { { 1, 0 }, { 1, 1 }, { 0, 1 }, { -1, 0 }, { -1, -1 }, { 0, -1 } }; public HexGrid(int columns, int rows, float size) { this.columns columns; this.rows rows; this.size size; } // 轴向坐标转世界坐标 public Vector2 AxialToWorld(int q, int r) { float x size * Mathf.Sqrt(3f) * (q r * 0.5f); float y size * 1.5f * r; return new Vector2(x, y); } // 世界坐标转轴向坐标最近格子 public Vector2Int WorldToAxial(Vector2 worldPos) { float q (Mathf.Sqrt(3f) / 3f * worldPos.x - 1f / 3f * worldPos.y) / size; float r (2f / 3f * worldPos.y) / size; return RoundAxial(q, r); } // 把浮点坐标四舍五入到最近的轴向整数坐标 private Vector2Int RoundAxial(float q, float r) { float x q; float z r; float y -x - z; int rx Mathf.RoundToInt(x); int ry Mathf.RoundToInt(y); int rz Mathf.RoundToInt(z); float xDiff Mathf.Abs(rx - x); float yDiff Mathf.Abs(ry - y); float zDiff Mathf.Abs(rz - z); if (xDiff yDiff xDiff zDiff) rx -ry - rz; else if (yDiff zDiff) ry -rx - rz; else rz -rx - ry; return new Vector2Int(rx, rz); } // 获取某格的所有合法邻居 public ListVector2Int GetNeighbors(int q, int r) { ListVector2Int result new ListVector2Int(); int[,] directions (r 1) 0 ? EvenNeighbors : OddNeighbors; for (int i 0; i 6; i) { int nq q directions[i, 0]; int nr r directions[i, 1]; if (IsInGrid(nq, nr)) result.Add(new Vector2Int(nq, nr)); } return result; } // 获取距离中心格 step 步以内的所有格子 public ListVector2Int GetRange(int q, int r, int steps) { ListVector2Int result new ListVector2Int(); for (int dq -steps; dq steps; dq) { int maxDr Mathf.Min(steps, -dq steps); int minDr Mathf.Max(-steps, -dq - steps); for (int dr minDr; dr maxDr; dr) { int nq q dq; int nr r dr; if (IsInGrid(nq, nr)) result.Add(new Vector2Int(nq, nr)); } } return result; } public bool IsInGrid(int q, int r) { return q 0 q columns r 0 r rows; } }2.2 世界坐标反算格子的数学原理WorldToAxial这个方法我再多解释几句。很多人的第一反应是“遍历所有格子找到最近的”这在原型期能用但格子一多性能就拉胯。上面的代码用的是数学反解。思路是按照轴向坐标转世界坐标的公式反过来用世界坐标解出浮点型的(q, r)。因为公式是线性变换可以直接求逆。解出来的浮点值大概率不是整数所以用RoundAxial方法做一次“立方体坐标四舍五入”的修正。为什么要用立方体坐标的round而不是直接对q、r分别round因为在六边形网格里浮点q、r必须满足整数关系才不会“穿模”。直接各自四舍五入可能得到一个不在任何格子中心附近的错误坐标。而换算成立方体坐标(x, y, z)后对三者各自round再调整偏差最大的那个分量就能保证结果落在离目标最近的真实格子中心上。这是一个很经典也很稳健的六边形取整算法。2.3 邻居方向表的由来GetNeighbors里用到的方向表是另一个重灾区。很多初学者会问为什么不直接用六个方向向量因为在轴向坐标里行奇偶性会导致邻居方向的偏移不同。偶数行时六边形北边有一个邻居南边有一个东北、西北、东南、西南各一个方向向量是(1,0)、(1,1)、(0,1)、(-1,0)、(-1,-1)、(0,-1)。奇数行时整个图案错位了半格邻居方向变成了(1,0)、(0,1)、(-1,1)、(-1,0)、(0,-1)、(1,-1)。这个方向表是固定的直接复用就好不需要每次动态算。如果你坚持用立方体坐标邻居方向可以统一为六个固定组合不需要分奇偶但这会引入额外的坐标换算。两套方案都可以我选轴向坐标就是因为它在这种场景下代码量最少。3. Unity可视化把格子真正画出来逻辑层跑通之后终于到了能看见东西的阶段。这一步要做三件事生成单个六边形的Mesh、批量实例化到场景里、调试时把每个格子的坐标画出来。3.1 生成六边形几何体并挂上MeshRendererUnity里没有现成的六边形Mesh需要用代码生成。生成六边形网格并不复杂把中心点作为顶点0然后绕一圈生成六个顶点按顺序连接成三角形。我写了一个通用方法可以随时生成一个指定半径的六边形Meshusing UnityEngine; public static class HexMeshBuilder { public static Mesh CreateHexMesh(float size) { Mesh mesh new Mesh(); mesh.name HexMesh; Vector3[] vertices new Vector3[7]; Vector2[] uv new Vector2[7]; int[] triangles new int[18]; // 中心点 vertices[0] Vector3.zero; uv[0] new Vector2(0.5f, 0.5f); // 六个顶点尖顶朝上所以从90度开始 for (int i 0; i 6; i) { float angle 90f * Mathf.Deg2Rad - i * 60f * Mathf.Deg2Rad; float x Mathf.Cos(angle) * size; float y Mathf.Sin(angle) * size; vertices[i 1] new Vector3(x, y, 0); uv[i 1] new Vector2((x / size 1f) * 0.5f, (y / size 1f) * 0.5f); } // 六个三角形 int idx 0; for (int i 0; i 6; i) { triangles[idx] 0; triangles[idx] i 1; triangles[idx] (i 1) % 6 1; } mesh.vertices vertices; mesh.uv uv; mesh.triangles triangles; mesh.RecalculateNormals(); return mesh; } }顶点顺序我写成逆时针这样正面是Z轴正方向。如果放在XY平面上从正面看没问题如果做成俯视地图可能要旋转相机或调整顶点顺序看你的项目朝向。3.2 批量生成网格对象并防止半透明穿插生成每个格子的GameObject时我给每个节点挂了一个MeshFilter、MeshRenderer和BoxCollider。MeshRenderer上可以统一用一个默认的Unlit颜色材质方便调试。BoxCollider用来做后面的点击检测。批量生成的部分我写在HexGridVisual里using UnityEngine; public class HexGridVisual : MonoBehaviour { [Header(网格参数)] public int columns 8; public int rows 8; public float hexSize 1f; [Header(调试选项)] public bool drawCoordinates true; public bool useSimpleMaterial true; private HexGrid hexGrid; private Mesh hexMesh; private GameObject[,] cellObjects; private Material defaultMaterial; private void Awake() { hexGrid new HexGrid(columns, rows, hexSize); hexMesh HexMeshBuilder.CreateHexMesh(hexSize); if (useSimpleMaterial) { Shader shader Shader.Find(Unlit/Color); if (shader null) shader Shader.Find(Universal Render Pipeline/Unlit); defaultMaterial new Material(shader); defaultMaterial.color new Color(0.85f, 0.85f, 0.85f); } else { defaultMaterial new Material(Shader.Find(Standard)); } GenerateGrid(); RegenerateColliders(); } private void GenerateGrid() { cellObjects new GameObject[columns, rows]; for (int r 0; r rows; r) { for (int q 0; q columns; q) { Vector2 pos hexGrid.AxialToWorld(q, r); GameObject go new GameObject($Hex_{q}_{r}); go.transform.SetParent(transform); go.transform.position new Vector3(pos.x, pos.y, 0); go.AddComponentMeshFilter().sharedMesh hexMesh; go.AddComponentMeshRenderer().sharedMaterial defaultMaterial; HexCell cell go.AddComponentHexCell(); cell.Initialize(q, r); cellObjects[q, r] go; } } } private void RegenerateColliders() { for (int r 0; r rows; r) { for (int q 0; q columns; q) { GameObject go cellObjects[q, r]; if (go.GetComponentCollider() null) { BoxCollider box go.AddComponentBoxCollider(); box.size new Vector3(hexSize * 1.2f, hexSize * 0.3f, hexSize * 0.3f); box.transform.localPosition new Vector3(0, 0, -0.1f); box.isTrigger true; } } } } public HexCell GetCellAtWorldPosition(Vector3 worldPos) { Vector2Int axial hexGrid.WorldToAxial(worldPos); if (!hexGrid.IsInGrid(axial.x, axial.y)) return null; GameObject go cellObjects[axial.x, axial.y]; return go null ? null : go.GetComponentHexCell(); } public HexGrid GetGrid() hexGrid; #if UNITY_EDITOR private void OnDrawGizmos() { if (!Application.isPlaying) return; if (hexGrid null) return; for (int r 0; r rows; r) { for (int q 0; q columns; q) { Vector2 center hexGrid.AxialToWorld(q, r); if (drawCoordinates) { UnityEditor.Handles.Label(new Vector3(center.x, center.y, 0.1f), ${q},{r}); } Gizmos.color new Color(0.2f, 0.2f, 0.2f, 0.6f); Vector3[] corners new Vector3[6]; for (int i 0; i 6; i) { float angle 90f * Mathf.Deg2Rad - i * 60f * Mathf.Deg2Rad; float x Mathf.Cos(angle) * hexSize center.x; float y Mathf.Sin(angle) * hexSize center.y; corners[i] new Vector3(x, y, 0); } for (int i 0; i 6; i) { Gizmos.DrawLine(corners[i], corners[(i 1) % 6]); } } } } #endif }这个脚本挂在场景里的一个空物体上即可。Awake里完成全部初始化OnDrawGizmos在编辑器里画出轮廓和坐标标签能非常直观地检查网格对不对。运行时每个格子都带一个BoxCollider大小我故意调得比六边形外接圆略小一点hexSize * 1.2倍因为碰撞体是方盒子如果按外接圆取整相邻格子之间的碰撞体会互相重叠点击时容易产生歧义。做成Trigger则不会阻挡射线只是用来做命中检测。HexCell本身是个很轻量的组件保存轴向坐标和一些运行时状态using UnityEngine; public class HexCell : MonoBehaviour { public int q; public int r; public bool isBlocked; public int terrainCost 1; // 地形成本寻路时会用到 public void Initialize(int axQ, int axR) { q axQ; r axR; name $Hex_{q}_{r}; } public Vector3 GetCenter() { return transform.position; } }这里我特意把terrainCost字段留出来。后期做地形系统时沼泽、山地、道路都可以通过这个值来影响移动力寻路算法无需改结构。关于碰撞体的层级我再说一个经验点击检测不要用2D Collider。虽然你可以把六边形地图拍扁变成2D场景用Physics2D来命中但后续如果要扩展到3D视角、倾斜相机甚至VR2D方案会很痛苦。直接用3D的BoxCollider加Physics.Raycast代码统一扩展性也好。4. 交互与寻路点击选中、邻居高亮和A*寻路光把格子画出来只是第一步游戏里还要让玩家能点击格子、选中格子、查看可移动范围、让单位寻路移动。这一节我把这些交互和能力串起来。4.1 用射线检测点击的格子并处理UI遮挡点击检测在Unity里很常规但有三个坑第一个坑是Input.GetMouseButtonDown在外面触发时如果场景里有UI按钮按钮点击也会同时触发场景点击。处理方式是用UnityEngine.EventSystems里的EventSystem.current.IsPointerOverGameObject()来判断如果鼠标在UI上就忽略场景点击。第二个坑是射线长度。六边形格子是平铺在XY平面上的Z方向很薄Camera.ScreenPointToRay打出来的射线可能因为相机不是正对平面而有很大角度。我的做法是给格子加一个Trigger碰撞体把整个六边形的厚度做成0.3左右然后把相机固定在俯视或45度角确保射线能穿透。第三个坑是世界坐标和格子坐标相互转换时的浮点误差。这个我建议无论点击、拖拽还是单位移动都统一走HexGrid的WorldToAxial再获取格子对象不要在多个地方自己复制公式否则改一处忘一处后面定位bug很痛苦。点击选中和高亮的示例代码using UnityEngine; using UnityEngine.EventSystems; public class HexInteraction : MonoBehaviour { public Camera targetCamera; public HexGridVisual gridVisual; public GameObject highlightPrefab; private HexCell lastSelectedCell; private void Update() { if (Input.GetMouseButtonDown(0)) { if (EventSystem.current ! null EventSystem.current.IsPointerOverGameObject()) return; Ray ray targetCamera.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 200f)) { HexCell cell hit.collider.GetComponentHexCell(); if (cell ! null) { SelectCell(cell); } } } } private void SelectCell(HexCell cell) { Debug.Log($选中格子: {cell.q}, {cell.r}); if (lastSelectedCell ! null) { Destroy(lastSelectedCell.transform.Find(Highlight)?.gameObject); } GameObject highlight Instantiate(highlightPrefab, cell.transform); highlight.name Highlight; highlight.transform.localPosition Vector3.zero Vector3.back * 0.1f; lastSelectedCell cell; } }高亮Prefab直接用一个同半径的半透明六边形Mesh就行。注意localPosition里要加一点Z偏移别让高亮和本体完全重叠否则可能因为深度冲突闪脸把相机裁剪调好之后看着干净点。Z-fighting问题在Unity里很常见两个面重合时画面闪烁偏移0.1基本能解决。highlightPrefab怎么创建最简单的方式是拖一个场景内的临时体给一个半透明材质把它做成Prefab。也可以用代码在启动时动态实例化MeshRenderer我这里用Prefab是方便你在美术阶段替换成漂亮的流光效果。4.2 在六边形地图上跑A*寻路A*算法本身大家都不陌生难点在于如何适配六边形网格。这里的适配点就两个邻居获取和代价计算。邻居获取直接复用HexGrid.GetNeighbors方法不需要改动。代价计算则要引入每个格子的terrainCost比如平原1森林2沼泽3山路4。一个单位移动时总代价是经过的所有格子的terrainCost之和。这样A*自然就能绕过沼泽走平原。我写了一个Static A*工具类不考虑怪物、动态阻挡这些专属于六边形地图using System.Collections.Generic; using UnityEngine; public static class HexPathfinder { public static ListVector2Int FindPath( HexGrid grid, Vector2Int start, Vector2Int goal, System.FuncVector2Int, int costFunc) { if (!grid.IsInGrid(start.x, start.y) || !grid.IsInGrid(goal.x, goal.y)) return null; DictionaryVector2Int, Vector2Int cameFrom new DictionaryVector2Int, Vector2Int(); DictionaryVector2Int, float gCost new DictionaryVector2Int, float(); DictionaryVector2Int, float fCost new DictionaryVector2Int, float(); gCost[start] 0; fCost[start] HexHeuristic(start, goal); ListVector2Int openList new ListVector2Int { start }; HashSetVector2Int closedList new HashSetVector2Int(); int safetyCounter 0; while (openList.Count 0 safetyCounter 10000) { Vector2Int current openList[0]; float currentF fCost[current]; int currentIndex 0; // 找F值最小的节点 for (int i 1; i openList.Count; i) { if (fCost[openList[i]] currentF) { current openList[i]; currentF fCost[current]; currentIndex i; } } openList.RemoveAt(currentIndex); if (current goal) { return ReconstructPath(cameFrom, goal); } closedList.Add(current); foreach (Vector2Int neighbor in grid.GetNeighbors(current.x, current.y)) { if (closedList.Contains(neighbor)) continue; int terrainCost costFunc ! null ? costFunc(neighbor) : 1; if (terrainCost 0) continue; float tentativeG gCost[current] terrainCost; if (!gCost.ContainsKey(neighbor) || tentativeG gCost[neighbor]) { cameFrom[neighbor] current; gCost[neighbor] tentativeG; fCost[neighbor] tentativeG HexHeuristic(neighbor, goal); if (!openList.Contains(neighbor)) openList.Add(neighbor); } } } return null; } private static float HexHeuristic(Vector2Int a, Vector2Int b) { // 六边形网格的曼哈顿距离立方体坐标形式 int dq Mathf.Abs(a.x - b.x); int dr Mathf.Abs(a.y - b.y); return Mathf.Max(dq, dr, Mathf.Abs(dq - dr)); } private static ListVector2Int ReconstructPath( DictionaryVector2Int, Vector2Int cameFrom, Vector2Int goal) { ListVector2Int path new ListVector2Int(); Vector2Int current goal; path.Add(current); int guard 0; while (cameFrom.ContainsKey(current) guard 10000) { current cameFrom[current]; path.Add(current); } path.Reverse(); return path; } }这里有个关键点六边形网格的启发函数HexHeuristic。很多人直接把方形网格的曼哈顿距离搬过来结果寻路路径变得很怪明明六边形相邻距离是等长的却要走斜线。正确的是用立方体坐标的切比雪夫距离变体取dq、dr、|dq - dr|的最大值。我写的是公式版本在六边形网格里这是严格的、允许的对角线距离的近似在A*里非常稳定。寻路性能方面这个实现用了List而不是堆网格规模在100x100以内没问题。如果你要支撑超大世界建议最小堆或索引优先队列来替换openList性能能提升一个量级。原型阶段先用简单版保证逻辑正确。4.3 移动范围和地形交互的拓展思路有了A*之后战棋常见的“可移动范围”功能也能顺手做了。做法是做一个带代价上限的BFS扩展从当前格子出发邻居的累计地形成本小于等于剩余移动力就把该格子加入可选列表否则跳过。这个BFS不用写A*直接从当前节点向外扩展即可。如果你照我上面的结构做只需要新增一个方法public static ListVector2Int GetReachableArea( HexGrid grid, Vector2Int start, float maxMovement, System.FuncVector2Int, int costFunc) { ListVector2Int result new ListVector2Int(); Queue(Vector2Int node, float cost) queue new Queue(Vector2Int, float)(); DictionaryVector2Int, float visited new DictionaryVector2Int, float(); queue.Enqueue((start, 0)); visited[start] 0; while (queue.Count 0) { var (node, cost) queue.Dequeue(); if (cost maxMovement) continue; if (node ! start) result.Add(node); foreach (var neighbor in grid.GetNeighbors(node.x, node.y)) { int terrainCost costFunc ! null ? costFunc(neighbor) : 1; float newCost cost terrainCost; if (newCost maxMovement) continue; if (!visited.ContainsKey(neighbor) || visited[neighbor] newCost) { visited[neighbor] newCost; queue.Enqueue((neighbor, newCost)); } } } return result; }这个方法的复杂度是O(可到达格子数 * 邻居数)地图规模小的时候完全扛得住。做回合制战棋时这个函数一定要放在点击单位后异步计算否则UI会卡一下。5. 常见问题与性能优化实战所有代码贴完再聊点真金白银的经验。这一节挑几个我实际踩过的坑每个都很容易让新人怀疑人生。5.1 格子坐标对不上、斜线蔓延、碰撞体错位最常见的问题就是网格看起来是歪的。如果你发现整个网格呈现砖墙堆砌但列对不齐先查轴向坐标转世界坐标时有没有加r * 0.5。这个偏移系数是整个六边形阵列能严丝合缝嵌合的命脉。歪掉的另一个原因可能是世界坐标反算公式用了错误的参数。我建议在Scene视图里打开Gizmos开关把坐标标签画出来鼠标点某个格子时在Console打印解析出来的轴坐标如果坐标和标签不一致逐个数对应关系很快能定位问题。另外碰撞体错位通常是因为给每个格子实例设置了transform.position之后又忘了调整BoxCollider的center所有格子的碰撞体中心还在父物体的原点。我每次都会记得设置box.center Vector3.zero然后让Object本身的位置遵循格子的中心点。这样碰撞体、Mesh和高亮天然对齐。5.2 点击命中率低或点一下选中两个格子点击命中率低的元凶几乎都是碰撞体大小。六边形格子是尖角的如果BoxCollider尺寸和外接圆一致相邻格子的碰撞体在边缘区域重叠射线命中时哪块碰得更近就选谁玩家会感觉“我想点左边结果选中了右边”。解决方案就是我上面写的把BoxCollider的长宽设置成外接圆的0.8到1.2倍之间。太小了点击漏风太大了边缘互相交叠。0.8到0.9比较稳我最终用1.2主要是为了让射线能穿到下层格子因为每个格子的厚度很薄。实测下来俯视视角下用1.2倍宽、0.3厚的Trigger碰撞体命中率很稳定。如果你做的是倾斜45度视角的战术地图建议把碰撞体厚度再加一点否则视线角度大的格子机会出现射线擦着板边飞过去的尴尬。碰撞体厚度不是美术问题是物理问题保证射线命中才是第一位。5.3 格子数量多了之后每帧都在循环查询怎么办原型里几百个格子完全没问题。到了几千、上万个格子如果每个格子都独立MeshRendererDrawCall会指数级膨胀移动设备当场烫手。两个路线第一个路线是合并Mesh。把所有六边形合并成一个Mesh共享一个Material和一条DrawCall。这是现在最推荐的做法但需要注意网格顶点数量有上限Unity的Mesh顶点的索引上限一般是65535超过上限要分块。第二个路线是使用HexGrid的对象池方案。如果地图很大但大部分格子没有单位或技能可以只对“活跃区域”生成可视化对象其余格子只存在于数据层。比如大世界地图玩家屏幕只显示一个小区域你只需要为屏幕内和周围的格子实例化对象。我这次原型阶段用了第一种方案的简化版每个格子一个独立Object但Renderer.sharedMaterial是同一个。因为场景中每个格子只有一条Mesh材质又是共享的Unity会尝试动态批处理小地图上的DrawCall其实可控。真正做大地图时我会改成合并Mesh或使用ECS数据层结构不变。5.4 阴影、WebGL写入、以及移动端适配说个和项目环境相关的坑。Unity默认的Standard材质在六边形Mesh上很容易因为UV和法线问题出现奇怪的阴影尤其是你把六边形压扁成薄片时光照方向会让格子的边缘出现明暗交错的污渍。我的做法是地图底层的格子一律用Unlit/Color材质颜色纯色即可。需要表现地形差异时用纹理则用Unlit/Texture或URP下的Unlit材质。这样可以彻底避开阴影问题也省性能。如果你要发布WebGL注意Unity的IndexedDB写入在部分浏览器下会失败这不是网格代码的问题但会导致存档失败影响玩家体验。处理方式是提前在游戏设置里检测浏览器是否支持IndexedDB或者把存档切到localStorage方案。这个属于WebGL发布管线里的常见坑和六边形网格无关但项目发布时一定会见到顺手提醒一下。移动端起格时建议把质量设置里的阴影、抗锯齿调低六边形薄片多抗锯齿在高DPI屏幕上极耗电量但视觉提升不明显。5.5 单元测试与回归验证最后强烈建议给HexGrid写一个单元测试。用Unity Test Runner或直接在OnDrawGizmos里写断言都可以。我最常用的是一个快速验证方法对所有格子随机取1000个世界坐标点WorldToAxial之后转回World和原始坐标的距离小于0.01就说明坐标系统没有bug。这个方法每次改代码都能跑一遍比如从pointy-top改成flat-top或者改了网格半径单元测试能立刻告诉你是不是哪里弄坏了。坐标系统是六边形网格的心脏坏一次全盘皆输。写在后面的一点体会这次做六边形网格最大的体会是先把数学层想清楚再动手写Unity可视化代码整个过程会顺畅很多。很多新人一上来就拖Tilemap、拉Prefab看到渲染结果挺高兴结果一到点击和寻路就发现坐标对不上回头改一遍等于重写。我的习惯是先画纸面推导把轴向坐标、世界坐标、邻居方向表全部用草稿纸列出来再写HexGrid类最后才接Unity渲染。这样所有逻辑都能在纯C#环境里单测改起来敢动手。六边形地图可玩的扩展方向很多地形高度、单位朝向、势力范围、战争迷雾、六边形AOE特效这些都能在这个骨架上长出来。你现在手里有一套可复用的坐标系统和寻路工具后续做玩法时最难的数学地基已经稳了。先跑通小地图再慢慢加玩法这个节奏最稳。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价