
简介这是一份基于Unity引擎的六边形网格地图生成系统源码工程面向策略类、模拟类游戏开发者帮助快速搭建六边形关卡地图并实现地形编辑与单位管理。整个资源共218个文件压缩包仅2.82MB由28个预制体、25个C#脚本、8个Shader及11个材质组成覆盖地图生成、渲染和交互的核心逻辑同时附带完整场景、项目配置文件与说明文档导入Unity即可直接运行调试。系统支持手动生成和多种随机地图算法可编辑地形类型、高度、水域、城市、农场、植物等级六类属性单位管理涵盖创建、移动、删除与路径查找并支持地图保存与加载。代码结构按生成、地形、单位、UI等模块划分便于理解与二次扩展。目前已有233人学习浏览适合中初级Unity开发者作为学习六边形网格算法或快速迭代游戏原型的实用工具。1. 六边形地图生成的关键不在画格子而在把坐标系统一拿到这类项目源码第一反应通常是去看 Mesh 生成那段代码觉得六边形地图的核心是把六个三角形拼成一个正六边形。实际把系统拆开看会发现真正决定地图能否扩展的是坐标寻址方案。六边形网格不像方形网格那样有天然的 (x, y) 二维数组下标同一个六边形存在 offset、cube、axial 三种主流表达方式选错坐标系会导致邻居查找、距离计算、寻路全部失控。这套系统的价值在于它不只是生成一张静态贴图而是把地形高度、生物群落、地块属性全部挂载到网格单元上构成一套可查询、可修改、可序列化的地图数据结构。对需要做回合制战棋、4X 策略或者模拟经营类项目的开发者来说这套代码能直接省掉从零踩坑的周期。下文按坐标系选型、网格构建、特征生成、数据落地四个层面拆解实现路径最后给出一组调试和验证技巧。2. 六边形网格坐标系选型offset、cube 与 axial 的取舍与转换2.1 三种坐标系的适用场景与换算关系Unity 开发中常见做法是先用 offset 坐标做地图编辑器里的直观排布因为它的存储方式最接近二维数组grid[x, y]直接对应地图上的一个格子。但 offset 坐标在做邻居查找时要区分奇偶行容易出边界错误。cube 坐标则在算法层面更优雅三个轴 q、r、s 满足 q r s 0距离计算变成简单的绝对值求和。axial 坐标是 cube 的降维表达去掉 s 轴只保留 q 和 r。三套坐标的换算关系是固定的cube 转 axial 直接丢弃 s 轴axial 转 cube 通过 s -q - r 补回第三轴。offset 转 cube 则需要根据行奇偶性做偏移修正odd-r 布局下 r 为奇数时 q 要减 1even-r 布局下 r 为偶数时 q 要减 1。这套系统的源码里同时保留了三套坐标的互转函数正是因为编辑器用 offset、算法层用 cube、存储层用 axial 是性价比最高的组合。2.2 坐标转换核心代码与参数说明public static class HexCoordinateConverter { // odd-r 布局r 为奇数时 q 向左偏移 public static Vector3Int OffsetToCube(int col, int row) { int q col - (row - (row 1)) / 2; int r row; int s -q - r; return new Vector3Int(q, r, s); } public static Vector2Int CubeToOffset(Vector3Int cube) { int col cube.x (cube.y - (cube.y 1)) / 2; int row cube.y; return new Vector2Int(col, row); } public static Vector2Int CubeToAxial(Vector3Int cube) { return new Vector2Int(cube.x, cube.y); } public static Vector3Int AxialToCube(Vector2Int axial) { int q axial.x; int r axial.y; return new Vector3Int(q, r, -q - r); } public static int CubeDistance(Vector3Int a, Vector3Int b) { return (Mathf.Abs(a.x - b.x) Mathf.Abs(a.y - b.y) Mathf.Abs(a.z - b.z)) / 2; } }OffsetToCube里的(row - (row 1)) / 2这行是关键row 1取出最低位判断奇偶奇数行整体减去 1 再除 2保证奇偶行在立方体坐标系下对齐。CubeDistance利用 cube 坐标三轴之和为零的特性三轴差值的绝对值相加后除以二就是曼哈顿距离的变体这也是 cube 坐标在寻路和范围计算中比 offset 坐标高效的根本原因。2.3 邻居查找与范围计算的工程实现public static readonly Vector3Int[] CubeDirections { new Vector3Int(1, -1, 0), new Vector3Int(1, 0, -1), new Vector3Int(0, 1, -1), new Vector3Int(-1, 1, 0), new Vector3Int(-1, 0, 1), new Vector3Int(0, -1, 1) }; public static ListVector3Int GetNeighbors(Vector3Int center) { ListVector3Int result new ListVector3Int(6); foreach (Vector3Int dir in CubeDirections) result.Add(center dir); return result; }六个方向向量在 cube 坐标系下各自满足 q r s 0相加后结果仍然是合法 cube 坐标因此无需边界修正。需要强调的是范围查找如攻击范围、视野范围不应该逐个遍历地图上所有格子然后求距离那样时间复杂度是 O(n)地图扩到 200×200 时每帧做一次就会明显卡顿。推荐做法是用半径增量遍历 cube 坐标从中心点出发按距离逐层扩展复杂度为 O(r²)六边形网格的半径遍历公式本身就是一个深度优先或广度优先的展开过程。3. 六边形网格 Mesh 构建从几何顶点到 UV 展开的完整流程3.1 六边形顶点的几何计算与朝向约定单个六边形网格单元由六个顶点围绕中心点构成。如果按正六边形处理相邻顶点之间的夹角是 60 度顶点坐标可以用center new Vector3(Mathf.Sin(angle), 0, Mathf.Cos(angle)) * outerRadius计算。这里有个工程上的细节会被忽略Mathf.Sin和Mathf.Cos的参数是弧度制而常规做法是写一个angle 60f * i * Mathf.Deg2Rad完成角度到弧度的转换。另外六边形有两种朝向——顶点朝上还是平面朝上这决定了 Mesh 在场景里的旋转偏移。这套系统默认采用顶点朝上的 orientation即第一个顶点在正上方适合大多数等距视角游戏。外半径outerRadius和内半径innerRadius的比例是固定值内半径 外半径 × √3 / 2内半径用于计算六边形之间的水平间距外半径用于计算垂直间距。如果生成的地图出现格子之间重叠或缝隙优先检查这里的 spacing 计算而不是怀疑顶点角度有误差。3.2 生成单个网格单元的 Mesh 代码public static Mesh CreateHexMesh(float outerRadius) { Mesh mesh new Mesh(); Vector3[] vertices new Vector3[7]; Vector2[] uv new Vector2[7]; int[] triangles new int[18]; // 6 个三角形每个 3 个顶点索引 vertices[0] Vector3.zero; // 中心顶点 uv[0] new Vector2(0.5f, 0.5f); for (int i 0; i 6; i) { float angle 60f * i * Mathf.Deg2Rad; float x Mathf.Sin(angle) * outerRadius; float z Mathf.Cos(angle) * outerRadius; vertices[i 1] new Vector3(x, 0, z); uv[i 1] new Vector2( Mathf.Sin(angle) * 0.5f 0.5f, Mathf.Cos(angle) * 0.5f 0.5f ); } for (int i 0; i 6; i) { int next (i 1) % 6; triangles[i * 3] 0; triangles[i * 3 1] i 1; triangles[i * 3 2] next 1; } mesh.vertices vertices; mesh.triangles triangles; mesh.uv uv; mesh.RecalculateNormals(); return mesh; }三角形索引的排列方式决定了面的朝向。这里按顺时针方向0 → i1 → next1排列索引在 Unity 左手坐标系下会导致面朝下不可见。实际使用时要么把索引顺序换成逆时针0 → next1 → i1要么在生成后执行mesh.RecalculateNormals()再手动翻转法线方向。这是个容易出现在 Unity 六边形教程里的经典错误如果在地图上方看不到格子但下方能看到就是这个原因。3.3 地图批量生成的 Combine 策略与性能参数单独生成每个格子的 Mesh 再挂 MeshFilter 是能跑但绝对不能用于生产的方案。一个 100×100 的地图需要一万个 Mesh 和对应的 Draw Call普通电脑直接卡死。正确做法是使用Mesh.CombineMeshes把所有格子合并成一个大 Mesh或者用 GPU Instancing 配合Graphics.DrawMeshInstanced绘制。前者适合静态地形后者适合地块需要动态变化如建造、破坏的场景。合并时需要注意顶点数量上限Unity 的 Mesh 索引使用 16 位整数时最多支持 65535 个顶点超过后必须设置mesh.indexFormat UnityEngine.Rendering.IndexFormat.UInt32。单个六边形有 7 个顶点理论上 9000 个格子就会触顶因此合并地图时设置 UInt32 是必须的操作。另一个参数是CombineMeshes的useMatrices参数传入每个格子的 transform 矩阵才能在合并后保留各自的位置信息。public static Mesh CombineHexGrid(HexCell[] cells, float outerRadius) { CombineInstance[] combines new CombineInstance[cells.Length]; for (int i 0; i cells.Length; i) { combines[i].mesh CreateHexMesh(outerRadius); Vector3 position HexGridPosition(cells[i].OffsetCoords, outerRadius); combines[i].transform Matrix4x4.TRS(position, Quaternion.identity, Vector3.one); } Mesh finalMesh new Mesh(); finalMesh.indexFormat UnityEngine.Rendering.IndexFormat.UInt32; finalMesh.CombineMeshes(combines, true, true); return finalMesh; }HexGridPosition是坐标到世界坐标的映射函数核心逻辑是 x (col row * 0.5) * innerRadius × 2z row * outerRadius × 1.5得到的是当前格子的中心点位置。这段代码在网格规模超过 150×150 时仍能保持稳定的构建耗时瓶颈在CreateHexMesh的重复调用优化手段是为单个格子缓存一个模板 MeshCombine 时直接复用。另外注意顶点法线在合并后不能使用RecalculateNormals因为接缝处的顶点无法自动产生平滑过渡地形高度变化时会看到明显的棱线这是六边形网格本身硬边风格的合理表现。4. 地图特征生成噪声、高度图与生物群落的叠加规则4.1 基于 Perlin 噪声的地形高度采样参数地图不能是纯平面。这套系统使用分形布朗运动fBm叠加多层 Perlin 噪声来生成高度场核心参数有四个频率frequency、振幅amplitude、倍频数octaves、种子seed。频率决定地形起伏的疏密程度振幅决定高度差倍频数决定细节层次种子用于保证同一地图可复现。工程上常见做法是使用四到六层 octave每层频率翻倍、振幅减半这样叠加出来的高度图既保留大尺度山脉轮廓又有小的起伏细节。Unity 的Mathf.PerlinNoise有两个限制需要注意它的输入输出都是二维的第三个维度需要通过不同种子偏移来模拟返回值范围是 0 到 1但它本身是连续的不能直接产生悬崖和陡坡。如果想要更自然的山脊线可以在采样时对坐标做Mathf.Abs变换让噪声值在零点折叠产生类似山脉分水岭的效果。public static float SampleHeight(int q, int r, int s, HeightMapConfig config) { float height 0f; float frequency config.frequency; float amplitude config.amplitude; float maxValue 0f; for (int i 0; i config.octaves; i) { float x (q config.seed * 3.7f) * frequency; float y (r config.seed * 7.1f) * frequency; float z (s config.seed * 5.3f) * frequency; float sample Mathf.PerlinNoise(x, y) Mathf.PerlinNoise(y, z) Mathf.PerlinNoise(z, x); height sample / 3f * amplitude; maxValue amplitude; frequency * config.lacunarity; amplitude * config.persistence; } return Mathf.InverseLerp(0f, maxValue, height); }这里在 q、r、s 三个维度上分别采样再取平均是为了避免二维 Perlin 噪声在 cube 坐标映射到平面时产生的轴向拉伸。lacunarity通常取 2.0控制频率的递增倍数persistence通常取 0.5控制振幅的递减比例。InverseLerp的作用是把累计值归一化到 0 到 1供后续地形类型映射使用。4.2 温度与湿度双因素生物群落映射表只靠高度无法划分生物群落沙漠、草原、森林、雪山的分布需要温度和湿度两个因素共同决定。常见的做法是生成两张独立的噪声图一张作为温度图、一张作为湿度图然后通过一个查找表判断当前格子的生物群落类型。温度通常与纬度相关可以在地图生成时直接传入纬度影响因子湿度则与高度相关因为海拔越高空气越干燥需要在采样时把高度值作为反向权重叠加到湿度噪声上。下表给出一个可直接落地的映射规则其中温度和湿度都是 0 到 1 的归一化值温度横轴湿度纵轴生物群落典型高度区间 0.2任意冰雪苔原较高区域0.2-0.5 0.3荒漠低地0.2-0.50.3-0.7草原平地0.2-0.5 0.7森林中等高度 0.5 0.3热带稀树草原低地 0.50.3-0.7温带森林中等高度 0.5 0.7雨林低洼这套映射的逻辑是先判断温度带、再在温度带内细分湿度区间。工程实现时可以用一个BiomeType[,]二维数组作为查找表直接用温度索引和湿度索引查表比连续 if-else 性能更好也方便在编辑器里调整阈值边界。阈值之间有重叠或空洞都会导致生物群落分布不自然调整时要检查每个格子都落在规则的某个区间内。4.3 地块属性与网格数据的绑定模式高度和生物群落最终要存储在网格单元上供游戏逻辑查询。推荐的数据结构是一个HexCell类字段包含坐标、高度、生物群落类型、移动消耗、是否可通行等。这个类不直接挂在 GameObject 上而是存在一个DictionaryVector2Int, HexCell里以 offset 坐标为键。运行时需要查询某个位置的地块信息时通过坐标转换把世界坐标转成 offset 坐标再查询字典。[System.Serializable] public class HexCell { public Vector2Int offsetCoords; public float height; public BiomeType biome; public int movementCost; public bool walkable; }movementCost和walkable是给寻路系统用的沼泽地形移动消耗设为 3、不可通行地形设为 -1 或者单独用布尔值标记。这种数据与表现分离的做法让地图系统可以脱离渲染独立运行——服务端不需要 Unity 渲染也能加载地图数据做逻辑判断客户端只负责把HexCell的数据映射到 Mesh 顶点位置和颜色上。5. 数据序列化与编辑器扩展让地图生成系统可复现、可调整5.1 ScriptableObject 配置与资源管线设计地图生成参数不应该硬编码在代码里。把频率、振幅、倍频数、种子、地图尺寸等参数全部收敛到一个HexMapSettings对象中用[CreateAssetMenu]暴露到 Project 面板策划可以随时调整参数并即时预览效果。这套系统的源码里已经把配置类设置成 ScriptableObject这意味着同一套生成逻辑可以产出完全不同的地图风格只需要切换不同的配置资产。配置项的最小集合包括地图宽度和高度、外半径、种子、噪声倍频数、lacunarity、persistence、温度偏移、湿度偏移以及海平面高度。海平面高度是一个容易被忽略但极其影响观感的参数它决定多少格子被水淹没直接改变地图的大陆分布形态。调整个参数时建议增量变化而不是一次改到 0.5因为地形的边缘形状对海平面非常敏感每次只调整 0.02 到 0.05 再观察效果。5.2 地图数据的 JSON 序列化方案地图生成后需要保存和加载。把DictionaryVector2Int, HexCell直接序列化是行不通的字典在 Unity 的 JsonUtility 中不受支持。常见的做法是序列化一个列表列表项包含坐标和地块数据字段。加载时再遍历列表重建字典。这种转换虽然多了一步但保证了数据格式对所有 JSON 解析库的兼容性。[System.Serializable] public class HexMapSaveData { public ListHexCellData cells new ListHexCellData(); } [System.Serializable] public class HexCellData { public int x; public int y; public float height; public int biomeIndex; public int movementCost; public bool walkable; } public static string SerializeMap(DictionaryVector2Int, HexCell map) { HexMapSaveData data new HexMapSaveData(); foreach (var pair in map) { HexCell c pair.Value; data.cells.Add(new HexCellData { x pair.Key.x, y pair.Key.y, height c.height, biomeIndex (int)c.biome, movementCost c.movementCost, walkable c.walkable }); } return JsonUtility.ToJson(data); }序列化后的 JSON 字符串可以写入Application.persistentDataPath下的文件也可以上传到远程服务器做多人同步。反序列化的逆操作是从 JSON 还原列表再逐项填入字典。这套实现针对一张 200×200 的地图序列化后的 JSON 文件大小约在 1 到 2 MB如果超出这个量级说明冗余字段过多可以考虑用二进制序列化或者只保存增量变化的数据。5.3 编辑器工具一键生成与参数预览在编辑器中提供生成入口可以显著提高调参效率。通过[MenuItem(Tools/Generate Hex Map)]添加菜单项点击后读入当前选中的 HexMapSettings 资产并执行生成流程生成完成后自动选中场景中的根节点。除此之外在 Inspector 面板上实现OnInspectorGUI重绘让参数变化时自动触发预览重建这个能力是全流程中提效最明显的。[CustomEditor(typeof(MapPreview))] public class MapPreviewEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); MapPreview preview (MapPreview)target; if (GUILayout.Button(Regenerate Preview)) { preview.Regenerate(); } } }这里Regenerate是挂在预览对象上的一个公开方法内部执行噪声采样、生物群落映射和 Mesh 重建三步。编辑器里的重建耗时和运行时一致100×100 的地图在普通开发机上通常在 100 毫秒以内超过这个数说明噪声采样逻辑有冗余计算优先检查是否在循环内重复创建了数组或对象。6. 用邻接一致性检查验证网格生成质量六边形网格生成系统完成后最容易出现的两类 bug 是坐标错位和地块间隙。坐标错位表现为邻居关系不对称——A 认为 B 是邻居但 B 不认为 A 是邻居地块间隙表现为格子之间存在三角形缝隙鼠标点击时点不中任何格子。用一段独立于地图生成的校验代码做遍历检查可以在开发和回归测试时快速暴露这两类问题。校验逻辑分两步第一步是遍历所有格子检查每个格子的六个邻居是否都存在于地图范围内边界格子除外第二步是检查每个邻居的邻居列表是否包含当前格子这一步能同时发现坐标转换函数中的奇偶性错误和方向向量定义不一致的问题。public static bool ValidateHexGrid(DictionaryVector2Int, HexCell map) { foreach (var key in map.Keys) { Vector3Int cube HexCoordinateConverter.OffsetToCube(key.x, key.y); var neighbors HexCoordinateConverter.GetNeighbors(cube); foreach (var n in neighbors) { Vector2Int offset HexCoordinateConverter.CubeToOffset(n); if (!map.ContainsKey(offset)) continue; // 地图边界跳过 if (!map.ContainsKey(key)) { Debug.LogError($邻居 {offset} 的反向检查失败); return false; } } } return true; }实际运行时可以把这段检查挂在地图加载完成后编辑器下用[MenuItem]触发发布版本中通过#if UNITY_EDITOR排除。排查坐标错位时还要在 Scene 视图下打开 Gizmos 显示每个格子的坐标标签逐层放大检查奇偶行之间是否交错对齐。噪声和生物群落的分布问题排查则需要在材质上临时输出调试颜色高度图用灰度渐变生物群落用调色板映射这样可以直观看到噪声采样是否出现轴向拉伸或重复图案。本文还有配套的精品资源点击获取