
简介这是一份面向Python开发者与地图生成爱好者的六角形世界地图生成器源码包可用于为浏览器游戏、跑团战役或程序化内容实验快速构造随机行星表面并通过参数调节地形、气候与河流等要素还能把地图切分为任意大小的“领土”。压缩包共34个文件、约548KB以20个Python源码文件为主体配合3个Markdown说明文档、1个JavaScript脚本及配置、测试与依赖清单涵盖网格、高度图、地理形态、领土划分、日历和地图导出等模块目录组织较清晰完整便于按功能阅读、检索和二次开发。已有1550人学习下载适合想研究程序化地图生成、六边形网格算法或直接复用生成器搭建游戏与跑团原型的中级Python读者从中可获取可运行示例、模块化实现、测试入口、环境搭建线索与扩展思路。1. 一个半径 40 的六角地图把我逼着自己写了一套生成器去年做一款回合制策略原型策划要一张“看着像文明”的世界图六边形格子、有大陆有海、有山脉有沙漠而且同一个种子要能复现同一张图——数值策划要拿它调平衡。我一开始去翻现成库试了几个要么格子是方的要么生成完只吐一张 PNG拿不到每个格子的海拔和地形类型策划要在格子上放城池、算移动力光有图根本不够用。后来照着六角网格那套经典的坐标数学自己搭了一遍就是这篇要拆的 hexgen一个纯 Python 的六角形世界地图生成器输入半径和随机种子输出地形数据加渲染图几十行命令就能跑出一张能直接丢进原型的地图。适合做游戏原型、跑团世界设定也适合当噪声算法和六边形坐标系的练手项目。2. 工程骨架与运行链路三条命令把第一张地图跑出来2.1 目录结构与模块职责这个项目我按“数据 → 计算 → 呈现”切了三层没有做过度抽象因为地图生成本身是一条很直的流水线先生成格子集合再逐格算地形属性最后统一渲染。目录长这样hexgen/ ├── hexgen/ │ ├── __init__.py │ ├── grid.py # 坐标转换、邻居查找、螺旋生成地图 │ ├── noisefn.py # fBm 分形噪声封装 │ ├── terrain.py # 海拔/温度/湿度 - 生物群系 │ ├── hydro.py # 河流追溯与湖泊填充 │ ├── render.py # matplotlib 多边形绘制 │ ├── export.py # PNG / JSON 导出 │ └── cli.py # argparse 命令行入口 ├── tests/ │ └── test_grid.py # 坐标往返转换单测 ├── requirements.txt └── README.md分层的好处是改造时不容易互相牵扯想换渲染后端比如换成 PIL 或者直接吐 SVG只要 render.py 里那个函数签名不变terrain 和 grid 一行都不用动。我踩过一次教训——早期把 matplotlib 的绘图调用直接塞进了 terrain.py后来想换成无头导出改了两个小时才拆干净后悔药没得吃。2.2 依赖与安装numpy、noise、matplotlib 各管什么依赖不多三个核心库就够跑库建议版本在这个项目里负责什么numpy1.24批量属性计算、噪声采样前的数组准备noise1.2.2Perlin / Simplex 噪声地形起伏的源头matplotlib3.7RegularPolygon 画六边形、导出 PNGpytest7.0开发期单测跑坐标转换的往返一致性安装就一行建议在虚拟环境里做避免和系统自带的 matplotlib 打架python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install -r requirements.txtnoise这个库是 C 扩展Windows 上如果 pip 装不上多半是缺编译工具链用预编译 wheel 或者换成pip install noise1.2.2指定版本通常能过。这一点在别的机器上部署时会被反复提起。2.3 CLI 参数与一次完整运行命令行入口用 argparse参数不追求多够用就行# hexgen/cli.py import argparse def build_parser(): p argparse.ArgumentParser(proghexgen) # 半径决定地图大小格子总数约 3r(r1)1r24 时是 1801 格 p.add_argument(--radius, typeint, default30, help六边形地图半径) # 种子是复现的唯一入口同种子必须同结果后面避坑章会专门说 p.add_argument(--seed, typeint, default20240101, help随机种子) # 海平面阈值调大海域变多调小大陆变碎 p.add_argument(--sea-level, typefloat, default0.48, help海平面 0~1) p.add_argument(--out, defaultmap.png, help渲染输出路径) p.add_argument(--json, defaultNone, help可选同时导出格子数据) return p一次完整的运行是这样# 生成半径 24 的地图固定种子 42同时导出图片和数据 python -m hexgen --radius 24 --seed 42 --out world.png --json world.json--radius是最影响耗时的参数按平方增长半径从 24 加到 60格子数从 1801 涨到 10861肉眼可见地变慢。--sea-level调到 0.55 以上基本就是群岛世界调到 0.35 左右会冒出一整块超级大陆这个参数适合做原型阶段快速试风格。跑完在输出目录能看到world.png有 JSON 的话还能检查每格的elev和biome字段对不对。3. 六角网格的坐标系统axial、cube 和像素之间的三次转换3.1 为什么不能直接用行列号六边形网格最烦人的地方是“相邻”这件事没法用简单的(row, col)表达。方形网格里上下左右四个邻居是固定偏移六边形有六个邻居而且奇偶行的偏移方向还不一样。用行列号写下去代码里会充满if row % 2 0这种分支越写越玄学。业内的常见做法是换一套坐标系轴向坐标axial用两个整数(q, r)表示格子第三个轴由-q-r推出来三个轴之和恒为 0。这样六个邻居方向就变成了固定的六个向量不用再判断奇偶。代价是要写几个转换函数但写完之后整个项目都会轻松很多。3.2 axial 转 cube、立方坐标取整# hexgen/grid.py import math from typing import Iterator, Tuple # 六个邻居方向逆时针排列后文的螺旋算法依赖这个顺序 DIRECTIONS [(1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1), (0, 1)] def axial_to_cube(q: int, r: int) - Tuple[int, int, int]: axial - cube三轴和恒为 0距离和插值在这套坐标里最直观 return q, -q - r, r def cube_round(x: float, y: float, z: float) - Tuple[int, int]: 浮点 cube 吸附到最近整数格先各自四舍五入再把误差最大的那个轴反推回去 rx, ry, rz round(x), round(y), round(z) dx, dy, dz abs(rx - x), abs(ry - y), abs(rz - z) if dx dy and dx dz: rx -ry - rz elif dy dz: ry -rx - rz else: rz -rx - ry return rx, rzcube_round是整个坐标系统里最容易写错的一个函数看着简单但如果不做“反推误差最大的轴”这一步写出来的结果会在格子边界上随机跳动鼠标点哪个格子点到隔壁去。这里返回(rx, rz)是因为最终统一用 axial 表示cube 只是中间态。参数上没有什么可调的x / y / z必须是满足x y z 0的浮点数传进来的通常是像素坐标反算的结果。如果传的浮点数不满足这个约束反推那一步会引入额外误差表现出来就是光标偏移。3.3 用螺旋算法生成长半径为 N 的六边形地图地图的形状本身也是个六边形从中心往外一圈圈铺格子最自然def hex_spiral(radius: int) - Iterator[Tuple[int, int]]: 从中心出发一圈圈往外生成正好覆盖半径 radius 的六边形区域 yield (0, 0) for ring in range(1, radius 1): # 每圈的起点固定在该圈的西南角然后沿六个方向各走 ring 步回到原点 q, r -ring, ring for dq, dr in DIRECTIONS: for _ in range(ring): yield (q, r) q, r q dq, r dr这个算法的精妙之处在于起点(-ring, ring)和DIRECTIONS的顺序是绑定的从西南角出发依次走东、东北、西北、西、西南、东南每个方向正好ring步最后一圈走完刚好回到起点不多不少。如果把DIRECTIONS的顺序打乱生成出来会是一条乱七八糟的线而不是闭合的环——我第一次写的时候按直觉排了个序结果地图长成了一条蛇。radius就是命令行传进来的半径总格子数是3 * radius * (radius 1) 1。radius0只生成一格用于单测最方便。3.4 邻接、距离与像素映射邻居查找基于前面的DIRECTIONSdef neighbors(q: int, r: int): 返回六个邻居坐标顺序稳定方便后面对结果做确定性比较 return [(q dq, r dr) for dq, dr in DIRECTIONS] def hex_distance(a: Tuple[int, int], b: Tuple[int, int]) - int: cube 坐标下的距离是三个轴差值的最大值比欧氏距离更符合格子语义 ax, ay, az axial_to_cube(*a) bx, by, bz axial_to_cube(*b) return max(abs(ax - bx), abs(ay - by), abs(az - bz))像素映射用的是 pointy-top尖顶朝上布局六边形的顶点在正上方SQRT3 math.sqrt(3) def hex_to_pixel(q: int, r: int, size: float) - Tuple[float, float]: axial - 屏幕像素size 是六边形外接圆半径 x size * (SQRT3 * q SQRT3 / 2 * r) y size * (1.5 * r) return x, y def pixel_to_hex(x: float, y: float, size: float) - Tuple[int, int]: 屏幕像素 - 最近的格子用于点选交互 fq (SQRT3 / 3 * x - 1 / 3 * y) / size fr (2 / 3 * y) / size return cube_round(fq, -fq - fr, fr)size是外接圆半径也是相邻六边形中心距除以sqrt(3)。改size只会整体缩放不影响格子之间的拓扑关系所以渲染时可以随便调。pixel_to_hex这个函数只有在做交互时才需要纯生成的场景用不上但坐标系写全了后面接前端或者引擎会很省事。4. 地形生成管线从 fBm 噪声到一张能看的海陆分布4.1 海拔层分形噪声叠加径向衰减海拔是整条管线的地基其余属性都挂在它上面。单层 Perlin 噪声太规整山脉分布像洗衣板所以用分形布朗运动fBm叠几层低频打大轮廓高频加细节# hexgen/noisefn.py from noise import snoise2 def fbm(q: float, r: float, seed: int, octaves: int 4, freq: float 0.06, persistence: float 0.5, lacunarity: float 2.0) - float: 分形噪声每层频率翻倍、振幅减半结果归一化到 [-1, 1] total, amp, norm 0.0, 1.0, 0.0 for i in range(octaves): # seed 加上层号偏移避免每层采到同一个噪声图 total amp * snoise2(q * freq, r * freq, baseseed i * 97) norm amp amp * persistence freq * lacunarity return total / normoctaves控制在 4 到 6 之间比较合适再多就只剩毛刺地图会失去大结构。freq是基础频率0.06 这个量级在半径 30 左右的地图上一个“大陆”大概占十几格视觉上比较像回事。persistence越大细节越粗我一般固定 0.5。lacunarity是每层频率的倍数2.0 是标准值改成 1.8 或者 2.2 能让地形看起来不那么工整。光有噪声还不够——直接采样出来是一块块随机分布的岛不像世界地图。所以要乘一个径向衰减把地图边缘压到海面以下def continental_falloff(q: int, r: int, radius: int) - float: 六边形距离归一化后做幂次衰减越靠边海拔越低 d max(abs(q), abs(r), abs(q r)) / radius return 1.0 - d ** 2.2关键在于这里用max(abs(q), abs(r), abs(q r))而不是欧氏距离。六边形地图在六个角上会比边上更远用欧氏距离会导致角落提前衰减地图看起来是个圆形四周全是水。用 cube 距离的最大值衰减轮廓才会和地图边界对齐。指数 2.2 是我试了七八个值之后定下来的太小边缘太陡大陆贴边太大衰减不够地图四周会漏出陆地。最终海拔是两者相乘再拉正raw fbm(q, r, seed) # -1 ~ 1 elev (raw * 0.5 0.5) * falloff * 1.15 # 拉到 0~1 附近 elev max(0.0, min(1.0, elev))1.15这个系数是为了补偿衰减带来的整体压低让陆地占比落在 30% 到 40% 之间具体数值可以跟着--sea-level一起调。4.2 温度与湿度两套独立噪声加纬度梯度温度不能纯用噪声否则赤道旁边出现雪原会显得很假。常见做法是拿纬度做梯度再用噪声扰动def compute_temperature(r: int, radius: int, seed: int, noise_val: float) - float: 纬度梯度为主噪声为辅中间热两极冷 lat abs(r) / radius # 0 在中心1 在上下边缘 base 1.0 - lat ** 1.4 # 梯度不是线性的赤道附近更平缓 return max(0.0, min(1.0, base * 0.85 noise_val * 0.15))湿度单独用一套噪声换个种子偏移量让它和海拔不相关moist fbm(q, r, seed 1337) * 0.5 0.5这里最容易出错的地方是给海拔和湿度复用同一个种子——这样两套噪声完全同步结果是所有高山都在雨林里或者沙漠永远贴着海岸线地图的随机性会被吃掉一半。换成独立种子之后地形才会真正“活”起来。4.3 生物群系查表与调色板有了海拔、温度、湿度三个 0 到 1 的值剩下的就是查表。用函数比用大字典好维护SEA_LEVEL 0.48 def classify(elev: float, temp: float, moist: float) - str: 按优先级从高到低判断顺序错了会出现海洋里长树的诡异结果 if elev SEA_LEVEL - 0.08: return ocean if elev SEA_LEVEL: return shallow if elev 0.82 and temp 0.35: return snow_peak if elev 0.78: return mountain if temp 0.25: return tundra if temp 0.5: return taiga if moist 0.45 else tundra if moist 0.25: return desert if moist 0.55: return grassland return forest判断顺序是有讲究的海洋必须最先判断山脉必须在温度判断之前否则高海拔的雪山会被归成苔原。调色板我放在terrain.py顶部的一个字典里深蓝到浅蓝表示水米黄到深绿表示陆地雪峰用接近白色生物群系颜色值常见位置ocean#1b3a5c地图外围与低洼区shallow#3d7ea6紧贴海岸一圈desert#d9c08a低湿中温内陆grassland#8fae5c中湿中温平原forest#3f6b3a高湿区域mountain#7a6f63海拔 0.78 以上snow_peak#e8ecef高海拔加低温这张表是最容易改的地方想换成赛博朋克风或者羊皮纸风只改这一个字典就行其他逻辑不用碰。4.4 河流从高点出发的最陡下降河流不是必需品但加上之后地图会立刻有故事感。思路很简单从若干高海拔格子出发每一步走向邻居里最低的那个直到入海或者走不动# hexgen/hydro.py def trace_river(start, grid, sea_level: float, max_len: int 80): 最陡下降法追溯河道遇到海平面以下或者局部最低点就停止 path [start] cur start visited {start} for _ in range(max_len): candidates [n for n in neighbors(*cur) if n in grid and n not in visited] if not candidates: break low min(candidates, keylambda n: grid[n].elev) if grid[low].elev grid[cur].elev: break # 局部洼地河断在这里 path.append(low) visited.add(low) cur low if grid[cur].elev sea_level: break return pathmax_len是河道长度上限防止在某些情况下绕圈走不完。用visited保证不回头。这个方法生成的河流有时候会在内陆“断头”形成没有出口的湖除非再补一个填洼算法否则只能接受这种小瑕疵。对原型来说问题不大真要严谨可以后处理一遍。5. 排查与避坑五个把地图跑歪的真实原因5.1 图上有白色网格缝现象渲染出来的图每个六边形之间有细细的白线放大之后尤其明显看着像地图被切碎了。原因RegularPolygon的radius参数是外接圆半径理论上相邻六边形正好相切但抗锯齿在接缝处会混入背景色实际上每个边都会透出一条亚像素级别的缝。解决把绘制半径放大千分之五到百分之一让相邻格子轻微重叠from matplotlib.patches import RegularPolygon # 半径乘 1.008靠轻微重叠吃掉抗锯齿造成的缝 poly RegularPolygon((x, y), numVertices6, radiussize * 1.008, orientation0, facecolorcolor, edgecolorcolor, linewidth0.5)顺便把edgecolor设成和facecolor一样这样即使有缝也是同色看不出来。5.2 同一个种子跑两遍结果不一样现象--seed 42跑两次第一次有片大森林第二次变成了沙漠坐标和地形对不上。原因一般有两个。一是代码里用了set存邻居再做min集合的迭代顺序虽然在同一进程内稳定但跨进程会因为字符串哈希随机化而变化如果中间有字符串参与就会出错。二是噪声库的base参数被复用或者随机数生成器用了系统时间做种子。解决所有随机源必须从 CLI 传进来的那一个seed派生派生方式写死中间容器全部用list或有序的dict不要用set做顺序敏感的操作。我在grid.py里专门写了一个neighbors()每次都返回新列表就是为了避免这个坑。5.3 半径调大之后直接卡死现象--radius 24几秒出图改到--radius 80之后跑了五分钟还没动静内存也涨得厉害。原因每个格子创建一个RegularPolygon并add_patch半径 80 是接近两万个格子matplotlib 的路径对象管理撑不住而且 Python 层的双重循环本身就是纯解释器开销。解决先在terrain阶段用 numpy 把属性算成数组渲染时按生物群系分批绘制——同一类的格子合成一个PathCollection而不是逐个 patch。再不行就换渲染后端直接用 PIL 的ImageDraw.polygon同样的格子数量能快一个数量级。我一般把半径超过 40 的场景都走 PIL 分支。5.4 大陆轮廓永远是圆的现象调了很多次噪声参数地图看起来始终是一个圆形的大陆加一圈水怎么都不像地球上的大陆分布。原因衰减函数用错了距离度量。用sqrt(q*q r*r)做归一化在六边形地图的六个角上距离会偏大导致角落过早衰减成海整个地图的有效陆地被压成一个圆。解决改成 cube 距离的最大值max(abs(q), abs(r), abs(q r))这样衰减轮廓和地图边界是同一个六边形。改完之后再看大陆会延伸到六个角上边界参差不齐反而更自然。5.5 导出的 PNG 四周留白一大圈现象图片保存下来地图居中四周有一片白边放到 UI 里占位置又难看。原因fig.savefig()默认会保留 figure 的边距ax.axis(off)只是隐藏了坐标轴并没有收缩画布。解决保存时把边界参数一起带上同时在创建 figure 时手动收缩子图fig, ax plt.subplots(figsize(12, 12)) fig.subplots_adjust(left0, right1, bottom0, top1) # ... fig.savefig(path, dpi200, bbox_inchestight, pad_inches0)bbox_inchestight加上pad_inches0是必写的组合少一个都会留下几像素的边。6. 二次开发加势力划分和 JSON 导出让地图进得了引擎生成器只吐图片的话价值天花板很低。真正让它变得有用的改造是两件事给格子加归属以及把数据序列化出去。势力划分不需要引入 Voronoi 这种重家伙多源 BFS 就够用效果和 Voronoi 一致边界还天然贴合六边形from collections import deque def assign_factions(grid, centers, sea_level: float): 多源 BFS每个格子归属最先到达它的势力海洋格子不参与 owner {} dq deque() for fid, pos in enumerate(centers): if pos in grid: owner[pos] fid dq.append(pos) while dq: q, r dq.popleft() for dq_dir, dr_dir in DIRECTIONS: nb (q dq_dir, r dr_dir) if nb in grid and nb not in owner and grid[nb].elev sea_level: owner[nb] owner[(q, r)] dq.append(nb) return ownercenters是势力首都坐标列表随便挑几块高海拔陆地就行。BFS 的先后顺序决定了谁先抢到争议地块实践中会在centers前面加一个随机打乱或者给每个势力加一个权重来错开扩张速度否则边界会过于工整。导出 JSON 的字段设计要往后想一步——引擎侧通常只需要位置和归属海拔和生物群系用来做表现import json def export_json(grid, owner, seed: int, path: str): payload { seed: seed, layout: pointy-top, tiles: [ {q: q, r: r, elev: round(t.elev, 4), biome: t.biome, owner: owner.get((q, r), -1)} for (q, r), t in grid.items() ], } with open(path, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse)改造的优先级我一般这么排改造点难度主要影响文件换调色板 / 风格低terrain.py加大陆数量与形状中noisefn.py、terrain.py加势力划分中新增 factions.py导出 JSON / 给引擎用低export.py换渲染后端提速高render.py验证改造有没有跑偏最省事的办法是固定 seed 做快照对比改之前把关键统计打出来陆地占比、海洋占比、各生物群系格子数改之后要求这几个数字的偏差在可解释范围内。我吃过一次亏调衰减指数的时候把陆地占比从 34% 顺手改到了 62%图看着还行直到测试同学说“这地图怎么全是陆地”才发现。从那以后我每次改地形参数都强制先跑一遍统计脚本再去看图图的观感会骗人数字不会。希望这些能帮你少走两天弯路。本文还有配套的精品资源点击获取