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/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""mapwright — domain-neutral procedural fantasy map & world generation.
|
|
2
|
+
|
|
3
|
+
A dependency-light (numpy-only) library:
|
|
4
|
+
|
|
5
|
+
* :class:`SeededRNG` — one seed drives everything; ``.derive(label)`` gives
|
|
6
|
+
independent, reproducible sub-streams.
|
|
7
|
+
* :class:`NameGenerator` — order-k Markov place/person names in several culture
|
|
8
|
+
styles, seed-reproducible across processes.
|
|
9
|
+
* :class:`RegionalTerrainGenerator` — Voronoi cells (Lloyd-relaxed) → heightmap
|
|
10
|
+
→ Planchon–Darboux depression fill → flux + hydraulic/creep erosion → rivers
|
|
11
|
+
→ latitude/elevation climate → Whittaker biomes. Returns neutral data
|
|
12
|
+
(:class:`Biome`, :class:`TerrainResult`); mapping it onto a host app's tile
|
|
13
|
+
vocabulary is the caller's job.
|
|
14
|
+
* :class:`RegionalSVGRenderer` — shaded-relief (hillshade) SVG: biome polygons,
|
|
15
|
+
coastline, rivers, labelled :class:`Marker` points.
|
|
16
|
+
|
|
17
|
+
Built clean-room from the published ideas in Azgaar's Fantasy-Map-Generator (MIT)
|
|
18
|
+
and rlguy/Mewo2's FantasyMapGenerator (Zlib). See NOTICE.
|
|
19
|
+
|
|
20
|
+
Quickstart::
|
|
21
|
+
|
|
22
|
+
from mapwright import SeededRNG, RegionalTerrainGenerator, RegionalSVGRenderer
|
|
23
|
+
terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(60, 40)
|
|
24
|
+
svg = RegionalSVGRenderer().render(terrain)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .config import WorldMapConfig, PRESETS
|
|
28
|
+
from .dungeon import Dungeon, DungeonConfig, DungeonGenerator, Rect
|
|
29
|
+
from .names import NameGenerator, MarkovNameGenerator, NAMEBASES
|
|
30
|
+
from .rng import SeededRNG
|
|
31
|
+
from .svg_renderer import Marker, RegionalSVGRenderer
|
|
32
|
+
from .terrain import (
|
|
33
|
+
Biome,
|
|
34
|
+
River,
|
|
35
|
+
TerrainCell,
|
|
36
|
+
TerrainResult,
|
|
37
|
+
RegionalTerrainGenerator,
|
|
38
|
+
compute_cell_polygons,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"SeededRNG",
|
|
45
|
+
"WorldMapConfig",
|
|
46
|
+
"PRESETS",
|
|
47
|
+
"NameGenerator",
|
|
48
|
+
"MarkovNameGenerator",
|
|
49
|
+
"NAMEBASES",
|
|
50
|
+
"Biome",
|
|
51
|
+
"River",
|
|
52
|
+
"TerrainCell",
|
|
53
|
+
"TerrainResult",
|
|
54
|
+
"RegionalTerrainGenerator",
|
|
55
|
+
"compute_cell_polygons",
|
|
56
|
+
"Marker",
|
|
57
|
+
"RegionalSVGRenderer",
|
|
58
|
+
"Dungeon",
|
|
59
|
+
"DungeonConfig",
|
|
60
|
+
"DungeonGenerator",
|
|
61
|
+
"Rect",
|
|
62
|
+
]
|
mapwright/config.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Tunable world-generation parameters.
|
|
2
|
+
|
|
3
|
+
``WorldMapConfig`` is the single knob-set that shapes a generated world. Every
|
|
4
|
+
field is a bounded scalar or small int with a clear semantic and a default that
|
|
5
|
+
yields a balanced single-continent world — so it doubles as a **schema a host
|
|
6
|
+
app (or an LLM) can populate from a description**: "a frozen archipelago of
|
|
7
|
+
scattered isles" → ``WorldMapConfig(continents=7, sea_level=0.55, temperature=-0.8)``.
|
|
8
|
+
|
|
9
|
+
The library deliberately depends only on numpy, so this is a plain dataclass (no
|
|
10
|
+
pydantic). A host that wants JSON-schema/LLM population can mirror these fields
|
|
11
|
+
in its own model and call :meth:`WorldMapConfig.from_dict` (which clamps to
|
|
12
|
+
valid ranges, so a sloppy LLM payload can't produce a broken world).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import asdict, dataclass, fields
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
|
21
|
+
return max(lo, min(hi, value))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Single source of truth for every knob: (name, type, min, max, description).
|
|
25
|
+
# Drives both __post_init__ clamping and json_schema(), so the validation
|
|
26
|
+
# behaviour and the published contract can never disagree.
|
|
27
|
+
_SPEC: list[tuple] = [
|
|
28
|
+
("sea_level", float, 0.05, 0.9,
|
|
29
|
+
"Fraction of the height range below water. Higher = more ocean."),
|
|
30
|
+
("continents", int, 1, 24,
|
|
31
|
+
"Number of major landmasses. 1 = single continent; 4-8 = archipelago."),
|
|
32
|
+
("continent_spread", float, 0.0, 1.0,
|
|
33
|
+
"0 = landmasses cluster at the centre; 1 = pushed toward the edges."),
|
|
34
|
+
("edge_falloff", float, 0.0, 2.0,
|
|
35
|
+
"0 = land may reach the map border; 1 = strong 'ringed by sea' coastline."),
|
|
36
|
+
("mountain_density", float, 0.0, 1.0,
|
|
37
|
+
"Abundance and height of hills/ranges."),
|
|
38
|
+
("roughness", float, 0.0, 1.0,
|
|
39
|
+
"Terrain detail (number of erosion passes)."),
|
|
40
|
+
("temperature", float, -1.0, 1.0,
|
|
41
|
+
"Global temperature bias: -1 frozen .. +1 scorching."),
|
|
42
|
+
("moisture", float, -1.0, 1.0,
|
|
43
|
+
"Global moisture bias: -1 arid .. +1 drowned."),
|
|
44
|
+
("river_density", float, 0.0, 1.0,
|
|
45
|
+
"How readily rivers are traced; more = more, smaller rivers."),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class WorldMapConfig:
|
|
51
|
+
"""Knobs for :meth:`RegionalTerrainGenerator.generate`. Defaults = baseline."""
|
|
52
|
+
|
|
53
|
+
# --- Topology / landmass ---
|
|
54
|
+
sea_level: float = 0.32
|
|
55
|
+
"""0..1 — fraction of the height range below water. Higher ⇒ more ocean."""
|
|
56
|
+
continents: int = 1
|
|
57
|
+
"""Number of major landmasses. 1 = single continent; 4–8 ⇒ archipelago."""
|
|
58
|
+
continent_spread: float = 0.5
|
|
59
|
+
"""0 ⇒ landmasses cluster at the centre; 1 ⇒ pushed toward the edges."""
|
|
60
|
+
edge_falloff: float = 1.0
|
|
61
|
+
"""0 ⇒ land may reach the map border; 1 ⇒ strong 'ringed by sea' coastline."""
|
|
62
|
+
|
|
63
|
+
# --- Relief ---
|
|
64
|
+
mountain_density: float = 0.5
|
|
65
|
+
"""0..1 — abundance and height of hills/ranges."""
|
|
66
|
+
roughness: float = 0.5
|
|
67
|
+
"""0..1 — terrain detail (drives the number of erosion passes)."""
|
|
68
|
+
|
|
69
|
+
# --- Climate ---
|
|
70
|
+
temperature: float = 0.0
|
|
71
|
+
"""-1 (frozen) .. +1 (scorching) — global temperature bias."""
|
|
72
|
+
moisture: float = 0.0
|
|
73
|
+
"""-1 (arid) .. +1 (drowned) — global moisture bias."""
|
|
74
|
+
|
|
75
|
+
# --- Hydrology ---
|
|
76
|
+
river_density: float = 0.5
|
|
77
|
+
"""0..1 — how readily rivers are traced (more ⇒ more, smaller rivers)."""
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
# Clamp everything so out-of-range inputs (e.g. from an LLM) are safe.
|
|
81
|
+
for name, typ, lo, hi, _desc in _SPEC:
|
|
82
|
+
value = _clamp(getattr(self, name), lo, hi)
|
|
83
|
+
setattr(self, name, int(value) if typ is int else float(value))
|
|
84
|
+
|
|
85
|
+
# -- serialisation / interop ----------------------------------------
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
return asdict(self)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict) -> "WorldMapConfig":
|
|
92
|
+
"""Build from a (possibly partial / noisy) mapping; unknown keys ignored."""
|
|
93
|
+
known = {f.name for f in fields(cls)}
|
|
94
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def json_schema(cls) -> dict:
|
|
98
|
+
"""A JSON Schema (draft 2020-12) describing this config.
|
|
99
|
+
|
|
100
|
+
This is mapwright's machine-readable **contract** for world parameters:
|
|
101
|
+
a host app or an LLM can validate/generate payloads against it, then feed
|
|
102
|
+
them through :meth:`from_dict` (which additionally clamps). Generated from
|
|
103
|
+
the same field spec used for clamping, so schema and behaviour can't drift.
|
|
104
|
+
"""
|
|
105
|
+
defaults = cls()
|
|
106
|
+
properties = {
|
|
107
|
+
name: {
|
|
108
|
+
"type": "integer" if typ is int else "number",
|
|
109
|
+
"minimum": lo,
|
|
110
|
+
"maximum": hi,
|
|
111
|
+
"default": getattr(defaults, name),
|
|
112
|
+
"description": desc,
|
|
113
|
+
}
|
|
114
|
+
for name, typ, lo, hi, desc in _SPEC
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
118
|
+
"title": "WorldMapConfig",
|
|
119
|
+
"description": "Parameters that shape a mapwright world.",
|
|
120
|
+
"type": "object",
|
|
121
|
+
"additionalProperties": False,
|
|
122
|
+
"properties": properties,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# -- presets (also demonstrate the knobs & seed LLM choices) --------
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def preset(cls, name: str) -> "WorldMapConfig":
|
|
129
|
+
"""A named starting point. Raises KeyError for an unknown preset."""
|
|
130
|
+
return cls.from_dict(dict(PRESETS[name]))
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def preset_names() -> list[str]:
|
|
134
|
+
return sorted(PRESETS.keys())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Named presets — ready-made worlds and good LLM anchors. Each maps to a kind of
|
|
138
|
+
# narrative setting; a host can expose these by name or let an LLM pick + tweak.
|
|
139
|
+
PRESETS: dict[str, dict] = {
|
|
140
|
+
"continent": {}, # the balanced default
|
|
141
|
+
"pangaea": {"continents": 1, "sea_level": 0.22, "continent_spread": 0.15,
|
|
142
|
+
"edge_falloff": 0.55},
|
|
143
|
+
"archipelago": {"continents": 7, "sea_level": 0.55, "continent_spread": 0.75,
|
|
144
|
+
"mountain_density": 0.4},
|
|
145
|
+
"highlands": {"continents": 1, "mountain_density": 0.95, "roughness": 0.75,
|
|
146
|
+
"river_density": 0.7},
|
|
147
|
+
"desert": {"temperature": 0.85, "moisture": -0.85, "sea_level": 0.28,
|
|
148
|
+
"mountain_density": 0.25, "river_density": 0.12},
|
|
149
|
+
"arctic": {"temperature": -0.85, "moisture": 0.1, "mountain_density": 0.5},
|
|
150
|
+
"tropical": {"temperature": 0.6, "moisture": 0.85, "river_density": 0.85,
|
|
151
|
+
"mountain_density": 0.55},
|
|
152
|
+
"islands": {"continents": 12, "sea_level": 0.62, "continent_spread": 0.85,
|
|
153
|
+
"mountain_density": 0.3},
|
|
154
|
+
}
|
mapwright/dungeon.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Procedural dungeon generation: BSP rooms + minimum-spanning-tree corridors.
|
|
2
|
+
|
|
3
|
+
Clean-room from the ideas of two permissively-licensed generators:
|
|
4
|
+
* **BSP space partitioning** — Adrian Kulawik's *Dungeon-Generator* (MIT): split
|
|
5
|
+
the map recursively into leaves and place one room per leaf, so rooms never
|
|
6
|
+
overlap and space is used evenly.
|
|
7
|
+
* **MST connectivity** — *donjuan* (CC0): connect rooms with a minimum spanning
|
|
8
|
+
tree (Prim's) so every room is reachable with no redundant corridors, then add
|
|
9
|
+
a few extra edges for loops.
|
|
10
|
+
|
|
11
|
+
Domain-neutral: returns rooms (rectangles), carved corridor cells, and a boolean
|
|
12
|
+
walkable grid. A host maps these onto its own tiles/doors/encounters. Fully
|
|
13
|
+
seed-deterministic via :class:`SeededRNG`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .rng import SeededRNG
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Rect:
|
|
27
|
+
"""An axis-aligned room rectangle (integer tile coordinates)."""
|
|
28
|
+
|
|
29
|
+
x: int
|
|
30
|
+
y: int
|
|
31
|
+
w: int
|
|
32
|
+
h: int
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def cx(self) -> int:
|
|
36
|
+
return self.x + self.w // 2
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def cy(self) -> int:
|
|
40
|
+
return self.y + self.h // 2
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def center(self) -> tuple[int, int]:
|
|
44
|
+
return (self.cx, self.cy)
|
|
45
|
+
|
|
46
|
+
def intersects(self, other: "Rect", pad: int = 0) -> bool:
|
|
47
|
+
return (
|
|
48
|
+
self.x - pad < other.x + other.w
|
|
49
|
+
and self.x + self.w + pad > other.x
|
|
50
|
+
and self.y - pad < other.y + other.h
|
|
51
|
+
and self.y + self.h + pad > other.y
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class DungeonConfig:
|
|
57
|
+
"""Knobs for dungeon generation. Defaults give a balanced multi-room layout."""
|
|
58
|
+
|
|
59
|
+
min_leaf: int = 8
|
|
60
|
+
"""Smallest BSP leaf (tiles) — a leaf this size or smaller is not split."""
|
|
61
|
+
room_min: int = 3
|
|
62
|
+
"""Smallest room edge length."""
|
|
63
|
+
room_padding: int = 1
|
|
64
|
+
"""Gap kept between a room and its leaf's edges."""
|
|
65
|
+
split_jitter: float = 0.35
|
|
66
|
+
"""0 = always split a leaf in half; up to 0.5 = split position varies."""
|
|
67
|
+
extra_corridor_chance: float = 0.12
|
|
68
|
+
"""Probability per non-tree room-pair of an extra (loop) corridor."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Dungeon:
|
|
73
|
+
"""Generated dungeon: rooms, carved corridor cells, and a walkable grid."""
|
|
74
|
+
|
|
75
|
+
width: int
|
|
76
|
+
height: int
|
|
77
|
+
rooms: list[Rect]
|
|
78
|
+
corridors: list[tuple[int, int]]
|
|
79
|
+
grid: np.ndarray # bool [height, width]; True = floor
|
|
80
|
+
edges: list[tuple[int, int]] = field(default_factory=list) # connected room-index pairs
|
|
81
|
+
|
|
82
|
+
def ascii(self) -> str:
|
|
83
|
+
"""A quick ``#``/``.`` map (rooms+corridors as floor) for eyeballing/tests."""
|
|
84
|
+
rows = []
|
|
85
|
+
for y in range(self.height):
|
|
86
|
+
rows.append("".join("." if self.grid[y, x] else "#" for x in range(self.width)))
|
|
87
|
+
return "\n".join(rows)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DungeonGenerator:
|
|
91
|
+
"""Builds a :class:`Dungeon` for a ``width×height`` grid."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, rng: SeededRNG):
|
|
94
|
+
self._rng = rng.derive("dungeon")
|
|
95
|
+
|
|
96
|
+
def generate(
|
|
97
|
+
self, width: int, height: int, config: DungeonConfig | None = None
|
|
98
|
+
) -> Dungeon:
|
|
99
|
+
cfg = config or DungeonConfig()
|
|
100
|
+
leaves = self._bsp(0, 0, width, height, cfg)
|
|
101
|
+
rooms = [r for leaf in leaves if (r := self._room_in_leaf(leaf, cfg))]
|
|
102
|
+
|
|
103
|
+
grid = np.zeros((height, width), dtype=bool)
|
|
104
|
+
for r in rooms:
|
|
105
|
+
grid[r.y : r.y + r.h, r.x : r.x + r.w] = True
|
|
106
|
+
|
|
107
|
+
corridors, edges = self._connect(rooms, grid, cfg)
|
|
108
|
+
return Dungeon(width, height, rooms, corridors, grid, edges)
|
|
109
|
+
|
|
110
|
+
# -- 1. BSP partitioning --------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _bsp(self, x: int, y: int, w: int, h: int, cfg: DungeonConfig) -> list[Rect]:
|
|
113
|
+
"""Recursively split a region into leaves no smaller than ``min_leaf``."""
|
|
114
|
+
# Decide whether (and how) to split. Stop when too small to yield two
|
|
115
|
+
# viable children in either axis.
|
|
116
|
+
can_h = w >= 2 * cfg.min_leaf
|
|
117
|
+
can_v = h >= 2 * cfg.min_leaf
|
|
118
|
+
if not can_h and not can_v:
|
|
119
|
+
return [Rect(x, y, w, h)]
|
|
120
|
+
|
|
121
|
+
# Prefer splitting the longer axis so leaves stay squarish.
|
|
122
|
+
if can_h and can_v:
|
|
123
|
+
split_horizontal = w > h if abs(w - h) > cfg.min_leaf else self._rng.chance(0.5)
|
|
124
|
+
else:
|
|
125
|
+
split_horizontal = can_h
|
|
126
|
+
|
|
127
|
+
if split_horizontal:
|
|
128
|
+
lo, hi = cfg.min_leaf, w - cfg.min_leaf
|
|
129
|
+
cut = self._jittered_cut(lo, hi, w, cfg)
|
|
130
|
+
return (self._bsp(x, y, cut, h, cfg)
|
|
131
|
+
+ self._bsp(x + cut, y, w - cut, h, cfg))
|
|
132
|
+
else:
|
|
133
|
+
lo, hi = cfg.min_leaf, h - cfg.min_leaf
|
|
134
|
+
cut = self._jittered_cut(lo, hi, h, cfg)
|
|
135
|
+
return (self._bsp(x, y, w, cut, cfg)
|
|
136
|
+
+ self._bsp(x, y + cut, w, h - cut, cfg))
|
|
137
|
+
|
|
138
|
+
def _jittered_cut(self, lo: int, hi: int, span: int, cfg: DungeonConfig) -> int:
|
|
139
|
+
mid = span / 2
|
|
140
|
+
jitter = span * cfg.split_jitter
|
|
141
|
+
cut = int(round(self._rng.uniform(mid - jitter, mid + jitter)))
|
|
142
|
+
return max(lo, min(hi, cut))
|
|
143
|
+
|
|
144
|
+
# -- 2. Rooms --------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _room_in_leaf(self, leaf: Rect, cfg: DungeonConfig) -> Rect | None:
|
|
147
|
+
"""Place a random room inside a leaf, inset by ``room_padding``."""
|
|
148
|
+
pad = cfg.room_padding
|
|
149
|
+
max_w = leaf.w - 2 * pad
|
|
150
|
+
max_h = leaf.h - 2 * pad
|
|
151
|
+
if max_w < cfg.room_min or max_h < cfg.room_min:
|
|
152
|
+
return None
|
|
153
|
+
rw = self._rng.randint(cfg.room_min, max_w)
|
|
154
|
+
rh = self._rng.randint(cfg.room_min, max_h)
|
|
155
|
+
rx = leaf.x + pad + self._rng.randint(0, max_w - rw)
|
|
156
|
+
ry = leaf.y + pad + self._rng.randint(0, max_h - rh)
|
|
157
|
+
return Rect(rx, ry, rw, rh)
|
|
158
|
+
|
|
159
|
+
# -- 3. Corridors (MST + loops) -------------------------------------
|
|
160
|
+
|
|
161
|
+
def _connect(
|
|
162
|
+
self, rooms: list[Rect], grid: np.ndarray, cfg: DungeonConfig
|
|
163
|
+
) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
|
|
164
|
+
"""Connect rooms with a Prim MST (+ optional loops); carve L-corridors."""
|
|
165
|
+
n = len(rooms)
|
|
166
|
+
corridors: list[tuple[int, int]] = []
|
|
167
|
+
edges: list[tuple[int, int]] = []
|
|
168
|
+
if n < 2:
|
|
169
|
+
return corridors, edges
|
|
170
|
+
|
|
171
|
+
centers = [r.center for r in rooms]
|
|
172
|
+
|
|
173
|
+
def dist2(i: int, j: int) -> int:
|
|
174
|
+
(ax, ay), (bx, by) = centers[i], centers[j]
|
|
175
|
+
return (ax - bx) ** 2 + (ay - by) ** 2
|
|
176
|
+
|
|
177
|
+
# Prim's MST over room centers (dense graph, n is small).
|
|
178
|
+
in_tree = {0}
|
|
179
|
+
while len(in_tree) < n:
|
|
180
|
+
best = None
|
|
181
|
+
best_d = None
|
|
182
|
+
for i in in_tree:
|
|
183
|
+
for j in range(n):
|
|
184
|
+
if j in in_tree:
|
|
185
|
+
continue
|
|
186
|
+
d = dist2(i, j)
|
|
187
|
+
if best_d is None or d < best_d:
|
|
188
|
+
best_d, best = d, (i, j)
|
|
189
|
+
i, j = best
|
|
190
|
+
in_tree.add(j)
|
|
191
|
+
edges.append((i, j))
|
|
192
|
+
|
|
193
|
+
# A few extra edges → loops (less tree-like, more interesting).
|
|
194
|
+
for i in range(n):
|
|
195
|
+
for j in range(i + 1, n):
|
|
196
|
+
if (i, j) in edges or (j, i) in edges:
|
|
197
|
+
continue
|
|
198
|
+
if self._rng.chance(cfg.extra_corridor_chance):
|
|
199
|
+
edges.append((i, j))
|
|
200
|
+
|
|
201
|
+
for i, j in edges:
|
|
202
|
+
corridors.extend(self._carve_l(centers[i], centers[j], grid))
|
|
203
|
+
return corridors, edges
|
|
204
|
+
|
|
205
|
+
def _carve_l(
|
|
206
|
+
self, a: tuple[int, int], b: tuple[int, int], grid: np.ndarray
|
|
207
|
+
) -> list[tuple[int, int]]:
|
|
208
|
+
"""Carve an L-shaped corridor between two points; return the new cells."""
|
|
209
|
+
(ax, ay), (bx, by) = a, b
|
|
210
|
+
cells: list[tuple[int, int]] = []
|
|
211
|
+
h_first = self._rng.chance(0.5)
|
|
212
|
+
corner = (bx, ay) if h_first else (ax, by)
|
|
213
|
+
|
|
214
|
+
def line(p: tuple[int, int], q: tuple[int, int]) -> None:
|
|
215
|
+
(px, py), (qx, qy) = p, q
|
|
216
|
+
if px == qx:
|
|
217
|
+
for y in range(min(py, qy), max(py, qy) + 1):
|
|
218
|
+
if not grid[y, px]:
|
|
219
|
+
grid[y, px] = True
|
|
220
|
+
cells.append((px, y))
|
|
221
|
+
else:
|
|
222
|
+
for x in range(min(px, qx), max(px, qx) + 1):
|
|
223
|
+
if not grid[py, x]:
|
|
224
|
+
grid[py, x] = True
|
|
225
|
+
cells.append((x, py))
|
|
226
|
+
|
|
227
|
+
line(a, corner)
|
|
228
|
+
line(corner, b)
|
|
229
|
+
return cells
|