mud/src/mudlib/world/terrain.py
Jared Miller 8934397b1e
Make world wrap seamlessly in both axes
Tileable Perlin noise: each octave wraps its integer grid coordinates
with modulo at the octave's frequency, so gradients at opposite edges
match and the noise field is continuous across the boundary.

Coarse elevation grid interpolation wraps instead of padding boundary
cells. Rivers can flow across world edges. All coordinate access
(get_tile, is_passable, get_viewport) wraps via modulo. Movement,
spawn search, nearby-player detection, and viewport relative positions
all handle the toroidal topology.
2026-02-07 13:50:06 -05:00

285 lines
8.6 KiB
Python

"""Terrain generation for MUD worlds using deterministic noise."""
import math
import random
def _make_perm(seed: int) -> list[int]:
"""Generate permutation table from seed."""
rng = random.Random(seed)
p = list(range(256))
rng.shuffle(p)
return p + p
def _noise(perm: list[int], x: float, y: float, px: int, py: int) -> float:
"""2D tileable Perlin noise.
Wraps seamlessly with period px along x and py along y.
"""
# unit grid cell
fx = math.floor(x)
fy = math.floor(y)
xi = int(fx) % px
yi = int(fy) % py
xi1 = (xi + 1) % px
yi1 = (yi + 1) % py
# relative coordinates within cell
xf = x - fx
yf = y - fy
# fade curves (inlined)
u = xf * xf * xf * (xf * (xf * 6 - 15) + 10)
v = yf * yf * yf * (yf * (yf * 6 - 15) + 10)
# hash coordinates of 4 corners (wrapping)
aa = perm[perm[xi] + yi]
ab = perm[perm[xi] + yi1]
ba = perm[perm[xi1] + yi]
bb = perm[perm[xi1] + yi1]
# grad + lerp inlined for all 4 corners
xf1 = xf - 1
yf1 = yf - 1
def _g(h: int, gx: float, gy: float) -> float:
h &= 7
a = gx if h < 4 else gy
b = gy if h < 4 else gx
return (a if (h & 1) == 0 else -a) + (b if (h & 2) == 0 else -b)
g_aa = _g(aa, xf, yf)
g_ba = _g(ba, xf1, yf)
g_ab = _g(ab, xf, yf1)
g_bb = _g(bb, xf1, yf1)
x1 = g_aa + u * (g_ba - g_aa)
x2 = g_ab + u * (g_bb - g_ab)
return x1 + v * (x2 - x1)
class World:
"""Procedurally generated terrain world."""
TERRAIN_CHARS = {
"mountain": "^",
"forest": "T",
"grass": ".",
"sand": ":",
"water": "~",
}
def __init__(self, seed: int = 42, width: int = 1000, height: int = 1000):
self.seed = seed
self.width = width
self.height = height
# generate terrain grid
self.terrain = self._generate_terrain()
def _generate_elevation(self) -> list[list[float]]:
"""Generate tileable elevation map using fractal noise.
Computes at 1/4 resolution and interpolates up for speed.
Tiles seamlessly in both axes so the world can wrap.
"""
w = self.width
h = self.height
perm = _make_perm(self.seed)
noise = _noise
# generate at 1/4 resolution (no boundary padding, we wrap instead)
step = 4
cw = w // step
ch = h // step
coarse = [[0.0] * cw for _ in range(ch)]
min_val = float("inf")
max_val = float("-inf")
for cy in range(ch):
row = coarse[cy]
for cx in range(cw):
# map coarse coords back to world space [0, 1)
wx = cx * step / w
wy = cy * step / h
# each octave tiles at its own frequency
value = (
noise(perm, wx * 4, wy * 4, 4, 4)
+ noise(perm, wx * 8, wy * 8, 8, 8) * 0.5
+ noise(perm, wx * 16, wy * 16, 16, 16) * 0.25
)
row[cx] = value
if value < min_val:
min_val = value
if value > max_val:
max_val = value
# normalize coarse grid to [0, 1]
val_range = max_val - min_val
if val_range > 0:
inv_range = 1.0 / val_range
for cy in range(ch):
row = coarse[cy]
for cx in range(cw):
row[cx] = (row[cx] - min_val) * inv_range
# bilinear interpolation to full resolution (wrapping at edges)
inv_step = 1.0 / step
elevation = [[0.0] * w for _ in range(h)]
for y in range(h):
cy_f = y * inv_step
cy_floor = int(cy_f)
cy0 = cy_floor % ch
cy1 = (cy0 + 1) % ch
fy = cy_f - cy_floor
fy_inv = 1.0 - fy
row_0 = coarse[cy0]
row_1 = coarse[cy1]
out_row = elevation[y]
for x in range(w):
cx_f = x * inv_step
cx_floor = int(cx_f)
cx0 = cx_floor % cw
cx1 = (cx0 + 1) % cw
fx = cx_f - cx_floor
# bilinear interpolation
out_row[x] = (
row_0[cx0] * (1.0 - fx) * fy_inv
+ row_0[cx1] * fx * fy_inv
+ row_1[cx0] * (1.0 - fx) * fy
+ row_1[cx1] * fx * fy
)
return elevation
def _derive_terrain_from_elevation(
self, elevation: list[list[float]]
) -> list[list[str]]:
"""Convert elevation map to terrain types."""
w = self.width
h = self.height
# local lookups
mountain = self.TERRAIN_CHARS["mountain"]
forest = self.TERRAIN_CHARS["forest"]
grass = self.TERRAIN_CHARS["grass"]
sand = self.TERRAIN_CHARS["sand"]
water = self.TERRAIN_CHARS["water"]
terrain = [[""] * w for _ in range(h)]
for y in range(h):
elev_row = elevation[y]
terr_row = terrain[y]
for x in range(w):
e = elev_row[x]
if e > 0.75:
terr_row[x] = mountain
elif e > 0.55:
terr_row[x] = forest
elif e > 0.25:
terr_row[x] = grass
elif e > 0.15:
terr_row[x] = sand
else:
terr_row[x] = water
return terrain
def _generate_rivers(
self, terrain: list[list[str]], elevation: list[list[float]]
) -> None:
"""Generate rivers from mountains to water bodies."""
rng = random.Random(self.seed)
w = self.width
h = self.height
# find some high-elevation starting points
num_rivers = max(5, (w * h) // 50000)
for _ in range(num_rivers):
# pick random high elevation point
x = rng.randint(0, w - 1)
y = rng.randint(0, h - 1)
if elevation[y][x] < 0.6:
continue
# trace downhill (wrapping at edges)
visited = set()
while True:
if (x, y) in visited or len(visited) > 200:
break
visited.add((x, y))
# if we hit water, stop
if terrain[y][x] == self.TERRAIN_CHARS["water"]:
break
# carve river
if elevation[y][x] < 0.75: # don't carve through mountains
terrain[y][x] = self.TERRAIN_CHARS["water"]
# find steepest descent to neighbor (wrapping)
current_elevation = elevation[y][x]
best_x, best_y = x, y
best_elevation = current_elevation
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx = (x + dx) % w
ny = (y + dy) % h
if elevation[ny][nx] < best_elevation:
best_elevation = elevation[ny][nx]
best_x, best_y = nx, ny
if (best_x, best_y) == (x, y):
break
x, y = best_x, best_y
def _generate_terrain(self) -> list[list[str]]:
"""Generate complete terrain map."""
elevation = self._generate_elevation()
terrain = self._derive_terrain_from_elevation(elevation)
self._generate_rivers(terrain, elevation)
return terrain
def wrap(self, x: int, y: int) -> tuple[int, int]:
"""Wrap coordinates to stay within world bounds."""
return x % self.width, y % self.height
def get_tile(self, x: int, y: int) -> str:
"""Return terrain character at position (wrapping)."""
wx, wy = self.wrap(x, y)
return self.terrain[wy][wx]
def is_passable(self, x: int, y: int) -> bool:
"""Check if position is passable (wrapping)."""
tile = self.get_tile(x, y)
return tile not in {self.TERRAIN_CHARS["mountain"], self.TERRAIN_CHARS["water"]}
def get_viewport(
self, cx: int, cy: int, width: int, height: int
) -> list[list[str]]:
"""Return 2D slice of terrain centered on (cx, cy), wrapping at edges."""
viewport = []
half_width = width // 2
half_height = height // 2
start_y = cy - half_height
start_x = cx - half_width
for dy in range(height):
row = []
for dx in range(width):
wx, wy = self.wrap(start_x + dx, start_y + dy)
row.append(self.terrain[wy][wx])
viewport.append(row)
return viewport