mapwright 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mapwright/__init__.py +62 -0
- mapwright/config.py +154 -0
- mapwright/dungeon.py +229 -0
- mapwright/names.py +261 -0
- mapwright/rng.py +163 -0
- mapwright/svg_renderer.py +249 -0
- mapwright/terrain.py +542 -0
- mapwright-0.2.0.dist-info/METADATA +136 -0
- mapwright-0.2.0.dist-info/RECORD +12 -0
- mapwright-0.2.0.dist-info/WHEEL +4 -0
- mapwright-0.2.0.dist-info/licenses/LICENSE +21 -0
- mapwright-0.2.0.dist-info/licenses/NOTICE +28 -0
mapwright/terrain.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Procedural regional terrain: Voronoi cells, erosion, rivers, biomes.
|
|
2
|
+
|
|
3
|
+
A clean-room Python port of the *ideas* in Azgaar's Fantasy-Map-Generator (MIT)
|
|
4
|
+
and rlguy/Mewo2's FantasyMapGenerator (Zlib) — no code copied from either. It
|
|
5
|
+
replaces the old ``sin/cos`` noise in ``layout_generator._generate_regional_terrain``
|
|
6
|
+
with a physically-motivated pipeline that produces organic coastlines, drainage
|
|
7
|
+
basins, rivers, and climate-driven biomes.
|
|
8
|
+
|
|
9
|
+
Pipeline (all on a Voronoi cell graph, then rasterised to the tile grid):
|
|
10
|
+
|
|
11
|
+
1. **Voronoi cells** — jittered seed points + nearest-seed assignment, refined
|
|
12
|
+
by a couple of Lloyd-relaxation passes (the FMG/Watabou trick for evenly
|
|
13
|
+
organic cells). Done in pure numpy; no scipy dependency.
|
|
14
|
+
2. **Heightmap** — additive primitives (a central landmass hill, a few random
|
|
15
|
+
hills/ranges) minus a radial edge falloff so the map reads as a continent
|
|
16
|
+
ringed by sea.
|
|
17
|
+
3. **Planchon–Darboux depression fill** — guarantees every land cell drains to
|
|
18
|
+
the sea, so flux/rivers never dead-end in a pit.
|
|
19
|
+
4. **Flux + hydraulic erosion** — water flows to the lowest neighbour, flux
|
|
20
|
+
accumulates downstream, and height is lowered by
|
|
21
|
+
``river·√flux·slope + creep·slope²`` (FantasyMapGenerator's hybrid model),
|
|
22
|
+
carving valleys. Iterated a few passes.
|
|
23
|
+
5. **Rivers** — cells whose accumulated flux exceeds a threshold trace a
|
|
24
|
+
downhill polyline; width scales with √flux.
|
|
25
|
+
6. **Climate** — temperature from latitude minus an elevation lapse; moisture
|
|
26
|
+
from graph distance to water plus river proximity.
|
|
27
|
+
7. **Biomes** — a Whittaker-style temperature×moisture matrix → biome → tile.
|
|
28
|
+
|
|
29
|
+
Everything is driven by a single :class:`SeededRNG`, so a seed reproduces the
|
|
30
|
+
whole world. The cell model is deliberately geometry-light (centroids + adjacency)
|
|
31
|
+
so a future SVG/graph exporter can swap nearest-seed cells for true
|
|
32
|
+
``scipy.spatial.Voronoi`` polygons without touching the hydrology/climate code.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import math
|
|
38
|
+
from collections import deque
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from enum import IntEnum
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
from .config import WorldMapConfig
|
|
45
|
+
from .rng import SeededRNG
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Biome(IntEnum):
|
|
49
|
+
"""Coarse biome classes. Mapping to a host app's tile vocabulary is the
|
|
50
|
+
consumer's job (this library stays domain-neutral)."""
|
|
51
|
+
|
|
52
|
+
OCEAN = 0
|
|
53
|
+
COAST = 1
|
|
54
|
+
BEACH = 2
|
|
55
|
+
DESERT = 3
|
|
56
|
+
PLAINS = 4
|
|
57
|
+
FOREST = 5
|
|
58
|
+
SWAMP = 6
|
|
59
|
+
HILLS = 7
|
|
60
|
+
MOUNTAIN = 8
|
|
61
|
+
TUNDRA = 9
|
|
62
|
+
SNOW = 10
|
|
63
|
+
RIVER = 11
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class TerrainCell:
|
|
68
|
+
"""One Voronoi cell with its terrain/hydrology/climate state."""
|
|
69
|
+
|
|
70
|
+
id: int
|
|
71
|
+
cx: float
|
|
72
|
+
cy: float
|
|
73
|
+
neighbors: list[int] = field(default_factory=list)
|
|
74
|
+
height: float = 0.0 # 0..1, sea level is generation sea_level
|
|
75
|
+
filled: float = 0.0 # depression-filled height (for drainage)
|
|
76
|
+
flux: float = 1.0 # accumulated upstream flow
|
|
77
|
+
downhill: int = -1 # cell id water flows to, or -1 at the sea
|
|
78
|
+
is_water: bool = False
|
|
79
|
+
is_river: bool = False
|
|
80
|
+
temperature: float = 0.5 # 0 (frozen) .. 1 (hot)
|
|
81
|
+
moisture: float = 0.5 # 0 (arid) .. 1 (wet)
|
|
82
|
+
biome: Biome = Biome.PLAINS
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class River:
|
|
87
|
+
"""A traced river as a polyline of cell centroids."""
|
|
88
|
+
|
|
89
|
+
cells: list[int]
|
|
90
|
+
width: float
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class TerrainResult:
|
|
95
|
+
"""Full regional terrain output: cells, the rasterised grid, and rivers."""
|
|
96
|
+
|
|
97
|
+
width: int
|
|
98
|
+
height: int
|
|
99
|
+
cells: list[TerrainCell]
|
|
100
|
+
cell_of: np.ndarray # int grid [height, width] -> cell id
|
|
101
|
+
rivers: list[River]
|
|
102
|
+
sea_level: float
|
|
103
|
+
|
|
104
|
+
def elevation_at(self, cell: "TerrainCell") -> float:
|
|
105
|
+
"""Normalised height above sea level, 0..1 — handy for rasterisers."""
|
|
106
|
+
return max(0.0, (cell.height - self.sea_level) / max(1e-6, 1 - self.sea_level))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RegionalTerrainGenerator:
|
|
110
|
+
"""Builds :class:`TerrainResult` for a width×height regional map."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, rng: SeededRNG):
|
|
113
|
+
# All terrain draws come from a derived sub-stream so terrain stays
|
|
114
|
+
# decoupled from naming/placement streams sharing the same root seed.
|
|
115
|
+
self._rng = rng.derive("terrain")
|
|
116
|
+
self._np = self._rng.numpy
|
|
117
|
+
|
|
118
|
+
# -- public entry ----------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def generate(
|
|
121
|
+
self,
|
|
122
|
+
width: int,
|
|
123
|
+
height: int,
|
|
124
|
+
config: WorldMapConfig | None = None,
|
|
125
|
+
*,
|
|
126
|
+
cell_area: float = 6.0,
|
|
127
|
+
relax_iterations: int = 2,
|
|
128
|
+
) -> TerrainResult:
|
|
129
|
+
"""Run the full pipeline and return terrain for a ``width×height`` grid.
|
|
130
|
+
|
|
131
|
+
``config`` (a :class:`WorldMapConfig`) shapes the world — sea level,
|
|
132
|
+
number of continents, climate, mountains, rivers. ``cell_area`` and
|
|
133
|
+
``relax_iterations`` are quality/detail dials independent of the world's
|
|
134
|
+
character. With no config a balanced single-continent world is produced.
|
|
135
|
+
"""
|
|
136
|
+
cfg = config or WorldMapConfig()
|
|
137
|
+
sea_level = cfg.sea_level
|
|
138
|
+
erosion_passes = max(1, round(1 + cfg.roughness * 4))
|
|
139
|
+
|
|
140
|
+
n_cells = int(np.clip(round(width * height / cell_area), 16, 1500))
|
|
141
|
+
seeds = self._sample_seeds(width, height, n_cells)
|
|
142
|
+
cell_of, seeds = self._voronoi(width, height, seeds, relax_iterations)
|
|
143
|
+
cells = self._build_cells(seeds, cell_of)
|
|
144
|
+
|
|
145
|
+
self._init_heightmap(cells, width, height, cfg)
|
|
146
|
+
for cell in cells:
|
|
147
|
+
cell.is_water = cell.height < sea_level
|
|
148
|
+
|
|
149
|
+
for _ in range(erosion_passes):
|
|
150
|
+
self._fill_depressions(cells, sea_level)
|
|
151
|
+
self._compute_flux(cells)
|
|
152
|
+
self._erode(cells, sea_level)
|
|
153
|
+
for cell in cells:
|
|
154
|
+
cell.is_water = cell.height < sea_level
|
|
155
|
+
|
|
156
|
+
# Final hydrology pass for stable rivers, then climate + biomes.
|
|
157
|
+
self._fill_depressions(cells, sea_level)
|
|
158
|
+
self._compute_flux(cells)
|
|
159
|
+
rivers = self._trace_rivers(cells, cfg.river_density)
|
|
160
|
+
self._compute_climate(cells, width, height, sea_level, cfg)
|
|
161
|
+
self._assign_biomes(cells, sea_level)
|
|
162
|
+
|
|
163
|
+
return TerrainResult(
|
|
164
|
+
width=width,
|
|
165
|
+
height=height,
|
|
166
|
+
cells=cells,
|
|
167
|
+
cell_of=cell_of,
|
|
168
|
+
rivers=rivers,
|
|
169
|
+
sea_level=sea_level,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# -- 1. Voronoi cells (pure numpy) -----------------------------------
|
|
173
|
+
|
|
174
|
+
def _sample_seeds(self, width: int, height: int, n: int) -> np.ndarray:
|
|
175
|
+
"""Jittered-grid seed points — even coverage without clumping."""
|
|
176
|
+
cols = max(1, int(round(math.sqrt(n * width / max(1, height)))))
|
|
177
|
+
rows = max(1, int(math.ceil(n / cols)))
|
|
178
|
+
cw, ch = width / cols, height / rows
|
|
179
|
+
pts = []
|
|
180
|
+
for r in range(rows):
|
|
181
|
+
for c in range(cols):
|
|
182
|
+
jx = self._rng.random()
|
|
183
|
+
jy = self._rng.random()
|
|
184
|
+
pts.append(((c + jx) * cw, (r + jy) * ch))
|
|
185
|
+
return np.array(pts[:n] if len(pts) >= n else pts, dtype=float)
|
|
186
|
+
|
|
187
|
+
def _voronoi(
|
|
188
|
+
self, width: int, height: int, seeds: np.ndarray, relax: int
|
|
189
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
190
|
+
"""Assign every grid cell to its nearest seed, with Lloyd relaxation."""
|
|
191
|
+
xs, ys = np.meshgrid(np.arange(width), np.arange(height))
|
|
192
|
+
coords = np.stack([xs.ravel(), ys.ravel()], axis=1).astype(float)
|
|
193
|
+
cell_of = self._nearest(coords, seeds).reshape(height, width)
|
|
194
|
+
|
|
195
|
+
for _ in range(relax):
|
|
196
|
+
# Move each seed to its region's centroid, then reassign.
|
|
197
|
+
new_seeds = seeds.copy()
|
|
198
|
+
flat = cell_of.ravel()
|
|
199
|
+
for cid in range(len(seeds)):
|
|
200
|
+
mask = flat == cid
|
|
201
|
+
if mask.any():
|
|
202
|
+
new_seeds[cid] = coords[mask].mean(axis=0)
|
|
203
|
+
seeds = new_seeds
|
|
204
|
+
cell_of = self._nearest(coords, seeds).reshape(height, width)
|
|
205
|
+
return cell_of, seeds
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _nearest(coords: np.ndarray, seeds: np.ndarray) -> np.ndarray:
|
|
209
|
+
"""Nearest-seed index per coord, computed in blocks to bound memory."""
|
|
210
|
+
p, n = coords.shape[0], seeds.shape[0]
|
|
211
|
+
out = np.empty(p, dtype=np.int32)
|
|
212
|
+
block = max(1, int(4_000_000 / max(1, n))) # ~4M float cap per block
|
|
213
|
+
for start in range(0, p, block):
|
|
214
|
+
chunk = coords[start : start + block]
|
|
215
|
+
d2 = ((chunk[:, None, :] - seeds[None, :, :]) ** 2).sum(axis=2)
|
|
216
|
+
out[start : start + block] = d2.argmin(axis=1)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def _build_cells(self, seeds: np.ndarray, cell_of: np.ndarray) -> list[TerrainCell]:
|
|
220
|
+
"""Construct cells with centroids and grid-adjacency neighbours."""
|
|
221
|
+
cells = [TerrainCell(id=i, cx=float(s[0]), cy=float(s[1])) for i, s in enumerate(seeds)]
|
|
222
|
+
neigh: list[set[int]] = [set() for _ in seeds]
|
|
223
|
+
# Two cells are adjacent if they touch horizontally or vertically.
|
|
224
|
+
a = cell_of[:, :-1]
|
|
225
|
+
b = cell_of[:, 1:]
|
|
226
|
+
for u, v in np.unique(np.stack([a.ravel(), b.ravel()], axis=1), axis=0):
|
|
227
|
+
if u != v:
|
|
228
|
+
neigh[u].add(int(v))
|
|
229
|
+
neigh[v].add(int(u))
|
|
230
|
+
a = cell_of[:-1, :]
|
|
231
|
+
b = cell_of[1:, :]
|
|
232
|
+
for u, v in np.unique(np.stack([a.ravel(), b.ravel()], axis=1), axis=0):
|
|
233
|
+
if u != v:
|
|
234
|
+
neigh[u].add(int(v))
|
|
235
|
+
neigh[v].add(int(u))
|
|
236
|
+
for cell in cells:
|
|
237
|
+
cell.neighbors = sorted(neigh[cell.id])
|
|
238
|
+
return cells
|
|
239
|
+
|
|
240
|
+
# -- 2. Heightmap primitives -----------------------------------------
|
|
241
|
+
|
|
242
|
+
def _init_heightmap(
|
|
243
|
+
self, cells: list[TerrainCell], width: int, height: int, cfg: WorldMapConfig
|
|
244
|
+
) -> None:
|
|
245
|
+
cx, cy = width / 2, height / 2
|
|
246
|
+
diag = math.hypot(width, height)
|
|
247
|
+
centroids = np.array([[c.cx, c.cy] for c in cells])
|
|
248
|
+
|
|
249
|
+
def gaussian(px: float, py: float, radius: float, amp: float) -> np.ndarray:
|
|
250
|
+
d2 = (centroids[:, 0] - px) ** 2 + (centroids[:, 1] - py) ** 2
|
|
251
|
+
return amp * np.exp(-d2 / (2 * radius * radius))
|
|
252
|
+
|
|
253
|
+
# Major landmasses. One ⇒ a central continent; several ⇒ blobs on a ring.
|
|
254
|
+
n = cfg.continents
|
|
255
|
+
major_radius = diag * 0.28 / (1 + 0.45 * (n - 1))
|
|
256
|
+
centers: list[tuple[float, float]] = []
|
|
257
|
+
if n == 1:
|
|
258
|
+
centers.append((cx + self._rng.fuzzy(0, width * 0.1),
|
|
259
|
+
cy + self._rng.fuzzy(0, height * 0.1)))
|
|
260
|
+
else:
|
|
261
|
+
ring = cfg.continent_spread * min(width, height) * 0.42
|
|
262
|
+
for i in range(n):
|
|
263
|
+
ang = 2 * math.pi * i / n + self._rng.fuzzy(0, 0.5)
|
|
264
|
+
centers.append((cx + ring * math.cos(ang) + self._rng.fuzzy(0, width * 0.06),
|
|
265
|
+
cy + ring * math.sin(ang) + self._rng.fuzzy(0, height * 0.06)))
|
|
266
|
+
|
|
267
|
+
# Combine landmasses with MAX (not sum) so adjacent continents don't form
|
|
268
|
+
# additive land-bridges between them — that's what makes islands islands.
|
|
269
|
+
h = np.zeros(len(cells))
|
|
270
|
+
for px, py in centers:
|
|
271
|
+
amp = 1.0 if n == 1 else self._rng.uniform(0.8, 1.0)
|
|
272
|
+
h = np.maximum(h, gaussian(px, py, major_radius, amp))
|
|
273
|
+
|
|
274
|
+
# Hills/ranges — count and height scale with mountain_density. Anchored
|
|
275
|
+
# to a landmass and *added* on top, so they decorate continents.
|
|
276
|
+
n_small = round(2 + cfg.mountain_density * 8)
|
|
277
|
+
spread = major_radius * 0.55 # keep ranges on their landmass (no bridging)
|
|
278
|
+
for _ in range(n_small):
|
|
279
|
+
bx, by = self._rng.choice(centers)
|
|
280
|
+
h = h + gaussian(bx + self._rng.fuzzy(0, spread),
|
|
281
|
+
by + self._rng.fuzzy(0, spread),
|
|
282
|
+
radius=diag * self._rng.uniform(0.06, 0.16),
|
|
283
|
+
amp=self._rng.uniform(0.2, 0.35 + cfg.mountain_density * 0.5))
|
|
284
|
+
|
|
285
|
+
# Radial edge falloff: push the map border below sea level so the map
|
|
286
|
+
# reads as land ringed by sea (and gives real coastlines).
|
|
287
|
+
d_edge = np.sqrt((centroids[:, 0] - cx) ** 2 + (centroids[:, 1] - cy) ** 2) / (diag / 2)
|
|
288
|
+
h = h - np.clip((d_edge - 0.45) / 0.55, 0, 1) * (1.15 * cfg.edge_falloff)
|
|
289
|
+
|
|
290
|
+
# Normalise to 0..1.
|
|
291
|
+
h = h - h.min()
|
|
292
|
+
if h.max() > 0:
|
|
293
|
+
h = h / h.max()
|
|
294
|
+
for cell, hv in zip(cells, h):
|
|
295
|
+
cell.height = float(hv)
|
|
296
|
+
|
|
297
|
+
# -- 3. Planchon–Darboux depression fill -----------------------------
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _fill_depressions(cells: list[TerrainCell], sea_level: float, epsilon: float = 1e-4) -> None:
|
|
301
|
+
"""Raise pits so every land cell has a downhill path to the sea."""
|
|
302
|
+
INF = float("inf")
|
|
303
|
+
for c in cells:
|
|
304
|
+
# Water cells (and thus the sea) are fixed outlets at their height.
|
|
305
|
+
c.filled = c.height if c.height < sea_level else INF
|
|
306
|
+
changed = True
|
|
307
|
+
# Iterate to a fixed point; small cell counts make this cheap.
|
|
308
|
+
while changed:
|
|
309
|
+
changed = False
|
|
310
|
+
for c in cells:
|
|
311
|
+
if c.filled == c.height:
|
|
312
|
+
continue
|
|
313
|
+
lowest = min((cells[n].filled for n in c.neighbors), default=INF)
|
|
314
|
+
candidate = max(c.height, lowest + epsilon)
|
|
315
|
+
if candidate < c.filled:
|
|
316
|
+
c.filled = candidate
|
|
317
|
+
changed = True
|
|
318
|
+
|
|
319
|
+
# -- 4. Flux + hydraulic erosion -------------------------------------
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _compute_flux(cells: list[TerrainCell]) -> None:
|
|
323
|
+
"""Route flow downhill on filled heights and accumulate flux."""
|
|
324
|
+
for c in cells:
|
|
325
|
+
c.flux = 1.0
|
|
326
|
+
c.downhill = -1
|
|
327
|
+
land = [c for c in cells if c.height >= 0 and not c.is_water]
|
|
328
|
+
for c in cells:
|
|
329
|
+
if c.is_water:
|
|
330
|
+
continue
|
|
331
|
+
# Steepest-descent neighbour on the filled surface.
|
|
332
|
+
best, best_h = -1, c.filled
|
|
333
|
+
for n in c.neighbors:
|
|
334
|
+
if cells[n].filled < best_h:
|
|
335
|
+
best_h, best = cells[n].filled, n
|
|
336
|
+
c.downhill = best
|
|
337
|
+
# Accumulate from high to low so upstream flux is ready first.
|
|
338
|
+
for c in sorted(land, key=lambda c: c.filled, reverse=True):
|
|
339
|
+
if c.downhill >= 0:
|
|
340
|
+
cells[c.downhill].flux += c.flux
|
|
341
|
+
|
|
342
|
+
def _erode(self, cells: list[TerrainCell], sea_level: float,
|
|
343
|
+
river_factor: float = 0.06, creep_factor: float = 0.02,
|
|
344
|
+
max_rate: float = 0.045) -> None:
|
|
345
|
+
"""Lower each land cell by hydraulic + creep erosion (capped)."""
|
|
346
|
+
for c in cells:
|
|
347
|
+
if c.is_water or c.downhill < 0:
|
|
348
|
+
continue
|
|
349
|
+
down = cells[c.downhill]
|
|
350
|
+
dist = math.hypot(c.cx - down.cx, c.cy - down.cy) or 1.0
|
|
351
|
+
slope = max(0.0, (c.filled - down.filled) / dist)
|
|
352
|
+
erosion = river_factor * math.sqrt(c.flux) * slope + creep_factor * slope * slope
|
|
353
|
+
c.height = max(sea_level * 0.5, c.height - min(erosion, max_rate))
|
|
354
|
+
|
|
355
|
+
# -- 5. Rivers -------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _trace_rivers(cells: list[TerrainCell], river_density: float) -> list[River]:
|
|
359
|
+
"""Trace downhill polylines from genuine high-flux river sources.
|
|
360
|
+
|
|
361
|
+
Rivers are *rare* — only trunk streams that gathered real drainage. We
|
|
362
|
+
pick sources above a flux quantile (with an absolute floor so small maps
|
|
363
|
+
don't over-river), follow each to the sea, and only then mark the cells
|
|
364
|
+
of kept rivers as ``is_river`` — so short, spurious paths never paint the
|
|
365
|
+
interior blue. ``river_density`` (0..1) lowers both the quantile and the
|
|
366
|
+
floor so more, smaller rivers appear.
|
|
367
|
+
"""
|
|
368
|
+
land = [c for c in cells if not c.is_water]
|
|
369
|
+
if not land:
|
|
370
|
+
return []
|
|
371
|
+
# A source needs accumulated flux above a threshold that scales with map
|
|
372
|
+
# size (so big maps aren't flooded) and shrinks with river_density, so
|
|
373
|
+
# higher density ⇒ lower bar ⇒ more (and smaller) rivers.
|
|
374
|
+
cutoff = max(5.0, (0.10 - 0.075 * river_density) * len(cells))
|
|
375
|
+
rivers: list[River] = []
|
|
376
|
+
used: set[int] = set()
|
|
377
|
+
sources = sorted(
|
|
378
|
+
(c for c in cells if not c.is_water and c.flux >= cutoff),
|
|
379
|
+
key=lambda c: c.flux, reverse=True,
|
|
380
|
+
)
|
|
381
|
+
for src in sources:
|
|
382
|
+
if src.id in used:
|
|
383
|
+
continue
|
|
384
|
+
path, cur = [], src
|
|
385
|
+
# Follow the trunk downhill to the sea (or into an existing river).
|
|
386
|
+
while cur is not None and not cur.is_water:
|
|
387
|
+
path.append(cur.id)
|
|
388
|
+
if cur.id in used:
|
|
389
|
+
break # merged into a previously traced river
|
|
390
|
+
cur = cells[cur.downhill] if cur.downhill >= 0 else None
|
|
391
|
+
if len(path) >= 2:
|
|
392
|
+
for cid in path:
|
|
393
|
+
used.add(cid)
|
|
394
|
+
cells[cid].is_river = True
|
|
395
|
+
rivers.append(River(cells=path, width=math.sqrt(src.flux)))
|
|
396
|
+
return rivers
|
|
397
|
+
|
|
398
|
+
# -- 6. Climate ------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
def _compute_climate(
|
|
401
|
+
self, cells: list[TerrainCell], width: int, height: int, sea_level: float,
|
|
402
|
+
cfg: WorldMapConfig,
|
|
403
|
+
) -> None:
|
|
404
|
+
# Temperature: warm band at a randomly placed "equator" latitude, minus
|
|
405
|
+
# an elevation lapse rate so peaks are cold, plus a global config bias.
|
|
406
|
+
equator = self._rng.uniform(0.35, 0.65)
|
|
407
|
+
for c in cells:
|
|
408
|
+
lat = c.cy / max(1, height - 1)
|
|
409
|
+
temp = 1.0 - 2.0 * abs(lat - equator)
|
|
410
|
+
temp -= 0.6 * max(0.0, c.height - sea_level) # lapse with elevation
|
|
411
|
+
temp += cfg.temperature # global bias
|
|
412
|
+
c.temperature = float(np.clip(temp + self._rng.fuzzy(0, 0.05), 0.0, 1.0))
|
|
413
|
+
|
|
414
|
+
# Moisture: multi-source BFS hop-distance from water over the cell graph,
|
|
415
|
+
# decaying inland; rivers add local moisture.
|
|
416
|
+
dist = {c.id: math.inf for c in cells}
|
|
417
|
+
q: deque[int] = deque()
|
|
418
|
+
for c in cells:
|
|
419
|
+
if c.is_water:
|
|
420
|
+
dist[c.id] = 0
|
|
421
|
+
q.append(c.id)
|
|
422
|
+
while q:
|
|
423
|
+
cid = q.popleft()
|
|
424
|
+
for n in cells[cid].neighbors:
|
|
425
|
+
if dist[n] > dist[cid] + 1:
|
|
426
|
+
dist[n] = dist[cid] + 1
|
|
427
|
+
q.append(n)
|
|
428
|
+
scale = max(3.0, (width + height) / 12.0)
|
|
429
|
+
for c in cells:
|
|
430
|
+
base = math.exp(-dist[c.id] / scale)
|
|
431
|
+
if c.is_river:
|
|
432
|
+
base = min(1.0, base + 0.35)
|
|
433
|
+
base += cfg.moisture * 0.5 # global bias
|
|
434
|
+
c.moisture = float(np.clip(base + self._rng.fuzzy(0, 0.05), 0.0, 1.0))
|
|
435
|
+
|
|
436
|
+
# -- 7. Biome assignment ---------------------------------------------
|
|
437
|
+
|
|
438
|
+
def _assign_biomes(self, cells: list[TerrainCell], sea_level: float) -> None:
|
|
439
|
+
water_ids = {c.id for c in cells if c.is_water}
|
|
440
|
+
for c in cells:
|
|
441
|
+
if c.is_water:
|
|
442
|
+
# Shoreline water (touching land) reads as coast/shallows.
|
|
443
|
+
touches_land = any(n not in water_ids for n in c.neighbors)
|
|
444
|
+
c.biome = Biome.COAST if touches_land else Biome.OCEAN
|
|
445
|
+
continue
|
|
446
|
+
if c.is_river:
|
|
447
|
+
c.biome = Biome.RIVER
|
|
448
|
+
continue
|
|
449
|
+
c.biome = self._whittaker(c, sea_level, water_ids)
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def _whittaker(c: TerrainCell, sea_level: float, water_ids: set[int]) -> Biome:
|
|
453
|
+
"""Temperature×moisture×elevation → biome (Whittaker-style matrix)."""
|
|
454
|
+
rel = (c.height - sea_level) / max(1e-6, 1 - sea_level) # 0..1 above sea
|
|
455
|
+
t, m = c.temperature, c.moisture
|
|
456
|
+
|
|
457
|
+
# Elevation dominates at the extremes.
|
|
458
|
+
if rel > 0.72:
|
|
459
|
+
return Biome.SNOW if t < 0.35 else Biome.MOUNTAIN
|
|
460
|
+
if rel > 0.48:
|
|
461
|
+
return Biome.HILLS
|
|
462
|
+
|
|
463
|
+
touches_sea = any(n in water_ids for n in c.neighbors)
|
|
464
|
+
|
|
465
|
+
# Land just above the waterline that touches the sea → beach.
|
|
466
|
+
if rel < 0.06 and touches_sea:
|
|
467
|
+
return Biome.BEACH
|
|
468
|
+
|
|
469
|
+
# Inland low-lying very wet ground → swamp. Gated off the coast so we
|
|
470
|
+
# don't paint a swamp ring around every shoreline.
|
|
471
|
+
if m > 0.82 and rel < 0.15 and not touches_sea:
|
|
472
|
+
return Biome.SWAMP
|
|
473
|
+
|
|
474
|
+
if t < 0.25:
|
|
475
|
+
return Biome.TUNDRA
|
|
476
|
+
if t > 0.7:
|
|
477
|
+
return Biome.DESERT if m < 0.3 else Biome.FOREST
|
|
478
|
+
# Temperate band.
|
|
479
|
+
if m < 0.33:
|
|
480
|
+
return Biome.PLAINS
|
|
481
|
+
return Biome.FOREST if m > 0.5 else Biome.PLAINS
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
# Voronoi polygon reconstruction (for vector/SVG rendering).
|
|
486
|
+
#
|
|
487
|
+
# The cell graph stores only centroids + adjacency, so for vector output we
|
|
488
|
+
# rebuild each cell's convex polygon by clipping the map rectangle with the
|
|
489
|
+
# perpendicular bisector between the cell and each of its neighbours
|
|
490
|
+
# (Sutherland–Hodgman half-plane clipping). Pure Python, no scipy — exact for
|
|
491
|
+
# the relaxed seed sites we generate.
|
|
492
|
+
# ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
Point = tuple[float, float]
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _clip_halfplane(poly: list[Point], mx: float, my: float, ax: float, ay: float) -> list[Point]:
|
|
498
|
+
"""Keep the part of ``poly`` on the ``c`` side of a bisector.
|
|
499
|
+
|
|
500
|
+
The half-plane is ``{p : (p - m)·a <= 0}`` where ``m`` is the bisector
|
|
501
|
+
midpoint and ``a`` points from the cell toward its neighbour.
|
|
502
|
+
"""
|
|
503
|
+
def inside(p: Point) -> bool:
|
|
504
|
+
return (p[0] - mx) * ax + (p[1] - my) * ay <= 1e-9
|
|
505
|
+
|
|
506
|
+
def intersect(a: Point, b: Point) -> Point:
|
|
507
|
+
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
508
|
+
denom = dx * ax + dy * ay
|
|
509
|
+
if abs(denom) < 1e-12:
|
|
510
|
+
return a
|
|
511
|
+
t = ((mx - a[0]) * ax + (my - a[1]) * ay) / denom
|
|
512
|
+
return (a[0] + t * dx, a[1] + t * dy)
|
|
513
|
+
|
|
514
|
+
out: list[Point] = []
|
|
515
|
+
n = len(poly)
|
|
516
|
+
for i in range(n):
|
|
517
|
+
a, b = poly[i], poly[(i + 1) % n]
|
|
518
|
+
a_in, b_in = inside(a), inside(b)
|
|
519
|
+
if a_in:
|
|
520
|
+
out.append(a)
|
|
521
|
+
if a_in != b_in:
|
|
522
|
+
out.append(intersect(a, b))
|
|
523
|
+
return out
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def compute_cell_polygons(
|
|
527
|
+
cells: list[TerrainCell], width: int, height: int
|
|
528
|
+
) -> dict[int, list[Point]]:
|
|
529
|
+
"""Return a convex polygon (list of points) for each cell, clipped to the map."""
|
|
530
|
+
rect: list[Point] = [(0.0, 0.0), (float(width), 0.0),
|
|
531
|
+
(float(width), float(height)), (0.0, float(height))]
|
|
532
|
+
polys: dict[int, list[Point]] = {}
|
|
533
|
+
for c in cells:
|
|
534
|
+
poly = rect
|
|
535
|
+
for nid in c.neighbors:
|
|
536
|
+
n = cells[nid]
|
|
537
|
+
mx, my = (c.cx + n.cx) / 2, (c.cy + n.cy) / 2
|
|
538
|
+
poly = _clip_halfplane(poly, mx, my, n.cx - c.cx, n.cy - c.cy)
|
|
539
|
+
if len(poly) < 3:
|
|
540
|
+
break
|
|
541
|
+
polys[c.id] = poly
|
|
542
|
+
return polys
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mapwright
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Domain-neutral procedural fantasy map & world generation: Voronoi terrain, hydraulic erosion, biomes, rivers, Markov place-names, and shaded-relief SVG.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sligara7/mapwright
|
|
6
|
+
Project-URL: Repository, https://github.com/sligara7/mapwright
|
|
7
|
+
Author: Anthony Sligar
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: NOTICE
|
|
11
|
+
Keywords: biomes,erosion,fantasy-map,procedural-generation,svg,terrain,ttrpg,voronoi,worldgen
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Games/Entertainment :: Role-Playing
|
|
16
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: numpy>=1.26
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# mapwright
|
|
25
|
+
|
|
26
|
+
> ⚠️ **Early development (v0.1, alpha).** The API is still moving and may change without
|
|
27
|
+
> notice between versions. Extracted from a working application; usable today, but pin a
|
|
28
|
+
> commit if you depend on it.
|
|
29
|
+
|
|
30
|
+
**Domain-neutral procedural fantasy map & world generation** — Voronoi terrain with
|
|
31
|
+
hydraulic erosion, climate-driven biomes, rivers, Markov place-names, and shaded-relief
|
|
32
|
+
SVG rendering. Pure Python, `numpy`-only, fully seed-deterministic.
|
|
33
|
+
|
|
34
|
+
mapwright produces *neutral data* (cells, biomes, rivers, polygons) and a self-contained
|
|
35
|
+
SVG renderer. It has no opinion about your application's models — map its output onto your
|
|
36
|
+
own tiles/entities however you like.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install git+https://github.com/sligara7/mapwright.git
|
|
42
|
+
# or, for local development:
|
|
43
|
+
pip install -e ".[dev]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from mapwright import SeededRNG, RegionalTerrainGenerator, RegionalSVGRenderer, Marker
|
|
50
|
+
|
|
51
|
+
# Same seed -> same world, every time.
|
|
52
|
+
terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(width=60, height=40)
|
|
53
|
+
|
|
54
|
+
markers = [Marker(name="Eldmoor", x=30, y=18, kind="settlement_city")]
|
|
55
|
+
svg = RegionalSVGRenderer().render(terrain, markers)
|
|
56
|
+
open("world.svg", "w").write(svg)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Shape the world with `WorldMapConfig` — or describe it and let an LLM fill the config:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from mapwright import WorldMapConfig, RegionalTerrainGenerator, SeededRNG
|
|
63
|
+
|
|
64
|
+
desert = WorldMapConfig.preset("desert") # ready-made worlds...
|
|
65
|
+
custom = WorldMapConfig(continents=7, sea_level=0.55, temperature=-0.8) # ...or tune
|
|
66
|
+
world = RegionalTerrainGenerator(SeededRNG(1)).generate(60, 40, config=desert)
|
|
67
|
+
|
|
68
|
+
# Every field is a bounded scalar with a clear meaning, so it doubles as a schema
|
|
69
|
+
# a host app (or an LLM) can populate. from_dict clamps junk to valid ranges:
|
|
70
|
+
WorldMapConfig.from_dict({"temperature": 5, "continents": -3}) # -> safe, clamped
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Presets: `continent`, `pangaea`, `archipelago`, `islands`, `highlands`, `desert`,
|
|
74
|
+
`arctic`, `tropical`.
|
|
75
|
+
|
|
76
|
+
Procedural place-names in several culture styles:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from mapwright import SeededRNG, NameGenerator
|
|
80
|
+
|
|
81
|
+
namer = NameGenerator(SeededRNG(7))
|
|
82
|
+
namer.settlement("nordic") # -> 'Eirmundheim'
|
|
83
|
+
namer.settlement("elvish") # -> 'Faelynnwood'
|
|
84
|
+
namer.region("dwarvish") # -> 'The Korvald Reach'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## What's inside
|
|
88
|
+
|
|
89
|
+
| Component | What it does |
|
|
90
|
+
|-----------|--------------|
|
|
91
|
+
| `SeededRNG` | One seed drives everything; `.derive(label)` yields independent, reproducible sub-streams (unifies stdlib + numpy). |
|
|
92
|
+
| `NameGenerator` | Order-k character Markov names over hand-authored culture namebases; reproducible across processes. |
|
|
93
|
+
| `RegionalTerrainGenerator` | Voronoi cells (Lloyd-relaxed) → heightmap → Planchon–Darboux depression fill → flux + hydraulic/creep erosion → rivers → latitude/elevation climate → Whittaker biomes. |
|
|
94
|
+
| `compute_cell_polygons` | Reconstructs convex Voronoi polygons (half-plane clipping) for vector rendering. |
|
|
95
|
+
| `RegionalSVGRenderer` | Shaded-relief (hillshade) SVG: biome polygons, coastline, rivers, labelled markers. |
|
|
96
|
+
| `DungeonGenerator` | BSP-partitioned rooms + minimum-spanning-tree corridors → rooms, corridor cells, and a walkable grid (with `Dungeon.ascii()`). |
|
|
97
|
+
|
|
98
|
+
Everything is neutral: `RegionalTerrainGenerator` returns a `TerrainResult` of `TerrainCell`s
|
|
99
|
+
(each with a `Biome`), and you decide how a `Biome` maps to your world.
|
|
100
|
+
|
|
101
|
+
## Determinism
|
|
102
|
+
|
|
103
|
+
Every generator draws from a `SeededRNG`. The same seed (and parameters) reproduces an
|
|
104
|
+
identical world — terrain, names, rivers, and SVG — across runs *and across processes*
|
|
105
|
+
(the Markov chains are built in sorted order, so output never depends on `PYTHONHASHSEED`).
|
|
106
|
+
|
|
107
|
+
## API stability & contract
|
|
108
|
+
|
|
109
|
+
The **public API is exactly the names exported in `mapwright.__all__`** — that's
|
|
110
|
+
the contract. It's pinned by `tests/test_api_contract.py` (public surface, key
|
|
111
|
+
signatures), so an accidental breaking change fails CI.
|
|
112
|
+
|
|
113
|
+
For the world parameters specifically, `WorldMapConfig.json_schema()` returns a
|
|
114
|
+
JSON Schema (draft 2020-12) — the machine-readable contract a host app or LLM can
|
|
115
|
+
validate/generate against, then feed through `WorldMapConfig.from_dict()` (which
|
|
116
|
+
clamps to valid ranges). Schema and runtime clamping are generated from the same
|
|
117
|
+
field spec, so they can't drift.
|
|
118
|
+
|
|
119
|
+
Versioning follows [SemVer](https://semver.org/). While at `0.x` the API may still
|
|
120
|
+
change between minor versions; every change is recorded in `CHANGELOG.md`. Pin a
|
|
121
|
+
tag or commit if you depend on it.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
python -m venv .venv && . .venv/bin/activate
|
|
127
|
+
pip install -e ".[dev]"
|
|
128
|
+
pytest
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Credits & license
|
|
132
|
+
|
|
133
|
+
MIT licensed (see `LICENSE`). Algorithms were implemented clean-room from the publicly
|
|
134
|
+
described techniques of **Azgaar's Fantasy-Map-Generator** (MIT) and **Martin O'Leary /
|
|
135
|
+
Ryan L. Guy's FantasyMapGenerator** (Zlib); see `NOTICE` for details. The bundled name
|
|
136
|
+
lists are original.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mapwright/__init__.py,sha256=bFO6dMNMsxfq3f-7IXI9tSMkq_jqHTS0Krqxg4ERr1Y,2039
|
|
2
|
+
mapwright/config.py,sha256=dy0qfR7AV7_lFTYc2XnBtXwdW1k5Nf2RSjTlXz-Mz3k,6540
|
|
3
|
+
mapwright/dungeon.py,sha256=chU4rAYstUsokLb2XSfJVrOzsoNi7Zlayu-U7TQV-tE,8292
|
|
4
|
+
mapwright/names.py,sha256=Jt3Sc4_bOUTzvzkVkkNjRG-7xXaWlfkPIFY-GepswYw,11600
|
|
5
|
+
mapwright/rng.py,sha256=rfK-K8ed0u2f3Ummqx_1SMCCFB5j5EuvkexS_kn1LQY,6419
|
|
6
|
+
mapwright/svg_renderer.py,sha256=xC9OlwgZfb5nYHSPhT38Pa9iLIcX6YtWe7kwtAoeXfg,10008
|
|
7
|
+
mapwright/terrain.py,sha256=CuhHWBLtkpdjDMKMJy5PbedYN2-hULsweIsW3x-3fkM,22716
|
|
8
|
+
mapwright-0.2.0.dist-info/METADATA,sha256=57ej047tIMnDi-seQuuW0yfv3XWtBsF3vIFj1AxyjNU,5839
|
|
9
|
+
mapwright-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
mapwright-0.2.0.dist-info/licenses/LICENSE,sha256=SH_gQdl3Ihoor4g_7cE-qaRM8o1tGRh1Alo3oPsLJGI,1071
|
|
11
|
+
mapwright-0.2.0.dist-info/licenses/NOTICE,sha256=eyG7FgFirXwK_YzzcdZHKjOSh2bRfeOyXEvXRK5NzCo,1384
|
|
12
|
+
mapwright-0.2.0.dist-info/RECORD,,
|