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.
This commit is contained in:
Jared Miller 2026-02-07 13:50:06 -05:00
parent 9cd9c6f790
commit 8934397b1e
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
6 changed files with 112 additions and 80 deletions

View file

@ -34,9 +34,19 @@ async def cmd_look(player: Player, args: str) -> None:
if other.name == player.name: if other.name == player.name:
continue continue
# Calculate relative position # Calculate relative position (shortest path wrapping)
rel_x = other.x - player.x + center_x dx = other.x - player.x
rel_y = other.y - player.y + center_y dy = other.y - player.y
if dx > world.width // 2:
dx -= world.width
elif dx < -(world.width // 2):
dx += world.width
if dy > world.height // 2:
dy -= world.height
elif dy < -(world.height // 2):
dy += world.height
rel_x = dx + center_x
rel_y = dy + center_y
# Check if within viewport bounds # Check if within viewport bounds
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:

View file

@ -65,8 +65,7 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
dy: Y delta dy: Y delta
direction_name: Full name of the direction for messages direction_name: Full name of the direction for messages
""" """
target_x = player.x + dx target_x, target_y = world.wrap(player.x + dx, player.y + dy)
target_y = player.y + dy
# Check if the target is passable # Check if the target is passable
if not world.is_passable(target_x, target_y): if not world.is_passable(target_x, target_y):
@ -111,8 +110,12 @@ async def send_nearby_message(player: Player, x: int, y: int, message: str) -> N
if other.name == player.name: if other.name == player.name:
continue continue
# Check if other player is within viewport range # Check if other player is within viewport range (wrapping)
if abs(other.x - x) <= viewport_range and abs(other.y - y) <= viewport_range: dx_dist = abs(other.x - x)
dy_dist = abs(other.y - y)
dx_dist = min(dx_dist, world.width - dx_dist)
dy_dist = min(dy_dist, world.height - dy_dist)
if dx_dist <= viewport_range and dy_dist <= viewport_range:
other.writer.write(message) other.writer.write(message)
await other.writer.drain() await other.writer.drain()

View file

@ -38,7 +38,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
if world.is_passable(start_x, start_y): if world.is_passable(start_x, start_y):
return start_x, start_y return start_x, start_y
# Spiral outward from the starting position # Spiral outward from the starting position (wrapping)
for radius in range(1, 100): for radius in range(1, 100):
for dx in range(-radius, radius + 1): for dx in range(-radius, radius + 1):
for dy in range(-radius, radius + 1): for dy in range(-radius, radius + 1):
@ -46,12 +46,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
if abs(dx) != radius and abs(dy) != radius: if abs(dx) != radius and abs(dy) != radius:
continue continue
x = start_x + dx x, y = world.wrap(start_x + dx, start_y + dy)
y = start_y + dy
# Check bounds
if not (0 <= x < world.width and 0 <= y < world.height):
continue
if world.is_passable(x, y): if world.is_passable(x, y):
return x, y return x, y

View file

@ -12,13 +12,18 @@ def _make_perm(seed: int) -> list[int]:
return p + p return p + p
def _noise(perm: list[int], x: float, y: float) -> float: def _noise(perm: list[int], x: float, y: float, px: int, py: int) -> float:
"""2D Perlin noise, fully inlined for speed.""" """2D tileable Perlin noise.
Wraps seamlessly with period px along x and py along y.
"""
# unit grid cell # unit grid cell
fx = math.floor(x) fx = math.floor(x)
fy = math.floor(y) fy = math.floor(y)
xi = int(fx) & 255 xi = int(fx) % px
yi = int(fy) & 255 yi = int(fy) % py
xi1 = (xi + 1) % px
yi1 = (yi + 1) % py
# relative coordinates within cell # relative coordinates within cell
xf = x - fx xf = x - fx
@ -28,11 +33,11 @@ def _noise(perm: list[int], x: float, y: float) -> float:
u = xf * xf * xf * (xf * (xf * 6 - 15) + 10) u = xf * xf * xf * (xf * (xf * 6 - 15) + 10)
v = yf * yf * yf * (yf * (yf * 6 - 15) + 10) v = yf * yf * yf * (yf * (yf * 6 - 15) + 10)
# hash coordinates of 4 corners # hash coordinates of 4 corners (wrapping)
aa = perm[perm[xi] + yi] aa = perm[perm[xi] + yi]
ab = perm[perm[xi] + yi + 1] ab = perm[perm[xi] + yi1]
ba = perm[perm[xi + 1] + yi] ba = perm[perm[xi1] + yi]
bb = perm[perm[xi + 1] + yi + 1] bb = perm[perm[xi1] + yi1]
# grad + lerp inlined for all 4 corners # grad + lerp inlined for all 4 corners
xf1 = xf - 1 xf1 = xf - 1
@ -74,19 +79,20 @@ class World:
self.terrain = self._generate_terrain() self.terrain = self._generate_terrain()
def _generate_elevation(self) -> list[list[float]]: def _generate_elevation(self) -> list[list[float]]:
"""Generate elevation map using fractal noise. """Generate tileable elevation map using fractal noise.
Computes at 1/4 resolution and interpolates up for speed. Computes at 1/4 resolution and interpolates up for speed.
Tiles seamlessly in both axes so the world can wrap.
""" """
w = self.width w = self.width
h = self.height h = self.height
perm = _make_perm(self.seed) perm = _make_perm(self.seed)
noise = _noise noise = _noise
# generate at 1/4 resolution # generate at 1/4 resolution (no boundary padding, we wrap instead)
step = 4 step = 4
cw = w // step + 2 # +2 for interpolation boundary cw = w // step
ch = h // step + 2 ch = h // step
coarse = [[0.0] * cw for _ in range(ch)] coarse = [[0.0] * cw for _ in range(ch)]
min_val = float("inf") min_val = float("inf")
@ -95,14 +101,15 @@ class World:
for cy in range(ch): for cy in range(ch):
row = coarse[cy] row = coarse[cy]
for cx in range(cw): for cx in range(cw):
# map coarse coords back to world space # map coarse coords back to world space [0, 1)
wx = cx * step / w wx = cx * step / w
wy = cy * step / h wy = cy * step / h
# each octave tiles at its own frequency
value = ( value = (
noise(perm, wx * 4, wy * 4) noise(perm, wx * 4, wy * 4, 4, 4)
+ noise(perm, wx * 8, wy * 8) * 0.5 + noise(perm, wx * 8, wy * 8, 8, 8) * 0.5
+ noise(perm, wx * 16, wy * 16) * 0.25 + noise(perm, wx * 16, wy * 16, 16, 16) * 0.25
) )
row[cx] = value row[cx] = value
if value < min_val: if value < min_val:
@ -119,15 +126,16 @@ class World:
for cx in range(cw): for cx in range(cw):
row[cx] = (row[cx] - min_val) * inv_range row[cx] = (row[cx] - min_val) * inv_range
# bilinear interpolation to full resolution # bilinear interpolation to full resolution (wrapping at edges)
inv_step = 1.0 / step inv_step = 1.0 / step
elevation = [[0.0] * w for _ in range(h)] elevation = [[0.0] * w for _ in range(h)]
for y in range(h): for y in range(h):
cy_f = y * inv_step cy_f = y * inv_step
cy0 = int(cy_f) cy_floor = int(cy_f)
cy1 = min(cy0 + 1, ch - 1) cy0 = cy_floor % ch
fy = cy_f - cy0 cy1 = (cy0 + 1) % ch
fy = cy_f - cy_floor
fy_inv = 1.0 - fy fy_inv = 1.0 - fy
row_0 = coarse[cy0] row_0 = coarse[cy0]
row_1 = coarse[cy1] row_1 = coarse[cy1]
@ -135,9 +143,10 @@ class World:
for x in range(w): for x in range(w):
cx_f = x * inv_step cx_f = x * inv_step
cx0 = int(cx_f) cx_floor = int(cx_f)
cx1 = min(cx0 + 1, cw - 1) cx0 = cx_floor % cw
fx = cx_f - cx0 cx1 = (cx0 + 1) % cw
fx = cx_f - cx_floor
# bilinear interpolation # bilinear interpolation
out_row[x] = ( out_row[x] = (
@ -187,47 +196,44 @@ class World:
) -> None: ) -> None:
"""Generate rivers from mountains to water bodies.""" """Generate rivers from mountains to water bodies."""
rng = random.Random(self.seed) rng = random.Random(self.seed)
w = self.width
h = self.height
# find some high-elevation starting points # find some high-elevation starting points
num_rivers = max(5, (self.width * self.height) // 50000) num_rivers = max(5, (w * h) // 50000)
for _ in range(num_rivers): for _ in range(num_rivers):
# pick random high elevation point # pick random high elevation point
x = rng.randint(0, self.width - 1) x = rng.randint(0, w - 1)
y = rng.randint(0, self.height - 1) y = rng.randint(0, h - 1)
if elevation[y][x] < 0.6: if elevation[y][x] < 0.6:
continue continue
# trace downhill # trace downhill (wrapping at edges)
visited = set() visited = set()
while True: while True:
if (x, y) in visited or len(visited) > 200: if (x, y) in visited or len(visited) > 200:
break break
visited.add((x, y)) visited.add((x, y))
# if we hit water or edge, stop # if we hit water, stop
if terrain[y][x] == self.TERRAIN_CHARS["water"]: if terrain[y][x] == self.TERRAIN_CHARS["water"]:
break break
if x <= 0 or x >= self.width - 1 or y <= 0 or y >= self.height - 1:
break
# carve river # carve river
if elevation[y][x] < 0.75: # don't carve through mountains if elevation[y][x] < 0.75: # don't carve through mountains
terrain[y][x] = self.TERRAIN_CHARS["water"] terrain[y][x] = self.TERRAIN_CHARS["water"]
# find steepest descent to neighbor # find steepest descent to neighbor (wrapping)
current_elevation = elevation[y][x] current_elevation = elevation[y][x]
best_x, best_y = x, y best_x, best_y = x, y
best_elevation = current_elevation best_elevation = current_elevation
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy nx = (x + dx) % w
if ( ny = (y + dy) % h
0 <= nx < self.width if elevation[ny][nx] < best_elevation:
and 0 <= ny < self.height
and elevation[ny][nx] < best_elevation
):
best_elevation = elevation[ny][nx] best_elevation = elevation[ny][nx]
best_x, best_y = nx, ny best_x, best_y = nx, ny
@ -243,39 +249,36 @@ class World:
self._generate_rivers(terrain, elevation) self._generate_rivers(terrain, elevation)
return terrain 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: def get_tile(self, x: int, y: int) -> str:
"""Return terrain character at position.""" """Return terrain character at position (wrapping)."""
if not (0 <= x < self.width and 0 <= y < self.height): wx, wy = self.wrap(x, y)
raise ValueError(f"Coordinates ({x}, {y}) out of bounds") return self.terrain[wy][wx]
return self.terrain[y][x]
def is_passable(self, x: int, y: int) -> bool: def is_passable(self, x: int, y: int) -> bool:
"""Check if position is passable.""" """Check if position is passable (wrapping)."""
tile = self.get_tile(x, y) tile = self.get_tile(x, y)
return tile not in {self.TERRAIN_CHARS["mountain"], self.TERRAIN_CHARS["water"]} return tile not in {self.TERRAIN_CHARS["mountain"], self.TERRAIN_CHARS["water"]}
def get_viewport( def get_viewport(
self, cx: int, cy: int, width: int, height: int self, cx: int, cy: int, width: int, height: int
) -> list[list[str]]: ) -> list[list[str]]:
"""Return 2D slice of terrain centered on (cx, cy).""" """Return 2D slice of terrain centered on (cx, cy), wrapping at edges."""
viewport = [] viewport = []
# calculate bounds
half_width = width // 2 half_width = width // 2
half_height = height // 2 half_height = height // 2
start_y = cy - half_height start_y = cy - half_height
end_y = start_y + height
start_x = cx - half_width start_x = cx - half_width
end_x = start_x + width
for y in range(start_y, end_y): for dy in range(height):
row = [] row = []
for x in range(start_x, end_x): for dx in range(width):
# clamp to world bounds wx, wy = self.wrap(start_x + dx, start_y + dy)
wx = max(0, min(x, self.width - 1))
wy = max(0, min(y, self.height - 1))
row.append(self.terrain[wy][wx]) row.append(self.terrain[wy][wx])
viewport.append(row) viewport.append(row)

View file

@ -25,7 +25,10 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def mock_world(): def mock_world():
world = MagicMock() world = MagicMock()
world.width = 100
world.height = 100
world.is_passable = MagicMock(return_value=True) world.is_passable = MagicMock(return_value=True)
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
# Create a 21x11 viewport filled with "." # Create a 21x11 viewport filled with "."
viewport = [["." for _ in range(21)] for _ in range(11)] viewport = [["." for _ in range(21)] for _ in range(11)]
world.get_viewport = MagicMock(return_value=viewport) world.get_viewport = MagicMock(return_value=viewport)

View file

@ -1,5 +1,3 @@
import pytest
from mudlib.world.terrain import World from mudlib.world.terrain import World
@ -36,12 +34,11 @@ def test_world_dimensions():
world.get_tile(0, 0) world.get_tile(0, 0)
world.get_tile(99, 49) world.get_tile(99, 49)
# should raise for out of bounds # coordinates wrap instead of raising
with pytest.raises((IndexError, ValueError)): assert world.get_tile(100, 0) == world.get_tile(0, 0)
world.get_tile(100, 0) assert world.get_tile(0, 50) == world.get_tile(0, 0)
assert world.get_tile(-1, 0) == world.get_tile(99, 0)
with pytest.raises((IndexError, ValueError)): assert world.get_tile(0, -1) == world.get_tile(0, 49)
world.get_tile(0, 50)
def test_get_tile_returns_valid_terrain(): def test_get_tile_returns_valid_terrain():
@ -129,19 +126,23 @@ def test_get_viewport_centers_correctly():
assert center_tile == world.get_tile(50, 50) assert center_tile == world.get_tile(50, 50)
def test_get_viewport_handles_edges(): def test_get_viewport_wraps_at_edges():
"""Viewport handles world boundaries correctly.""" """Viewport wraps around world boundaries."""
world = World(seed=42, width=100, height=100) world = World(seed=42, width=100, height=100)
# near top-left corner # near top-left corner - viewport should wrap to bottom/right
viewport = world.get_viewport(2, 2, width=5, height=5) viewport = world.get_viewport(0, 0, width=5, height=5)
assert len(viewport) == 5 assert len(viewport) == 5
assert len(viewport[0]) == 5 assert len(viewport[0]) == 5
# top-left of viewport wraps to bottom-right of world
assert viewport[0][0] == world.get_tile(98, 98)
# near bottom-right corner # near bottom-right corner - viewport should wrap to top/left
viewport = world.get_viewport(97, 97, width=5, height=5) viewport = world.get_viewport(99, 99, width=5, height=5)
assert len(viewport) == 5 assert len(viewport) == 5
assert len(viewport[0]) == 5 assert len(viewport[0]) == 5
# bottom-right of viewport wraps to top-left of world
assert viewport[4][4] == world.get_tile(1, 1)
def test_terrain_distribution_reasonable(): def test_terrain_distribution_reasonable():
@ -178,6 +179,23 @@ def test_rivers_exist():
assert water_count > 0 assert water_count > 0
def test_terrain_tiles_seamlessly():
"""Terrain at opposite edges matches for seamless wrapping."""
world = World(seed=42, width=100, height=100)
# check that a strip along the right edge is continuous with the left edge
# by verifying the elevation-derived terrain doesn't have a hard seam.
# the noise is tileable, so adjacent tiles across the boundary should be
# from the same smooth noise field (not identical, but not a cliff).
# we verify the wrap helper itself works correctly:
for y in range(100):
assert world.get_tile(-1, y) == world.get_tile(99, y)
assert world.get_tile(100, y) == world.get_tile(0, y)
for x in range(100):
assert world.get_tile(x, -1) == world.get_tile(x, 99)
assert world.get_tile(x, 100) == world.get_tile(x, 0)
def test_large_world_generation(): def test_large_world_generation():
"""Can generate a 1000x1000 world without errors.""" """Can generate a 1000x1000 world without errors."""
world = World(seed=42, width=1000, height=1000) world = World(seed=42, width=1000, height=1000)