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/names.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Procedural name generation via character-level Markov chains.
|
|
2
|
+
|
|
3
|
+
Ported (clean-room) from the technique in Azgaar's Fantasy-Map-Generator (MIT):
|
|
4
|
+
train a Markov chain on a small list of example names that share a linguistic
|
|
5
|
+
"feel", then walk the chain to emit *new* names in the same style. This is
|
|
6
|
+
cheap, fully offline (no LLM call), deterministic given a :class:`SeededRNG`,
|
|
7
|
+
and language-agnostic — swap the training list to change the culture.
|
|
8
|
+
|
|
9
|
+
Why a Markov chain rather than just sampling syllables: an order-k chain
|
|
10
|
+
captures local letter-transition statistics (which clusters are plausible in a
|
|
11
|
+
given language) without needing a phonotactic grammar. Order 3 over the seed
|
|
12
|
+
lists below gives names that read as "Nordic" or "Elvish" without ever copying
|
|
13
|
+
a training word verbatim (we reject exact training-set hits).
|
|
14
|
+
|
|
15
|
+
The seed namebases here are intentionally compact, hand-authored word lists —
|
|
16
|
+
*not* copied from any GPL/unlicensed generator — so the output and this module
|
|
17
|
+
are clean for reuse. They're large enough for an order-3 chain; extend them
|
|
18
|
+
freely (more examples → richer output).
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
rng = SeededRNG(seed)
|
|
23
|
+
namer = NameGenerator(rng)
|
|
24
|
+
namer.place("nordic") # -> "Skjoldur"
|
|
25
|
+
namer.settlement("generic") # -> "Eldmoor" (base name + culture suffix)
|
|
26
|
+
namer.person("elvish") # -> "Aelirien"
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
from .rng import SeededRNG
|
|
35
|
+
|
|
36
|
+
# Sentinel marking a word boundary in the Markov context/emission alphabet.
|
|
37
|
+
_BOUNDARY = "\x00"
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Seed namebases — hand-authored, license-clean training lists, one per culture.
|
|
41
|
+
# Order-3 chains need only a couple dozen examples to produce varied output.
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
NAMEBASES: dict[str, list[str]] = {
|
|
45
|
+
"generic": [
|
|
46
|
+
"Aldwin", "Brennan", "Caldor", "Dunmere", "Eldric", "Faron",
|
|
47
|
+
"Garrick", "Hadwin", "Ironholt", "Kelmoor", "Lannet", "Marden",
|
|
48
|
+
"Norwick", "Oakheart", "Pelmont", "Ravenwood", "Stonefel", "Thornby",
|
|
49
|
+
"Ulmar", "Vendry", "Westmere", "Wyndham", "Yardley", "Ashford",
|
|
50
|
+
"Briarcliff", "Greymoor", "Holloway", "Redwyn", "Tarnwick",
|
|
51
|
+
],
|
|
52
|
+
"nordic": [
|
|
53
|
+
"Bjorn", "Sigurd", "Ragnar", "Eirik", "Halldor", "Gunnar", "Ivar",
|
|
54
|
+
"Knut", "Leif", "Olaf", "Sten", "Torvald", "Ulf", "Vidar", "Asgeir",
|
|
55
|
+
"Skuli", "Hakon", "Brandr", "Geirmund", "Snorri", "Thorgil",
|
|
56
|
+
"Frosta", "Helga", "Ingrid", "Sigrun", "Yngvar", "Skjold", "Dagny",
|
|
57
|
+
],
|
|
58
|
+
"latin": [
|
|
59
|
+
"Marcus", "Valeria", "Cassius", "Aurelia", "Decimus", "Octavia",
|
|
60
|
+
"Tiberius", "Lucilla", "Quintus", "Flavia", "Septimus", "Antonia",
|
|
61
|
+
"Cornelius", "Drusilla", "Gaius", "Livia", "Maximus", "Vipsania",
|
|
62
|
+
"Aelius", "Camilla", "Tarquin", "Sabina", "Verus", "Junia",
|
|
63
|
+
],
|
|
64
|
+
"elvish": [
|
|
65
|
+
"Aelar", "Caelynn", "Eluvian", "Faelar", "Galadriel", "Ithilien",
|
|
66
|
+
"Laeroth", "Mirelle", "Naerith", "Oromis", "Saelihn", "Thalion",
|
|
67
|
+
"Aerendyl", "Cithreth", "Elarian", "Faewyn", "Illianor", "Lithriel",
|
|
68
|
+
"Nimriel", "Sylvaris", "Vaelynn", "Aelirien", "Maeglin", "Tinuviel",
|
|
69
|
+
],
|
|
70
|
+
"dwarvish": [
|
|
71
|
+
"Borin", "Durgan", "Thrain", "Balgrim", "Kragdor", "Norund",
|
|
72
|
+
"Gimrek", "Brundal", "Khazek", "Morgrum", "Thoradin", "Brokkur",
|
|
73
|
+
"Dvalin", "Grundvik", "Hrothgar", "Kazrik", "Orin", "Throndur",
|
|
74
|
+
"Belmund", "Garrundr", "Korvald", "Stonebeard", "Ironfist", "Deepdelve",
|
|
75
|
+
],
|
|
76
|
+
"eastern": [
|
|
77
|
+
"Akihiro", "Renjiro", "Haruki", "Kenshin", "Daichi", "Sora",
|
|
78
|
+
"Takeo", "Yorimoto", "Ryusei", "Kaede", "Michiyo", "Tamaki",
|
|
79
|
+
"Hideaki", "Naoki", "Shinobu", "Yukihiro", "Asuka", "Reiko",
|
|
80
|
+
"Toshiro", "Mizuki", "Hayato", "Sayuri", "Kojiro", "Emiko",
|
|
81
|
+
],
|
|
82
|
+
"desert": [
|
|
83
|
+
"Aziz", "Farran", "Jamil", "Karim", "Nasir", "Rashid", "Tariq",
|
|
84
|
+
"Yusuf", "Zahir", "Amina", "Layla", "Nadira", "Samira", "Zaida",
|
|
85
|
+
"Hakim", "Ibrahim", "Khalil", "Mansur", "Rafiq", "Saladin",
|
|
86
|
+
"Bahram", "Cyrus", "Darius", "Faridun",
|
|
87
|
+
],
|
|
88
|
+
"dark": [
|
|
89
|
+
"Malketh", "Vornak", "Drathys", "Skorvath", "Nyxara", "Vexmoor",
|
|
90
|
+
"Gorthak", "Zultharn", "Morvath", "Skarn", "Velkith", "Drusk",
|
|
91
|
+
"Azgoth", "Korrath", "Nethyr", "Sablethorn", "Grimwald", "Vauldrek",
|
|
92
|
+
"Mortessa", "Shaelgar", "Thessaroth", "Ulvyn", "Xathorne", "Zerith",
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Optional culture-flavoured settlement suffixes, appended to a base name to
|
|
97
|
+
# form place names ("Eld" + "moor" -> "Eldmoor"). Mirrors how Azgaar composes
|
|
98
|
+
# burg names from a root plus a geographic suffix.
|
|
99
|
+
SETTLEMENT_SUFFIXES: dict[str, list[str]] = {
|
|
100
|
+
"generic": ["ton", "ford", "moor", "wick", "bury", "hill", "dale", "field",
|
|
101
|
+
"gate", "haven", "crest", "hollow", "march", "watch"],
|
|
102
|
+
"nordic": ["heim", "vik", "fjord", "gard", "stad", "ness", "fell", "by"],
|
|
103
|
+
"latin": ["um", "ium", "polis", "ara", "ena", "ica", "anum"],
|
|
104
|
+
"elvish": ["wood", "lond", "thil", "mar", "vale", "shire", "loth", "wen"],
|
|
105
|
+
"dwarvish": ["delve", "hold", "forge", "deep", "barrow", "mount", "karak"],
|
|
106
|
+
"eastern": ["mura", "gawa", "yama", "shiro", "do", "ji", "saki"],
|
|
107
|
+
"desert": ["abad", "stan", "kar", "oasis", "dune", "mir", "sah"],
|
|
108
|
+
"dark": ["spire", "gloom", "barrow", "throne", "mire", "fang", "shroud"],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# A reasonable default when a caller asks for an unknown culture.
|
|
112
|
+
_FALLBACK_CULTURE = "generic"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class MarkovNameGenerator:
|
|
116
|
+
"""An order-``k`` character-level Markov chain trained on one word list."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, words: list[str], order: int = 3):
|
|
119
|
+
self.order = order
|
|
120
|
+
# Keep a clean, lowercased copy of the training words so we can reject
|
|
121
|
+
# exact reproductions and know plausible length bounds.
|
|
122
|
+
self._training = {w.lower() for w in words if w and w.isalpha()}
|
|
123
|
+
self._min_len, self._max_len = self._length_bounds(self._training)
|
|
124
|
+
self._chain: dict[str, list[str]] = self._build_chain(self._training)
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _length_bounds(words: set[str]) -> tuple[int, int]:
|
|
128
|
+
if not words:
|
|
129
|
+
return 4, 10
|
|
130
|
+
lengths = [len(w) for w in words]
|
|
131
|
+
return max(3, min(lengths)), max(lengths) + 2
|
|
132
|
+
|
|
133
|
+
def _build_chain(self, words: set[str]) -> dict[str, list[str]]:
|
|
134
|
+
"""Map each k-char context to the list of characters that follow it.
|
|
135
|
+
|
|
136
|
+
We store the raw (multiset) list rather than a Counter so that picking a
|
|
137
|
+
next char with ``rng.choice`` is automatically frequency-weighted.
|
|
138
|
+
Words are padded with ``order`` boundary sentinels on each side.
|
|
139
|
+
|
|
140
|
+
``words`` is iterated in **sorted** order, not set order: the follower
|
|
141
|
+
lists are positional, so set iteration (salted by ``PYTHONHASHSEED``)
|
|
142
|
+
would make ``rng.choice`` pick differently across processes and break
|
|
143
|
+
the library's seed-reproducibility guarantee.
|
|
144
|
+
"""
|
|
145
|
+
chain: dict[str, list[str]] = defaultdict(list)
|
|
146
|
+
pad = _BOUNDARY * self.order
|
|
147
|
+
for word in sorted(words):
|
|
148
|
+
padded = pad + word + _BOUNDARY
|
|
149
|
+
for i in range(len(padded) - self.order):
|
|
150
|
+
context = padded[i : i + self.order]
|
|
151
|
+
nxt = padded[i + self.order]
|
|
152
|
+
chain[context].append(nxt)
|
|
153
|
+
return dict(chain)
|
|
154
|
+
|
|
155
|
+
def generate(
|
|
156
|
+
self,
|
|
157
|
+
rng: SeededRNG,
|
|
158
|
+
min_len: Optional[int] = None,
|
|
159
|
+
max_len: Optional[int] = None,
|
|
160
|
+
max_attempts: int = 40,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Walk the chain to produce a single capitalised name.
|
|
163
|
+
|
|
164
|
+
Retries up to ``max_attempts`` times to satisfy the length bounds and
|
|
165
|
+
avoid echoing a training word; falls back to the best candidate seen.
|
|
166
|
+
"""
|
|
167
|
+
if not self._chain:
|
|
168
|
+
return ""
|
|
169
|
+
lo = min_len if min_len is not None else self._min_len
|
|
170
|
+
hi = max_len if max_len is not None else self._max_len
|
|
171
|
+
pad = _BOUNDARY * self.order
|
|
172
|
+
|
|
173
|
+
best = ""
|
|
174
|
+
for _ in range(max_attempts):
|
|
175
|
+
context = pad
|
|
176
|
+
out: list[str] = []
|
|
177
|
+
while True:
|
|
178
|
+
followers = self._chain.get(context)
|
|
179
|
+
if not followers:
|
|
180
|
+
break
|
|
181
|
+
nxt = rng.choice(followers)
|
|
182
|
+
if nxt == _BOUNDARY:
|
|
183
|
+
break
|
|
184
|
+
out.append(nxt)
|
|
185
|
+
if len(out) >= hi: # hard stop on runaway chains
|
|
186
|
+
break
|
|
187
|
+
context = (context + nxt)[-self.order :]
|
|
188
|
+
word = "".join(out)
|
|
189
|
+
if len(word) > len(best):
|
|
190
|
+
best = word
|
|
191
|
+
if lo <= len(word) <= hi and word not in self._training:
|
|
192
|
+
return word.capitalize()
|
|
193
|
+
return best.capitalize() if best else ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class NameGenerator:
|
|
197
|
+
"""High-level, culture-aware naming facade over per-culture Markov chains.
|
|
198
|
+
|
|
199
|
+
Holds a :class:`SeededRNG` and lazily builds (and caches) one
|
|
200
|
+
:class:`MarkovNameGenerator` per culture. All draws go through a derived
|
|
201
|
+
``"names"`` sub-stream so naming stays decoupled from terrain generation —
|
|
202
|
+
adding a name call never shifts the terrain RNG.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, rng: SeededRNG, order: int = 3):
|
|
206
|
+
self._rng = rng.derive("names")
|
|
207
|
+
self._order = order
|
|
208
|
+
self._chains: dict[str, MarkovNameGenerator] = {}
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def cultures() -> list[str]:
|
|
212
|
+
"""The set of built-in culture keys available for naming."""
|
|
213
|
+
return sorted(NAMEBASES.keys())
|
|
214
|
+
|
|
215
|
+
def _chain_for(self, culture: str) -> MarkovNameGenerator:
|
|
216
|
+
key = culture if culture in NAMEBASES else _FALLBACK_CULTURE
|
|
217
|
+
if key not in self._chains:
|
|
218
|
+
self._chains[key] = MarkovNameGenerator(NAMEBASES[key], self._order)
|
|
219
|
+
return self._chains[key]
|
|
220
|
+
|
|
221
|
+
# -- public naming API ----------------------------------------------
|
|
222
|
+
|
|
223
|
+
def place(self, culture: str = "generic") -> str:
|
|
224
|
+
"""A bare place/region name in the given culture's style."""
|
|
225
|
+
return self._chain_for(culture).generate(self._rng)
|
|
226
|
+
|
|
227
|
+
def person(self, culture: str = "generic") -> str:
|
|
228
|
+
"""A personal name in the given culture's style."""
|
|
229
|
+
return self._chain_for(culture).generate(self._rng)
|
|
230
|
+
|
|
231
|
+
def settlement(self, culture: str = "generic", suffix_chance: float = 0.65) -> str:
|
|
232
|
+
"""A settlement name: a root name, often plus a geographic suffix.
|
|
233
|
+
|
|
234
|
+
e.g. ``"Eld" + "moor" -> "Eldmoor"``. With probability
|
|
235
|
+
``1 - suffix_chance`` the bare root is returned instead, for variety.
|
|
236
|
+
"""
|
|
237
|
+
root = self.place(culture)
|
|
238
|
+
if not root:
|
|
239
|
+
return root
|
|
240
|
+
suffixes = SETTLEMENT_SUFFIXES.get(culture) or SETTLEMENT_SUFFIXES[_FALLBACK_CULTURE]
|
|
241
|
+
if suffixes and self._rng.chance(suffix_chance):
|
|
242
|
+
# Drop a trailing vowel before a vowel-initial suffix to avoid
|
|
243
|
+
# awkward clusters ("Elda" + "moor" -> "Eldmoor").
|
|
244
|
+
suffix = self._rng.choice(suffixes)
|
|
245
|
+
if root[-1].lower() in "aeiou" and suffix[0].lower() not in "aeiou":
|
|
246
|
+
root = root[:-1]
|
|
247
|
+
return (root + suffix).capitalize()
|
|
248
|
+
return root
|
|
249
|
+
|
|
250
|
+
def region(self, culture: str = "generic") -> str:
|
|
251
|
+
"""A region/territory name, e.g. "The Tarnwick Reach"."""
|
|
252
|
+
forms = [
|
|
253
|
+
"{name}",
|
|
254
|
+
"The {name} Reach",
|
|
255
|
+
"{name} March",
|
|
256
|
+
"{name}land",
|
|
257
|
+
"Vale of {name}",
|
|
258
|
+
"The {name} Wastes",
|
|
259
|
+
]
|
|
260
|
+
name = self.place(culture)
|
|
261
|
+
return self._rng.choice(forms).format(name=name) if name else name
|
mapwright/rng.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Unified seeded random-number generator for procedural map generation.
|
|
2
|
+
|
|
3
|
+
Ported from the discipline used by Azgaar's Fantasy-Map-Generator (MIT) and
|
|
4
|
+
Watabou's generators: a *single* seed drives *every* stage of generation, so
|
|
5
|
+
the same seed always reproduces the same world. The key idea that the previous
|
|
6
|
+
``random.seed(seed)`` / ``np.random.seed(seed)`` pattern lacked:
|
|
7
|
+
|
|
8
|
+
* It mutated Python's **global** ``random`` module state, so any other code
|
|
9
|
+
that drew from ``random`` (or any reordering of draws) silently changed the
|
|
10
|
+
output. Here the stream is **instance-local** — nothing leaks in or out.
|
|
11
|
+
* It kept two *uncoordinated* streams (stdlib ``random`` and ``numpy``). Here
|
|
12
|
+
both are derived from one seed, so a single integer reproduces terrain
|
|
13
|
+
*and* noise.
|
|
14
|
+
* Adding a draw anywhere shifted everything downstream. :meth:`derive` gives
|
|
15
|
+
each generation stage (terrain, naming, settlements, …) its own independent
|
|
16
|
+
sub-stream keyed by a label, so stages can evolve without desyncing.
|
|
17
|
+
|
|
18
|
+
Example::
|
|
19
|
+
|
|
20
|
+
rng = SeededRNG(request.seed) # rng.seed is the resolved int seed
|
|
21
|
+
terrain_rng = rng.derive("terrain") # independent, reproducible sub-stream
|
|
22
|
+
name_rng = rng.derive("names") # adding/removing draws here cannot
|
|
23
|
+
# shift terrain_rng's output
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import random
|
|
30
|
+
from typing import Optional, Sequence, TypeVar
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
# Seeds are kept inside the signed 31-bit range so they round-trip cleanly
|
|
37
|
+
# through JSON, the ``generation_seed`` column, and numpy's seed API.
|
|
38
|
+
_SEED_MODULUS = 2**31
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _coerce_seed(seed: Optional[int]) -> int:
|
|
42
|
+
"""Resolve an optional seed to a concrete, reproducible 31-bit integer.
|
|
43
|
+
|
|
44
|
+
When ``seed`` is ``None`` we draw a fresh one from the OS entropy pool and
|
|
45
|
+
*return it* so callers can persist it and replay the exact same map later.
|
|
46
|
+
"""
|
|
47
|
+
if seed is None:
|
|
48
|
+
# SystemRandom is process-state-free, so picking the auto-seed never
|
|
49
|
+
# perturbs any SeededRNG stream.
|
|
50
|
+
return random.SystemRandom().randrange(_SEED_MODULUS)
|
|
51
|
+
return int(seed) % _SEED_MODULUS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _derive_seed(parent_seed: int, label: str) -> int:
|
|
55
|
+
"""Deterministically mix ``parent_seed`` with ``label`` into a child seed.
|
|
56
|
+
|
|
57
|
+
Uses BLAKE2b rather than the builtin ``hash()`` because ``hash()`` of a
|
|
58
|
+
string is salted per-process (``PYTHONHASHSEED``) and would make derived
|
|
59
|
+
streams non-reproducible across runs.
|
|
60
|
+
"""
|
|
61
|
+
digest = hashlib.blake2b(
|
|
62
|
+
f"{parent_seed}:{label}".encode("utf-8"), digest_size=8
|
|
63
|
+
).digest()
|
|
64
|
+
return int.from_bytes(digest, "big") % _SEED_MODULUS
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SeededRNG:
|
|
68
|
+
"""A reproducible, instance-local random stream with derivable sub-streams.
|
|
69
|
+
|
|
70
|
+
Wraps a private :class:`random.Random` and a private
|
|
71
|
+
:class:`numpy.random.Generator`, both seeded from the same integer. None of
|
|
72
|
+
the methods touch global RNG state.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
__slots__ = ("seed", "_rng", "_np")
|
|
76
|
+
|
|
77
|
+
def __init__(self, seed: Optional[int] = None):
|
|
78
|
+
self.seed: int = _coerce_seed(seed)
|
|
79
|
+
self._rng = random.Random(self.seed)
|
|
80
|
+
self._np: Optional[np.random.Generator] = None # lazily constructed
|
|
81
|
+
|
|
82
|
+
# -- sub-streams -----------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def derive(self, label: str) -> "SeededRNG":
|
|
85
|
+
"""Return an independent child stream keyed by ``label``.
|
|
86
|
+
|
|
87
|
+
Same parent seed + same label always yields the same child, but the
|
|
88
|
+
child's draws are statistically independent of the parent's and of
|
|
89
|
+
sibling labels. This is how generation stages stay decoupled.
|
|
90
|
+
"""
|
|
91
|
+
return SeededRNG(_derive_seed(self.seed, label))
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def numpy(self) -> np.random.Generator:
|
|
95
|
+
"""A numpy ``Generator`` seeded from the same seed (for vectorised noise)."""
|
|
96
|
+
if self._np is None:
|
|
97
|
+
self._np = np.random.default_rng(self.seed)
|
|
98
|
+
return self._np
|
|
99
|
+
|
|
100
|
+
# -- scalar draws ----------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def random(self) -> float:
|
|
103
|
+
"""Float in ``[0.0, 1.0)``."""
|
|
104
|
+
return self._rng.random()
|
|
105
|
+
|
|
106
|
+
def uniform(self, low: float, high: float) -> float:
|
|
107
|
+
"""Float in ``[low, high)``."""
|
|
108
|
+
return self._rng.uniform(low, high)
|
|
109
|
+
|
|
110
|
+
def randint(self, low: int, high: int) -> int:
|
|
111
|
+
"""Integer in ``[low, high]`` (inclusive on both ends, like stdlib)."""
|
|
112
|
+
return self._rng.randint(low, high)
|
|
113
|
+
|
|
114
|
+
def chance(self, probability: float) -> bool:
|
|
115
|
+
"""``True`` with the given probability (Watabou's ``Random.bool``)."""
|
|
116
|
+
return self._rng.random() < probability
|
|
117
|
+
|
|
118
|
+
def gauss(self, mu: float = 0.0, sigma: float = 1.0) -> float:
|
|
119
|
+
"""A normally-distributed float."""
|
|
120
|
+
return self._rng.gauss(mu, sigma)
|
|
121
|
+
|
|
122
|
+
def fuzzy(self, value: float, spread: float) -> float:
|
|
123
|
+
"""Jitter ``value`` by ``±spread`` uniformly.
|
|
124
|
+
|
|
125
|
+
Watabou uses this constantly to break up grid regularity (e.g. nudging
|
|
126
|
+
polygon vertices). ``rng.fuzzy(10, 2)`` returns a float in ``[8, 12)``.
|
|
127
|
+
"""
|
|
128
|
+
return value + self._rng.uniform(-spread, spread)
|
|
129
|
+
|
|
130
|
+
# -- sequence draws --------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def choice(self, seq: Sequence[T]) -> T:
|
|
133
|
+
"""Uniformly pick one element."""
|
|
134
|
+
return self._rng.choice(seq)
|
|
135
|
+
|
|
136
|
+
def choices(
|
|
137
|
+
self,
|
|
138
|
+
population: Sequence[T],
|
|
139
|
+
weights: Optional[Sequence[float]] = None,
|
|
140
|
+
k: int = 1,
|
|
141
|
+
) -> list[T]:
|
|
142
|
+
"""Weighted sampling with replacement (stdlib semantics)."""
|
|
143
|
+
return self._rng.choices(population, weights=weights, k=k)
|
|
144
|
+
|
|
145
|
+
def weighted(self, options: dict[T, float]) -> T:
|
|
146
|
+
"""Pick one key from a ``{value: weight}`` mapping, proportional to weight.
|
|
147
|
+
|
|
148
|
+
The bread-and-butter of Azgaar's culture/state/biome selection.
|
|
149
|
+
"""
|
|
150
|
+
keys = list(options.keys())
|
|
151
|
+
weights = list(options.values())
|
|
152
|
+
return self._rng.choices(keys, weights=weights, k=1)[0]
|
|
153
|
+
|
|
154
|
+
def shuffle(self, seq: list) -> None:
|
|
155
|
+
"""In-place shuffle."""
|
|
156
|
+
self._rng.shuffle(seq)
|
|
157
|
+
|
|
158
|
+
def sample(self, population: Sequence[T], k: int) -> list[T]:
|
|
159
|
+
"""Sample ``k`` distinct elements (without replacement)."""
|
|
160
|
+
return self._rng.sample(list(population), k)
|
|
161
|
+
|
|
162
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
163
|
+
return f"SeededRNG(seed={self.seed})"
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Vector (SVG) renderer for regional terrain, with shaded relief.
|
|
2
|
+
|
|
3
|
+
Renders a :class:`~src.mapwright.terrain.TerrainResult` as a scalable SVG map:
|
|
4
|
+
organic Voronoi biome polygons, slope-based **shaded relief** (the hillshade
|
|
5
|
+
technique from rlguy/Mewo2's FantasyMapGenerator — per-cell surface normals lit
|
|
6
|
+
by a fixed light direction), a coastline stroke, rivers whose width tracks
|
|
7
|
+
discharge, and labelled settlement markers.
|
|
8
|
+
|
|
9
|
+
Why SVG for this tier (vs the PNG tile compositor used for tactical maps):
|
|
10
|
+
world/regional maps want to zoom, restyle, and carry crisp labels — all of which
|
|
11
|
+
vector handles for free, and it sidesteps the "fog/grid baked into the PNG"
|
|
12
|
+
tech-debt. Everything here is pure string-building (no new dependencies).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import xml.sax.saxutils as su
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Optional, Sequence
|
|
21
|
+
|
|
22
|
+
from .terrain import Biome, TerrainCell, TerrainResult, compute_cell_polygons
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Marker:
|
|
27
|
+
"""A neutral point-of-interest to label on the map (e.g. a settlement).
|
|
28
|
+
|
|
29
|
+
Domain-neutral on purpose: a host app maps its own feature objects onto this
|
|
30
|
+
rather than the renderer depending on the host's models. ``kind`` selects the
|
|
31
|
+
marker size (see ``_SETTLEMENT_RADIUS``) and a substring of it (e.g. "city")
|
|
32
|
+
bumps the label font.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
x: float
|
|
37
|
+
y: float
|
|
38
|
+
kind: str = ""
|
|
39
|
+
|
|
40
|
+
# Base biome fill colours (before relief shading), tuned for a parchment-ish
|
|
41
|
+
# fantasy palette rather than the dungeon tile colours.
|
|
42
|
+
_BIOME_RGB: dict[Biome, tuple[int, int, int]] = {
|
|
43
|
+
Biome.OCEAN: (31, 78, 107),
|
|
44
|
+
Biome.COAST: (61, 126, 166),
|
|
45
|
+
Biome.BEACH: (217, 199, 155),
|
|
46
|
+
Biome.DESERT: (214, 196, 130),
|
|
47
|
+
Biome.PLAINS: (169, 196, 127),
|
|
48
|
+
Biome.FOREST: (79, 130, 74),
|
|
49
|
+
Biome.SWAMP: (107, 123, 74),
|
|
50
|
+
Biome.HILLS: (160, 154, 100),
|
|
51
|
+
Biome.MOUNTAIN: (140, 132, 122),
|
|
52
|
+
Biome.TUNDRA: (188, 196, 180),
|
|
53
|
+
Biome.SNOW: (240, 244, 248),
|
|
54
|
+
Biome.RIVER: (127, 168, 106), # riverbank green; the river line draws on top
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_OCEAN_BG = (24, 62, 86)
|
|
58
|
+
_COASTLINE = (40, 54, 64)
|
|
59
|
+
_RIVER_COLOR = (74, 130, 175)
|
|
60
|
+
|
|
61
|
+
_SETTLEMENT_RADIUS = {
|
|
62
|
+
"settlement_city": 5.5,
|
|
63
|
+
"settlement_town": 4.0,
|
|
64
|
+
"settlement_village": 3.0,
|
|
65
|
+
"settlement_castle": 4.5,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _shade(base: tuple[int, int, int], brightness: float) -> str:
|
|
70
|
+
"""Apply a relief brightness multiplier to a colour → ``#rrggbb``."""
|
|
71
|
+
return "#%02x%02x%02x" % tuple(
|
|
72
|
+
max(0, min(255, int(round(ch * brightness)))) for ch in base
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _rgb(c: tuple[int, int, int]) -> str:
|
|
77
|
+
return "#%02x%02x%02x" % c
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RegionalSVGRenderer:
|
|
81
|
+
"""Renders a :class:`TerrainResult` to an SVG document string."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, scale: float = 16.0, relief_strength: float = 60.0):
|
|
84
|
+
# scale = pixels per tile-unit; relief_strength exaggerates slope so the
|
|
85
|
+
# hillshade reads on gentle terrain (height diffs between cells are tiny,
|
|
86
|
+
# so this needs to be large).
|
|
87
|
+
self.scale = scale
|
|
88
|
+
self.relief_strength = relief_strength
|
|
89
|
+
# Light from the upper-left (classic cartographic convention).
|
|
90
|
+
lx, ly, lz = -1.0, -1.0, 1.4
|
|
91
|
+
norm = math.sqrt(lx * lx + ly * ly + lz * lz)
|
|
92
|
+
self._light = (lx / norm, ly / norm, lz / norm)
|
|
93
|
+
|
|
94
|
+
def render(
|
|
95
|
+
self,
|
|
96
|
+
terrain: TerrainResult,
|
|
97
|
+
markers: Optional[Sequence[Marker]] = None,
|
|
98
|
+
*,
|
|
99
|
+
show_relief: bool = True,
|
|
100
|
+
show_labels: bool = True,
|
|
101
|
+
) -> str:
|
|
102
|
+
s = self.scale
|
|
103
|
+
w_px, h_px = terrain.width * s, terrain.height * s
|
|
104
|
+
polys = compute_cell_polygons(terrain.cells, terrain.width, terrain.height)
|
|
105
|
+
brightness = self._relief(terrain.cells) if show_relief else {}
|
|
106
|
+
|
|
107
|
+
parts: list[str] = [
|
|
108
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w_px:.0f}" '
|
|
109
|
+
f'height="{h_px:.0f}" viewBox="0 0 {w_px:.0f} {h_px:.0f}">',
|
|
110
|
+
f'<rect width="{w_px:.0f}" height="{h_px:.0f}" fill="{_rgb(_OCEAN_BG)}"/>',
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# 1. Biome polygons with relief shading.
|
|
114
|
+
parts.append('<g stroke-linejoin="round">')
|
|
115
|
+
for cell in terrain.cells:
|
|
116
|
+
poly = polys.get(cell.id)
|
|
117
|
+
if not poly or len(poly) < 3:
|
|
118
|
+
continue
|
|
119
|
+
fill = _shade(_BIOME_RGB[cell.biome], brightness.get(cell.id, 1.0))
|
|
120
|
+
pts = " ".join(f"{x * s:.1f},{y * s:.1f}" for x, y in poly)
|
|
121
|
+
# A hairline stroke in the fill colour hides seams between cells.
|
|
122
|
+
parts.append(f'<polygon points="{pts}" fill="{fill}" stroke="{fill}" '
|
|
123
|
+
f'stroke-width="0.5"/>')
|
|
124
|
+
parts.append("</g>")
|
|
125
|
+
|
|
126
|
+
# 2. Coastline — edges of land cells that border the sea.
|
|
127
|
+
parts.append(self._coastline_svg(terrain, polys))
|
|
128
|
+
|
|
129
|
+
# 3. Rivers — downhill polylines, width by discharge.
|
|
130
|
+
parts.append(self._rivers_svg(terrain))
|
|
131
|
+
|
|
132
|
+
# 4. Settlements.
|
|
133
|
+
if markers:
|
|
134
|
+
parts.append(self._settlements_svg(markers, show_labels))
|
|
135
|
+
|
|
136
|
+
parts.append("</svg>")
|
|
137
|
+
return "".join(parts)
|
|
138
|
+
|
|
139
|
+
# -- relief ----------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _relief(self, cells: list[TerrainCell]) -> dict[int, float]:
|
|
142
|
+
"""Per-cell brightness from a slope normal lit by the fixed light."""
|
|
143
|
+
out: dict[int, float] = {}
|
|
144
|
+
lx, ly, lz = self._light
|
|
145
|
+
for c in cells:
|
|
146
|
+
if c.is_water:
|
|
147
|
+
out[c.id] = 1.0
|
|
148
|
+
continue
|
|
149
|
+
gx = gy = 0.0
|
|
150
|
+
count = 0
|
|
151
|
+
for nid in c.neighbors:
|
|
152
|
+
n = cells[nid]
|
|
153
|
+
dx, dy = n.cx - c.cx, n.cy - c.cy
|
|
154
|
+
d2 = dx * dx + dy * dy
|
|
155
|
+
if d2 <= 0:
|
|
156
|
+
continue
|
|
157
|
+
dh = (n.height - c.height) * self.relief_strength
|
|
158
|
+
gx += dh * dx / d2
|
|
159
|
+
gy += dh * dy / d2
|
|
160
|
+
count += 1
|
|
161
|
+
if count:
|
|
162
|
+
gx /= count
|
|
163
|
+
gy /= count
|
|
164
|
+
# Surface normal of the local plane z = -gx*x - gy*y, lit by the
|
|
165
|
+
# light direction. Steeper slopes facing the light brighten; those
|
|
166
|
+
# facing away darken — classic hillshade.
|
|
167
|
+
nx, ny, nz = -gx, -gy, 1.0
|
|
168
|
+
nlen = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
|
|
169
|
+
shade = (nx * lx + ny * ly + nz * lz) / nlen # ~0..1, ~0.7 when flat
|
|
170
|
+
# Re-centre around the flat-ground value (lz) so flat terrain stays
|
|
171
|
+
# neutral and aspect drives the contrast.
|
|
172
|
+
out[c.id] = max(0.66, min(1.28, 1.0 + 1.1 * (shade - lz)))
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
# -- coastline -------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _coastline_svg(self, terrain: TerrainResult, polys) -> str:
|
|
178
|
+
s = self.scale
|
|
179
|
+
cells = terrain.cells
|
|
180
|
+
segs: list[str] = []
|
|
181
|
+
eps = 1e-6
|
|
182
|
+
w, h = terrain.width, terrain.height
|
|
183
|
+
for c in cells:
|
|
184
|
+
if c.is_water:
|
|
185
|
+
continue
|
|
186
|
+
poly = polys.get(c.id)
|
|
187
|
+
if not poly or len(poly) < 3:
|
|
188
|
+
continue
|
|
189
|
+
n = len(poly)
|
|
190
|
+
for i in range(n):
|
|
191
|
+
a, b = poly[i], poly[(i + 1) % n]
|
|
192
|
+
mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
|
|
193
|
+
# Skip edges lying on the map border (not a real coastline).
|
|
194
|
+
if mx < eps or my < eps or mx > w - eps or my > h - eps:
|
|
195
|
+
continue
|
|
196
|
+
# The neighbour across this edge is the nearest cell-site to the
|
|
197
|
+
# edge midpoint (the edge lies on their shared bisector).
|
|
198
|
+
nb = min(c.neighbors,
|
|
199
|
+
key=lambda k: (cells[k].cx - mx) ** 2 + (cells[k].cy - my) ** 2,
|
|
200
|
+
default=None)
|
|
201
|
+
if nb is not None and cells[nb].is_water:
|
|
202
|
+
segs.append(f'M{a[0] * s:.1f},{a[1] * s:.1f} '
|
|
203
|
+
f'L{b[0] * s:.1f},{b[1] * s:.1f}')
|
|
204
|
+
if not segs:
|
|
205
|
+
return ""
|
|
206
|
+
return (f'<path d="{" ".join(segs)}" fill="none" stroke="{_rgb(_COASTLINE)}" '
|
|
207
|
+
f'stroke-width="2.0" stroke-linecap="round"/>')
|
|
208
|
+
|
|
209
|
+
# -- rivers ----------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _rivers_svg(self, terrain: TerrainResult) -> str:
|
|
212
|
+
s = self.scale
|
|
213
|
+
cells = terrain.cells
|
|
214
|
+
paths: list[str] = []
|
|
215
|
+
for river in terrain.rivers:
|
|
216
|
+
if len(river.cells) < 2:
|
|
217
|
+
continue
|
|
218
|
+
pts = [(cells[i].cx * s, cells[i].cy * s) for i in river.cells]
|
|
219
|
+
d = "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts)
|
|
220
|
+
width = max(0.8, min(4.0, 0.4 * river.width))
|
|
221
|
+
paths.append(f'<path d="{d}" fill="none" stroke="{_rgb(_RIVER_COLOR)}" '
|
|
222
|
+
f'stroke-width="{width:.1f}" stroke-linecap="round" '
|
|
223
|
+
f'stroke-linejoin="round"/>')
|
|
224
|
+
if not paths:
|
|
225
|
+
return ""
|
|
226
|
+
return '<g opacity="0.9">' + "".join(paths) + "</g>"
|
|
227
|
+
|
|
228
|
+
# -- settlements -----------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def _settlements_svg(self, markers: Sequence[Marker], labels: bool) -> str:
|
|
231
|
+
s = self.scale
|
|
232
|
+
out: list[str] = ['<g font-family="Georgia, serif">']
|
|
233
|
+
for m in markers:
|
|
234
|
+
r = _SETTLEMENT_RADIUS.get(m.kind, 3.0)
|
|
235
|
+
cx, cy = m.x * s, m.y * s
|
|
236
|
+
out.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" '
|
|
237
|
+
f'fill="#f4ead8" stroke="#2b2b2b" stroke-width="1.2"/>')
|
|
238
|
+
if labels and m.name:
|
|
239
|
+
name = su.escape(m.name)
|
|
240
|
+
tx, ty = cx + r + 2, cy + 3
|
|
241
|
+
fs = 11 if "city" in m.kind else 9
|
|
242
|
+
# White halo behind the label for legibility over any biome.
|
|
243
|
+
out.append(
|
|
244
|
+
f'<text x="{tx:.1f}" y="{ty:.1f}" font-size="{fs}" '
|
|
245
|
+
f'stroke="#f7f3ea" stroke-width="3" paint-order="stroke" '
|
|
246
|
+
f'fill="#23211c">{name}</text>'
|
|
247
|
+
)
|
|
248
|
+
out.append("</g>")
|
|
249
|
+
return "".join(out)
|