☰
从六边形网格到fBm噪声:Python程序化地图生成器
2026/10/26 6:43:19 网站建设 项目流程

简介:这是一份面向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 各管什么

依赖不多,三个核心库就够跑:

库建议版本在这个项目里负责什么
numpy>=1.24批量属性计算、噪声采样前的数组准备
noise>=1.2.2Perlin / Simplex 噪声,地形起伏的源头
matplotlib>=3.7RegularPolygon 画六边形、导出 PNG
pytest>=7.0开发期单测,跑坐标转换的往返一致性

安装就一行,建议在虚拟环境里做,避免和系统自带的 matplotlib 打架:

python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install -r requirements.txt

noise这个库是 C 扩展,Windows 上如果 pip 装不上,多半是缺编译工具链,用预编译 wheel 或者换成pip install noise==1.2.2指定版本通常能过。这一点在别的机器上部署时会被反复提起。

2.3 CLI 参数与一次完整运行

命令行入口用 argparse,参数不追求多,够用就行:

# hexgen/cli.py import argparse def build_parser(): p = argparse.ArgumentParser(prog="hexgen") # 半径决定地图大小,格子总数约 3r(r+1)+1,r=24 时是 1801 格 p.add_argument("--radius", type=int, default=30, help="六边形地图半径") # 种子是复现的唯一入口,同种子必须同结果,后面避坑章会专门说 p.add_argument("--seed", type=int, default=20240101, help="随机种子") # 海平面阈值,调大海域变多,调小大陆变碎 p.add_argument("--sea-level", type=float, default=0.48, help="海平面 0~1") p.add_argument("--out", default="map.png", help="渲染输出路径") p.add_argument("--json", default=None, 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, rz

cube_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。radius=0只生成一格,用于单测最方便。

3.4 邻接、距离与像素映射

邻居查找基于前面的DIRECTIONS:

def 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, base=seed + i * 97) norm += amp amp *= persistence freq *= lacunarity return total / norm

octaves控制在 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, key=lambda 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 path

max_len是河道长度上限,防止在某些情况下绕圈走不完。用visited保证不回头。这个方法生成的河流有时候会在内陆“断头”,形成没有出口的湖,除非再补一个填洼算法,否则只能接受这种小瑕疵。对原型来说问题不大,真要严谨可以后处理一遍。

5. 排查与避坑:五个把地图跑歪的真实原因

5.1 图上有白色网格缝

现象:渲染出来的图每个六边形之间有细细的白线,放大之后尤其明显,看着像地图被切碎了。

原因:RegularPolygon的radius参数是外接圆半径,理论上相邻六边形正好相切,但抗锯齿在接缝处会混入背景色,实际上每个边都会透出一条亚像素级别的缝。

解决:把绘制半径放大千分之五到百分之一,让相邻格子轻微重叠:

from matplotlib.patches import RegularPolygon # 半径乘 1.008,靠轻微重叠吃掉抗锯齿造成的缝 poly = RegularPolygon((x, y), numVertices=6, radius=size * 1.008, orientation=0, facecolor=color, edgecolor=color, linewidth=0.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(left=0, right=1, bottom=0, top=1) # ... fig.savefig(path, dpi=200, bbox_inches="tight", pad_inches=0)

bbox_inches="tight"加上pad_inches=0是必写的组合,少一个都会留下几像素的边。

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 owner

centers是势力首都坐标列表,随便挑几块高海拔陆地就行。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", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False)

改造的优先级我一般这么排:

改造点难度主要影响文件
换调色板 / 风格低terrain.py
加大陆数量与形状中noisefn.py、terrain.py
加势力划分中新增 factions.py
导出 JSON / 给引擎用低export.py
换渲染后端提速高render.py

验证改造有没有跑偏,最省事的办法是固定 seed 做快照对比:改之前把关键统计打出来(陆地占比、海洋占比、各生物群系格子数),改之后要求这几个数字的偏差在可解释范围内。我吃过一次亏,调衰减指数的时候把陆地占比从 34% 顺手改到了 62%,图看着还行,直到测试同学说“这地图怎么全是陆地”才发现。从那以后我每次改地形参数,都强制先跑一遍统计脚本再去看图,图的观感会骗人,数字不会。

希望这些能帮你少走两天弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询