Unity六边形地图SLG开发实战从网格生成到A*寻路全解析六边形网格在策略游戏领域有着不可替代的优势——它比方形网格更接近真实世界的距离感又比圆形更易于实现规则化的地图逻辑。从《文明》系列到《火焰纹章》无数经典SLG都验证了六边形地图的独特魅力。但对于独立开发者而言从零实现一套完整的六边形地图系统却充满挑战坐标系的转换、网格交互的实现、寻路算法的适配每个环节都需要精确的数学计算和巧妙的工程实践。本文将带你完整走通这个技术链条。我们会先用立体坐标系构建基础网格接着开发可视化的地图编辑器功能最后攻克六边形A*寻路的核心算法。所有代码示例都基于Unity 2022 LTS版本可直接集成到你的SLG项目中。1. 六边形网格的数学基础1.1 立体坐标系最优雅的表示法六边形网格有三种主流坐标系轴向坐标系类似方形网格的(x,y)表示偏移坐标系通过奇偶行偏移实现平铺立体坐标系(x,y,z)三轴表示满足xyz0我们选择立体坐标系又称立方体坐标系作为核心逻辑表示因为它在数学运算上具有天然优势// 立体坐标结构体 public struct CubeCoord { public int x; public int y; public int z; public CubeCoord(int x, int y) { this.x x; this.y y; this.z -x - y; // 强制满足xyz0 } }立体坐标系的六个基本方向向量定义如下public static readonly CubeCoord[] Directions { new CubeCoord(1, -1, 0), // 正右 new CubeCoord(1, 0, -1), // 右下 new CubeCoord(0, 1, -1), // 左下 new CubeCoord(-1, 1, 0), // 正左 new CubeCoord(-1, 0, 1), // 左上 new CubeCoord(0, -1, 1) // 右上 };1.2 关键几何参数计算六边形有两个核心半径参数内径(innerRadius)中心到边的垂直距离外径(outerRadius)中心到顶点的距离它们的数学关系为外径 内径 × 2/√3 ≈ 内径 × 1.1547 内径 外径 × √3/2 ≈ 外径 × 0.8660建议在项目中固定内径为整数如100单位这样寻路时的格子距离就是整数值避免浮点数精度问题。Unity中的实现示例public class HexMetrics { public const float innerRadius 100f; public const float outerRadius innerRadius * 1.1547005f; // 六个顶点的局部坐标 public static Vector3[] corners { new Vector3(0f, 0f, outerRadius), new Vector3(innerRadius, 0f, 0.5f * outerRadius), new Vector3(innerRadius, 0f, -0.5f * outerRadius), new Vector3(0f, 0f, -outerRadius), new Vector3(-innerRadius, 0f, -0.5f * outerRadius), new Vector3(-innerRadius, 0f, 0.5f * outerRadius) }; }2. 网格生成与编辑器实现2.1 矩形平铺算法虽然六边形本质上是非正交的但为了地图设计的便利性我们通常采用矩形平铺方式。关键点在于处理行偏移public void GenerateGrid(int width, int height) { for (int z 0; z height; z) { for (int x 0; x width; x) { // 每奇数行向右偏移半个六边形宽度 float xPos x * (HexMetrics.innerRadius * 2f); if (z % 2 1) { xPos HexMetrics.innerRadius; } float zPos z * (HexMetrics.outerRadius * 1.5f); Vector3 position new Vector3(xPos, 0f, zPos); Instantiate(hexPrefab, position, Quaternion.identity, transform); } } }注意平铺时建议使用对象池管理六边形实例避免频繁的Instantiate/Destroy操作2.2 交互功能实现SLG地图需要支持三种基础交互鼠标悬停高亮格子点击事件移动范围显示通过射线检测实现格子选取的核心代码void Update() { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit)) { HexCell cell hit.collider.GetComponentHexCell(); if (cell ! currentHoverCell) { // 处理悬停状态变化 currentHoverCell?.SetHovered(false); currentHoverCell cell; cell.SetHovered(true); } if (Input.GetMouseButtonDown(0)) { OnCellClicked?.Invoke(cell); } } }3. 六边形A*寻路实现3.1 算法适配关键点方形网格的A*算法需要做三个主要调整邻居获取六边形每个格子有6个相邻格子距离计算采用立体坐标的曼哈顿距离代价计算考虑地形移动损耗六边形曼哈顿距离公式distance (|x1-x2| |y1-y2| |z1-z2|) / 2代码实现public static int Distance(CubeCoord a, CubeCoord b) { return (Mathf.Abs(a.x - b.x) Mathf.Abs(a.y - b.y) Mathf.Abs(a.z - b.z)) / 2; }3.2 完整A*实现public class HexPathfinding { public static ListCubeCoord FindPath(CubeCoord start, CubeCoord end, HexGrid grid) { PriorityQueueCubeCoord openSet new PriorityQueueCubeCoord(); DictionaryCubeCoord, CubeCoord cameFrom new DictionaryCubeCoord, CubeCoord(); DictionaryCubeCoord, int gScore new DictionaryCubeCoord, int(); openSet.Enqueue(start, 0); gScore[start] 0; while (openSet.Count 0) { CubeCoord current openSet.Dequeue(); if (current.Equals(end)) { return ReconstructPath(cameFrom, current); } foreach (CubeCoord neighbor in GetNeighbors(current, grid)) { int tentativeGScore gScore[current] GetMoveCost(current, neighbor, grid); if (!gScore.ContainsKey(neighbor) || tentativeGScore gScore[neighbor]) { cameFrom[neighbor] current; gScore[neighbor] tentativeGScore; int fScore tentativeGScore Distance(neighbor, end); openSet.Enqueue(neighbor, fScore); } } } return null; // 未找到路径 } private static ListCubeCoord GetNeighbors(CubeCoord coord, HexGrid grid) { ListCubeCoord neighbors new ListCubeCoord(); foreach (CubeCoord dir in CubeCoord.Directions) { CubeCoord neighbor coord dir; if (grid.Contains(neighbor) !grid.IsBlocked(neighbor)) { neighbors.Add(neighbor); } } return neighbors; } }3.3 性能优化技巧优先队列优化使用二叉堆实现优先队列使Enqueue/Dequeue复杂度降至O(log n)缓存寻路结果对静态地图缓存常用路径分层寻路大地图采用分块处理// 二叉堆实现的优先队列 public class PriorityQueueT { private List(T item, int priority) elements new List(T, int)(); public void Enqueue(T item, int priority) { elements.Add((item, priority)); int i elements.Count - 1; while (i 0) { int parent (i - 1) / 2; if (elements[parent].priority elements[i].priority) break; (elements[i], elements[parent]) (elements[parent], elements[i]); i parent; } } public T Dequeue() { T result elements[0].item; elements[0] elements[elements.Count - 1]; elements.RemoveAt(elements.Count - 1); int i 0; while (true) { int left 2 * i 1; int right 2 * i 2; if (left elements.Count) break; int min left; if (right elements.Count elements[right].priority elements[left].priority) { min right; } if (elements[i].priority elements[min].priority) break; (elements[i], elements[min]) (elements[min], elements[i]); i min; } return result; } }4. 高级功能扩展4.1 视野与战争迷雾六边形视野计算采用BFS算法考虑地形视野阻挡public void CalculateFOV(CubeCoord center, int range) { HashSetCubeCoord visibleCells new HashSetCubeCoord(); Queue(CubeCoord, int) queue new Queue(CubeCoord, int)(); queue.Enqueue((center, 0)); while (queue.Count 0) { var (current, distance) queue.Dequeue(); visibleCells.Add(current); if (distance range) continue; foreach (CubeCoord neighbor in GetNeighbors(current)) { if (!visibleCells.Contains(neighbor) HasLineOfSight(center, neighbor)) { queue.Enqueue((neighbor, distance 1)); } } } // 更新迷雾状态 foreach (HexCell cell in allCells) { cell.SetVisible(visibleCells.Contains(cell.coord)); } }4.2 动态地图编辑实现可实时修改的地图编辑器需要地形类型系统每种地形定义移动消耗和视觉表现序列化存储使用JSON或二进制保存地图数据撤销重做命令模式实现编辑历史记录public class HexMapEditor : MonoBehaviour { private StackICommand commandHistory new StackICommand(); public void ChangeTerrain(HexCell cell, TerrainType newType) { var cmd new TerrainChangeCommand(cell, cell.TerrainType, newType); cmd.Execute(); commandHistory.Push(cmd); } public void Undo() { if (commandHistory.Count 0) { commandHistory.Pop().Undo(); } } } public interface ICommand { void Execute(); void Undo(); } public class TerrainChangeCommand : ICommand { private HexCell cell; private TerrainType oldType; private TerrainType newType; public TerrainChangeCommand(HexCell cell, TerrainType oldType, TerrainType newType) { this.cell cell; this.oldType oldType; this.newType newType; } public void Execute() { cell.TerrainType newType; } public void Undo() { cell.TerrainType oldType; } }在实现六边形地图系统时最容易被忽视的是坐标系转换的一致性。曾经在一个项目中我们混合使用了两种坐标转换方法导致寻路结果出现难以察觉的偏差。最终通过编写完整的单元测试套件验证所有坐标转换的往返一致性才彻底解决了这个问题。