Python六角格世界地图生成器:坐标、噪声、群系与河流
简介:hexgen 是一套面向游戏开发、跑团场景与地理生成实验的 Python 六角形世界地图生成工具,适合具备基础 Python 能力、希望快速获得可复现随机行星表面的开发者与爱好者。它以六边形网格组织地图,通过可调参数生成不同地貌与气候特征的行星表面,并支持将世界划分为任意大小的 territory,为浏览器游戏原型、DnD 战役地图或开源项目提供可直接改造的地图底板。压缩包共 34 个文件、约 548KB,以 20 个 Python 源码文件为主体,辅以 3 个 Markdown 文档、JavaScript 与 JSON 相关脚本/导出配置,以及环境搭建、测试、构建和许可文件,体量轻便但结构完整。源码按职责拆分出高度图、河流、领地、六边形网格、地图生成、气候与日历等模块,并附示例入口、导出数据和文档生成脚本,便于读者理解生成流程后替换参数或扩展规则。已有 1550 人学习,适合用于游戏地图原型、跑团世界构建与六边形网格算法练习,也能作为二次开发的基础工程参考。
1. 六角格世界地图生成器:把「像不像一块大陆」拆成四层
第一次看到用六角格铺出来的世界地图,多数人会本能地觉得这不过是把方格旋转了 30 度。真跑一遍就知道不是这么回事:方形网格里(x+1, y)和(x, y+1)就是两个邻居,六角格却要同时维护两套坐标,距离算法、边界判定、渲染顶点全都不一样,稍不留神就是偏移一格、相邻格子之间漏一道黑缝。hexgen 这个基于 Python 的六角形世界地图生成器,把整件事拆成了四层:六角坐标数学、噪声地形、群系与河流、导出。层与层之间耦合不深,你可以只拿坐标层去写战棋寻路,也可以只改群系表换一套画风。它适合想给战棋、4X、Roguelike 做程序化世界的开发者,也适合只想搞明白六角网格到底怎么寻址的人。
2. 六角坐标系统:两套坐标怎么互转、怎么寻址
坐标层是整个生成器的地基,也是最容易被跳过的一层。很多人上来就写噪声、画格子,等到要做「以某个格子为中心三环内的所有地块」这种需求时,才发现自己的坐标系统根本算不出距离。我在拆这套代码时做的第一件事,就是把坐标层单独拎出来跑单元测试,确认互转、距离、环遍历三件事都对,再往上叠地形。下面的三段代码基本就是 hexgen 里坐标模块的骨架,可以直接抄进自己的项目。
2.1 轴向坐标和立方坐标:为什么要多存一个 s
六角格有个天然的麻烦:它没有两条互相垂直的基向量。方格可以用(x, y)正交铺开,六角格不行,六个邻居方向在任何一个二维基底下都不会是整齐的整数对。
主流做法是用轴向坐标(q, r),两个整数就能唯一定位一个格子。但轴向坐标在算距离、算旋转、做取整时不够顺手,所以实际运算时会临时升到三维的立方坐标(x, y, z),并强制满足x + y + z = 0。这意味着第三个分量不用存,用s = -q - r现算就行,既省内存又不会出现三者不自洽的脏数据。
from dataclasses import dataclass @dataclass(frozen=True) class Hex: """轴向坐标:q 指向右上方,r 指向正下方(尖顶朝上的排布)""" q: int r: int @property def s(self) -> int: # 立方坐标第三轴,恒满足 q + r + s == 0,所以不落库、只现算 return -self.q - self.r def __add__(self, other: "Hex") -> "Hex": return Hex(self.q + other.q, self.r + other.r) def __sub__(self, other: "Hex") -> "Hex": return Hex(self.q - other.q, self.r - other.r) def __mul__(self, k: int) -> "Hex": # 只允许整数倍缩放,浮点缩放会破坏整数网格的对齐 return Hex(self.q * k, self.r * k)这段代码看着简单,但有两个决定后面所有事情的细节。第一是frozen=True,坐标对象天然应该是值语义,把它当字典键去索引地形数据时必须可哈希,可变对象会直接报错。第二是只实现整数乘法,因为六角格上的「方向 × 步数」是整数运算,一旦允许浮点,取整误差会累积成肉眼可见的错位。
__add__、__sub__、__mul__三个方法不是炫技,而是让后面的环遍历、螺旋遍历、河流回溯能写成h + d * k这种可读形式。省掉它们,代码里就会到处是手工拼Hex(h.q + dq, h.r + dr)。
2.2 像素互转:渲染、拾取、碰撞共用一套公式
坐标层最容易翻车的地方不是数学,是「你以为的 size 和别人以为的 size 不是同一个 size」。六角格有两个半径:外接圆半径(中心到顶点)和内切圆半径(中心到边中点),两者相差√3/2 ≈ 0.866。渲染用外接圆半径画多边形,拾取如果错用内切圆半径,鼠标就会稳定偏半格,而且偏得很规律,特别容易误判成「噪声太大」。
下面这段是尖顶朝上(pointy-top)排布的互转,hexgen 默认用这一套;如果你的美术资源是平顶朝上,把两个公式整体换成 flat-top 版本即可,不要混着用。
import math SQRT3 = math.sqrt(3.0) def axial_to_pixel(h: Hex, size: float, origin=(0.0, 0.0)): """size = 外接圆半径(中心到顶点),origin = 画布左上角偏移""" x = size * SQRT3 * (h.q + h.r / 2.0) y = size * 1.5 * h.r return (x + origin[0], y + origin[1]) def pixel_to_axial(x: float, y: float, size: float, origin=(0.0, 0.0)): """把屏幕坐标反解成最近的格子,用于鼠标拾取""" x -= origin[0] y -= origin[1] q = (SQRT3 / 3.0 * x - 1.0 / 3.0 * y) / size r = (2.0 / 3.0 * y) / size return _cube_round(q, r, -q - r) def _cube_round(q: float, r: float, s: float) -> Hex: """三分量一起四舍五入,再把误差最大的那个分量反推回去""" rq, rr, rs = round(q), round(r), round(s) dq, dr, ds = abs(rq - q), abs(rr - r), abs(rs - s) if dq > dr and dq > ds: rq = -rr - rs elif dr > ds: rr = -rq - rs else: rs = -rq - rr return Hex(int(rq), int(rr))_cube_round是这段代码的核心,也是最常被写错的地方。直接Hex(round(q), round(r))在格子中心附近没问题,一旦靠近六角格的边界就会跳到相邻格,表现为「点击边缘区域选中隔壁」。原因是对q和r分别取整后,两个值的误差方向可能相反,取整结果落在一个不满足q + r + s = 0的位置上,映射回平面就漂了。三个分量一起取整、再把偏差最大的那个用另外两个反推,才能保证结果一定落在最近的格子里。
参数上size建议用奇数像素值,比如 17、19、21。用偶数时,size * 1.5和size * √3的小数部分在相邻行之间容易产生半像素级的不一致,导出小图时看着不明显,放大到 4K 壁纸尺寸就会看到行与行之间的接缝粗细不均。
2.3 邻居、距离与环遍历:视野、寻路、刷怪都用得上
有了坐标,接下来是三个高频查询:某格的六个邻居是谁、两格之间隔了几步、以某格为中心半径 R 的一圈(或一整片)是哪几格。
AXIAL_DIRS = ((1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1), (0, 1)) def neighbors(h: Hex): return [Hex(h.q + dq, h.r + dr) for dq, dr in AXIAL_DIRS] def distance(a: Hex, b: Hex) -> int: # 立方坐标下的曼哈顿距离除以 2 dq, dr = a.q - b.q, a.r - b.r return (abs(dq) + abs(dq + dr) + abs(dr)) // 2 def ring(center: Hex, radius: int): """只取第 radius 圈,不含内部""" if radius == 0: yield center return h = center + Hex(-1, 1) * radius # 从第 4 号方向切入,绕一圈正好闭合 for dq, dr in AXIAL_DIRS: for _ in range(radius): yield h h = h + Hex(dq, dr) def spiral(center: Hex, radius: int): """取半径 radius 内的全部格子,由内向外""" yield center for k in range(1, radius + 1): yield from ring(center, k)distance那个公式初看有点怪,其实它就是立方坐标下(|dq| + |dr| + |ds|) / 2,因为ds = -(dq + dr),展开就是上面那行。用立方坐标写更容易记,用轴向坐标写少一次加法。六角格上两格的距离恰好等于绕行步数,这个性质和方形网格的对角线不一样,做寻路时可以直接拿它当启发式函数,A* 的 h 值不需要额外缩放。
ring的起点选择是血泪经验。六个方向里如果从 0 号方向切入,第一格会和最后一格的收尾位置重叠或错开一格,最终画出来的环在某个角度缺一块。从 4 号方向(-1, +1)切入、按固定顺序绕行六次、每次走radius步,才能正好回到起点。这个错误在小半径下不明显,半径一大就会出现一条明显的缺口。
spiral由内向外逐圈产出,配合生成器惰性求值,可以在找到第一个满足条件的地块时就停下,做「最近的陆地」「最近的友方单位」这类查询很省事。
3. 高程与气候图层:把噪声调成一块有海岸线的世界
坐标层只解决了「格子在哪」,真正决定地图好不好看的是往上叠的那几层浮点数。hexgen 的做法很常规:高程一张图、湿度一张图、温度一张图,三张图的大小都等于格子总数,索引就是Hex对象。这种「网格 + 平行数组」的结构比给每个格子塞一个对象要省内存得多,半径 200 的世界大概是 12 万个格子,用对象存分分钟上百 MB。
3.1 分形噪声与大陆掩膜:先决定海陆,再决定山
单层噪声出来的地形像一盘散沙,到处是小岛和碎湖,没有任何「大陆感」。常见做法是分形叠加(fBm):把不同频率、不同振幅的噪声叠起来,低频决定大陆轮廓,高频决定山脉起伏。
import numpy as np def fbm(noise_fn, xs, ys, octaves=5, lacunarity=2.0, gain=0.5): """noise_fn 需支持向量化调用,返回 [-1, 1] 区间的 ndarray""" total = np.zeros_like(xs, dtype=np.float64) amp, freq, norm = 1.0, 1.0, 0.0 for _ in range(octaves): total += amp * noise_fn(xs * freq, ys * freq) norm += amp amp *= gain freq *= lacunarity return total / norm # 归一化到 [-1, 1],避免 octaves 改变整体尺度 def radial_mask(qs, rs, radius, falloff=0.45): """边缘衰减:让大陆不会在地图边界被硬生生切断""" d = np.abs(qs) + np.abs(qs + rs) + np.abs(rs) d = (d / 2.0) / radius # 归一化到 [0, 1] edge = np.clip((1.0 - d) / falloff, 0.0, 1.0) return edge ** 1.5 # 指数越大,海岸线越靠内 def build_elevation(gen, qs, rs, radius, octaves=5, lacunarity=2.0, gain=0.5): xs = qs + rs * 0.5 ys = rs * 0.8660254 raw = fbm(gen.noise2array, xs, ys, octaves, lacunarity, gain) masked = (raw * 0.5 + 0.5) * radial_mask(qs, rs, radius) return masked # 落在 [0, 1]octaves控制细节层数,5 层是大多数场景的甜点区:再少地形发闷,再多高频噪点会把河流算法搅乱。lacunarity是每层频率的倍数,取 2.0 是标准做法;取 1.8 左右会让山脊显得更「自然」一些,但会牺牲一点可复现性——不同版本的实现细节差异会把这点差别放大。gain是振幅衰减,0.5 表示每层能量减半;调到 0.6 山会更陡,调到 0.4 平原会更大。
radial_mask是让地图「像一块大陆」而不是「像一块被裁切过的噪声」的关键。没有它,半径 100 的世界大概率会在四个角上出现被切断的陆地,视觉上非常突兀。falloff控制在多宽的边缘带内完成衰减,0.45 意味着最外圈 45% 的半径范围在逐步压低高程,海岸线通常落在中间偏外的位置。
坐标系那一步的xs = qs + rs * 0.5和ys = rs * 0.866不是随便写的,它把轴向坐标映射成平面上的近似等距采样点,这样噪声在六角格上的分布才均匀。直接拿(q, r)去采样会得到一个被斜切过的噪声场,表现出来就是所有山脉都朝同一个方向拉长。
3.2 海平面不该写死:用分位数控制陆地比例
新手最常见的写法是land = elev > 0.5。问题在于 fBm 的输出分布集中在中间,0.5恰好是最密集的位置,海平面稍微动一点点,陆地比例就会从 20% 跳到 60%,同一个参数在不同种子下完全不同。
hexgen 用的是分位数定海平面:先声明「我要 35% 的陆地」,再反解出对应的阈值。
def pick_sea_level(elev: np.ndarray, land_ratio: float = 0.35) -> float: # 分位数定阈,保证陆地比例与噪声分布无关 return float(np.quantile(elev, 1.0 - land_ratio)) def shape_land(elev: np.ndarray, sea_level: float, power: float = 1.6): """把海平面以上的部分重映射到 [0, 1],让山地占比可控""" land = np.clip((elev - sea_level) / max(1e-6, 1.0 - sea_level), 0.0, 1.0) return land ** power| 参数 | 常用区间 | 调大后的效果 | 调小后的效果 |
|---|---|---|---|
land_ratio | 0.25 ~ 0.45 | 大陆更完整,海洋变少 | 群岛化,适合航海题材 |
octaves | 4 ~ 7 | 地形细节多,河流更碎 | 地形平滑,像低多边形风格 |
lacunarity | 1.8 ~ 2.2 | 山脊更密 | 山体更宽更缓 |
gain | 0.4 ~ 0.6 | 高山更多,起伏剧烈 | 台地感强,落差小 |
power | 1.2 ~ 2.0 | 平原面积大,山集中 | 丘陵铺满,缺平原 |
power这个参数值得单独说。它是重映射的伽马值,power > 1会把低海拔区域进一步压低,让「平原」和「山地」的分界更干脆。默认 1.6 是给战棋类游戏调的,平原多、可通行区域大;如果做的是登山探险题材,调到 0.8 让丘陵铺满,会更有压迫感。
3.3 温度、湿度和纬度:给群系准备两个正交维度
群系判定本质上是查表,查表需要两个正交的输入维度。温度用纬度加海拔衰减,湿度用噪声加海岸距离,这两个维度不能互相污染,否则查表会出现「同一纬度同一湿度却是两种地貌」的诡异情况。
def temperature(qs, rs, land, radius, lapse=0.55): # 纬度按 r 轴近似,|r| / radius 越大越靠两极 lat = np.abs(rs) / max(1, radius) base = 1.0 - lat return np.clip(base - lapse * land, 0.0, 1.0) def moisture(gen, qs, rs, land, radius, sea_boost=0.35): xs, ys = qs + rs * 0.5, rs * 0.8660254 raw = fbm(gen.noise2array, xs * 0.6, ys * 0.6, octaves=4) * 0.5 + 0.5 # 离海越近越湿:用高程低于某个阈值的邻域近似,简单但够用 coastal = np.clip(1.0 - land * 2.0, 0.0, 1.0) * sea_boost return np.clip(raw * (1.0 - sea_boost * 0.5) + coastal, 0.0, 1.0)lapse是气温直减率,取 0.55 表示一座从海平面升到最高点的山会损失 55% 的「温度值」。这个值调大会让高山上出现不该有的雪线,调小则整片大陆温差不够。真实地理里这个衰减是非线性的,但生成器不需要精确,只要保证山顶比山脚冷、极地比赤道冷,查表结果就站得住。
湿度的sea_boost是个实用技巧:纯噪声生成的湿度场经常出现「内陆深处比海边还湿」的情况,导致雨林长在大陆中心。加一个基于高程的海岸加成,能让群系分布明显更可信。这里的实现是简化版,没用真正的「到海距离场」,因为算那个要跑一遍 BFS,半径 200 的世界在纯 Python 里要几秒;如果对真实性要求高,把 BFS 距离场缓存下来是值得的。
4. 群系与河流:把浮点数变成地貌
到这一步,每个格子已经有了高程、温度、湿度三个浮点数,但它们对玩家来说毫无意义。要变成「针叶林」「稀树草原」「冰盖」这些能看懂的标签,还需要一次映射和一次水文模拟。
4.1 群系查表:把两个浮点数压成 25 个格子
查表的好处是可解释、可调、不会突然冒出一个四不像的生物群系。做法是把温度和湿度各分 5 档,得到一个 5×5 的表。
T_BANDS = (0.20, 0.38, 0.56, 0.74) M_BANDS = (0.18, 0.36, 0.55, 0.72) # 行 = 温度由冷到热,列 = 湿度由干到湿 BIOMES = [ ["冰盖", "冰盖", "苔原", "苔原", "冻沼"], ["寒漠", "针叶林", "针叶林", "针叶林", "泥炭沼"], ["温带草原", "温带草原", "落叶林", "落叶林", "温带雨林"], ["荒漠", "灌木地", "草原", "混交林", "雨林"], ["荒漠", "荒漠", "稀树草原", "热带雨林", "热带雨林"], ] def band_index(value: float, bands) -> int: for i, edge in enumerate(bands): if value < edge: return i return len(bands) def classify(temp: float, moist: float, land: float, sea_level: float) -> str: if land <= 0.0: return "海洋" ti = band_index(temp, T_BANDS) mi = band_index(moist, M_BANDS) return BIOMES[ti][mi]band_index这个函数看着平淡,但边界处理很容易出错。用<=还是<,决定了落在分界点上的格子归到哪一档;如果同一批参数在不同机器上跑出不同群系,十有八九是这里用了浮点相等比较。另外len(bands)这个兜底不能省,浮点值恰好等于 1.0 时遍历完所有边界都返回不了索引,直接IndexError。
表本身的设计原则是「相邻格子的群系差异不能太跳」。荒漠紧挨雨林在游戏里会很违和,所以表里把「灌木地」「草原」放在中间做过渡。这张表是这套代码里最值得动手改的地方:把「冰盖」换成「永冻土」、「雨林」换成「蘑菇林」,一张表就能换一套世界观,其他代码一行都不用动。
4.2 河流:最陡下降 + 流量累积,别指望一次就对
河流是六角地图里最容易做出「一眼假」的部分。纯随机撒几条线不行,从头到尾做流体模拟又太重。hexgen 用的是最陡下降加流量累积:把所有格子按高程从高到低排序,每个格子找邻居里最低的那个作为下游,然后把自己的流量累加过去。
def trace_rivers(elev: dict, land: dict, min_flow: int = 12): order = sorted(elev, key=lambda h: (-elev[h], h.q, h.r)) # 排序键必须完全确定 flow = {h: 1 for h in order} downstream = {} for h in order: if land[h] <= 0.0: continue # 已经是海,河流到此为止 cands = [n for n in neighbors(h) if n in elev] if not cands: continue low = min(cands, key=lambda n: elev[n]) if elev[low] >= elev[h]: continue # 局部洼地:先跳过,交给填洼处理 downstream[h] = low flow[low] += flow[h] rivers = {h for h, f in flow.items() if f >= min_flow and land[h] > 0.0} return rivers, downstream, flow排序键写(-elev[h], h.q, h.r)而不是只写-elev[h],是因为高程相同的格子顺序会随字典插入顺序变化,导致同一颗种子跑出两条不同的河。这种不确定性在测试阶段特别难查,看起来像是「随机性没固定」,实际是排序不稳定。
min_flow控制河流密度,取 12 意味着至少汇聚了 12 个格子的降水才会被画成河。半径 100 的世界调到 8 会河网密布,调到 25 只剩几条主干河流。
局部洼地是这个算法绕不过去的问题。噪声生成的地形里必然存在「四周都比自己高」的坑,水往低处流会卡在里面,产出一条断头的短河。正规解法是先用优先队列做一遍填洼(把洼地抬到最低出口的高度),再跑流量累积。如果不想引入这套逻辑,退而求其次的做法是把洼地标记成湖泊,让河流终止在湖边,视觉上也说得通,只是要接受「湖没有出口」这个不真实。
4.3 导出:PNG 给人看,JSON 给引擎用
生成结果最终要落到文件。hexgen 导出两种格式,各有各的用途:PNG 用来看效果和做美术参考,JSON 拿去给引擎或后端消费。
import json from PIL import Image, ImageDraw def hex_corner(cx, cy, size, i): ang = math.radians(60 * i - 30) # 尖顶朝上:第一个顶点在 -30 度 return (cx + size * math.cos(ang), cy + size * math.sin(ang)) def render_png(tiles, size=19, pad=None, path="world.png"): pad = pad or size * 2 pts_all = [axial_to_pixel(h, size, (pad, pad)) for h in tiles] w = int(max(p[0] for p in pts_all) + pad) h_ = int(max(p[1] for p in pts_all) + pad) img = Image.new("RGB", (w, h_), (9, 12, 20)) draw = ImageDraw.Draw(img) for h, t in tiles.items(): cx, cy = axial_to_pixel(h, size, (pad, pad)) # 顶点外扩 0.5px,消除相邻多边形之间的黑缝 pts = [hex_corner(cx, cy, size + 0.5, i) for i in range(6)] draw.polygon(pts, fill=t.color) img.save(path) def export_json(tiles, seed, radius, path="world.json"): payload = { "seed": seed, "radius": radius, "tiles": [ {"q": h.q, "r": h.r, "elev": round(t.elev, 4), "temp": round(t.temp, 4), "moist": round(t.moist, 4), "biome": t.biome, "river": bool(t.river)} for h, t in sorted(tiles.items(), key=lambda kv: (kv[0].q, kv[0].r)) ], } with open(path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))render_png里那个size + 0.5是补丁式的解决办法,但确实有效。PIL 的polygon在相邻多边形共享边时,浮点坐标算出来的顶点不会严格重合,偶尔会漏出一个像素的背景色,在深色背景上就是一条细黑线。把每个多边形外扩半像素,让相邻格子的填充区域重叠,缝就消失了。
JSON 里round(..., 4)不只是为了好看。全精度浮点会写出十几位小数,12 万个格子能撑到几十 MB,而 4 位小数对地形精度完全够用。separators去掉空格同理,能把体积再压三成左右。排序输出也是刻意的,方便做 diff——改一次参数,两版 JSON 的差异应该只在数值上,而不是行序全乱。
5. 避坑与排查:五个真实翻车点
这套代码跑通不难,跑出「能看」的结果要踩几轮。下面五条是我在拆解过程中实际遇到、并且能稳定复现的问题,按现象、原因、解决三段记。
5.1 几何与渲染上的三个翻车点
拼接黑缝。现象是导出大图时,相邻六角格之间出现一条浅色细线,深色背景上尤其明显。原因在于多边形的顶点由浮点三角运算得到,相邻两个共享边的格子算出的顶点坐标在末位有差异,抗锯齿关闭时不会混合,就会漏出背景。解决办法是把绘制半径统一加0.5px,让填充区域互相重叠;如果对边缘质量要求高,可以按 2 倍尺寸渲染再resize回去,用重采样代替抗锯齿。
鼠标拾取偏移一格。现象是点击格子中心和边缘选中的格子不一样,越靠近边缘越明显。原因有两个,一是拾取用的size是内切圆半径而渲染用的是外接圆半径,二是pixel_to_axial忘了减掉渲染时的origin偏移。排查方法很简单:取一个已知格子的中心坐标,正向转成像素再反向转回来,如果得到的不是原格子,问题就在互转链路上,跟噪声和渲染都没关系。
行与行之间的高度不均。现象是地图上下方向看起来比左右方向「挤」或者「松」。原因是size * 1.5这个纵向步长被手动改过,或者size取了偶数导致半像素累积。六角格的纵向行距严格等于1.5 × size,横向列距严格等于√3 × size,这两个值任何改动都会破坏等距性,除非你本来就想做轴向拉伸的效果。
5.2 数据与工程上的两个翻车点
同一个种子两次结果不同。现象是记下seed=42重跑,海岸线一样但河流和群系变了。原因通常有三个:生成过程里混进了没设种子的random或np.random;用set做遍历导致顺序不稳定;排序时只用了高程作为键,相同高程的格子顺序取决于字典插入顺序。解决方式是所有随机数统一从一个np.random.default_rng(seed)派生,所有排序键补上(q, r)做二级比较,需要集合的地方改用列表加去重。
半径一过 150 就慢得离谱。现象是小图几秒出,稍微放大半径就等好几分钟。原因是纯 Python 的逐格循环在十几万次迭代下开销很大,尤其是每格都要调一次噪声函数。优化方向是按优先级排:先把噪声采样向量化(一次性传入所有格子的坐标数组),再把河流的排序和邻接查询从「每个格子现算」改成「预计算邻接表」,最后才考虑上多进程。前两步通常就够把耗时压下一个数量级,多进程反而会因为序列化开销得不偿失。
6. 从能跑到能改:种子复现、边界校验和接进自家管线
生成器真正能用的标志不是「能出图」,而是「改一个参数就知道会发生什么」。为此我在坐标层加了一项固定动作:每次出图前跑一遍边界校验,确认所有互转没有累积误差。
def self_check(size=19, radius=30): bad = 0 for h in spiral(Hex(0, 0), radius): x, y = axial_to_pixel(h, size, (500, 500)) if pixel_to_axial(x, y, size, (500, 500)) != h: bad += 1 # 中心点必须落在本格内,且到六个邻居的距离都是 1 assert all(distance(h, n) == 1 for n in neighbors(h)) return bad # 期望恒为 0,非 0 说明互转链路有系统偏差 print(self_check())这段自检看起来朴素,但它把「几何正确」和「地形好看」彻底分开了。出问题时先看self_check是不是 0,是 0 就往噪声和群系那边找,不是 0 就老老实实修坐标,能省掉大量猜测。
参数层面,我的习惯是固定三个旋钮、只动一个:land_ratio、octaves、min_flow这三个决定地图的整体骨架,power、gain、sea_boost只影响质感。一次只动一个,把 JSON 存成种子_参数名_值.json,跑一批下来能直观看出每个参数的影响曲线,比凭感觉调高效得多。接进自家管线时,JSON 里的q、r可以直接映射到引擎的轴向坐标网格,不需要中间格式转换;唯一要注意的是坐标系朝向,Python 这边r轴朝向正下方,引擎里如果是左手系,导入时对r取反即可,别在生成端改,否则所有已有数据都得重新对一遍。
从那以后我每次改完噪声或群系表,都会先跑一遍自检、再出一张半径 40 的小图看海岸线,最后才批量生成大图——这个顺序能挡住八成返工。希望帮到你。
本文还有配套的精品资源,点击获取