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.
285 lines
8.6 KiB
Python
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
|