简介: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, scale=0.06, octaves=5, persistence=0.5, lacunarity=2.0): xs = np.arange(width) ys = np.arange(height) heightmap = np.zeros((height, width), dtype=np.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, octaves=1, base=seed + 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_gradient=0.45, lapse_rate=0.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_direction=0.6, rain_shadow=0.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 / height | 48~160 | 地图六边形网格尺寸 | 超过 120 时噪声采样耗时会明显增长 |
radius | 4~8 | 单个六边形外接圆半径 | 渲染精度相关,坐标逻辑不受影响 |
seed | 任意整数 | 随机数种子 | 固定种子才能复现地图 |
scale | 0.025~0.06 | 噪声频率 | 调小得到大块大陆,调大得到碎岛 |
octaves | 3~6 | 噪声细分数 | 越大地形细节越丰富,但耗时增加 |
sea_level | 0.35~0.55 | 海平面高度 | 0.5 附近大陆海洋比例接近 1:1 |
mountain_threshold | 0.7~0.8 | 被标为山脉的格子的高度 | 值越低山脉越密集 |
moisture_wind | 0.4~0.7 | 风向水汽比例 | 调大让大陆迎风坡更湿润 |
wrap | True/False | 开启东西向无缝地图 | TRPG截图建议 False,行星世界建议 True |
参数之间不是完全独立的。sea_level提高后露出水面的大陆变少,相应地mountain_threshold也会截到更多沿海山地,看起来像岛屿链。我经常先用seed=0跑一次全默认,确认大陆形态符合世界观氛围后,再沿当前种子微调scale和sea_level。
4.2 从配置到生成和落盘
实际调用时,最直观的方式是把生成和导出拆成两个步骤。生成完成后,拿到的是一个包含 height、temperature、moisture、biome 编号的六边形字典,下一步就是导出成各种内容。
from hexgen.core import WorldConfig, generate_world config = WorldConfig( width=80, height=56, radius=6, seed=20240718, scale=0.04, octaves=5, sea_level=0.44, mountain_threshold=0.75, moisture_wind=0.6, wrap=True, ) 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, fill=BIOME_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", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2)在世界地图生成时sorted()很重要。Python 的字典遍历顺序在 3.7 之后虽然是插入序,但多进程生成或序列化后顺序会变,不排序导出的 JSON 前后两次可能不一样,导致同一个 seed 产生 “diff” 假象。这不算 hexgen 的 bug,是任何 Python 数据导出都会遇到的确定性陷阱。
提示:如果渲染后六边形之间出现细线,不要调整抗锯齿,去检查
hex_to_pixel的半径常量。渲染半径比逻辑半径小 1 个像素就会出现马赛克边界,把半径改成逻辑半径加 0.5,立即消失。
5. 避坑与排查:从坐标缝隙到种子漂移的六类高频翻车记录
5.1 大陆东西两边各说各话,无缝地图接不上
现象:开启wrap=True后生成的地图,左边沿海和右边沿海完全是两条海岸线,接缝处一个陆地一个海洋。
原因:噪声采样没有做周期化处理。Pnoise 或 Perlin 在左右边界是连续函数但非周期函数,地图最左和最右的坐标虽然在数值上接近,但噪声值不保证相等。仅靠wrap参数做邻居取模,解决不了噪声场自带的不连续。
解决:在做噪声采样时,让横坐标先映射到 0~1 的圆环上。对每个格子位置,横向先计算tx = (x / width) % 1.0,再把tx展开回噪声坐标做采样。这个方法比使用周期噪声库更直接,不受底层噪声实现限制。
5.2 固定同一个种子,两次生成的结果不一致
现象:把 seed 固定为 20240718,第一次生成的地图是一片大陆加两个岛,第二次运行变成碎岛群。
原因:常见于 Python 版本差异和哈希随机化。如果代码里有任何用字符串做键、再直接遍历字典的地方,而键又恰好是数字组成的字符串,PYTHONHASHSEED随机化会导致迭代顺序改变。噪声库的base参数也可能因为内部状态被前一次调用污染。
解决:在入口模块最前面固定环境变量,或者强制对所有字典键排序后再遍历。我自己习惯双固定:命令行启动脚本里写PYTHONHASHSEED=0,同时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_keys=True的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_dir="worlds"): run_id = f"run_{config.seed}_{hashlib.md5(str(config).encode()).hexdigest()[:6]}" run_dir = os.path.join(base_dir, run_id) os.makedirs(run_dir, exist_ok=True) 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", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=False, indent=2) 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 都会强制走一遍“种子目录 + 参数清单 + 编辑记录”这个流程再动工;生成器给了性价比高的确定性结果,但最终让地图服从世界观的,还是那层手工刻画的可回溯数据。希望帮到你。
本文还有配套的精品资源,点击获取