
简介这是一套基于Unity引擎的六边形网格地图生成系统源码面向策略与模拟游戏开发者可用来快速搭建并管理六边形地图省去手动生成网格和编辑地形的重复工作。资源包共218个文件压缩后约2.82MB主要包含C#逻辑脚本、Unity预制体、材质、着色器和配置文件其中C#脚本承载网格生成与寻路等核心逻辑预制体和材质用于构建场景表现配置则保存项目各项参数。该资源目前已有233人学习浏览。压缩包内是完整可运行的Unity工程支持随机地图生成可编辑地形类型、高度、水域等级、城市等级、农场等级、植物等级等多级属性支持单位创建、移动、删除与路径查找并具备地图保存和加载能力方便使用者直接体验或抽取模块二次开发适合学习Unity六边形网格算法和快速搭建游戏原型。1. 六边形网格地图生成系统的坐标系选型做策略游戏、战棋或数字孪生场景时地图单元的形状会直接决定寻路逻辑和操作手感。正方形网格实现简单但六边形网格每个格子有六个等距邻居中心到中心的距离一致不存在正方形网格对角线距离大于边距的问题因此移动代价计算更准确地图边界也更接近圆形适合做蜂窝状的地形表现。很多源码项目把六边形网格地图生成系统做成一个独立工程核心并不是渲染而是三套坐标系的互相转换偏移坐标、轴向坐标和立方坐标。看这套源码时先想清楚坐标转换再去看Mesh生成和点击拾取。六边形网格坐标有扁顶和平顶两种朝向源码里通常用平顶或扁顶中的一种统一表示。你会看到Hex、HexGrid、HexCell这样的类名字接近但职责不同。HexCell存储单个地块的数据HexGrid管理整张地图的生成和查询。这一篇从数据层开始把坐标、邻居、地图规则、Mesh、寻路和拾取串成一条可复现的链路。2. 在Unity中实现HexGrid数据层与地图规则2.1 六边形网格坐标与邻居计算六边形网格坐标最常见的困惑是数组下标和世界坐标对不上。你不能直接拿int x, y去当数组索引因为奇数列的格子会向偶数列偏移半个格宽。源码里推荐用一种“轴向坐标”来统一处理即用(q, r)表示格子坐标q是列号r是行号。要转成Unity里的世界坐标只需按格宽和格高计算偏移量。下面的核心类直接粘贴到 Unity 工程里可以运行。using System.Collections.Generic; using UnityEngine; public enum HexOrientation { FlatTop, PointyTop } public struct HexCoord { public readonly int q; public readonly int r; public HexCoord(int q, int r) { this.q q; this.r r; } public static HexCoord FromOffset(int col, int row) { // 奇数列偏移列号为奇数时行向下错开半个格子 int q col; int r row - (col - (col 1)) / 2; return new HexCoord(q, r); } public Vector3 ToWorld(float cellSize, HexOrientation orientation) { if (orientation HexOrientation.PointyTop) { float x cellSize * (Mathf.Sqrt(3f) * q Mathf.Sqrt(3f) / 2f * r); float z cellSize * (1.5f * r); return new Vector3(x, 0f, z); } else { float x cellSize * (1.5f * q); float z cellSize * (Mathf.Sqrt(3f) / 2f * q Mathf.Sqrt(3f) * r); return new Vector3(x, 0f, z); } } }FromOffset做的事情是把 UI 上的二维数组下标转换成轴向坐标。工程里常见的错误是少做这一步导致网格显示时奇数行错位。ToWorld里区分了扁顶和尖顶两种朝向尖顶时水平方向间隔是sqrt(3) * size垂直方向行距是1.5 * size扁顶时反过来。世界坐标计算没有用到网格式的索引因为每个格子的位置只由(q, r)决定这样地图滚动、动态裁剪都更容易处理。邻居计算是寻路的基础六个方向的偏移量固定。public static readonly HexCoord[] Directions { new HexCoord(1, 0), new HexCoord(1, -1), new HexCoord(0, -1), new HexCoord(-1, 0), new HexCoord(-1, 1), new HexCoord(0, 1) }; public ListHexCoord GetNeighbors(HexCoord center) { var list new ListHexCoord(6); for (int i 0; i Directions.Length; i) { list.Add(new HexCoord(center.q Directions[i].q, center.r Directions[i].r)); } return list; }这六组偏移量在轴向坐标下不需要分支判断干净且快。后面做寻路时GetNeighbors就是唯一的邻居来源。可以顺手加一个带权重的版本通过字典传入移动消耗方便后续做地形影响。2.2 地图生成参数与地形规则数据层的地图生成不直接操作GameObject而是先生成一个二维状态数组或字典。工程里常见的做法是用DictionaryHexCoord, HexCellData存储地块数据这样即使地图不规则也不会浪费内存。HexCellData包含地形类型、高度、是否可通行等字段。下面是可运行的最小生成逻辑输出为文本格式的规则表。using System.Collections.Generic; using UnityEngine; public enum TerrainType { Grass, Forest, Mountain, Water } public struct HexCellData { public TerrainType terrain; public float height; public int movementCost; public bool walkable; } public class HexMapGenerator : MonoBehaviour { [Header(地图生成参数)] public int mapWidth 30; public int mapHeight 30; public float noiseScale 0.08f; public int seed 12345; private DictionaryHexCoord, HexCellData cellData new DictionaryHexCoord, HexCellData(); public void GenerateMap() { cellData.Clear(); Random.InitState(seed); for (int col 0; col mapWidth; col) { for (int row 0; row mapHeight; row) { HexCoord coord HexCoord.FromOffset(col, row); float noiseValue Mathf.PerlinNoise(coord.q * noiseScale, coord.r * noiseScale); HexCellData data new HexCellData(); data.terrain GetTerrainFromNoise(noiseValue); // 不同地形给不同的移动消耗 switch (data.terrain) { case TerrainType.Grass: data.movementCost 1; data.walkable true; break; case TerrainType.Forest: data.movementCost 2; data.walkable true; break; case TerrainType.Mountain: data.movementCost 3; data.walkable false; break; case TerrainType.Water: data.movementCost 99; data.walkable false; break; } data.height noiseValue * 10f; cellData[coord] data; } } } private TerrainType GetTerrainFromNoise(float value) { // 用分段函数把噪声值映射到地形类型 if (value 0.3f) return TerrainType.Water; if (value 0.6f) return TerrainType.Grass; if (value 0.85f) return TerrainType.Forest; return TerrainType.Mountain; } }这段代码里的关键参数是noiseScale和seed。noiseScale越小噪声越平缓地图的大陆块越完整seed控制随机种子方便复现同一张地图。地形映射用的是分段阈值阈值可通过序列化字段暴露到Inspector中方便调出符合预期的陆地比例。movementCost会直接喂给后面的寻路算法所以walkable为 false 的格子要在邻居过滤时被剔除。2.2.1 地形权重表与参数调节给一张常用的阈值配置参考基于上面的噪声映射。地形类型噪声阈值区间移动消耗可否通行推荐用途Water0.0 – 0.399否地图边界/护城河Grass0.3 – 0.61是普通道路/出生点Forest0.6 – 0.852是防御地形/资源点Mountain0.85 – 1.03否阻挡/高地视野这套规则不是唯一解但是可扩展性较好加新地形只需扩展TerrainType枚举并补上移动消耗和渲染颜色。工程里如果还要做气候带可以叠加第二层噪声来调制地形映射这一层在数据生成阶段做不影响渲染管线。3. 用Mesh合并在Unity中渲染六边形网格地图生成系统3.1 六边形几何顶点与三角面片计算数据层生成完毕后下一步要把每个格子变成可见的图形。逐个创建GameObject加MeshFilter会导致上千次DrawCall因此常见的源码实现是合并Mesh。这一节直接给一个能创建六边形Mesh的核心函数输入顶点坐标列表输出Unity Mesh对象。using UnityEngine; public static class HexMeshBuilder { public static Mesh BuildHexMesh(float size, HexOrientation orientation) { var vertices new Vector3[6]; for (int i 0; i 6; i) { float angle Mathf.Deg2Rad * (orientation HexOrientation.PointyTop ? (60f * i - 30f) : (60f * i)); vertices[i] new Vector3(Mathf.Cos(angle) * size, 0f, Mathf.Sin(angle) * size); } var triangles new int[12]; // 将六边形拆成四个三角形中心点使用同一顶点 triangles[0] 0; triangles[1] 1; triangles[2] 2; triangles[3] 0; triangles[4] 2; triangles[5] 3; triangles[6] 0; triangles[7] 3; triangles[8] 4; triangles[9] 0; triangles[10] 4; triangles[11] 5; var mesh new Mesh(); mesh.vertices vertices; mesh.triangles triangles; mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; } }每片六边形由6个顶点和4个三角形组成。如果要把多个格子合到同一个Mesh里不能直接复用这个mesh而是把顶点偏移到HexCoord.ToWorld计算出的世界坐标再将索引做基底偏移。合并逻辑通常写在HexGrid的RebuildMesh方法里遍历地图的所有坐标把每个格子的顶点坐标累加进一个大数组。值得注意的一点是法线的处理。如果每个面都是平坦的RecalculateNormals会算出垂直于整个六边形面的法线但相邻格子的边缘法线会存在折痕导致光照出现明显的网格状接缝。这里有三种常见做法把相邻格子接缝处的顶点位置做微小融合简化成一个顶点或者不生成共享顶点让每个三角形独立计算法线再或者对顶点法线做平滑处理让六边形看起来更圆润。源码里为了兼顾程序化地形的高度起伏通常不共享边缘顶点而是在每个格子的边缘处做2–3个顶点的渐变形成斜面过渡。3.2 顶点缓存与Mesh生成过程完整的Mesh合并代码较长这里给出带注释的核心循环逻辑它在HexGrid更新时被反复调用。public void RebuildCombinedMesh() { ListVector3 vertices new ListVector3(); Listint triangles new Listint(); ListColor colors new ListColor(); ListVector2 uvs new ListVector2(); foreach (var kv in cellData) { HexCoord coord kv.Key; Vector3 center coord.ToWorld(cellSize, orientation); int baseIndex vertices.Count; // 以格子中心为圆心围绕六个方向生成顶点 for (int i 0; i 6; i) { float angle Mathf.Deg2Rad * (60f * i (orientation HexOrientation.PointyTop ? -30f : 0f)); Vector3 corner center new Vector3(Mathf.Cos(angle) * cellSize, kv.Value.height, Mathf.Sin(angle) * cellSize); vertices.Add(corner); // 按地形类型染色方便排错时一眼看出地形分布 colors.Add(GetTerrainColor(kv.Value.terrain)); } // 生成四个三角形索引 int[] localTri new int[] { 0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5 }; for (int t 0; t localTri.Length; t) { triangles.Add(baseIndex localTri[t]); } // UV坐标这里先给一个固定值只用于验证网格位置 for (int i 0; i 6; i) { uvs.Add(new Vector2(coord.q % 2, coord.r % 2)); } } Mesh mesh new Mesh(); mesh.vertices vertices.ToArray(); mesh.triangles triangles.ToArray(); mesh.colors colors.ToArray(); mesh.uv uvs.ToArray(); mesh.RecalculateNormals(); GetComponentMeshFilter().sharedMesh mesh; GetComponentMeshCollider().sharedMesh mesh; }这段代码里有一个在Unity中常用的关键点MeshCollider需要在sharedMesh赋值后参与射线检测故生成完Mesh后马上设置碰撞体。colors数组的用法比贴图更轻量适合调试阶段使用可以用material.color的顶点色模式直接呈现地形分类。uvs这里只是占位如果要做草地贴图需要修正为对应六边形的纹理坐标但不要用世界坐标直接充当uv——那会导致网格渐变闪烁。3.3 Mesh生成的3个参数与常见错误cellSize、height和orientation三者联合决定地形的真实观感。参数名作用推荐区间调试时重点检查cellSize六边形半径0.5 – 1.5格子过大导致寻路边界穿过其他格子height高度偏移量0 – 10高度突变时注视线条会不会穿插orientation扁顶/尖顶切换PointyTop常用邻居坐标顺序必须与朝向匹配最常见的报错是格子显示正常但点击拾取不到这多半是MeshCollider没有跟随MeshFilter同步更新。另一个问题是高度差导致相邻格子边缘重叠在RebuildCombinedMesh里做一个边缘顶点插值把高度差值的一半施加到交界处可以缓解。4. 网格地图的寻路与点击拾取4.1 基于坐标的A*寻路实现有了GetNeighbors和movementCost寻路可以在纯数据层完成完全不依赖GameObject。用A*可以保证路径最优且实现在这里并不复杂。using System.Collections.Generic; using System.Linq; public class HexPathfinding { private DictionaryHexCoord, HexCellData _cells; public HexPathfinding(DictionaryHexCoord, HexCellData cells) { _cells cells; } public ListHexCoord FindPath(HexCoord start, HexCoord goal) { var openSet new SortedSetHexNode(ComparerHexNode.Create((a, b) a.fCost ! b.fCost ? a.fCost.CompareTo(b.fCost) : a.hCost.CompareTo(b.hCost))); var cameFrom new DictionaryHexCoord, HexCoord(); var gCost new DictionaryHexCoord, int(); var fCost new DictionaryHexCoord, int(); openSet.Add(new HexNode(start, 0, HexDistance(start, goal))); gCost[start] 0; while (openSet.Count 0) { HexNode current openSet.Min; openSet.Remove(current); if (current.coord.Equals(goal)) { return ReconstructPath(cameFrom, start, goal); } foreach (HexCoord neighborCoord in GetNeighbors(current.coord)) { if (!_cells.TryGetValue(neighborCoord, out var cellData) || !cellData.walkable) { // 不可通行或超出地图范围直接跳过 continue; } int tentativeG gCost[current.coord] cellData.movementCost; if (!gCost.TryGetValue(neighborCoord, out int neighborG) || tentativeG neighborG) { cameFrom[neighborCoord] current.coord; gCost[neighborCoord] tentativeG; int h HexDistance(neighborCoord, goal); fCost[neighborCoord] tentativeG h; openSet.Add(new HexNode(neighborCoord, tentativeG, h)); } } } return new ListHexCoord(); } private ListHexCoord ReconstructPath(DictionaryHexCoord, HexCoord cameFrom, HexCoord start, HexCoord goal) { var path new ListHexCoord(); HexCoord node goal; while (!node.Equals(start)) { path.Add(node); node cameFrom[node]; } path.Reverse(); return path; } private int HexDistance(HexCoord a, HexCoord b) { int dq Mathf.Abs(a.q - b.q); int dr Mathf.Abs(a.r - b.r); return Mathf.Max(dq, dr, Mathf.Abs(dq - dr)); } }A* 的核心在于gCost记录起点到当前点的实际移动消耗hCost用六边形距离公式估算剩余路径。这里没有使用List加Sort的方式而是用SortedSet配合fCost排序减少排序开销。代价小的格子先被展开寻路速度在几百格的地图上完全够用。若要扩展成带权寻路只需将movementCost乘以某个地形系数即可例如沼泽地movementCost * 2。若遇到无解场景FindPath返回空列表上层最好弹出UI提示避免角色原地乱走。4.2 屏幕坐标到网格坐标的拾取点击拾取的链路是射线打到MeshCollider拿到碰撞点世界坐标再转成轴向坐标。这里容易出问题的地方是碰撞点的高度偏移如果地形带有高度差必须先把碰撞点压平再转坐标。下面是射线拾取代码。public HexCoord PickHexFromScreen(Vector2 screenPosition, Camera cam) { Ray ray cam.ScreenPointToRay(screenPosition); if (Physics.Raycast(ray, out RaycastHit hit, 1000f)) { Vector3 point hit.point; point.y 0f; // 先忽略高度差拾取逻辑只需要x和z return WorldToHex(point); } return new HexCoord(-999, -999); } public HexCoord WorldToHex(Vector3 worldPos) { if (orientation HexOrientation.PointyTop) { float q (Mathf.Sqrt(3f) / 3f * worldPos.x - 1f / 3f * worldPos.z) / cellSize; float r (2f / 3f * worldPos.z) / cellSize; return HexRound(new HexCoord((int)q, (int)r)); } else { float q (2f / 3f * worldPos.x) / cellSize; float r (-1f / 3f * worldPos.x Mathf.Sqrt(3f) / 3f * worldPos.z) / cellSize; return HexRound(new HexCoord((int)q, (int)r)); } }WorldToHex是ToWorld的逆运算公式根据朝向做了匹配。关键是HexRound它做的是分数坐标的舍入。public HexCoord HexRound(HexCoord raw) { // 通用六边形坐标四舍五入法把偏移量归一化后取整 float q raw.q; float r raw.r; float s -q - r; int rq Mathf.RoundToInt(q); int rr Mathf.RoundToInt(r); int rs Mathf.RoundToInt(s); float dq Mathf.Abs(rq - q); float dr Mathf.Abs(rr - r); float ds Mathf.Abs(rs - s); if (dq dr dq ds) { rq -rr - rs; } else if (dr ds) { rr -rq - rs; } return new HexCoord(rq, rr); }若没有HexRound鼠标点击格子边缘时会跳格这是因为浮点坐标直接取整引入了误差。HexRound保证了点击区域精确覆盖整个六边形。4.3 移动与高亮验证拾取到目标格后先用FindPath计算路径再逐帧移动单位。Engine里常见的做法是使用协程逐步移动期间高亮路径颜色。IEnumerator MoveUnitAlongPath(ListHexCoord path) { foreach (var coord in path) { Vector3 target coord.ToWorld(cellSize, orientation); target.y groundY; while (Vector3.Distance(unit.transform.position, target) 0.01f) { unit.transform.position Vector3.MoveTowards(unit.transform.position, target, speed * Time.deltaTime); yield return null; } } }路径高亮可以单独做一个HighlightPath方法把path中的格子改成半透明材质这样玩家能直观看到移动的线路。这里建议使用一个独立的Shader用_BaseColor的透明度控制避免和地形材质互相干扰。“六边形网格地图生成系统”中拾取和寻路是一体的很多工程把这两部分分开写结果单位移动出现穿模——原因通常是WorldToHex与HexCoord.ToWorld使用的朝向不一致解决办法是在一个静态配置类里集中定义朝向。5. 调试编辑器与可调参数5.1 用Gizmos在Scene视图验证网格在编辑器里看不到正确的六边形排布时先在OnDrawGizmos里画出每个格子的中心点和邻居关系这一步能快速排除坐标公式错误。private void OnDrawGizmos() { foreach (var kv in cellData) { Vector3 center kv.Key.ToWorld(cellSize, orientation); Gizmos.color Color.yellow; Gizmos.DrawSphere(center, 0.1f); Gizmos.color kv.Value.walkable ? Color.green : Color.red; for (int i 0; i 6; i) { float angle Mathf.Deg2Rad * (60f * i (orientation HexOrientation.PointyTop ? -30f : 0f)); Vector3 corner center new Vector3(Mathf.Cos(angle) * cellSize, 0f, Mathf.Sin(angle) * cellSize); Gizmos.DrawLine(center, corner); } } }运行场景前先打开Gizmos如果看到六个方向辐射线长度必须相等且同一个朝向再去看Mesh绘制。这个验证方法与源码里的坐标实现一一对应省去在运行时才排查数据错位的成本。5.2 三个必调参数与推荐区间参数名影响推荐区间异常表现检查cellSize格子大小与拾取精度0.5 – 2.0点击别处格子导致误点noiseScale地图地形复杂度0.05 – 0.1地形过于细碎时调小speed单位移动速度5 – 15协程移动耗时过长其中noiseScale对地图生成的影响最直观如果地图变成一团噪点先检查seed是否每次都重新初始化再把noiseScale降到 0.05 以下测试。源码运行中若出现Mesh闪烁多为RebuildCombinedMesh每帧被调用改成仅在参数变化或加载时生成即可。5.3 源码阅读顺序与改造技巧拿到的源码工程如果只希望你改地图大小和颜色按顺序阅读HexCoord、HexMapGenerator、HexMeshBuilder三个文件就够。想在单位脚下加圆环或箭头指示时优先扩展HexCellData增加bool hasUnit和GameObject unitObject。这样网格数据与渲染逻辑解耦后续更换美术风格时不用动数据层。调试时把TerrainType枚举值直接绘制成颜色代替贴图能快速确认地图生成规则是否符合预期再替换为正式材质即可。对于需要地图刷新的编辑器应用场景在Inspector上加一个[ContextMenu(Rebuild)]标记右键组件菜单即可直接重建整个网格避免每次改参数都要进入Play模式。这个方法同样适合需要批量生成地图的关卡编辑器。本文还有配套的精品资源点击获取