news 2026/10/26 6:43:17

Python六边形世界地图生成:fBm噪声与生物群系

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python六边形世界地图生成:fBm噪声与生物群系

简介:这是一份基于 Python 的六角形世界地图生成器源码包,适合独立游戏开发者、跑团主持人及对程序化地图生成感兴趣的学习者,用于快速生成随机行星表面,并将地图切分为任意大小的“领土”,可作为浏览器游戏或桌游场景的原型开发底板。压缩包共 34 个文件,约 548KB,以 20 个 py 脚本为主体,辅以 md 文档、js 脚本、json 配置及 txt、Makefile、LICENSE 等工程文件,覆盖地形、气候、河流、领土划分、网格绘制与导出等模块,并附带示例和测试入口。已有 1550 人学习,便于读者理解六边形网格地图的数据组织与生成流程,参考参数化地形、气候与领土切分思路,快速改造为自己的地图工具或游戏开发原型。

1. 从一张手拼的海岸线说起

做跑团设定那阵子,我给一张大陆地图手拼了三天六边形,拼到海陆交界处就开始对不齐,最后干脆推倒重来。后来换成 hexgen 这类基于 Python 的六角形世界地图生成器,流程一下清爽了:给一个随机种子和地图半径,它把六边形网格、海拔场、湿度场、生物群系一路算下去,最后导出 PNG 和一份带坐标的 JSON。适合三类人——做战役设定的、给策略游戏铺地图底图的、以及想练手程序化生成(PCG)的 Python 开发者。纯 Python 加几个常见科学计算库,不挂游戏引擎,改起来没有黑匣子那层隔膜。

2. 项目结构与坐标系统:先把格子摆对

2.1 目录分层:生成、渲染、导出三段切开

这类项目最容易写成一锅粥:噪声、贴图、导出全塞在main.py里,改一个参数要重跑全部。我一般会按「算数据 → 画图 → 落盘」切成三层,中间用纯数据对象传递,渲染层不知道噪声怎么来的,导出层不知道图怎么画的。常见的目录形状是这样:

hexgen/ ├── hexgen/ │ ├── __init__.py │ ├── coords.py # 轴向坐标、邻居、距离、像素转换 │ ├── noise.py # value noise 与 fBm 叠加 │ ├── terrain.py # 海拔/湿度/温度 → 生物群系 │ ├── rivers.py # 河流下切与洼地填充 │ ├── render.py # Pillow 绘制与调色板 │ ├── export.py # PNG / JSON / CSV 导出 │ └── cli.py # 参数解析与主流程串联 ├── examples/ │ └── preset_continent.json ├── tests/ │ └── test_coords.py ├── requirements.txt └── README.md

分层的实际收益在二次开发时最明显。要给地图加一层「政治边界」,只需要在terrain.py之后插一个模块,往每个格子对象上挂owner字段,render.py里多画一条描边,其它一概不动。反过来,如果坐标转换和绘图逻辑搅在一起,加个导出 SVG 就得改半个项目。

coords.py是整个项目的地基,它不该依赖任何绘图库;render.py反过来只认格子对象上的字段。这条边界守住了,后面所有改动都是加法而不是手术。

2.2 用轴向坐标存储,别用偏移坐标

六边形网格有三种常见存法:偏移坐标(offset,用行列号,像方阵一样)、轴向坐标(axial,q/r 两个轴)、立方坐标(cube,q/r/s 三轴且和为零)。新手最直觉的是偏移坐标,因为它长得像二维数组;但它有个致命缺陷——相邻行的列号有半格错位,换行时要做奇偶判断,一旦哪次转换写反,地图上就会出现格子和格子边缘对不上的位置。

我一般全项目统一用轴向坐标,只在最后转像素时做一次映射。轴向坐标的加法、距离、邻居都很干净:

# coords.py class Hex: """轴向坐标:只用 q、r 两个轴,第三轴 s = -q-r 由约束推出""" __slots__ = ("q", "r") def __init__(self, q, r): self.q = q self.r = r @property def s(self): return -self.q - self.r def __add__(self, other): return Hex(self.q + other.q, self.r + other.r) def __eq__(self, other): return isinstance(other, Hex) and (self.q, self.r) == (other.q, other.r) def __hash__(self): return hash((self.q, self.r)) def distance(self, other): # 立方坐标下距离 = 三轴差绝对值之和的一半 return (abs(self.q - other.q) + abs(self.r - other.r) + abs(self.s - other.s)) // 2

__slots__是为了在半径 50 的地图上省内存,一张图大约 7651 个格子,每个格子还会派生数据对象,累积起来不小。__eq__和__hash__必须成对实现,否则河流追踪里用set去重会失效——这个坑在 5.1 节细说。

邻居方向也常量表定死,索引顺序固定,后面做环状遍历、算边界、找海岸线都依赖这个顺序:

# coords.py # flat-top 六边形的六个方向,从正东开始逆时针排列 DIRECTIONS = [ Hex(+1, 0), Hex(+1, -1), Hex(0, -1), Hex(-1, 0), Hex(-1, +1), Hex(0, +1), ] def neighbors(h): return [h + d for d in DIRECTIONS] def ring(center, radius): """返回距 center 恰好 radius 步的所有格子,从正西方向起步绕一圈""" if radius == 0: return [center] results = [] h = center + Hex(-radius, radius) # 先挪到起始角 for i, d in enumerate(DIRECTIONS): for _ in range(radius): results.append(h) h = h + DIRECTIONS[(i + 2) % 6] return results

ring的行走套路是:先把游标挪到某一圈的起点,然后沿六个方向各走radius步,每走一步转 60 度。方向索引加 2 是因为在 flat-top 布局里,沿某方向走完之后要拐向下一个方向的切线方向,偏移量固定是 2。写错了就会在原地打转或者画出五边形轨迹,调试时直接肉眼就能看出来。

2.3 噪声选型:numpy 加自实现 value noise 就够了

地形生成的核心是「一张连续、可复现、有层次的随机场」。常见方案有三种:直接调第三方noise库、用 Perlin/Simplex、自己写 value noise 加双线性插值。第三方库的问题是在某些平台上装不上或者版本行为不一致,团队里换台机器地形就变了;Simplex 实现复杂,调试成本高。这个项目场景下我一般选自实现 value noise,用 numpy 做向量化,几十行代码,行为完全可控。

原因很实际:地图生成不需要极致的视觉品质,它需要的是「同一个种子永远出同一张图」。自己实现意味着噪声函数的每个参数都捏在手里,换个插值曲线、加个域扭曲都是一行改动。

# noise.py import numpy as np def value_noise(shape, rng, freq): """在 freq x freq 的随机网格上做双线性插值,得到一个连续噪声场""" h, w = shape grid = rng.random((freq + 1, freq + 1), dtype=np.float32) xs = np.linspace(0, freq, w, endpoint=False) ys = np.linspace(0, freq, h, endpoint=False) x0 = np.floor(xs).astype(int) y0 = np.floor(ys).astype(int) tx = (xs - x0)[None, :] ty = (ys - y0)[:, None] # smoothstep 平滑,去掉插值产生的网格状棱线 tx = tx * tx * (3 - 2 * tx) ty = ty * ty * (3 - 2 * ty) g00 = grid[np.ix_(y0, x0)] g10 = grid[np.ix_(y0, x0 + 1)] g01 = grid[np.ix_(y0 + 1, x0)] g11 = grid[np.ix_(y0 + 1, x0 + 1)] top = g00 * (1 - tx) + g10 * tx bottom = g01 * (1 - tx) + g11 * tx return top * (1 - ty) + bottom * ty

参数里shape是输出噪声位图的高宽,rng是numpy.random.default_rng实例,保证种子隔离,freq是这一层的网格密度——freq=4出的是几个大块大陆,freq=32出的是海岸线附近的碎细节。

这里有个容易忽略的点:np.linspace(0, freq, w, endpoint=False)之后x0 + 1的最大值是freq,所以随机网格要开freq + 1列,否则索引越界。这个 off-by-one 我在第一次写的时候踩过,报错信息指向np.ix_,看起来完全不像是数组尺寸的问题。

单层噪声只是「一团平滑的随机」,真实地形需要叠加多个频率。这就是 fBm(分形布朗运动)干的事,下一章展开。

3. 核心模块拆解:从海拔噪声到生物群系的生成链路

3.1 fBm 叠出海拔场

单层噪声的地形要么全是缓坡,要么全是碎斑,缺的是「大陆尺度上有骨架、局部有细节」的层次感。fBm 的做法很朴素:把不同频率、不同振幅的噪声层加起来,低频层决定大轮廓,高频层决定局部起伏,每往上一层振幅衰减一次。

# noise.py def fbm(shape, seed, octaves=6, base_freq=4, persistence=0.5, lacunarity=2.0): """分形叠加:低频定大陆轮廓,高频补海岸线细节""" rng = np.random.default_rng(seed) total = np.zeros(shape, dtype=np.float32) amp, freq, norm = 1.0, float(base_freq), 0.0 for _ in range(octaves): total += amp * value_noise(shape, rng, int(freq)) norm += amp amp *= persistence freq *= lacunarity return (total / norm).astype(np.float32)

四个参数各有分工,调错了症状非常典型:

参数含义调大之后调小之后
octaves叠加层数细节多,耗时长地形平滑,像橡皮泥
base_freq最底层频率大陆块变小、岛变多一整块超级大陆
persistence每层振幅衰减高频占比大,地形破碎地形过于圆润
lacunarity每层频率倍率层次跳跃大,细节突兀各层频率接近,白费算力

base_freq和地图半径必须联动。半径 20 的小地图用base_freq=4,噪声网格只有 4×4,一片汪洋看不出陆地;半径 60 的大地图用base_freq=2,整张图就一个色块。常见做法是按包围盒像素宽高反推,让最底层噪声的波长大致等于地图宽度的三分之一到二分之一。

fBm 输出的是 0 到 1 之间但实际挤在中间的值,直接拿去切海平面会得到「全是浅海」的结果,所以要拉一次对比度:

# terrain.py import numpy as np def stretch(field, low_pct=2.0, high_pct=98.0): """按分位数拉伸到 0~1,避开极端值把分布压扁""" lo, hi = np.percentile(field, [low_pct, high_pct]) return np.clip((field - lo) / max(hi - lo, 1e-6), 0.0, 1.0)

用分位数而不是min/max的原因是:fBm 偶尔会出一个孤立的极值点,用min/max拉伸的话,整张图的有效范围被这一个点吃掉,地图一片灰。百分位裁剪相当于先丢掉最极端的 2%,剩下的 96% 铺满色阶。这个操作在图像处理里叫对比度拉伸,在地形生成里几乎是标准步骤。

3.2 湿度与温度:给每个格子挂上第二、第三属性

只有海拔只能分出山和水,想让地图有热带雨林和沙漠的区分,就得再叠两条独立噪声。关键在于种子要隔离:用同一个种子生成三条噪声,它们会高度相关,雨林永远长在山脚下,沙漠永远贴着海边,看起来非常假。

# terrain.py import numpy as np def build_fields(shape, seed): elev = stretch(fbm(shape, seed, octaves=6, base_freq=4)) moist = stretch(fbm(shape, seed + 7919, octaves=5, base_freq=6)) # 温度随纬度递减,再按海拔打折(每升高一点温度掉一点) yy = np.linspace(-1.0, 1.0, shape[0], dtype=np.float32)[:, None] temp = 1.0 - np.abs(yy) * 1.1 - elev * 0.55 temp = np.clip(temp, 0.0, 1.0) return elev, moist, temp

seed + 7919里的 7919 只是个素数偏移,目的是让三条噪声走不同的随机流。用素数而不是seed + 1是为了在种子连续取值时,三条场的碰撞概率更低。temp的公式里np.abs(yy)表示「越靠近极地越冷」,elev * 0.55表示「海拔越高越冷」,系数 0.55 是拍出来的——大于 1 会让高山全部变成冰原,太小的山看不出垂直分布。

三个场就绪之后,每个六边形格子在采样阶段各取一次值,得到一个三元组(elev, moist, temp)。注意这里的采样顺序是「先矢量化生成位图,再按六边形中心坐标去取」,不要对每个格子单独调一次fbm——那会把整个生成流程从毫秒级拖到分钟级。这是 5.2 节要展开的性能坑。

3.3 生物群系查表:用有序判断代替复杂阈值树

有了三元组,分类就是一张表的事。新手容易把分类写成一堆嵌套if/elif,改一个阈值要爬半天。我的习惯是先处理「水」,再处理「海拔极值」,最后看温度湿度,顺序本身就是优先级:

# terrain.py BIOME_COLORS = { "deep_ocean": (12, 36, 84), "ocean": (24, 72, 132), "beach": (214, 200, 140), "tundra": (196, 202, 206), "snow": (238, 240, 244), "mountain": (110, 104, 100), "rainforest": (32, 108, 58), "forest": (48, 96, 56), "grassland": (104, 152, 72), "savanna": (162, 150, 84), "desert": (206, 176, 112), } def classify(elev, moist, temp, sea_level=0.42): # 1) 先定水陆,海平面以下是海,再往下切一档深海 if elev < sea_level * 0.55: return "deep_ocean" if elev < sea_level: return "ocean" if elev < sea_level + 0.03: return "beach" # 窄窄一条滩,视觉上给海岸线留呼吸 # 2) 海拔极值优先于气候,否则雪线会被温度盖掉 if elev > 0.88: return "snow" if elev > 0.76: return "mountain" # 3) 气候分类:先冷后干,冷比干更有决定性 if temp < 0.22: return "tundra" if moist > 0.62: return "rainforest" if moist > 0.38: return "forest" if elev > 0.58 else "grassland" if moist > 0.20: return "savanna" return "desert"

判断顺序不能随便换。如果把temp < 0.22放到雪线判断前面,一座赤道附近的高山会先被判成 tundra 再也没机会变成 snow;如果把beach放在mountain判断后面,海边峭壁会失去那条浅色过渡。这类顺序问题在调试时表现为「某类地形永远不出现」,很隐蔽。

sea_level=0.42是最常被外部覆写的参数。调高通量会得到群岛地图,调低会得到超级大陆。我在examples/里放的几个预设就是靠这一个参数做出不同风味,参数本身没变。

3.4 河流:从高点往低点走,先填洼再下切

河流是这类项目里最容易翻车的模块。逻辑听起来简单:从山顶点出发,每一步走到海拔最低的邻居,一直到海。但真实噪声场里存在大量局部洼地,河走到一半就掉进一个坑里出不来,反复横跳或者直接停在半山腰。

处理顺序必须是「先填洼,再下切」。填洼用优先级洪水(priority-flood)算法:把所有海洋格压进最小堆,按海拔从低到高弹出,把每个邻居的洼地抬到当前水位略高一点,这样整个地形就变成「处处都能流到海」的状态。

# rivers.py import heapq import numpy as np def fill_depressions(elev, is_ocean, sea_level=0.42, eps=1e-4): """优先级洪水:从海洋向内涨水,把封闭洼地抬平""" h, w = elev.shape out = elev.copy() visited = np.zeros((h, w), dtype=bool) heap = [] for y, x in zip(*np.where(is_ocean)): out[y, x] = sea_level visited[y, x] = True heapq.heappush(heap, (sea_level, y, x)) nbrs = ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)) while heap: level, y, x = heapq.heappop(heap) for dy, dx in nbrs: ny, nx = y + dy, x + dx if 0 <= ny < h and 0 <= nx < w and not visited[ny, nx]: visited[ny, nx] = True out[ny, nx] = max(out[ny, nx], level + eps) heapq.heappush(heap, (out[ny, nx], ny, nx)) return out

eps不能省。如果邻居的海拔恰好等于当前水位,堆里会出现大量同值节点,弹出的顺序不确定,填出来的地形在平面上不可复现。加上一个极小的正数就保证了严格递增,堆排序稳定。

填完洼之后的下切就简单了,沿六边形邻居找最低点,撞到海或者撞到已走过的格子就停:

# rivers.py def trace_river(start, elev_at, sea_level=0.42, max_steps=300): path, seen = [start], {start} cur = start for _ in range(max_steps): cand = [n for n in neighbors(cur) if n in elev_at] if not cand: break nxt = min(cand, key=lambda n: elev_at[n]) if nxt in seen or elev_at[nxt] >= elev_at[cur]: break # 局部最低点或回流,停止 path.append(nxt) seen.add(nxt) cur = nxt if elev_at[cur] < sea_level: break return path

seen集合是防回流的。没有它,河流会在两个几乎等高的格子之间来回走,max_steps跑满了还停不下来。elev_at是个字典,键是Hex对象——这就是 2.2 节里必须实现__hash__的原因,缺了它set和dict都会悄悄退化成线性扫描,河流一多就卡死。

起点的选取一般按海拔排序取前 N 个峰,或者按elev > 0.8且周围没有更高点来筛。河流数量控制在 20 到 40 条之间,太少地图看着干,太多水网密到看不出主干。

4. 渲染与导出:把六边形网格画成能看的地图

4.1 Pillow 绘制平铺,靠超采样压锯齿

数据层出来的是一堆带颜色的格子对象,渲染层要干的事只有一件:把轴向坐标换成像素中心,画六个顶点的多边形。flat-top 布局的换算公式是固定的,水平间距1.5 * size,每行垂直偏移sqrt(3) * size:

# render.py import math def hex_to_pixel(h, size, origin=(0.0, 0.0)): """flat-top 布局:q 决定水平位置,r 加上半格偏移决定垂直位置""" x = size * 1.5 * h.q y = size * math.sqrt(3) * (h.r + h.q / 2.0) return x + origin[0], y + origin[1] def hex_corners(cx, cy, size): """flat-top 六边形的六个顶点,从 0 度起每 60 度一个""" return [(cx + size * math.cos(math.radians(60 * i)), cy + size * math.sin(math.radians(60 * i))) for i in range(6)]

Pillow 的ImageDraw.polygon不做抗锯齿,直接画出来的六边形边缘是硬台阶。常见做法是超采样:先在 2 倍或 3 倍尺寸上画,再用 LANCZOS 缩回去,锯齿被平均掉,边缘立刻顺滑。

# render.py from PIL import Image, ImageDraw def render(tiles, size=18, supersample=2, bg=(10, 14, 22)): s = size * supersample pts = [hex_to_pixel(t.coord, s) for t in tiles] pad = s * 1.6 minx = min(p[0] for p in pts) - pad miny = min(p[1] for p in pts) - pad w = int(max(p[0] for p in pts) - minx + pad) h = int(max(p[1] for p in pts) - miny + pad) img = Image.new("RGB", (w, h), bg) draw = ImageDraw.Draw(img) for t, (cx, cy) in zip(tiles, pts): # 半径收 2%,避免相邻多边形因浮点误差互相压边 draw.polygon(hex_corners(cx - minx, cy - miny, s * 0.98), fill=t.rgb) if supersample > 1: img = img.resize((w // supersample, h // supersample), Image.LANCZOS) return img

size * 0.98那个收缩是血泪经验。六边形理论上完美拼接,但浮点计算让相邻多边形的公共边偶尔差零点几个像素,后画的会把先画的压出黑边,整张图上出现一道道暗线,看着像渲染 bug。收 2% 之后边界重叠,接缝消失,视觉上看不出尺寸变化。

supersample=2意味着 4 倍像素量,半径 40 的地图在普通机器上大概几百毫秒,可以接受。再往上加收益递减,3 倍和 4 倍的差别肉眼几乎看不出。

4.2 导出 JSON:让别人能接手这张图

PNG 是给人看的,JSON 是给程序吃的。二次开发场景里最值钱的反而是 JSON——地图编辑器、战斗系统、小说大纲工具都要按格子查询。结构尽量扁平,不要把二维数组塞进去,让消费方自己按(q, r)建索引:

# export.py import json def export_json(tiles, path, meta): payload = { "version": 1, "meta": meta, # 种子、半径、海平面,复现必备 "tiles": [ {"q": t.coord.q, "r": t.coord.r, "elev": round(float(t.elev), 4), "moist": round(float(t.moist), 4), "temp": round(float(t.temp), 4), "biome": t.biome, "river": bool(t.river)} for t in tiles ], } with open(path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=1)

meta里必须存种子和所有影响结果的参数。少了它,别人拿到 JSON 想重新生成一张同风格但更大的图就没法弄,只能干瞪眼。round(..., 4)是控制体积,浮点全精度会让文件大三四倍,而地形精度到小数点后四位已经远超渲染需要。

除了 JSON,实际项目里还常常配一份 CSV,列是q, r, biome,方便直接拖进表格软件做统计,看看一张图里到底长了几片雨林。

4.3 生成耗时构成与优化点

一张半径 40 的地图大约 4921 个格子。整个流程的时间分布大致是:噪声位图生成占大头,填洼占一部分,渲染看超采样倍数,导出几乎可以忽略。

优化的着力点按收益排序:第一是把每条噪声都做成整幅位图一次性算完,绝对不要按格子调用;第二是位图分辨率别开太高,六边形中心采样点之间的距离本来就有一个size量级,位图边长达到格子数乘 2 就够;第三是渲染阶段如果只是预览,用supersample=1先出一版看地形走向,满意了再开 2 倍出成品。这个「先低清预览再高清出图」的习惯能省掉大量无意义的等待。

5. 避坑与排查:五个反复出现的问题

5.1 坐标与几何类

现象一:地图上相邻六边形之间出现黑缝或重叠。原因基本是存储用偏移坐标、渲染公式用轴向坐标,或者反过来的混搭。偏移坐标的col在奇数行和偶数行对应不同的q,如果没有在转换时统一处理奇偶偏移,行与行之间就会错半格。 解决办法是全链路只用轴向坐标,偏移坐标只在跟外部系统对接(比如把地图塞进一个按行列索引的关卡表)时临时转换一次,且转换函数集中在一个地方,测试里专门断言「offset_to_axial和axial_to_offset互为逆运算」往返一百个点全部相等。这条往返测试我基本是条件反射式地加。

现象二:河流追着追着就卡住不动,或者来回震荡。原因有两个:一是Hex没实现__hash__,seen集合去重失效;二是没做填洼,地形里存在封闭洼地。 解决是先检查Hex的哈希实现,再确认填洼在河流下切之前跑过。排查技巧是给trace_river加一个分支判断,当max_steps跑满还没到海时打印当前海拔和邻居海拔列表,一眼就能看出是哈希问题还是地形问题。前者表现为步数异常多但路径很短,后者表现为停在某一点后所有邻居都比自己高。

现象三:地图边缘被截断,最外圈格子少画了一半。原因是渲染时的画布尺寸按格子中心点算的边界,没给六边形本身留半径。中心的极值坐标加上半个宽度才是真正的图像边界。 解决办法是算画布时给min/max各减去和加上size * 1.6的 padding。这个值要略大于1.5 * size,否则最外一列还是会被切掉一个尖角。看起来是低级错误,但每次改渲染逻辑都可能重新引入。

5.2 噪声与性能类

现象四:不管怎么换种子,地图都是几乎全水或者几乎全陆。原因通常是base_freq和地图尺寸不匹配,或者忘了做对比度拉伸。频率太低,整张图落在一个噪声值的缓慢变化区;频率太高,细碎到海陆比例随机波动。 解决办法是先打印海拔场的直方图或者分位数,看看分布是不是挤在 0.3 到 0.5 之间——如果是,做拉伸;如果拉伸后依然单调,就是频率问题,按地图宽度重新推base_freq。我的习惯是在--verbose模式下直接把np.percentile(elev, [1, 25, 50, 75, 99])打到终端,地形合不合理一眼就看出来。

现象五:半径稍大一点生成就要等好几分钟。原因几乎都是按格子循环调用噪声函数,或者用 Python 的二维列表逐点算,numpy 完全没发挥作用。 解决办法是把噪声生成和地形采样彻底向量化:噪声一次性出一张位图,然后构造q、r的 numpy 数组,一次性算出所有格子的像素坐标,用花式索引把值全部取出来。这个改动通常能把时间从几分钟压到一秒以内。另一个隐性原因是每画一个六边形就创建一个ImageDraw对象,或者在循环里做字符串拼接,这类开销在格子数上千之后会指数级放大。

提示:这五个问题里有四个都能在「先跑一遍固定种子、输出中间产物」的流程下被提前发现。生成完先别急着看图,把海拔、湿度、填洼后的地形各导出一张灰度图,比盯着最终成品猜要快得多。

6. 二次开发:从改阈值到加导出格式

改这个项目最省力的入口是 CLI 参数,最省心的习惯是给每次改动配一个指纹断言。先把参数入口补全:

# cli.py import argparse def build_parser(): p = argparse.ArgumentParser(prog="hexgen") p.add_argument("--radius", type=int, default=30, help="地图半径,格数约 3r(r+1)+1") p.add_argument("--seed", type=int, default=42) p.add_argument("--sea-level", type=float, default=0.42, help="低于该海拔判为水,调高成群岛") p.add_argument("--moisture-bias", type=float, default=0.0, help="全局湿度偏移,正值更湿润") p.add_argument("--river-count", type=int, default=24) p.add_argument("--out", default="map.png") return p

加一个新地形类型是最常见的需求。做法是三步:在BIOME_COLORS里加颜色,在classify的合适优先级位置插入判断,在examples/的预设里验证它真的出现。第三步最容易被跳过,结果是新地形代码写了但条件永远不满足,跑一百张图都见不到。

验证靠指纹,不靠眼睛:

import hashlib, json def fingerprint(tiles): payload = [(t.q, t.r, t.biome) for t in sorted(tiles, key=lambda t: (t.q, t.r))] return hashlib.sha256(json.dumps(payload).encode()).hexdigest()[:16]

把seed=42, radius=30下的指纹写进测试常量。之后任何一次重构,只要指纹变了就说明生成结果被无意改动,必须解释清楚为什么变。这比人眼比对两张图可靠得多,尤其是在只改渲染代码、不该影响数据的那类提交上——指纹不变就说明改动确实只在表现层。

导出格式的扩展也顺手。加 SVG 只需要在render.py旁边写一个svg.py,把hex_corners的输出转成<polygon points="...">字符串,颜色格式从 RGB 元组转#rrggbb。数据结构没变,复用同一套坐标函数,不会引入新的坐标 bug。

我踩过最深的一次坑,是某天觉得temp的系数 0.55 太大,随手改成 0.35,结果一张跑了半小时才调好的预设地图整体退化,赤道附近全是雨林,原来的稀树草原消失了。当时没有指纹测试,重调花了整个下午。从那以后我每次动terrain.py里的任何一个系数,都强制先跑一遍固定种子的指纹对比,把差异范围看清楚再决定保留。希望这些能帮你少走两趟弯路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 6:43:03

C++类内声明与类外定义:从编译模型到工程实践

1. 类内声明到底是什么&#xff1a;先把概念掰开揉碎“类内声明”这四个字&#xff0c;我在带新人的时候被问过不下十次。刚上手写 C 的朋友经常一脸困惑地指着代码问&#xff1a;这行写在 class 大括号里的void draw();后面跟个分号&#xff0c;到底是啥&#xff1f;它和下面那…

作者头像 李华
网站建设 2026/10/26 6:43:03

STM32F103+CAN无刷电机控制链路:协议、PWM映射与调试避坑

简介&#xff1a;面向STM32F103与无刷电机控制学习者的CAN通信命令控制实践包&#xff0c;适合已具备单片机基础、希望打通通信协议与电机驱动环节的开发者。内容围绕CAN报文帧结构、标识符与数据字段配置&#xff0c;结合STM32内置CAN控制器的收发编程&#xff0c;并延伸到三相…

作者头像 李华
网站建设 2026/10/26 6:41:55

DeepSeek Harness 插件架构解析:Cordis 上下文、服务与依赖注入实战

1. 从一次插件加载失败说起&#xff1a;Cordis 架构到底解决了什么问题我第一次接触 DeepSeek Harness 的插件体系&#xff0c;是因为一个很具体的需求&#xff1a;团队内部有一个自研的代码审查流程&#xff0c;想在 Harness 每次生成补丁之后自动跑一遍静态检查&#xff0c;再…

作者头像 李华
网站建设 2026/10/26 6:41:51

校园生活服务系统全栈开发:PHP+UniApp实战解析

1. 项目概述&#xff1a;校园生活服务系统的全栈开发实践这个基于微信小程序的校园生活服务系统&#xff0c;是我去年为某高校开发的一个实战项目。系统采用PHPUniApp的全栈架构&#xff0c;整合了二手交易、自习室预约、会议室管理和失物招领四大核心功能模块。在实际运行中&a…

作者头像 李华
网站建设 2026/10/26 6:41:37

C++工业视觉模板匹配:度量选型、NMS与性能优化实战

工业视觉项目里&#xff0c;模板匹配大概是被调用次数最多、也最容易被低估的一个算子。产线上相机一拍&#xff0c;你要知道料框里那个零件在什么位置、角度偏了多少&#xff0c;第一步往往就是把事先截好的一小块模板拿去大图里做一次全局搜索——这个动作就是模板匹配。用C来…

作者头像 李华
网站建设 2026/10/26 6:40:02

GIS/RS与VORS-CCDM-地理探测器-GWR生态健康评价链

前几年帮一个团队复算某城市的生态健康评价&#xff0c;指标算完、图出完&#xff0c;评审时被问了一句&#xff1a;这张健康等级图&#xff0c;跟三年前那张除了色块位置变了&#xff0c;结论有什么不一样&#xff1f;那一刻我才真正意识到&#xff0c;GIS和遥感只是把数据搬到…

作者头像 李华