简介hexgen 是一份基于 Python 的六角形世界地图生成器源码包专门面向游戏开发者、DnD 主持人、程序化生成学习者与浏览器游戏爱好者。它通过六边形网格生成随机世界地图允许使用者调节参数创建不同行星表面并可将地表分割为任意规模的领土地带适合直接嵌入开源游戏或作为独立实验工具。资源共含 34 个文件其中 20 个是 Python 源文件构成地图生成、河流绘制、领地划分、边缘计算、高度图、几何体与常量定义等核心模块辅以 3 份 Markdown 文档、测试文件、JSON 配置、示例脚本、文档生成脚本与安装脚本压缩包整体约 548 KB。项目目录层次清楚既提供可直接运行的 example也附带测试用例和文档生成工具便于读者对照源码理解六边形网格坐标、地形生成流程和参数调整思路。已有 1547 人下载学习适合希望从零实现六边形地图、研究程序化生成或复用其游戏开发组件的 Python 开发者。1. 六角形世界地图生成器不看方眼看六边形地图的逻辑密度做世界设定最难受的不是画线而是画出来的大陆经不起盘问海岸线走向为什么这么规则山脉为什么漫无目的是地分布在平原中央河流为什么走着走着就消失。基于Python的hexgen六角形世界地图生成器把这件事拉回工程轨道——它用六角网格作为地图的基本单位用噪声生成高度、温度、湿度再合并出生物群落输出可以被游戏引擎和绘图工具直接消费的网格数据。适合独立游戏开发、TRPG桌游跑团地图、写作设定集的制图需求。会安装pip包、能读懂循环和字典的从业者半小时以内就能把一张带生态逻辑的地图跑出来。这套资源不是我开发的但拆过几轮有不少边界值得展开。2. 六角格坐标系为什么偏移坐标存储、轴向坐标算邻居能少走弯路2.1 方格地图解决不了的邻接歧义做路径寻路和战争迷雾时方格地图一直有个麻烦两个格子到底是“相邻”还是“斜角跳”上下左右是邻居没问题可对角线是不是不同规则里定义不一样让寻路算法、连通性判断、范围攻击都跟着歧义跑。我见过不止一个项目在方形地图上强行实现了六方向移动最后所有逻辑都套在“曼哈顿距离修正”上改动一个单位移动力就要回查四个模块。六角格用六个方向覆盖邻居关系不存在对角线模糊任意两个相邻格子共享一条完整边。这个特性对“河流是否流经边界”“山脉是否阻隔通行”这类判断非常友好。代价是坐标不像方格那样x加一就是右邻居偶数行和奇数行的偏移不一致需要单独处理。如果整个项目直接用像素坐标存地图数据渲染时很直观但做邻域遍历时就乱套了。这里的结构化方案是把两种坐标拆开用。2.2 存储用 offset 坐标计算切到 axial 坐标hexgen 常见做法是地图底层用 offset 坐标形如(col, row)方便人类阅读地图编辑器涉及邻居、距离、寻路时临时转 axial 坐标(q, r)因为轴向坐标的邻居和距离公式全是整数加减没有奇偶判断。转换函数我一般集中放在网格模块里不给业务代码碰。def offset_to_axial(hex_pos): col, row hex_pos # 奇行右移row 为奇数时 q 减 (row // 2 1) q col - (row - (row 1)) // 2 r row return (q, r) def axial_to_offset(axial_pos): q, r axial_pos col q (r - (r 1)) // 2 row r return (col, row)说明这里的row 1是判断奇偶的位运算核心目的是让偶数行和奇数行在“水平偏移”上各让一格。如果你用的六边形是 pointy-top 类型这个公式成立如果换成 flat-top偏移方向要落到列上公式里col和row的角色互换。参数上偏移量由六边形边长决定但坐标转换本身不涉及缩放六边形半径只影响渲染环节。邻居计算用轴向坐标后只需要一张方向表AXIAL_DIRECTIONS [ (1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1), (0, 1), ] def neighbors(axial_pos): q, r axial_pos return [(q dq, r dr) for dq, dr in AXIAL_DIRECTIONS]这六个方向是固定的不随行号奇偶变化。为什么单独列出这个表因为写 offset 坐标的邻居逻辑时偶数行和奇数行各要一套方向两套方向还互为镜像一旦地图行数是偶数时颠倒全图边界就斜着断。这个方向表几乎不需要改动能直接用于洪水填充、势力范围扩散、河流寻路。2.3 世界包裹要不要做东西向无缝循环生成行星级世界地图时地图左右两侧应该能首尾相连。实现无缝世界需要两步配合第一噪声生成必须使用周期噪声或者人为拼接第二邻居查询要允许坐标越过边界。很多生成器只做了前者忘了后者结果地图图面是连续的但左上角的格子查不到右上角的邻居接缝处就断了。hexgen 的理念里我倾向把包裹做成一个可开关选项而不是默认值。因为TRPG地图往往只截取一块局部区域强行包裹反而把大陆撕裂。开启包裹的逻辑是在邻居查询入口做一次取模def wrapped_neighbor(pos, width, wrap): q, r pos result [(q dq, r dr) for dq, dr in AXIAL_DIRECTIONS] if wrap: result [((x % width), y) for x, y in result] return result这里有个细节纵向r 方向包裹会让地图拓扑变成环面大陆繁殖逻辑会变复杂普通项目不要默认开。只做横向包裹地图看起来像“绕一圈回到原地”成本低且最贴近行星世界需求。提示坐标转换的坑主要在坐标轴符号上。自己写渲染时先打印相邻三个格子的像素坐标确认q增大和r增大的方向分别对应屏幕右边还是右下。方向反了生成的河流无论怎么调参数都会斜穿大陆。3. 地图生成管线从高度噪声到生物群落的一次成型流程3.1 高度图六边形网格采样与多倍频噪声参数生成大陆的第一步是给每个六边形赋值高度。最稳的做法是先建立二维连续噪声场再把六边形中心点像素坐标映射进去采样。这里的关键是噪声倍频设置单次Perlin噪声出来的地形太平滑所有山丘都像同一张波叠加多个频率后宏观大陆和微观丘陵才分得开。import numpy as np from noise import pnoise2 def generate_world_heightmap(width, height, seed, scale0.06, octaves5, persistence0.5, lacunarity2.0): xs np.arange(width) ys np.arange(height) heightmap np.zeros((height, width), dtypenp.float32) for octave in range(octaves): freq lacunarity ** octave amp persistence ** octave for y in range(height): for x in range(width): v pnoise2( x * scale * freq, y * scale * freq, octaves1, baseseed octave * 1013 ) heightmap[y, x] amp * v heightmap - heightmap.min() heightmap / heightmap.max() return heightmap逐个像素调用噪声函数在 map 尺寸变大后很慢但这套代码在 64×48 的六边形地图上一般能接受。scale控制噪声的“宏观程度”数值越小大陆块越大octaves越多细节越丰富但超过 6 层后边际收益很低persistence设为 0.5 是经典值每层高频噪声幅度减半。如果你拿到资源包后只需要大陆轮廓把octaves降到 3把scale调小到 0.03 以下。采样不是直接把二维数组当成 hex 序号来用的。六边形中心到像素坐标的换算一般是这样每个 hex 横向占 3/2 倍半径纵向占 sqrt(3) 倍半径并且奇数行整体偏移半个 hex。def hex_to_pixel(hex_pos, radius): x radius * 1.5 * hex_pos[0] y radius * 1.732 * (hex_pos[1] 0.5 * (hex_pos[0] 1)) return (x, y)参数radius是六边形的外接圆半径。奇数行偏移的0.5因子来自网格错位写死就行。注意这里的坐标方向和上一章保持一致q 向右r 向下。如果换用 flat-top公式要旋转90度别混用。3.2 温度湿度纬度衰减、海拔冷却与距海距离高度图只是骨架生态图还需要温度、湿度两个通道。温度的核心逻辑很直白纬度决定基础温度也就是地图上下两条边界冷、中间热海拔每升高一段温度按递减率降一截。湿度更多依赖“距海距离”——离海岸越远的格子水汽越难到达。def compute_temperature(elevation, row_index, map_height, latitude_gradient0.45, lapse_rate0.14): mid abs(row_index - map_height / 2) / (map_height / 2) temp 1.0 - mid * latitude_gradient temp - elevation * lapse_rate return np.clip(temp, 0.0, 1.0)latitude_gradient控制两极和赤道温差值设大了地图里温带区域会非常窄lapse_rate是海拔冷却系数山脚和山顶的温差靠它区分。对应到渲染上不解决这个参数会出现“高山雨林”这种反直觉生物群落。湿度计算复杂一些。最直接的实现是计算每个格子的最近海岸线距离再做归一化和方向偏置def compute_moisture(distance_to_coast, max_distance, wind_direction0.6, rain_shadow0.3): base 1.0 - distance_to_coast / max_distance base * wind_direction if base 0: base 0.0 return base - rain_shadowwind_direction实际上不是角度而是水汽到达比例0.5~0.7之间比较自然。rain_shadow模拟山脉背风坡在山区往一个方向压低湿度。这个简化模型能产生沙漠和雨林但做不到真实气候带那种“大陆东岸湿润、西岸干燥”的长期气候记忆。需要更现实的气候模拟可以后续叠加洋流数据但这个项目本身追求的是低成本生成可用的世界地图。3.3 生物群落查表Whittaker分类的落地参数有了温度、湿度、海拔三个通道生物群落可以查表决定。惯用方案是 Whittaker 生物群落分类根据温度和降水把环境划分成十几个区间。表格里温度和湿度都归一化到 0~1使用浮点区间判断而不是等距网格硬切。BIOME_TABLE [ (0.00, 0.30, ice), (0.30, 0.42, tundra), ... ]这个表我一般直接用函数实现代码清晰也不容易错def classify_biome(elevation, temperature, moisture, sea_level): if elevation sea_level: return ocean if temperature 0.2: return snow if moisture 0.25: return desert if temperature 0.5 else tundra if temperature 0.7 and moisture 0.6: return rainforest if elevation 0.75: return mountain return grassland这个分类表看起来简单但参数调起来有玄学成分temperature 0.2和moisture 0.25这两个阈值直接影响沙漠和冻土边界是否贴合大陆形状。如果地图上沙漠出现在赤道沿海位置多半是moisture阈值太低水汽没被距离衰减拦住。遇到这种问题先调湿度通道再做分类别去改表格。4. 配置参数与资源导出一份能直接调用的参数表和三种输出形式4.1 WorldConfig 配置参数表hexgen 这类资源包的标准用法是顶层定义一个 WorldConfig 对象把地图生成的所有可调项集中在同一处。这样实验不同世界观时不用翻代码找魔数直接改配置重跑。参数建议取值范围作用调参经验width / height48~160地图六边形网格尺寸超过 120 时噪声采样耗时会明显增长radius4~8单个六边形外接圆半径渲染精度相关坐标逻辑不受影响seed任意整数随机数种子固定种子才能复现地图scale0.025~0.06噪声频率调小得到大块大陆调大得到碎岛octaves3~6噪声细分数越大地形细节越丰富但耗时增加sea_level0.35~0.55海平面高度0.5 附近大陆海洋比例接近 1:1mountain_threshold0.7~0.8被标为山脉的格子的高度值越低山脉越密集moisture_wind0.4~0.7风向水汽比例调大让大陆迎风坡更湿润wrapTrue/False开启东西向无缝地图TRPG截图建议 False行星世界建议 True参数之间不是完全独立的。sea_level提高后露出水面的大陆变少相应地mountain_threshold也会截到更多沿海山地看起来像岛屿链。我经常先用seed0跑一次全默认确认大陆形态符合世界观氛围后再沿当前种子微调scale和sea_level。4.2 从配置到生成和落盘实际调用时最直观的方式是把生成和导出拆成两个步骤。生成完成后拿到的是一个包含 height、temperature、moisture、biome 编号的六边形字典下一步就是导出成各种内容。from hexgen.core import WorldConfig, generate_world config WorldConfig( width80, height56, radius6, seed20240718, scale0.04, octaves5, sea_level0.44, mountain_threshold0.75, moisture_wind0.6, wrapTrue, ) world generate_world(config) world.summary() # 输出示例大陆面积占比、山脉占比、海洋占比generate_world内部做的事情是先建六边形网格坐标再映射到噪声场取样高度、温度、湿度最后按生物群落分类函数给每个格子写入terrain_type。summary()不是必需功能但建议保留这个调试入口。查看大陆占比和整体纹理比盯着原始数据猜问题快得多。4.3 导出到 PNG 和 JSON给引擎和给人类的两套输出工作流中需要区分两类输出PNG 是给人看的预览JSON 是给游戏引擎和自动生成脚本吃的。PNG 导出本质是把每个六边形画成多边形按 biome 填色。这里最容易出的问题是边缘抗锯齿导致的六边形缝隙解决办法是渲染时给每个多边形加一个像素的描边扩充。def export_world(config, world, output_path): width config.width * int(config.radius * 2) height config.height * int(config.radius * 2) image Image.new(RGB, (width, height)) draw ImageDraw.Draw(image) for pos, hex_data in world.hexes.items(): pixel hex_to_pixel(pos, config.radius) points [] for corner in range(6): angle 60 * corner rad math.radians(angle) px pixel[0] config.radius * math.cos(rad) py pixel[1] config.radius * math.sin(rad) points.append((px, py)) draw.polygon(points, fillBIOME_COLORS[hex_data[biome]]) image.save(output_path)这段代码有几个参数陷阱width和height只是粗略画布上界六边形错位会导致右侧超出最好再加上一个半径长度BIOME_COLORS字典的键必须和classify_biome返回的字符串严格一致漏一个键整个导出直接翻车。JSON 导出格式方面我给每个格子存四类数据够引擎做逻辑判断又不至于体积爆炸def export_json(world, output_path): payload { meta: { width: world.config.width, height: world.config.height, radius: world.config.radius, seed: world.config.seed, wrap: world.config.wrap, }, tiles: [ { q: pos[0], r: pos[1], terrain: hex_data[biome], elevation: round(float(hex_data[elevation]), 4), moisture: round(float(hex_data[moisture]), 4) } for pos, hex_data in sorted(world.hexes.items()) ], } with open(output_path, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse, indent2)在世界地图生成时sorted()很重要。Python 的字典遍历顺序在 3.7 之后虽然是插入序但多进程生成或序列化后顺序会变不排序导出的 JSON 前后两次可能不一样导致同一个 seed 产生 “diff” 假象。这不算 hexgen 的 bug是任何 Python 数据导出都会遇到的确定性陷阱。提示如果渲染后六边形之间出现细线不要调整抗锯齿去检查hex_to_pixel的半径常量。渲染半径比逻辑半径小 1 个像素就会出现马赛克边界把半径改成逻辑半径加 0.5立即消失。5. 避坑与排查从坐标缝隙到种子漂移的六类高频翻车记录5.1 大陆东西两边各说各话无缝地图接不上现象开启wrapTrue后生成的地图左边沿海和右边沿海完全是两条海岸线接缝处一个陆地一个海洋。原因噪声采样没有做周期化处理。Pnoise 或 Perlin 在左右边界是连续函数但非周期函数地图最左和最右的坐标虽然在数值上接近但噪声值不保证相等。仅靠wrap参数做邻居取模解决不了噪声场自带的不连续。解决在做噪声采样时让横坐标先映射到 0~1 的圆环上。对每个格子位置横向先计算tx (x / width) % 1.0再把tx展开回噪声坐标做采样。这个方法比使用周期噪声库更直接不受底层噪声实现限制。5.2 固定同一个种子两次生成的结果不一致现象把 seed 固定为 20240718第一次生成的地图是一片大陆加两个岛第二次运行变成碎岛群。原因常见于 Python 版本差异和哈希随机化。如果代码里有任何用字符串做键、再直接遍历字典的地方而键又恰好是数字组成的字符串PYTHONHASHSEED随机化会导致迭代顺序改变。噪声库的base参数也可能因为内部状态被前一次调用污染。解决在入口模块最前面固定环境变量或者强制对所有字典键排序后再遍历。我自己习惯双固定命令行启动脚本里写PYTHONHASHSEED0同时generate_world每次初始化都重置噪声库的种子。5.3 六边形之间出现非地图底色的细缝现象导出的 PNG 放大看部分六边形之间有一条底色透出。改用大半径后缝隙没有消失只是跟着变大。原因这是典型的渲染半径和逻辑半径不一致。radius如果被用于计算六边形顶点而顶层WorldConfig里的radius被手动改成 4但hex_to_pixel函数内部写死了另一个值偏移和缩放就不匹配。多边形的计算起点也在边缘差一点就会露底色。解决所有用到半径的地方统一从配置读取。不要为了调整地图大小去改radius而是改width/height和scale。radius是全局常量一旦写死后续所有项目都要背这个错。5.4 生物群落出现“赤道雪山”或“沿海沙漠”等反直觉组合现象地图赤道区域出现大片雪原或者沿海低地出现沙漠视觉上非常别扭。原因classify_biome的阈值判断顺序不对。如果温度低于 0.2 就返回雪原但赤道控制变量是由纬度算出来的当地图高度较小时纬度的温差梯度不明显整个地图温度都偏低赤道也会走进雪原分支。另一种情况是湿度通道加了rain_shadow参数但没限制只发生在山区导致沿海平原也被投影。解决先分别输出温度图、湿度图做灰度图肉眼看看通道分布是否符合预期。然后再决定加阈值还是改参数。灰度图里赤道温度应该明显高于极地如果看不出梯度去查latitude_gradient是不是被设成了 0。5.5 河流入海点大量出现在悬崖边上视觉和逻辑都不对现象河流从高山一路向东流最后从 80 米高的悬崖直接跳进海里。生成结果看起来像瀑布但地形没有断崖。原因河流生成逻辑里只有“向低处流”这一个约束没有把“海拔差过大地点标为瀑布”或者限制高低差步长。当sea_level和mountain_threshold都偏高时海岸线就是高地边缘所以河流都会被逼到悬崖入海。解决河流生成时增加一个max_drop参数如果邻居格子的海拔差超过阈值该格河流终止为“瀑布”不再继续向下延伸。这个参数让地图逻辑合理了不少渲染时还能顺手把瀑布标成一个特殊点。5.6 导出 JSON 文件两次内容有差异git 提交噪音极大现象同一个 seed、同一套配置生成的 JSON 文件在两次运行之间存在大量数据顺序变动。git diff 看到几十行改动但实际上只有一个格子的生物群落变化。原因大概率是字典遍历顺序不同。我这里只处理了顶层world.hexes.items()的sorted但嵌套结构里的顺序没管。比如world.grid内部如果还有别的字典同样会乱。解决JSON 导出不只对tiles排序也要保证meta字段的顺序是固定的。Python 写字典时字段顺序完全是声明顺序两个版本之间反复切分支时很容易出现顺序漂移。现在的习惯是导出一律走sort_keysTrue的json.dumps避免这种毫无价值的 diff。注意上面这些坑全部是“现象 - 原因 - 解决”就能定位的不需要引入深奥算法。遇到地图形状和预期差很远时第一动作永远是输出中间的过程图高度、温度、湿度、生物群落各一张而不是对着最终 PNG 猜。6. 把随机地形变成可维护的世界种子清单与手工刻画的落袋技巧最后一个值得讲透的技巧是把“随机生成”转成“确定性地基允许手工改写”的版本化工作流。用 hexgen 这类生成器的常见痛点是跑 100 个种子挑到一个满意的地图存了 PNG 就算完结果后面想微调一块地形发现要么重新生成全部打乱要么直接在 PNG 上画导致数据和图片脱节。我现在的工作流是这样每个尝试过的种子都单独建一个目录名字用运行时间和 seed 拼成run_20240718_1815_seed_20240718。目录里放四个文件world.json、world.png、manifest.json、edit_log.json。manifest.json写入全部配置参数和生成时间edit_log.json记录手工改动的格子坐标和改动前后类型。import hashlib import os, json def save_world_session(world, config, base_dirworlds): run_id frun_{config.seed}_{hashlib.md5(str(config).encode()).hexdigest()[:6]} run_dir os.path.join(base_dir, run_id) os.makedirs(run_dir, exist_okTrue) export_json(world, os.path.join(run_dir, world.json)) export_world(config, world, os.path.join(run_dir, world.png)) manifest { seed: config.seed, params: vars(config), grid_size: [config.width, config.height], wrap: config.wrap, } with open(os.path.join(run_dir, manifest.json), w, encodingutf-8) as f: json.dump(manifest, f, ensure_asciiFalse, indent2) return run_id这里的run_id用哈希截断而不是完整 seed 是为了可读性。手动改数据时改的是world.json里对应格子的terrain字段并同步更新edit_log.json。这样原始生成结果和手工修改完全分离两者都保留一份。当你在引擎里看不到某块大陆想要的形状时有可能想整体微调参数也可能只想改三块格子做路网两条路径都走得通。还有个验证习惯每次手工修改完成后重新读取 JSON 统计一下地形类型占比看看改动是否影响到了大陆连通性。用洪水填充快速跑一遍def count_connected_continents(tiles): biome_map {(t[q], t[r]): t[terrain] for t in tiles} visited set() continents 0 for pos, biome in biome_map.items(): if biome ocean or pos in visited: continue continents 1 queue [pos] while queue: cur queue.pop() if cur in visited: continue visited.add(cur) for nq, nr in AXIAL_DIRECTIONS: neighbor (cur[0] nq, cur[1] nr) if neighbor in biome_map and biome_map[neighbor] ! ocean: queue.append(neighbor) return continents这个函数不仅能验证手工改动没有把地图拆成碎片也能顺手算出凌乱岛屿的数量用来判断某个种子是不是“碎大陆主题”。如果continents数量大于 20而你要的是三块大陆基本可以直接换种子不用浪费时间手工缝合。一开始我也省事把地图直接存成map_final_v3.png第二天回来看发现那个版本已经找不回来只能认命重跑。从那以后我每次跑 hexgen 都会强制走一遍“种子目录 参数清单 编辑记录”这个流程再动工生成器给了性价比高的确定性结果但最终让地图服从世界观的还是那层手工刻画的可回溯数据。希望帮到你。本文还有配套的精品资源点击获取